diff --git a/.env.local.example b/.env.local.example new file mode 100644 index 0000000..67fffd2 --- /dev/null +++ b/.env.local.example @@ -0,0 +1,30 @@ +# Node Banana - Environment Variables +# Copy this file to .env.local and fill in your API keys + +# ============================================================ +# GEMINI API (for Nano Banana and Nano Banana Pro models) +# ============================================================ +# Get your API key from: https://aistudio.google.com/apikey +GEMINI_API_KEY=your_gemini_api_key_here + +# ============================================================ +# OPENAI API (for OpenAI LLM provider - optional) +# ============================================================ +# Get your API key from: https://platform.openai.com/api-keys +OPENAI_API_KEY=your_openai_api_key_here + +# ============================================================ +# MICROSOFT FOUNDRY - FLUX.2 Pro (for Azure FLUX.2 Pro model) +# ============================================================ +# Get your API key from Microsoft Foundry portal +# Deploy FLUX.2-pro model and get your endpoint +AZURE_API_KEY=your_azure_flux_api_key_here +AZURE_FLUX_ENDPOINT=https://your-resource.openai.azure.com/providers/blackforestlabs/v1/flux-2-pro?api-version=preview + +# ============================================================ +# MICROSOFT FOUNDRY - GPT Image (for Azure GPT Image model) +# ============================================================ +# Get your API key from Microsoft Foundry portal +# Deploy gpt-image-1.5 model and get your endpoint +AZURE_GPT_IMAGE_API_KEY=your_azure_gpt_image_api_key_here +AZURE_GPT_IMAGE_ENDPOINT=https://your-resource.openai.azure.com/openai/v1/images/generations \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index d7c1506..2e225ed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,9 +1,32 @@ # 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 +## Supported Models + +### Image Generation Models + +The application supports multiple AI image generation providers: + +| Model ID | Display Name | Provider | API Model Name | +|----------|--------------|----------|----------------| +| `nano-banana` | Nano Banana | Google Gemini | gemini-2.5-flash-preview-image-generation | +| `nano-banana-pro` | Nano Banana Pro | Google Gemini | gemini-3-pro-image-preview | +| `azure-flux-pro` | Azure FLUX.2 Pro | Microsoft Foundry | FLUX.2-pro (Black Forest Labs) | +| `azure-gpt-image` | Azure GPT Image | Microsoft Foundry | gpt-image-1.5 | + +### Environment Variables + +- `GEMINI_API_KEY` - Required for Nano Banana models +- `OPENAI_API_KEY` - Optional, for OpenAI LLM provider +- `AZURE_API_KEY` - Required for Azure FLUX.2 Pro +- `AZURE_GPT_IMAGE_API_KEY` - Required for Azure GPT Image + +### API Endpoints (in `src/app/api/generate/route.ts`) + +- **Gemini**: Uses `@google/genai` SDK +- **Azure FLUX**: `https://.openai.azure.com/providers/blackforestlabs/v1/flux-2-pro?api-version=preview` + - Auth: `api-key` header +- **Azure GPT Image**: `https://.openai.azure.com/openai/v1/images/generations` + - Auth: `Authorization: Bearer` header diff --git a/README.md b/README.md index debba2e..ad2865e 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,22 @@ # Node Banana -> **Important note:** This is in early development and hasn't been tested off my machines,it probably has some issues. Use Chrome. +> **Note:** This is in early development and hasn't been extensively tested. Use Chrome for best results. -Node Banana is node-based workflow application for generating images with NBP. Build image generation pipelines by connecting nodes on a visual canvas. Built mainly with Opus 4.5. +Node Banana is a node-based workflow application for AI image generation. Build image generation pipelines by connecting nodes on a visual canvas. ## Features - **Visual Node Editor** - Drag-and-drop nodes onto an infinite canvas with pan and zoom - **Image Annotation** - Full-screen editor with drawing tools (rectangles, circles, arrows, freehand, text) -- **AI Image Generation** - Generate images using Google Gemini models +- **Multi-Provider AI Image Generation** - Generate images using: + - Google Gemini (Nano Banana, Nano Banana Pro) + - Microsoft Foundry FLUX.2 Pro (Black Forest Labs) + - Microsoft Foundry GPT Image - **Text Generation** - Generate text using Google Gemini or OpenAI models - **Workflow Chaining** - Connect multiple nodes to create complex pipelines - **Save/Load Workflows** - Export and import workflows as JSON files +- **Node Groups** - Organize nodes into collapsible groups +- **Auto-Save** - Automatically save workflows to disk ## Tech Stack @@ -21,34 +26,61 @@ Node Banana is node-based workflow application for generating images with NBP. B - **Canvas**: Konva.js / react-konva - **State Management**: Zustand - **Styling**: Tailwind CSS -- **AI**: Google Gemini API, OpenAI API ## Getting Started ### Prerequisites - Node.js 18+ -- npm +- pnpm (recommended) or npm + +### Installation + +```bash +# Clone the repository +git clone https://github.com/your-username/node-banana.git +cd node-banana + +# Install dependencies +pnpm install +``` ### Environment Variables -Create a `.env.local` file in the root directory: +Copy the example environment file and add your API keys: + +```bash +cp .env.local.example .env.local +``` + +Then edit `.env.local` with your API keys: ```env +# Google Gemini API (for Nano Banana models) +# Get your key from: https://aistudio.google.com/apikey GEMINI_API_KEY=your_gemini_api_key -OPENAI_API_KEY=your_openai_api_key # Optional, for OpenAI LLM provider -``` -### Installation +# OpenAI API (optional, for OpenAI LLM provider) +# Get your key from: https://platform.openai.com/api-keys +OPENAI_API_KEY=your_openai_api_key -```bash -npm install +# Microsoft Foundry - FLUX.2 Pro +# Get from Microsoft Foundry portal > Keys and Endpoint +AZURE_API_KEY=your_azure_flux_api_key +AZURE_FLUX_ENDPOINT=https://your-resource.openai.azure.com/providers/blackforestlabs/v1/flux-2-pro?api-version=preview + +# Microsoft Foundry - GPT Image +# Get from Microsoft Foundry portal > Keys and Endpoint +AZURE_GPT_IMAGE_API_KEY=your_azure_gpt_image_api_key +AZURE_GPT_IMAGE_ENDPOINT=https://your-resource.openai.azure.com/openai/v1/images/generations ``` +> **Note:** You only need to configure the API keys for the models you plan to use. + ### Development ```bash -npm run dev +pnpm dev ``` Open [http://localhost:3000](http://localhost:3000) in your browser. @@ -56,32 +88,87 @@ Open [http://localhost:3000](http://localhost:3000) in your browser. ### Build ```bash -npm run build -npm run start +pnpm build +pnpm start ``` -## Example Workflows +## Supported Models + +### Image Generation -The `/examples` directory contains some example workflow files from my personal projects. To try them: +| Model | Provider | Description | +|-------|----------|-------------| +| Nano Banana | Google Gemini | gemini-2.5-flash-image - Fast image generation | +| Nano Banana Pro | Google Gemini | gemini-3-pro-image-preview - Higher quality, more options | +| Azure FLUX.2 Pro | Microsoft Foundry | Black Forest Labs FLUX.2 Pro model | +| Azure GPT Image | Microsoft Foundry | GPT-based image generation with quality controls | -1. Start the dev server with `npm run dev` -2. Drag any `.json` file from the `/examples` folder into the browser window -3. Make sure you review each of the prompts before starting, these are fairly targetted to the examples. +### Text Generation (LLM Node) + +| Model | Provider | +|-------|----------| +| gemini-2.5-flash | Google | +| gemini-3-pro-preview | Google | +| gpt-4.1-mini | OpenAI | +| gpt-4.1-nano | OpenAI | ## Usage -1. **Add nodes** - Click the floating action bar to add nodes to the canvas +1. **Add nodes** - Click the floating action bar (+) to add nodes to the canvas 2. **Connect nodes** - Drag from output handles to input handles (matching types only) -3. **Configure nodes** - Adjust settings like model, aspect ratio, or drawing tools -4. **Run workflow** - Click the Run button to execute the pipeline +3. **Configure nodes** - Adjust settings like model, aspect ratio, quality, or drawing tools +4. **Run workflow** - Click the Run button (▶) to execute the pipeline 5. **Save/Load** - Use the header menu to save or load workflows +### Node Types + +| Node | Description | +|------|-------------| +| **Image Input** | Load images from your computer | +| **Annotation** | Draw on images with shapes, arrows, and text | +| **Prompt** | Create text prompts for generation | +| **Generate** | AI image generation with multiple model options | +| **LLM Generate** | AI text generation for dynamic prompts | +| **Output** | Display and export final images | + ## Connection Rules -- **Image** handles connect to **Image** handles only -- **Text** handles connect to **Text** handles only -- Image inputs on generation nodes accept multiple connections -- Text inputs accept single connections +- **Image** handles (left side) connect to **Image** handles only +- **Text** handles (left side) connect to **Text** handles only +- Image inputs on generation nodes accept **multiple connections** (for multi-image context) +- Text inputs accept **single connections** + +## Example Workflows + +The `/examples` directory contains example workflow files. To try them: + +1. Start the dev server with `pnpm dev` +2. Drag any `.json` file from `/examples` into the browser window +3. Review the prompts and adjust as needed +4. Click Run to execute + +## Microsoft Foundry Setup + +To use Azure models, you need to deploy them in Microsoft Foundry: + +1. Go to [Microsoft Foundry](https://ai.azure.com) +2. Create or select a project +3. Navigate to **Model catalog** +4. Deploy **FLUX.2-pro** (Black Forest Labs) and/or **gpt-image-1** (Azure OpenAI) +5. Copy your API keys from **Keys and Endpoint** + +### Endpoint Configuration + +The Azure endpoints are configured via environment variables. Set them in your `.env.local` file: + +```env +AZURE_FLUX_ENDPOINT=https://your-resource.openai.azure.com/providers/blackforestlabs/v1/flux-2-pro?api-version=preview +AZURE_GPT_IMAGE_ENDPOINT=https://your-resource.openai.azure.com/openai/v1/images/generations +``` + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. ## License diff --git a/package.json b/package.json index aaacbb1..ccbc13c 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@tailwindcss/postcss": "^4.1.17", "@xyflow/react": "^12.9.3", "autoprefixer": "^10.4.22", + "jszip": "^3.10.1", "konva": "^10.0.12", "next": "^16.0.6", "postcss": "^8.5.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..865a4f9 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1896 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@google/genai': + specifier: ^1.30.0 + version: 1.34.0 + '@tailwindcss/postcss': + specifier: ^4.1.17 + version: 4.1.18 + '@xyflow/react': + specifier: ^12.9.3 + version: 12.10.0(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + autoprefixer: + specifier: ^10.4.22 + version: 10.4.23(postcss@8.5.6) + jszip: + specifier: ^3.10.1 + version: 3.10.1 + konva: + specifier: ^10.0.12 + version: 10.0.12 + next: + specifier: ^16.0.6 + version: 16.1.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + postcss: + specifier: ^8.5.6 + version: 8.5.6 + react: + specifier: ^19.2.0 + version: 19.2.3 + react-dom: + specifier: ^19.2.0 + version: 19.2.3(react@19.2.3) + react-konva: + specifier: ^19.2.1 + version: 19.2.1(@types/react@19.2.7)(konva@10.0.12)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + tailwindcss: + specifier: ^4.1.17 + version: 4.1.18 + zustand: + specifier: ^5.0.9 + version: 5.0.9(@types/react@19.2.7)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3)) + devDependencies: + '@types/node': + specifier: ^24.10.1 + version: 24.10.4 + '@types/react': + specifier: ^19.2.7 + version: 19.2.7 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.7) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + +packages: + + '@alloc/quick-lru@5.2.0': + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + + '@emnapi/runtime@1.7.1': + resolution: {integrity: sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==} + + '@google/genai@1.34.0': + resolution: {integrity: sha512-vu53UMPvjmb7PGzlYu6Tzxso8Dfhn+a7eQFaS2uNemVtDZKwzSpJ5+ikqBbXplF7RGB1STcVDqCkPvquiwb2sw==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@modelcontextprotocol/sdk': ^1.24.0 + peerDependenciesMeta: + '@modelcontextprotocol/sdk': + optional: true + + '@img/colour@1.0.0': + resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@next/env@16.1.0': + resolution: {integrity: sha512-Dd23XQeFHmhf3KBW76leYVkejHlCdB7erakC2At2apL1N08Bm+dLYNP+nNHh0tzUXfPQcNcXiQyacw0PG4Fcpw==} + + '@next/swc-darwin-arm64@16.1.0': + resolution: {integrity: sha512-onHq8dl8KjDb8taANQdzs3XmIqQWV3fYdslkGENuvVInFQzZnuBYYOG2HGHqqtvgmEU7xWzhgndXXxnhk4Z3fQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@next/swc-darwin-x64@16.1.0': + resolution: {integrity: sha512-Am6VJTp8KhLuAH13tPrAoVIXzuComlZlMwGr++o2KDjWiKPe3VwpxYhgV6I4gKls2EnsIMggL4y7GdXyDdJcFA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@next/swc-linux-arm64-gnu@16.1.0': + resolution: {integrity: sha512-fVicfaJT6QfghNyg8JErZ+EMNQ812IS0lmKfbmC01LF1nFBcKfcs4Q75Yy8IqnsCqH/hZwGhqzj3IGVfWV6vpA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@next/swc-linux-arm64-musl@16.1.0': + resolution: {integrity: sha512-TojQnDRoX7wJWXEEwdfuJtakMDW64Q7NrxQPviUnfYJvAx5/5wcGE+1vZzQ9F17m+SdpFeeXuOr6v3jbyusYMQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@next/swc-linux-x64-gnu@16.1.0': + resolution: {integrity: sha512-quhNFVySW4QwXiZkZ34SbfzNBm27vLrxZ2HwTfFFO1BBP0OY1+pI0nbyewKeq1FriqU+LZrob/cm26lwsiAi8Q==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@next/swc-linux-x64-musl@16.1.0': + resolution: {integrity: sha512-6JW0z2FZUK5iOVhUIWqE4RblAhUj1EwhZ/MwteGb//SpFTOHydnhbp3868gxalwea+mbOLWO6xgxj9wA9wNvNw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@next/swc-win32-arm64-msvc@16.1.0': + resolution: {integrity: sha512-+DK/akkAvvXn5RdYN84IOmLkSy87SCmpofJPdB8vbLmf01BzntPBSYXnMvnEEv/Vcf3HYJwt24QZ/s6sWAwOMQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@next/swc-win32-x64-msvc@16.1.0': + resolution: {integrity: sha512-Tr0j94MphimCCks+1rtYPzQFK+faJuhHWCegU9S9gDlgyOk8Y3kPmO64UcjyzZAlligeBtYZ/2bEyrKq0d2wqQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@swc/helpers@0.5.15': + resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + + '@tailwindcss/node@4.1.18': + resolution: {integrity: sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==} + + '@tailwindcss/oxide-android-arm64@4.1.18': + resolution: {integrity: sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.1.18': + resolution: {integrity: sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.1.18': + resolution: {integrity: sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.1.18': + resolution: {integrity: sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18': + resolution: {integrity: sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.1.18': + resolution: {integrity: sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-musl@4.1.18': + resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-gnu@4.1.18': + resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-musl@4.1.18': + resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-wasm32-wasi@4.1.18': + resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.1.18': + resolution: {integrity: sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.1.18': + resolution: {integrity: sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.1.18': + resolution: {integrity: sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==} + engines: {node: '>= 10'} + + '@tailwindcss/postcss@4.1.18': + resolution: {integrity: sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-selection@3.0.11': + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + + '@types/node@24.10.4': + resolution: {integrity: sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react-reconciler@0.28.9': + resolution: {integrity: sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==} + peerDependencies: + '@types/react': '*' + + '@types/react-reconciler@0.32.3': + resolution: {integrity: sha512-cMi5ZrLG7UtbL7LTK6hq9w/EZIRk4Mf1Z5qHoI+qBh7/WkYkFXQ7gOto2yfUvPzF5ERMAhaXS5eTQ2SAnHjLzA==} + peerDependencies: + '@types/react': '*' + + '@types/react@19.2.7': + resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==} + + '@xyflow/react@12.10.0': + resolution: {integrity: sha512-eOtz3whDMWrB4KWVatIBrKuxECHqip6PfA8fTpaS2RUGVpiEAe+nqDKsLqkViVWxDGreq0lWX71Xth/SPAzXiw==} + peerDependencies: + react: '>=17' + react-dom: '>=17' + + '@xyflow/system@0.0.74': + resolution: {integrity: sha512-7v7B/PkiVrkdZzSbL+inGAo6tkR/WQHHG0/jhSvLQToCsfa8YubOGmBYd1s08tpKpihdHDZFwzQZeR69QSBb4Q==} + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + autoprefixer@10.4.23: + resolution: {integrity: sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: ^8.1.0 + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + baseline-browser-mapping@2.9.10: + resolution: {integrity: sha512-2VIKvDx8Z1a9rTB2eCkdPE5nSe28XnA+qivGnWHoB40hMMt/h1hSz0960Zqsn6ZyxWXUie0EBdElKv8may20AA==} + hasBin: true + + bignumber.js@9.3.1: + resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + + browserslist@4.28.1: + resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + + caniuse-lite@1.0.30001760: + resolution: {integrity: sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==} + + classcat@5.0.5: + resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==} + + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + + electron-to-chromium@1.5.267: + resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + enhanced-resolve@5.18.4: + resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==} + engines: {node: '>=10.13.0'} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + + fraction.js@5.3.4: + resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + + gaxios@7.1.3: + resolution: {integrity: sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==} + engines: {node: '>=18'} + + gcp-metadata@8.1.2: + resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} + engines: {node: '>=18'} + + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + hasBin: true + + google-auth-library@10.5.0: + resolution: {integrity: sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==} + engines: {node: '>=18'} + + google-logging-utils@1.1.3: + resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} + engines: {node: '>=14'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + gtoken@8.0.0: + resolution: {integrity: sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==} + engines: {node: '>=18'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + its-fine@2.0.0: + resolution: {integrity: sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==} + peerDependencies: + react: ^19.0.0 + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + + json-bigint@1.0.0: + resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + + jszip@3.10.1: + resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + + konva@10.0.12: + resolution: {integrity: sha512-DHmkeG5FbW6tLCkbMQTi1ihWycfzljrn0V7umUUuewxx7aoINcI71ksgBX9fTPNXhlsK4/JoMgKwI/iCde+BRw==} + + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + + lightningcss-android-arm64@1.30.2: + resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.30.2: + resolution: {integrity: sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.30.2: + resolution: {integrity: sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.30.2: + resolution: {integrity: sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.30.2: + resolution: {integrity: sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.30.2: + resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.30.2: + resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.30.2: + resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.30.2: + resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.30.2: + resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.30.2: + resolution: {integrity: sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.30.2: + resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==} + engines: {node: '>= 12.0.0'} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} + + minipass@7.1.2: + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + engines: {node: '>=16 || 14 >=14.17'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + next@16.1.0: + resolution: {integrity: sha512-Y+KbmDbefYtHDDQKLNrmzE/YYzG2msqo2VXhzh5yrJ54tx/6TmGdkR5+kP9ma7i7LwZpZMfoY3m/AoPPPKxtVw==} + engines: {node: '>=20.9.0'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.51.1 + babel-plugin-react-compiler: '*' + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true + + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + node-releases@2.0.27: + resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.4.31: + resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} + engines: {node: ^10 || ^12 || >=14} + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + react-dom@19.2.3: + resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==} + peerDependencies: + react: ^19.2.3 + + react-konva@19.2.1: + resolution: {integrity: sha512-sqZWCzQGpdMrU5aeunR0oxUY8UeCPbU8gnAYxMtAn6BT4coeSpiATKOctsoxRu6F56TAcF+s0c6Lul9ansNqQA==} + peerDependencies: + konva: ^8.0.1 || ^7.2.5 || ^9.0.0 || ^10.0.0 + react: ^19.2.0 + react-dom: ^19.2.0 + + react-reconciler@0.33.0: + resolution: {integrity: sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==} + engines: {node: '>=0.10.0'} + peerDependencies: + react: ^19.2.0 + + react@19.2.3: + resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==} + engines: {node: '>=0.10.0'} + + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + + rimraf@5.0.10: + resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==} + hasBin: true + + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@7.7.3: + resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + engines: {node: '>=10'} + hasBin: true + + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.1.2: + resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} + engines: {node: '>=12'} + + styled-jsx@5.1.6: + resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} + engines: {node: '>= 12.0.0'} + peerDependencies: + '@babel/core': '*' + babel-plugin-macros: '*' + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' + peerDependenciesMeta: + '@babel/core': + optional: true + babel-plugin-macros: + optional: true + + tailwindcss@4.1.18: + resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==} + + tapable@2.3.0: + resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} + engines: {node: '>=6'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + ws@8.18.3: + resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + zustand@4.5.7: + resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} + engines: {node: '>=12.7.0'} + peerDependencies: + '@types/react': '>=16.8' + immer: '>=9.0.6' + react: '>=16.8' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + + zustand@5.0.9: + resolution: {integrity: sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + +snapshots: + + '@alloc/quick-lru@5.2.0': {} + + '@emnapi/runtime@1.7.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@google/genai@1.34.0': + dependencies: + google-auth-library: 10.5.0 + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@img/colour@1.0.0': + optional: true + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.7.1 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.1.2 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@next/env@16.1.0': {} + + '@next/swc-darwin-arm64@16.1.0': + optional: true + + '@next/swc-darwin-x64@16.1.0': + optional: true + + '@next/swc-linux-arm64-gnu@16.1.0': + optional: true + + '@next/swc-linux-arm64-musl@16.1.0': + optional: true + + '@next/swc-linux-x64-gnu@16.1.0': + optional: true + + '@next/swc-linux-x64-musl@16.1.0': + optional: true + + '@next/swc-win32-arm64-msvc@16.1.0': + optional: true + + '@next/swc-win32-x64-msvc@16.1.0': + optional: true + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@swc/helpers@0.5.15': + dependencies: + tslib: 2.8.1 + + '@tailwindcss/node@4.1.18': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.18.4 + jiti: 2.6.1 + lightningcss: 1.30.2 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.1.18 + + '@tailwindcss/oxide-android-arm64@4.1.18': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.1.18': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.1.18': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.1.18': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.1.18': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.1.18': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.1.18': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.1.18': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.1.18': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.1.18': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.1.18': + optional: true + + '@tailwindcss/oxide@4.1.18': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.1.18 + '@tailwindcss/oxide-darwin-arm64': 4.1.18 + '@tailwindcss/oxide-darwin-x64': 4.1.18 + '@tailwindcss/oxide-freebsd-x64': 4.1.18 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.18 + '@tailwindcss/oxide-linux-arm64-gnu': 4.1.18 + '@tailwindcss/oxide-linux-arm64-musl': 4.1.18 + '@tailwindcss/oxide-linux-x64-gnu': 4.1.18 + '@tailwindcss/oxide-linux-x64-musl': 4.1.18 + '@tailwindcss/oxide-wasm32-wasi': 4.1.18 + '@tailwindcss/oxide-win32-arm64-msvc': 4.1.18 + '@tailwindcss/oxide-win32-x64-msvc': 4.1.18 + + '@tailwindcss/postcss@4.1.18': + dependencies: + '@alloc/quick-lru': 5.2.0 + '@tailwindcss/node': 4.1.18 + '@tailwindcss/oxide': 4.1.18 + postcss: 8.5.6 + tailwindcss: 4.1.18 + + '@types/d3-color@3.1.3': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-selection@3.0.11': {} + + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + + '@types/node@24.10.4': + dependencies: + undici-types: 7.16.0 + + '@types/react-dom@19.2.3(@types/react@19.2.7)': + dependencies: + '@types/react': 19.2.7 + + '@types/react-reconciler@0.28.9(@types/react@19.2.7)': + dependencies: + '@types/react': 19.2.7 + + '@types/react-reconciler@0.32.3(@types/react@19.2.7)': + dependencies: + '@types/react': 19.2.7 + + '@types/react@19.2.7': + dependencies: + csstype: 3.2.3 + + '@xyflow/react@12.10.0(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@xyflow/system': 0.0.74 + classcat: 5.0.5 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + zustand: 4.5.7(@types/react@19.2.7)(react@19.2.3) + transitivePeerDependencies: + - '@types/react' + - immer + + '@xyflow/system@0.0.74': + dependencies: + '@types/d3-drag': 3.0.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-zoom: 3.0.0 + + agent-base@7.1.4: {} + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.3: {} + + autoprefixer@10.4.23(postcss@8.5.6): + dependencies: + browserslist: 4.28.1 + caniuse-lite: 1.0.30001760 + fraction.js: 5.3.4 + picocolors: 1.1.1 + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + balanced-match@1.0.2: {} + + base64-js@1.5.1: {} + + baseline-browser-mapping@2.9.10: {} + + bignumber.js@9.3.1: {} + + brace-expansion@2.0.2: + dependencies: + balanced-match: 1.0.2 + + browserslist@4.28.1: + dependencies: + baseline-browser-mapping: 2.9.10 + caniuse-lite: 1.0.30001760 + electron-to-chromium: 1.5.267 + node-releases: 2.0.27 + update-browserslist-db: 1.2.3(browserslist@4.28.1) + + buffer-equal-constant-time@1.0.1: {} + + caniuse-lite@1.0.30001760: {} + + classcat@5.0.5: {} + + client-only@0.0.1: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + core-util-is@1.0.3: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + csstype@3.2.3: {} + + d3-color@3.1.0: {} + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-ease@3.0.1: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-selection@3.0.0: {} + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + data-uri-to-buffer@4.0.1: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + detect-libc@2.1.2: {} + + eastasianwidth@0.2.0: {} + + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + + electron-to-chromium@1.5.267: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + enhanced-resolve@5.18.4: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.0 + + escalade@3.2.0: {} + + extend@3.0.2: {} + + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + + fraction.js@5.3.4: {} + + gaxios@7.1.3: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + node-fetch: 3.3.2 + rimraf: 5.0.10 + transitivePeerDependencies: + - supports-color + + gcp-metadata@8.1.2: + dependencies: + gaxios: 7.1.3 + google-logging-utils: 1.1.3 + json-bigint: 1.0.0 + transitivePeerDependencies: + - supports-color + + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.5 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + + google-auth-library@10.5.0: + dependencies: + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + gaxios: 7.1.3 + gcp-metadata: 8.1.2 + google-logging-utils: 1.1.3 + gtoken: 8.0.0 + jws: 4.0.1 + transitivePeerDependencies: + - supports-color + + google-logging-utils@1.1.3: {} + + graceful-fs@4.2.11: {} + + gtoken@8.0.0: + dependencies: + gaxios: 7.1.3 + jws: 4.0.1 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + immediate@3.0.6: {} + + inherits@2.0.4: {} + + is-fullwidth-code-point@3.0.0: {} + + isarray@1.0.0: {} + + isexe@2.0.0: {} + + its-fine@2.0.0(@types/react@19.2.7)(react@19.2.3): + dependencies: + '@types/react-reconciler': 0.28.9(@types/react@19.2.7) + react: 19.2.3 + transitivePeerDependencies: + - '@types/react' + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + jiti@2.6.1: {} + + json-bigint@1.0.0: + dependencies: + bignumber.js: 9.3.1 + + jszip@3.10.1: + dependencies: + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + + konva@10.0.12: {} + + lie@3.3.0: + dependencies: + immediate: 3.0.6 + + lightningcss-android-arm64@1.30.2: + optional: true + + lightningcss-darwin-arm64@1.30.2: + optional: true + + lightningcss-darwin-x64@1.30.2: + optional: true + + lightningcss-freebsd-x64@1.30.2: + optional: true + + lightningcss-linux-arm-gnueabihf@1.30.2: + optional: true + + lightningcss-linux-arm64-gnu@1.30.2: + optional: true + + lightningcss-linux-arm64-musl@1.30.2: + optional: true + + lightningcss-linux-x64-gnu@1.30.2: + optional: true + + lightningcss-linux-x64-musl@1.30.2: + optional: true + + lightningcss-win32-arm64-msvc@1.30.2: + optional: true + + lightningcss-win32-x64-msvc@1.30.2: + optional: true + + lightningcss@1.30.2: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.30.2 + lightningcss-darwin-arm64: 1.30.2 + lightningcss-darwin-x64: 1.30.2 + lightningcss-freebsd-x64: 1.30.2 + lightningcss-linux-arm-gnueabihf: 1.30.2 + lightningcss-linux-arm64-gnu: 1.30.2 + lightningcss-linux-arm64-musl: 1.30.2 + lightningcss-linux-x64-gnu: 1.30.2 + lightningcss-linux-x64-musl: 1.30.2 + lightningcss-win32-arm64-msvc: 1.30.2 + lightningcss-win32-x64-msvc: 1.30.2 + + lru-cache@10.4.3: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + minimatch@9.0.5: + dependencies: + brace-expansion: 2.0.2 + + minipass@7.1.2: {} + + ms@2.1.3: {} + + nanoid@3.3.11: {} + + next@16.1.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + '@next/env': 16.1.0 + '@swc/helpers': 0.5.15 + baseline-browser-mapping: 2.9.10 + caniuse-lite: 1.0.30001760 + postcss: 8.4.31 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + styled-jsx: 5.1.6(react@19.2.3) + optionalDependencies: + '@next/swc-darwin-arm64': 16.1.0 + '@next/swc-darwin-x64': 16.1.0 + '@next/swc-linux-arm64-gnu': 16.1.0 + '@next/swc-linux-arm64-musl': 16.1.0 + '@next/swc-linux-x64-gnu': 16.1.0 + '@next/swc-linux-x64-musl': 16.1.0 + '@next/swc-win32-arm64-msvc': 16.1.0 + '@next/swc-win32-x64-msvc': 16.1.0 + sharp: 0.34.5 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + + node-domexception@1.0.0: {} + + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + + node-releases@2.0.27: {} + + package-json-from-dist@1.0.1: {} + + pako@1.0.11: {} + + path-key@3.1.1: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.2 + + picocolors@1.1.1: {} + + postcss-value-parser@4.2.0: {} + + postcss@8.4.31: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + process-nextick-args@2.0.1: {} + + react-dom@19.2.3(react@19.2.3): + dependencies: + react: 19.2.3 + scheduler: 0.27.0 + + react-konva@19.2.1(@types/react@19.2.7)(konva@10.0.12)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + '@types/react-reconciler': 0.32.3(@types/react@19.2.7) + its-fine: 2.0.0(@types/react@19.2.7)(react@19.2.3) + konva: 10.0.12 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + react-reconciler: 0.33.0(react@19.2.3) + scheduler: 0.27.0 + transitivePeerDependencies: + - '@types/react' + + react-reconciler@0.33.0(react@19.2.3): + dependencies: + react: 19.2.3 + scheduler: 0.27.0 + + react@19.2.3: {} + + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + rimraf@5.0.10: + dependencies: + glob: 10.5.0 + + safe-buffer@5.1.2: {} + + safe-buffer@5.2.1: {} + + scheduler@0.27.0: {} + + semver@7.7.3: + optional: true + + setimmediate@1.0.5: {} + + sharp@0.34.5: + dependencies: + '@img/colour': 1.0.0 + detect-libc: 2.1.2 + semver: 7.7.3 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + optional: true + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + signal-exit@4.1.0: {} + + source-map-js@1.2.1: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.2 + + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.1.2: + dependencies: + ansi-regex: 6.2.2 + + styled-jsx@5.1.6(react@19.2.3): + dependencies: + client-only: 0.0.1 + react: 19.2.3 + + tailwindcss@4.1.18: {} + + tapable@2.3.0: {} + + tslib@2.8.1: {} + + typescript@5.9.3: {} + + undici-types@7.16.0: {} + + update-browserslist-db@1.2.3(browserslist@4.28.1): + dependencies: + browserslist: 4.28.1 + escalade: 3.2.0 + picocolors: 1.1.1 + + use-sync-external-store@1.6.0(react@19.2.3): + dependencies: + react: 19.2.3 + + util-deprecate@1.0.2: {} + + web-streams-polyfill@3.3.3: {} + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.1.2 + + ws@8.18.3: {} + + zustand@4.5.7(@types/react@19.2.7)(react@19.2.3): + dependencies: + use-sync-external-store: 1.6.0(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.7 + react: 19.2.3 + + zustand@5.0.9(@types/react@19.2.7)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3)): + optionalDependencies: + '@types/react': 19.2.7 + react: 19.2.3 + use-sync-external-store: 1.6.0(react@19.2.3) diff --git a/src/app/api/generate/route.ts b/src/app/api/generate/route.ts index 09d1a59..d7b52dd 100644 --- a/src/app/api/generate/route.ts +++ b/src/app/api/generate/route.ts @@ -1,47 +1,88 @@ import { NextRequest, NextResponse } from "next/server"; import { GoogleGenAI } from "@google/genai"; -import { GenerateRequest, GenerateResponse, ModelType } from "@/types"; +import { GenerateRequest, GenerateResponse, ModelType, AzureFluxSize, AzureGptImageSize, AzureGptImageQuality } from "@/types"; -export const maxDuration = 300; // 5 minute timeout for Gemini API calls +export const maxDuration = 300; // 5 minute timeout for API calls export const dynamic = 'force-dynamic'; // Ensure this route is always dynamic -// Map model types to Gemini model IDs -const MODEL_MAP: Record = { - "nano-banana": "gemini-2.5-flash-image", // Updated to correct model name +// Map model types to Gemini model IDs (for Gemini-based models) +const GEMINI_MODEL_MAP: Record = { + "nano-banana": "gemini-2.5-flash-image", "nano-banana-pro": "gemini-3-pro-image-preview", }; +// Azure Foundry configuration +// These endpoints can be customized via environment variables +const AZURE_FLUX_ENDPOINT = process.env.AZURE_FLUX_ENDPOINT || "https://your-resource.openai.azure.com/providers/blackforestlabs/v1/flux-2-pro?api-version=preview"; +const AZURE_GPT_IMAGE_ENDPOINT = process.env.AZURE_GPT_IMAGE_ENDPOINT || "https://your-resource.openai.azure.com/openai/v1/images/generations"; + +// Map aspect ratios to Azure FLUX sizes +const ASPECT_RATIO_TO_SIZE: Record = { + "1:1": "1024x1024", + "4:3": "1024x768", + "3:4": "768x1024", + "3:2": "1536x1024", + "2:3": "1024x1536", + "16:9": "1536x1024", + "9:16": "1024x1536", + // Default fallback for other ratios +}; + +// Map aspect ratios to Azure GPT Image sizes +const ASPECT_RATIO_TO_GPT_SIZE: Record = { + "1:1": "1024x1024", + "3:2": "1536x1024", + "2:3": "1024x1536", + "16:9": "1536x1024", + "9:16": "1024x1536", + // Default fallback for other ratios +}; + export async function POST(request: NextRequest) { const requestId = Math.random().toString(36).substring(7); console.log(`\n[API:${requestId}] ========== NEW GENERATE REQUEST ==========`); console.log(`[API:${requestId}] Timestamp: ${new Date().toISOString()}`); try { + console.log(`[API:${requestId}] Parsing request body...`); + const body: GenerateRequest = await request.json(); + const { images, prompt, model = "nano-banana-pro", aspectRatio, resolution, useGoogleSearch, size, gptImageSize, gptImageQuality } = body; + + console.log(`[API:${requestId}] Request parameters:`); + console.log(`[API:${requestId}] - Model: ${model}`); + console.log(`[API:${requestId}] - Images count: ${images?.length || 0}`); + console.log(`[API:${requestId}] - Prompt length: ${prompt?.length || 0} chars`); + console.log(`[API:${requestId}] - Aspect Ratio: ${aspectRatio || 'default'}`); + console.log(`[API:${requestId}] - Resolution: ${resolution || 'default'}`); + console.log(`[API:${requestId}] - Size: ${size || 'default'}`); + console.log(`[API:${requestId}] - GPT Image Size: ${gptImageSize || 'default'}`); + console.log(`[API:${requestId}] - GPT Image Quality: ${gptImageQuality || 'default'}`); + console.log(`[API:${requestId}] - Google Search: ${useGoogleSearch || false}`); + + // Route to Azure FLUX if that model is selected + if (model === "azure-flux-pro") { + return handleAzureFluxGeneration(requestId, prompt, aspectRatio, size); + } + + // Route to Azure GPT Image if that model is selected + if (model === "azure-gpt-image") { + return handleAzureGptImageGeneration(requestId, prompt, aspectRatio, gptImageSize, gptImageQuality); + } + + // Continue with Gemini-based generation for other models const apiKey = process.env.GEMINI_API_KEY; if (!apiKey) { - console.error(`[API:${requestId}] ❌ No API key configured`); + console.error(`[API:${requestId}] ❌ No Gemini API key configured for model: ${model}`); return NextResponse.json( { success: false, - error: "API key not configured. Add GEMINI_API_KEY to .env.local", + error: `Gemini API key not configured for model "${model}". Either add GEMINI_API_KEY to .env.local or switch to Azure FLUX Pro model.`, }, { status: 500 } ); } - console.log(`[API:${requestId}] Parsing request body...`); - const body: GenerateRequest = await request.json(); - const { images, prompt, model = "nano-banana-pro", aspectRatio, resolution, useGoogleSearch } = body; - - console.log(`[API:${requestId}] Request parameters:`); - console.log(`[API:${requestId}] - Model: ${model} -> ${MODEL_MAP[model]}`); - console.log(`[API:${requestId}] - Images count: ${images?.length || 0}`); - console.log(`[API:${requestId}] - Prompt length: ${prompt?.length || 0} chars`); - console.log(`[API:${requestId}] - Aspect Ratio: ${aspectRatio || 'default'}`); - console.log(`[API:${requestId}] - Resolution: ${resolution || 'default'}`); - console.log(`[API:${requestId}] - Google Search: ${useGoogleSearch || false}`); - if (!images || images.length === 0 || !prompt) { console.error(`[API:${requestId}] ❌ Validation failed: missing images or prompt`); return NextResponse.json( @@ -53,6 +94,7 @@ export async function POST(request: NextRequest) { ); } + console.log(`[API:${requestId}] Model mapped to: ${GEMINI_MODEL_MAP[model]}`); console.log(`[API:${requestId}] Extracting image data...`); // Extract base64 data and MIME types from data URLs const imageData = images.map((image, idx) => { @@ -125,7 +167,7 @@ export async function POST(request: NextRequest) { const geminiStartTime = Date.now(); const response = await ai.models.generateContent({ - model: MODEL_MAP[model], + model: GEMINI_MODEL_MAP[model], contents: [ { role: "user", @@ -316,3 +358,287 @@ export async function POST(request: NextRequest) { ); } } + +// Azure FLUX.2-pro generation handler +async function handleAzureFluxGeneration( + requestId: string, + prompt: string, + aspectRatio?: string, + size?: AzureFluxSize +): Promise> { + console.log(`[API:${requestId}] Using Azure FLUX.2-pro model`); + + const azureApiKey = process.env.AZURE_API_KEY; + + if (!azureApiKey) { + console.error(`[API:${requestId}] ❌ No Azure API key configured`); + return NextResponse.json( + { + success: false, + error: "Azure API key not configured. Add AZURE_API_KEY to .env.local", + }, + { status: 500 } + ); + } + + if (!prompt) { + console.error(`[API:${requestId}] ❌ Validation failed: missing prompt`); + return NextResponse.json( + { + success: false, + error: "Prompt is required for Azure FLUX generation", + }, + { status: 400 } + ); + } + + // Determine size from aspect ratio if not explicitly provided + let imageSize: AzureFluxSize = size || "1024x1024"; + if (!size && aspectRatio) { + imageSize = ASPECT_RATIO_TO_SIZE[aspectRatio] || "1024x1024"; + } + + console.log(`[API:${requestId}] Azure FLUX parameters:`); + console.log(`[API:${requestId}] - Size: ${imageSize}`); + console.log(`[API:${requestId}] - Prompt: ${prompt.substring(0, 100)}...`); + + try { + console.log(`[API:${requestId}] Calling Azure FLUX API...`); + const azureStartTime = Date.now(); + + const response = await fetch(AZURE_FLUX_ENDPOINT, { + method: "POST", + headers: { + "Content-Type": "application/json", + "api-key": azureApiKey, + }, + body: JSON.stringify({ + prompt, + size: imageSize, + n: 1, + model: "FLUX.2-pro", + }), + }); + + const azureDuration = Date.now() - azureStartTime; + console.log(`[API:${requestId}] Azure FLUX API call completed in ${azureDuration}ms`); + + if (!response.ok) { + const errorText = await response.text(); + console.error(`[API:${requestId}] ❌ Azure API error: ${response.status} ${response.statusText}`); + console.error(`[API:${requestId}] Error body: ${errorText}`); + return NextResponse.json( + { + success: false, + error: `Azure FLUX API error: ${response.status} - ${errorText.substring(0, 200)}`, + }, + { status: response.status } + ); + } + + const data = await response.json(); + console.log(`[API:${requestId}] Azure response keys:`, Object.keys(data)); + + // Extract base64 image from response + if (data.data && data.data.length > 0 && data.data[0].b64_json) { + const base64Image = data.data[0].b64_json; + const imageSizeKB = (base64Image.length / 1024).toFixed(2); + console.log(`[API:${requestId}] ✓ Found image in response: ${imageSizeKB}KB base64`); + + const dataUrl = `data:image/png;base64,${base64Image}`; + const responsePayload = { success: true, image: dataUrl }; + const responseSize = JSON.stringify(responsePayload).length; + const responseSizeMB = (responseSize / (1024 * 1024)).toFixed(2); + console.log(`[API:${requestId}] Total response payload size: ${responseSizeMB}MB`); + + console.log(`[API:${requestId}] ✓✓✓ SUCCESS - Returning Azure FLUX image ✓✓✓`); + + const jsonResponse = NextResponse.json(responsePayload); + jsonResponse.headers.set('Content-Type', 'application/json'); + jsonResponse.headers.set('Content-Length', responseSize.toString()); + + return jsonResponse; + } + + // Check for URL-based response + if (data.data && data.data.length > 0 && data.data[0].url) { + console.log(`[API:${requestId}] Found URL in response, fetching image...`); + const imageUrl = data.data[0].url; + const imageResponse = await fetch(imageUrl); + const imageBuffer = await imageResponse.arrayBuffer(); + const base64Image = Buffer.from(imageBuffer).toString('base64'); + + const dataUrl = `data:image/png;base64,${base64Image}`; + const responsePayload = { success: true, image: dataUrl }; + + console.log(`[API:${requestId}] ✓✓✓ SUCCESS - Returning Azure FLUX image from URL ✓✓✓`); + return NextResponse.json(responsePayload); + } + + console.error(`[API:${requestId}] ❌ No image found in Azure response`); + console.error(`[API:${requestId}] Full response:`, JSON.stringify(data, null, 2)); + return NextResponse.json( + { + success: false, + error: "No image in Azure FLUX response", + }, + { status: 500 } + ); + } catch (error) { + console.error(`[API:${requestId}] ❌ Azure FLUX API exception:`, error); + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + return NextResponse.json( + { + success: false, + error: `Azure FLUX generation failed: ${errorMessage}`, + }, + { status: 500 } + ); + } +} + +// Azure GPT Image generation handler +async function handleAzureGptImageGeneration( + requestId: string, + prompt: string, + aspectRatio?: string, + gptImageSize?: AzureGptImageSize, + gptImageQuality?: AzureGptImageQuality +): Promise> { + console.log(`[API:${requestId}] Using Azure GPT Image model`); + + const azureApiKey = process.env.AZURE_GPT_IMAGE_API_KEY; + + if (!azureApiKey) { + console.error(`[API:${requestId}] ❌ No Azure GPT Image API key configured`); + return NextResponse.json( + { + success: false, + error: "Azure GPT Image API key not configured. Add AZURE_GPT_IMAGE_API_KEY to .env.local", + }, + { status: 500 } + ); + } + + if (!prompt) { + console.error(`[API:${requestId}] ❌ Validation failed: missing prompt`); + return NextResponse.json( + { + success: false, + error: "Prompt is required for Azure GPT Image generation", + }, + { status: 400 } + ); + } + + // Determine size from aspect ratio if not explicitly provided + let imageSize: AzureGptImageSize = gptImageSize || "1024x1024"; + if (!gptImageSize && aspectRatio) { + imageSize = ASPECT_RATIO_TO_GPT_SIZE[aspectRatio] || "1024x1024"; + } + + // Default quality to medium if not specified + const quality = gptImageQuality || "medium"; + + console.log(`[API:${requestId}] Azure GPT Image parameters:`); + console.log(`[API:${requestId}] - Size: ${imageSize}`); + console.log(`[API:${requestId}] - Quality: ${quality}`); + console.log(`[API:${requestId}] - Prompt: ${prompt.substring(0, 100)}...`); + + try { + console.log(`[API:${requestId}] Calling Azure GPT Image API...`); + const azureStartTime = Date.now(); + + const response = await fetch(AZURE_GPT_IMAGE_ENDPOINT, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${azureApiKey}`, + }, + body: JSON.stringify({ + prompt, + size: imageSize, + quality, + output_compression: 100, + output_format: "png", + n: 1, + model: "gpt-image-1.5", + }), + }); + + const azureDuration = Date.now() - azureStartTime; + console.log(`[API:${requestId}] Azure GPT Image API call completed in ${azureDuration}ms`); + + if (!response.ok) { + const errorText = await response.text(); + console.error(`[API:${requestId}] ❌ Azure GPT Image API error: ${response.status} ${response.statusText}`); + console.error(`[API:${requestId}] Error body: ${errorText}`); + return NextResponse.json( + { + success: false, + error: `Azure GPT Image API error: ${response.status} - ${errorText.substring(0, 200)}`, + }, + { status: response.status } + ); + } + + const data = await response.json(); + console.log(`[API:${requestId}] Azure GPT Image response keys:`, Object.keys(data)); + + // Extract base64 image from response + if (data.data && data.data.length > 0 && data.data[0].b64_json) { + const base64Image = data.data[0].b64_json; + const imageSizeKB = (base64Image.length / 1024).toFixed(2); + console.log(`[API:${requestId}] ✓ Found image in response: ${imageSizeKB}KB base64`); + + const dataUrl = `data:image/png;base64,${base64Image}`; + const responsePayload = { success: true, image: dataUrl }; + const responseSize = JSON.stringify(responsePayload).length; + const responseSizeMB = (responseSize / (1024 * 1024)).toFixed(2); + console.log(`[API:${requestId}] Total response payload size: ${responseSizeMB}MB`); + + console.log(`[API:${requestId}] ✓✓✓ SUCCESS - Returning Azure GPT Image ✓✓✓`); + + const jsonResponse = NextResponse.json(responsePayload); + jsonResponse.headers.set('Content-Type', 'application/json'); + jsonResponse.headers.set('Content-Length', responseSize.toString()); + + return jsonResponse; + } + + // Check for URL-based response + if (data.data && data.data.length > 0 && data.data[0].url) { + console.log(`[API:${requestId}] Found URL in response, fetching image...`); + const imageUrl = data.data[0].url; + const imageResponse = await fetch(imageUrl); + const imageBuffer = await imageResponse.arrayBuffer(); + const base64Image = Buffer.from(imageBuffer).toString('base64'); + + const dataUrl = `data:image/png;base64,${base64Image}`; + const responsePayload = { success: true, image: dataUrl }; + + console.log(`[API:${requestId}] ✓✓✓ SUCCESS - Returning Azure GPT Image from URL ✓✓✓`); + return NextResponse.json(responsePayload); + } + + console.error(`[API:${requestId}] ❌ No image found in Azure GPT Image response`); + console.error(`[API:${requestId}] Full response:`, JSON.stringify(data, null, 2)); + return NextResponse.json( + { + success: false, + error: "No image in Azure GPT Image response", + }, + { status: 500 } + ); + } catch (error) { + console.error(`[API:${requestId}] ❌ Azure GPT Image API exception:`, error); + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + return NextResponse.json( + { + success: false, + error: `Azure GPT Image generation failed: ${errorMessage}`, + }, + { status: 500 } + ); + } +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 0d0dd08..ad13f47 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -6,6 +6,7 @@ import { Header } from "@/components/Header"; import { WorkflowCanvas } from "@/components/WorkflowCanvas"; import { FloatingActionBar } from "@/components/FloatingActionBar"; import { AnnotationModal } from "@/components/AnnotationModal"; +import { CropperModal } from "@/components/CropperModal"; import { useWorkflowStore } from "@/store/workflowStore"; export default function Home() { @@ -26,6 +27,7 @@ export default function Home() { + ); diff --git a/src/components/CropperModal.tsx b/src/components/CropperModal.tsx new file mode 100644 index 0000000..b44cb37 --- /dev/null +++ b/src/components/CropperModal.tsx @@ -0,0 +1,753 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { Stage, Layer, Image as KonvaImage, Rect, Line, Text, Transformer } from "react-konva"; +import Konva from "konva"; +import { useCropperStore, extractRegions, CropRegion } from "@/store/cropperStore"; +import { useWorkflowStore } from "@/store/workflowStore"; +import { + detectGrid, + detectGridWithDimensions, + splitImage, + GridDetectionResult, + GridCell, + getGridCandidates, +} from "@/utils/gridSplitter"; +import { downloadImage, downloadImages, downloadImagesAsZip } from "@/utils/downloadImage"; + +const GRID_PRESETS = [ + { rows: 2, cols: 2, label: "2×2" }, + { rows: 2, cols: 3, label: "2×3" }, + { rows: 3, cols: 2, label: "3×2" }, + { rows: 3, cols: 3, label: "3×3" }, + { rows: 4, cols: 4, label: "4×4" }, +]; + +const CELL_COLORS = { + default: "rgba(59, 130, 246, 0.3)", // blue with transparency + hover: "rgba(59, 130, 246, 0.5)", + selected: "rgba(34, 197, 94, 0.4)", // green for selected + stroke: "rgba(255, 255, 255, 0.8)", + strokeSelected: "rgba(34, 197, 94, 1)", +}; + +export function CropperModal() { + const { + isOpen, + sourceImage, + sourceNodeId, + gridResult, + selectedCells, + customRows, + customCols, + cropMode, + cropRegions, + currentRegion, + selectedRegionId, + closeModal, + setImageDimensions, + setGridResult, + setCustomGrid, + toggleCellSelection, + selectAllCells, + clearCellSelection, + setCropMode, + startRegion, + updateRegion, + finishRegion, + deleteRegion, + selectRegion, + clearRegions, + reset, + } = useCropperStore(); + + const addNode = useWorkflowStore((state) => state.addNode); + const updateNodeData = useWorkflowStore((state) => state.updateNodeData); + const addToGlobalHistory = useWorkflowStore((state) => state.addToGlobalHistory); + + const stageRef = useRef(null); + const transformerRef = useRef(null); + const containerRef = useRef(null); + + const [image, setImage] = useState(null); + const [stageSize, setStageSize] = useState({ width: 800, height: 600 }); + const [scale, setScale] = useState(1); + const [position, setPosition] = useState({ x: 0, y: 0 }); + const [hoveredCell, setHoveredCell] = useState(null); + const [isDrawing, setIsDrawing] = useState(false); + const [isAutoDetecting, setIsAutoDetecting] = useState(false); + const [showExportMenu, setShowExportMenu] = useState(false); + const [suggestedGrids, setSuggestedGrids] = useState<{ rows: number; cols: number; score: number }[]>([]); + + // Load image when modal opens + useEffect(() => { + if (sourceImage && isOpen) { + const img = new window.Image(); + img.crossOrigin = "anonymous"; + img.onload = () => { + setImage(img); + setImageDimensions({ width: img.width, height: img.height }); + + if (containerRef.current) { + const containerWidth = containerRef.current.clientWidth - 48; + const containerHeight = containerRef.current.clientHeight - 48; + const scaleX = containerWidth / img.width; + const scaleY = containerHeight / img.height; + const newScale = Math.min(scaleX, scaleY, 1); + setScale(newScale); + setStageSize({ width: img.width, height: img.height }); + setPosition({ + x: (containerWidth - img.width * newScale) / 2 + 24, + y: (containerHeight - img.height * newScale) / 2 + 24, + }); + } + + // Get grid candidates for suggestions + const candidates = getGridCandidates(img.width, img.height); + setSuggestedGrids( + candidates.slice(0, 5).map((c) => ({ + rows: c.rows, + cols: c.cols, + score: c.score, + })) + ); + }; + img.src = sourceImage; + } + }, [sourceImage, isOpen, setImageDimensions]); + + // Update transformer when selection changes in freeform mode + useEffect(() => { + if (transformerRef.current && stageRef.current && cropMode === "freeform") { + const selectedNode = stageRef.current.findOne(`#${selectedRegionId}`); + if (selectedNode) { + transformerRef.current.nodes([selectedNode]); + } else { + transformerRef.current.nodes([]); + } + transformerRef.current.getLayer()?.batchDraw(); + } + }, [selectedRegionId, cropMode]); + + // Keyboard shortcuts + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (!isOpen) return; + + if (e.key === "Escape") { + closeModal(); + } + if (e.key === "Delete" || e.key === "Backspace") { + if (selectedRegionId) { + deleteRegion(selectedRegionId); + } + } + if ((e.ctrlKey || e.metaKey) && e.key === "a") { + e.preventDefault(); + if (cropMode === "grid" && gridResult) { + selectAllCells(); + } + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [isOpen, selectedRegionId, cropMode, gridResult, closeModal, deleteRegion, selectAllCells]); + + const getRelativePointerPosition = useCallback(() => { + const stage = stageRef.current; + if (!stage) return { x: 0, y: 0 }; + const transform = stage.getAbsoluteTransform().copy().invert(); + const pos = stage.getPointerPosition(); + if (!pos) return { x: 0, y: 0 }; + return transform.point(pos); + }, []); + + const handleAutoDetect = useCallback(async () => { + if (!sourceImage) return; + + setIsAutoDetecting(true); + try { + const result = await detectGrid(sourceImage); + setGridResult(result); + } catch (error) { + console.error("Grid detection failed:", error); + } finally { + setIsAutoDetecting(false); + } + }, [sourceImage, setGridResult]); + + const handleApplyGrid = useCallback(async (rows: number, cols: number) => { + if (!sourceImage) return; + + try { + const result = await detectGridWithDimensions(sourceImage, rows, cols); + setGridResult(result); + setCustomGrid(rows, cols); + } catch (error) { + console.error("Grid creation failed:", error); + } + }, [sourceImage, setGridResult, setCustomGrid]); + + const handleMouseDown = useCallback( + (e: Konva.KonvaEventObject) => { + if (cropMode !== "freeform") return; + + const clickedOnEmpty = + e.target === e.target.getStage() || e.target.getClassName() === "Image"; + + if (clickedOnEmpty) { + selectRegion(null); + const pos = getRelativePointerPosition(); + startRegion(pos.x, pos.y); + setIsDrawing(true); + } + }, + [cropMode, getRelativePointerPosition, startRegion, selectRegion] + ); + + const handleMouseMove = useCallback(() => { + if (!isDrawing || cropMode !== "freeform") return; + const pos = getRelativePointerPosition(); + updateRegion(pos.x, pos.y); + }, [isDrawing, cropMode, getRelativePointerPosition, updateRegion]); + + const handleMouseUp = useCallback(() => { + if (!isDrawing || cropMode !== "freeform") return; + finishRegion(); + setIsDrawing(false); + }, [isDrawing, cropMode, finishRegion]); + + const handleWheel = useCallback( + (e: Konva.KonvaEventObject) => { + e.evt.preventDefault(); + const scaleBy = 1.1; + const oldScale = scale; + const newScale = e.evt.deltaY > 0 ? oldScale / scaleBy : oldScale * scaleBy; + setScale(Math.min(Math.max(newScale, 0.1), 5)); + }, + [scale] + ); + + const handleExtract = useCallback(async (action: "download" | "download-zip" | "add-nodes" | "add-history") => { + if (!sourceImage) return; + + let imagesToExtract: string[] = []; + + if (cropMode === "grid" && gridResult) { + // Get selected cells + const selectedCellList: GridCell[] = []; + gridResult.cells.forEach((cell, index) => { + if (selectedCells.has(index)) { + selectedCellList.push(cell); + } + }); + + if (selectedCellList.length === 0) { + alert("Please select at least one cell"); + return; + } + + // Split the image + const splitResult = await splitImage(sourceImage, { + ...gridResult, + cells: selectedCellList, + }); + imagesToExtract = splitResult; + } else if (cropMode === "freeform") { + if (cropRegions.length === 0) { + alert("Please draw at least one crop region"); + return; + } + + imagesToExtract = await extractRegions(sourceImage, cropRegions); + } + + // Perform the action + switch (action) { + case "download": + downloadImages(imagesToExtract, "cropped"); + break; + case "download-zip": + await downloadImagesAsZip(imagesToExtract, "cropped-images.zip"); + break; + case "add-nodes": + // Create ImageInput nodes for each extracted image + imagesToExtract.forEach((img, index) => { + const nodeId = addNode("imageInput", { + x: 100 + index * 50, + y: 100 + index * 50, + }); + // Load the image to get dimensions + const tempImg = new Image(); + tempImg.onload = () => { + updateNodeData(nodeId, { + image: img, + filename: `cropped-${index + 1}.png`, + dimensions: { width: tempImg.width, height: tempImg.height }, + }); + }; + tempImg.src = img; + }); + closeModal(); + break; + case "add-history": + imagesToExtract.forEach((img) => { + addToGlobalHistory({ + image: img, + timestamp: Date.now(), + prompt: "Cropped from grid", + aspectRatio: "1:1", + model: "nano-banana", + }); + }); + closeModal(); + break; + } + + setShowExportMenu(false); + }, [ + sourceImage, + cropMode, + gridResult, + selectedCells, + cropRegions, + addNode, + updateNodeData, + addToGlobalHistory, + closeModal, + ]); + + const handleDownloadOriginal = useCallback(() => { + if (!sourceImage) return; + downloadImage(sourceImage, { filename: `original-${Date.now()}.png` }); + }, [sourceImage]); + + const renderGridCells = () => { + if (!gridResult) return null; + + return gridResult.cells.map((cell, index) => { + const isSelected = selectedCells.has(index); + const isHovered = hoveredCell === index; + + return ( + toggleCellSelection(index)} + onMouseEnter={() => setHoveredCell(index)} + onMouseLeave={() => setHoveredCell(null)} + /> + ); + }); + }; + + const renderGridLabels = () => { + if (!gridResult) return null; + + return gridResult.cells.map((cell, index) => { + const isSelected = selectedCells.has(index); + + return ( + + ); + }); + }; + + const renderCropRegions = () => { + return cropRegions.map((region) => { + const isSelected = selectedRegionId === region.id; + + return ( + selectRegion(region.id)} + onDragEnd={(e) => { + const { cropRegions } = useCropperStore.getState(); + const updated = cropRegions.map((r) => + r.id === region.id ? { ...r, x: e.target.x(), y: e.target.y() } : r + ); + useCropperStore.setState({ cropRegions: updated }); + }} + /> + ); + }); + }; + + const renderCurrentRegion = () => { + if (!currentRegion) return null; + + return ( + + ); + }; + + if (!isOpen) return null; + + const selectedCount = cropMode === "grid" ? selectedCells.size : cropRegions.length; + + return ( +
+ {/* Top Bar */} +
+
+ {/* Mode Toggle */} +
+ + +
+ +
+ + {/* Grid Controls */} + {cropMode === "grid" && ( + <> + + + {GRID_PRESETS.map((preset) => ( + + ))} + + {/* Custom Grid Input */} +
+ setCustomGrid(parseInt(e.target.value) || 1, customCols)} + className="w-10 px-1.5 py-1 bg-neutral-800 border border-neutral-700 rounded text-center text-neutral-200" + /> + × + setCustomGrid(customRows, parseInt(e.target.value) || 1)} + className="w-10 px-1.5 py-1 bg-neutral-800 border border-neutral-700 rounded text-center text-neutral-200" + /> + +
+ + {gridResult && ( + <> +
+ + + + )} + + )} + + {/* Freeform Controls */} + {cropMode === "freeform" && ( + <> + + Click and drag to create crop regions + + {cropRegions.length > 0 && ( + <> +
+ + + )} + + )} +
+ +
+ {/* Selection info */} + {selectedCount > 0 && ( + + {selectedCount} {cropMode === "grid" ? "cell" : "region"}{selectedCount !== 1 ? "s" : ""} selected + + )} + + {/* Download Original */} + + + + + {/* Export Menu */} +
+ + + {showExportMenu && selectedCount > 0 && ( +
+ + +
+ + +
+ )} +
+
+
+ + {/* Canvas Container */} +
+ { + if (e.target === stageRef.current) { + setPosition({ x: e.target.x(), y: e.target.y() }); + } + }} + onMouseDown={handleMouseDown} + onMouseMove={handleMouseMove} + onMouseUp={handleMouseUp} + onMouseLeave={handleMouseUp} + onWheel={handleWheel} + > + + {image && ( + + )} + + {/* Grid overlay */} + {cropMode === "grid" && renderGridCells()} + {cropMode === "grid" && renderGridLabels()} + + {/* Freeform crop regions */} + {cropMode === "freeform" && renderCropRegions()} + {cropMode === "freeform" && renderCurrentRegion()} + + {/* Transformer for freeform mode */} + {cropMode === "freeform" && } + + +
+ + {/* Bottom Bar */} +
+
+ {/* Suggested grids */} + {cropMode === "grid" && suggestedGrids.length > 0 && ( +
+ Suggested: + {suggestedGrids.slice(0, 3).map((sg, index) => ( + + ))} +
+ )} + + {/* Grid info */} + {cropMode === "grid" && gridResult && ( + + {gridResult.rows}×{gridResult.cols} grid • {gridResult.cells.length} cells • {(gridResult.confidence * 100).toFixed(0)}% confidence + + )} + + {/* Freeform info */} + {cropMode === "freeform" && ( + + {cropRegions.length} region{cropRegions.length !== 1 ? "s" : ""} • Press Delete to remove selected + + )} +
+ + {/* Zoom controls */} +
+ + {Math.round(scale * 100)}% + + +
+
+ + {/* Click outside to close export menu */} + {showExportMenu && ( +
setShowExportMenu(false)} + /> + )} +
+ ); +} diff --git a/src/components/GlobalImageHistory.tsx b/src/components/GlobalImageHistory.tsx index f2bc01f..cd5b45d 100644 --- a/src/components/GlobalImageHistory.tsx +++ b/src/components/GlobalImageHistory.tsx @@ -3,6 +3,8 @@ import { useState, useRef, useEffect, useCallback } from "react"; import { createPortal } from "react-dom"; import { useWorkflowStore } from "@/store/workflowStore"; +import { useCropperStore } from "@/store/cropperStore"; +import { downloadImage } from "@/utils/downloadImage"; import { ImageHistoryItem } from "@/types"; // Helper function for relative time display @@ -78,15 +80,20 @@ function HistorySidebar({ onClear, onClose, onDragStart, + onDownload, + onOpenCropper, triggerRect, }: { history: ImageHistoryItem[]; onClear: () => void; onClose: () => void; onDragStart: (e: React.DragEvent, item: ImageHistoryItem) => void; + onDownload: (item: ImageHistoryItem) => void; + onOpenCropper: (item: ImageHistoryItem) => void; triggerRect: DOMRect | null; }) { const sidebarRef = useRef(null); + const [contextMenu, setContextMenu] = useState<{ x: number; y: number; item: ImageHistoryItem } | null>(null); // Close on click outside useEffect(() => { @@ -105,11 +112,22 @@ function HistorySidebar({ // Close on Escape useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") onClose(); + if (e.key === "Escape") { + if (contextMenu) { + setContextMenu(null); + } else { + onClose(); + } + } }; document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); - }, [onClose]); + }, [onClose, contextMenu]); + + const handleContextMenu = useCallback((e: React.MouseEvent, item: ImageHistoryItem) => { + e.preventDefault(); + setContextMenu({ x: e.clientX, y: e.clientY, item }); + }, []); // Position the sidebar near the trigger, but ensure it stays on screen const sidebarStyle: React.CSSProperties = { @@ -167,16 +185,38 @@ function HistorySidebar({ key={item.id} draggable onDragStart={(e) => onDragStart(e, item)} + onContextMenu={(e) => handleContextMenu(e, item)} className="flex gap-3 p-2 rounded-lg hover:bg-neutral-700/50 cursor-grab active:cursor-grabbing group transition-colors" > {/* Thumbnail */} -
+
{`History + {/* Quick action buttons on hover */} +
+ + +
{/* Info */} @@ -192,6 +232,39 @@ function HistorySidebar({ ))}
+ {/* Context Menu */} + {contextMenu && ( + <> +
setContextMenu(null)} + /> +
+ + +
+ + )} + {/* Footer */}
Drag images to canvas to create nodes @@ -209,6 +282,7 @@ export function GlobalImageHistory() { const history = useWorkflowStore((state) => state.globalImageHistory); const clearGlobalHistory = useWorkflowStore((state) => state.clearGlobalHistory); + const openCropper = useCropperStore((state) => state.openModal); // Show max 10 items in fan const fanItems = history.slice(0, 10); @@ -286,6 +360,18 @@ export function GlobalImageHistory() { setShowSidebar(false); }, [clearGlobalHistory]); + const handleDownload = useCallback((item: ImageHistoryItem) => { + downloadImage(item.image, { + filename: `generated-${Date.now()}.png`, + }); + }, []); + + const handleOpenCropper = useCallback((item: ImageHistoryItem) => { + openCropper(item.image); + setShowSidebar(false); + setIsOpen(false); + }, [openCropper]); + if (history.length === 0) return null; return ( @@ -368,6 +454,8 @@ export function GlobalImageHistory() { onClear={handleClear} onClose={handleCloseSidebar} onDragStart={handleDragStart} + onDownload={handleDownload} + onOpenCropper={handleOpenCropper} triggerRect={triggerRef.current?.getBoundingClientRect() || null} /> )} diff --git a/src/components/nodes/ImageInputNode.tsx b/src/components/nodes/ImageInputNode.tsx index d4b571d..8e39dbf 100644 --- a/src/components/nodes/ImageInputNode.tsx +++ b/src/components/nodes/ImageInputNode.tsx @@ -4,6 +4,8 @@ import { useCallback, useRef } from "react"; import { Handle, Position, NodeProps, Node } from "@xyflow/react"; import { BaseNode } from "./BaseNode"; import { useWorkflowStore } from "@/store/workflowStore"; +import { useCropperStore } from "@/store/cropperStore"; +import { downloadImage } from "@/utils/downloadImage"; import { ImageInputNodeData } from "@/types"; type ImageInputNodeType = Node; @@ -11,6 +13,7 @@ type ImageInputNodeType = Node; export function ImageInputNode({ id, data, selected }: NodeProps) { const nodeData = data; const updateNodeData = useWorkflowStore((state) => state.updateNodeData); + const openCropper = useCropperStore((state) => state.openModal); const fileInputRef = useRef(null); const handleFileChange = useCallback( @@ -77,6 +80,18 @@ export function ImageInputNode({ id, data, selected }: NodeProps { + if (!nodeData.image) return; + downloadImage(nodeData.image, { + filename: nodeData.filename || `image-${Date.now()}.png`, + }); + }, [nodeData.image, nodeData.filename]); + + const handleOpenCropper = useCallback(() => { + if (!nodeData.image) return; + openCropper(nodeData.image, id); + }, [nodeData.image, id, openCropper]); + return ( - + {/* Action buttons overlay */} +
+ + + +
{nodeData.filename} diff --git a/src/components/nodes/NanoBananaNode.tsx b/src/components/nodes/NanoBananaNode.tsx index 412fe48..f00a3a5 100644 --- a/src/components/nodes/NanoBananaNode.tsx +++ b/src/components/nodes/NanoBananaNode.tsx @@ -4,7 +4,9 @@ import { useCallback } from "react"; import { Handle, Position, NodeProps, Node } from "@xyflow/react"; import { BaseNode } from "./BaseNode"; import { useWorkflowStore } from "@/store/workflowStore"; -import { NanoBananaNodeData, AspectRatio, Resolution, ModelType } from "@/types"; +import { useCropperStore } from "@/store/cropperStore"; +import { downloadImage } from "@/utils/downloadImage"; +import { NanoBananaNodeData, AspectRatio, Resolution, ModelType, AzureFluxSize, AzureGptImageSize, AzureGptImageQuality } from "@/types"; // All 10 aspect ratios supported by both models const ASPECT_RATIOS: AspectRatio[] = ["1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9"]; @@ -12,9 +14,35 @@ const ASPECT_RATIOS: AspectRatio[] = ["1:1", "2:3", "3:2", "3:4", "4:3", "4:5", // Resolutions only for Nano Banana Pro (gemini-3-pro-image-preview) const RESOLUTIONS: Resolution[] = ["1K", "2K", "4K"]; +// Size options for Azure FLUX model +const AZURE_FLUX_SIZES: { value: AzureFluxSize; label: string }[] = [ + { value: "1024x1024", label: "1024×1024" }, + { value: "1024x768", label: "1024×768" }, + { value: "768x1024", label: "768×1024" }, + { value: "1536x1024", label: "1536×1024" }, + { value: "1024x1536", label: "1024×1536" }, +]; + +// Size options for Azure GPT Image model +const AZURE_GPT_IMAGE_SIZES: { value: AzureGptImageSize; label: string }[] = [ + { value: "1024x1024", label: "1024×1024" }, + { value: "1536x1024", label: "1536×1024" }, + { value: "1024x1536", label: "1024×1536" }, + { value: "auto", label: "Auto" }, +]; + +// Quality options for Azure GPT Image model +const AZURE_GPT_IMAGE_QUALITIES: { value: AzureGptImageQuality; label: string }[] = [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High" }, +]; + const MODELS: { value: ModelType; label: string }[] = [ { value: "nano-banana", label: "Nano Banana" }, { value: "nano-banana-pro", label: "Nano Banana Pro" }, + { value: "azure-flux-pro", label: "Azure FLUX.2 Pro" }, + { value: "azure-gpt-image", label: "Azure GPT Image" }, ]; type NanoBananaNodeType = Node; @@ -22,6 +50,7 @@ type NanoBananaNodeType = Node; export function NanoBananaNode({ id, data, selected }: NodeProps) { const nodeData = data; const updateNodeData = useWorkflowStore((state) => state.updateNodeData); + const openCropper = useCropperStore((state) => state.openModal); const handleAspectRatioChange = useCallback( (e: React.ChangeEvent) => { @@ -51,10 +80,43 @@ export function NanoBananaNode({ id, data, selected }: NodeProps) => { + updateNodeData(id, { size: e.target.value as AzureFluxSize }); + }, + [id, updateNodeData] + ); + + const handleGptImageSizeChange = useCallback( + (e: React.ChangeEvent) => { + updateNodeData(id, { gptImageSize: e.target.value as AzureGptImageSize }); + }, + [id, updateNodeData] + ); + + const handleGptImageQualityChange = useCallback( + (e: React.ChangeEvent) => { + updateNodeData(id, { gptImageQuality: e.target.value as AzureGptImageQuality }); + }, + [id, updateNodeData] + ); + const handleClearImage = useCallback(() => { updateNodeData(id, { outputImage: null, status: "idle", error: null }); }, [id, updateNodeData]); + const handleDownload = useCallback(() => { + if (!nodeData.outputImage) return; + downloadImage(nodeData.outputImage, { + filename: `generated-${Date.now()}.png`, + }); + }, [nodeData.outputImage]); + + const handleOpenCropper = useCallback(() => { + if (!nodeData.outputImage) return; + openCropper(nodeData.outputImage, id); + }, [nodeData.outputImage, id, openCropper]); + const regenerateNode = useWorkflowStore((state) => state.regenerateNode); const isRunning = useWorkflowStore((state) => state.isRunning); @@ -63,6 +125,9 @@ export function NanoBananaNode({ id, data, selected }: NodeProps )}
+ +
+ {/* Quality selector - only for Azure GPT Image */} + {isAzureGptImage && ( + + )} + {/* Google Search toggle - only for Nano Banana Pro */} {isNanoBananaPro && (
) : (
diff --git a/src/store/cropperStore.ts b/src/store/cropperStore.ts new file mode 100644 index 0000000..09d9c81 --- /dev/null +++ b/src/store/cropperStore.ts @@ -0,0 +1,297 @@ +import { create } from "zustand"; +import { GridDetectionResult, GridCell } from "@/utils/gridSplitter"; + +export type CropMode = "grid" | "freeform"; + +export interface CropRegion { + id: string; + x: number; + y: number; + width: number; + height: number; +} + +interface CropperStore { + // Modal state + isOpen: boolean; + sourceImage: string | null; + sourceNodeId: string | null; + imageDimensions: { width: number; height: number } | null; + + // Grid mode state + gridResult: GridDetectionResult | null; + selectedCells: Set; // indices of selected cells + customRows: number; + customCols: number; + + // Freeform mode state + cropMode: CropMode; + cropRegions: CropRegion[]; + currentRegion: CropRegion | null; // region being drawn + selectedRegionId: string | null; + + // Extracted results + extractedImages: string[]; + + // Actions + openModal: (sourceImage: string, sourceNodeId?: string) => void; + closeModal: () => void; + setImageDimensions: (dimensions: { width: number; height: number }) => void; + + // Grid actions + setGridResult: (result: GridDetectionResult | null) => void; + setCustomGrid: (rows: number, cols: number) => void; + toggleCellSelection: (index: number) => void; + selectAllCells: () => void; + clearCellSelection: () => void; + + // Freeform actions + setCropMode: (mode: CropMode) => void; + startRegion: (x: number, y: number) => void; + updateRegion: (x: number, y: number) => void; + finishRegion: () => void; + deleteRegion: (id: string) => void; + selectRegion: (id: string | null) => void; + clearRegions: () => void; + + // Results + setExtractedImages: (images: string[]) => void; + clearExtractedImages: () => void; + reset: () => void; +} + +const initialState = { + isOpen: false, + sourceImage: null, + sourceNodeId: null, + imageDimensions: null, + gridResult: null, + selectedCells: new Set(), + customRows: 2, + customCols: 2, + cropMode: "grid" as CropMode, + cropRegions: [] as CropRegion[], + currentRegion: null, + selectedRegionId: null, + extractedImages: [] as string[], +}; + +export const useCropperStore = create((set, get) => ({ + ...initialState, + + openModal: (sourceImage: string, sourceNodeId?: string) => { + set({ + isOpen: true, + sourceImage, + sourceNodeId: sourceNodeId || null, + // Reset other state + gridResult: null, + selectedCells: new Set(), + cropRegions: [], + currentRegion: null, + selectedRegionId: null, + extractedImages: [], + }); + }, + + closeModal: () => { + set({ isOpen: false }); + }, + + setImageDimensions: (dimensions) => { + set({ imageDimensions: dimensions }); + }, + + setGridResult: (result) => { + set({ gridResult: result }); + // Auto-select all cells when grid is set + if (result) { + const allCells = new Set(); + for (let i = 0; i < result.cells.length; i++) { + allCells.add(i); + } + set({ selectedCells: allCells }); + } + }, + + setCustomGrid: (rows, cols) => { + set({ customRows: rows, customCols: cols }); + }, + + toggleCellSelection: (index) => { + const { selectedCells } = get(); + const newSelection = new Set(selectedCells); + if (newSelection.has(index)) { + newSelection.delete(index); + } else { + newSelection.add(index); + } + set({ selectedCells: newSelection }); + }, + + selectAllCells: () => { + const { gridResult } = get(); + if (!gridResult) return; + const allCells = new Set(); + for (let i = 0; i < gridResult.cells.length; i++) { + allCells.add(i); + } + set({ selectedCells: allCells }); + }, + + clearCellSelection: () => { + set({ selectedCells: new Set() }); + }, + + setCropMode: (mode) => { + set({ cropMode: mode }); + }, + + startRegion: (x, y) => { + const id = `region-${Date.now()}`; + set({ + currentRegion: { id, x, y, width: 0, height: 0 }, + }); + }, + + updateRegion: (x, y) => { + const { currentRegion } = get(); + if (!currentRegion) return; + + const width = x - currentRegion.x; + const height = y - currentRegion.y; + + set({ + currentRegion: { + ...currentRegion, + width, + height, + }, + }); + }, + + finishRegion: () => { + const { currentRegion, cropRegions } = get(); + if (!currentRegion) return; + + // Normalize negative width/height + let { x, y, width, height } = currentRegion; + if (width < 0) { + x += width; + width = Math.abs(width); + } + if (height < 0) { + y += height; + height = Math.abs(height); + } + + // Only add if region is meaningful (at least 10x10 pixels) + if (width >= 10 && height >= 10) { + const normalizedRegion: CropRegion = { + ...currentRegion, + x, + y, + width, + height, + }; + set({ + cropRegions: [...cropRegions, normalizedRegion], + currentRegion: null, + }); + } else { + set({ currentRegion: null }); + } + }, + + deleteRegion: (id) => { + set((state) => ({ + cropRegions: state.cropRegions.filter((r) => r.id !== id), + selectedRegionId: state.selectedRegionId === id ? null : state.selectedRegionId, + })); + }, + + selectRegion: (id) => { + set({ selectedRegionId: id }); + }, + + clearRegions: () => { + set({ cropRegions: [], currentRegion: null, selectedRegionId: null }); + }, + + setExtractedImages: (images) => { + set({ extractedImages: images }); + }, + + clearExtractedImages: () => { + set({ extractedImages: [] }); + }, + + reset: () => { + set(initialState); + }, +})); + +/** + * Helper function to extract a crop region from an image + */ +export async function extractRegion( + imageDataUrl: string, + region: CropRegion | GridCell +): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + img.crossOrigin = "anonymous"; + + img.onload = () => { + try { + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + + if (!ctx) { + reject(new Error("Could not get canvas context")); + return; + } + + canvas.width = region.width; + canvas.height = region.height; + + ctx.drawImage( + img, + region.x, + region.y, + region.width, + region.height, + 0, + 0, + region.width, + region.height + ); + + resolve(canvas.toDataURL("image/png")); + } catch (error) { + reject(error); + } + }; + + img.onerror = () => { + reject(new Error("Failed to load image")); + }; + + img.src = imageDataUrl; + }); +} + +/** + * Helper function to extract multiple regions from an image + */ +export async function extractRegions( + imageDataUrl: string, + regions: (CropRegion | GridCell)[] +): Promise { + const results: string[] = []; + for (const region of regions) { + const extracted = await extractRegion(imageDataUrl, region); + results.push(extracted); + } + return results; +} diff --git a/src/store/workflowStore.ts b/src/store/workflowStore.ts index 51a95ea..d7e02ab 100644 --- a/src/store/workflowStore.ts +++ b/src/store/workflowStore.ts @@ -152,6 +152,9 @@ const createDefaultNodeData = (type: NodeType): WorkflowNodeData => { resolution: "1K", model: "nano-banana-pro", useGoogleSearch: false, + size: "1024x1024", + gptImageSize: "1024x1024", + gptImageQuality: "medium", status: "idle", error: null, } as NanoBananaNodeData; @@ -772,6 +775,9 @@ export const useWorkflowStore = create((set, get) => ({ resolution: nodeData.resolution, model: nodeData.model, useGoogleSearch: nodeData.useGoogleSearch, + size: nodeData.size, + gptImageSize: nodeData.gptImageSize, + gptImageQuality: nodeData.gptImageQuality, }; const response = await fetch("/api/generate", { @@ -1006,6 +1012,9 @@ export const useWorkflowStore = create((set, get) => ({ resolution: nodeData.resolution, model: nodeData.model, useGoogleSearch: nodeData.useGoogleSearch, + size: nodeData.size, + gptImageSize: nodeData.gptImageSize, + gptImageQuality: nodeData.gptImageQuality, }), }); diff --git a/src/types/index.ts b/src/types/index.ts index faaea67..8c96b80 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -16,7 +16,16 @@ export type AspectRatio = "1:1" | "2:3" | "3:2" | "3:4" | "4:3" | "4:5" | "5:4" export type Resolution = "1K" | "2K" | "4K"; // Image Generation Model Options -export type ModelType = "nano-banana" | "nano-banana-pro"; +export type ModelType = "nano-banana" | "nano-banana-pro" | "azure-flux-pro" | "azure-gpt-image"; + +// Size options for Azure FLUX model +export type AzureFluxSize = "1024x1024" | "1024x768" | "768x1024" | "1536x1024" | "1024x1536"; + +// Size options for Azure GPT Image model +export type AzureGptImageSize = "1024x1024" | "1536x1024" | "1024x1536" | "auto"; + +// Quality options for Azure GPT Image model +export type AzureGptImageQuality = "low" | "medium" | "high"; // LLM Provider Options export type LLMProvider = "google" | "openai"; @@ -125,6 +134,9 @@ export interface NanoBananaNodeData extends BaseNodeData { resolution: Resolution; // Only used by Nano Banana Pro model: ModelType; useGoogleSearch: boolean; // Only available for Nano Banana Pro + size: AzureFluxSize; // Only used by Azure FLUX + gptImageSize: AzureGptImageSize; // Only used by Azure GPT Image + gptImageQuality: AzureGptImageQuality; // Only used by Azure GPT Image status: NodeStatus; error: string | null; } @@ -179,6 +191,9 @@ export interface GenerateRequest { resolution?: Resolution; // Only for Nano Banana Pro model?: ModelType; useGoogleSearch?: boolean; // Only for Nano Banana Pro + size?: AzureFluxSize; // Only for Azure FLUX model + gptImageSize?: AzureGptImageSize; // Only for Azure GPT Image model + gptImageQuality?: AzureGptImageQuality; // Only for Azure GPT Image model } export interface GenerateResponse { diff --git a/src/utils/downloadImage.ts b/src/utils/downloadImage.ts new file mode 100644 index 0000000..b182842 --- /dev/null +++ b/src/utils/downloadImage.ts @@ -0,0 +1,87 @@ +/** + * Download Image Utility + * + * Reusable function for downloading images from base64 data URLs. + */ + +export interface DownloadOptions { + filename?: string; + format?: "png" | "jpg" | "webp"; +} + +/** + * Downloads an image from a base64 data URL + */ +export function downloadImage( + dataUrl: string, + options: DownloadOptions = {} +): void { + const { filename, format = "png" } = options; + + const defaultFilename = `image-${Date.now()}.${format}`; + const finalFilename = filename || defaultFilename; + + const link = document.createElement("a"); + link.href = dataUrl; + link.download = finalFilename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); +} + +/** + * Downloads multiple images as individual files + * Note: Browser may prompt for permission for multiple downloads + */ +export function downloadImages( + dataUrls: string[], + baseFilename: string = "image" +): void { + dataUrls.forEach((dataUrl, index) => { + // Stagger downloads slightly to avoid browser blocking + setTimeout(() => { + downloadImage(dataUrl, { + filename: `${baseFilename}-${index + 1}.png`, + }); + }, index * 100); + }); +} + +/** + * Creates a ZIP file containing multiple images and downloads it + * Requires JSZip library - falls back to individual downloads if not available + */ +export async function downloadImagesAsZip( + dataUrls: string[], + zipFilename: string = "images.zip" +): Promise { + // Try to dynamically import JSZip + try { + const JSZip = (await import("jszip")).default; + const zip = new JSZip(); + + // Add each image to the zip + dataUrls.forEach((dataUrl, index) => { + // Extract base64 data from data URL + const base64Data = dataUrl.split(",")[1]; + zip.file(`image-${index + 1}.png`, base64Data, { base64: true }); + }); + + // Generate the zip file + const content = await zip.generateAsync({ type: "blob" }); + + // Download the zip + const url = URL.createObjectURL(content); + const link = document.createElement("a"); + link.href = url; + link.download = zipFilename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + } catch { + // JSZip not available, fall back to individual downloads + console.warn("JSZip not available, downloading images individually"); + downloadImages(dataUrls, "image"); + } +}