Full Code of amrrs/fal-nanobanana-studio for AI

main 4a64ccd81945 cached
23 files
115.3 KB
30.3k tokens
39 symbols
1 requests
Download .txt
Repository: amrrs/fal-nanobanana-studio
Branch: main
Commit: 4a64ccd81945
Files: 23
Total size: 115.3 KB

Directory structure:
gitextract_ymb6w0qn/

├── .gitignore
├── CHANGELOG.md
├── DEVELOPMENT.md
├── LICENSE
├── README.md
├── SETUP.md
├── backend/
│   ├── env.example
│   ├── package.json
│   └── server.js
├── frontend/
│   ├── index.html
│   ├── package.json
│   ├── src/
│   │   ├── App.css
│   │   ├── App.tsx
│   │   ├── api.ts
│   │   ├── index.css
│   │   ├── main.tsx
│   │   ├── react-easy-crop.d.ts
│   │   ├── types.ts
│   │   └── utils.ts
│   ├── tsconfig.json
│   ├── tsconfig.node.json
│   └── vite.config.ts
└── package.json

================================================
FILE CONTENTS
================================================

================================================
FILE: .gitignore
================================================
node_modules/
dist/
build/
.env
.DS_Store
*.log
.vscode/
.idea/



================================================
FILE: CHANGELOG.md
================================================
# Changelog

## [1.0.0] - Initial Release

### Features
- ✨ AI-powered image editing with natural language prompts
- 🎨 Image generation from text descriptions
- 🖼️ Dark theme UI with modern design
- 📥 Image upload with validation
- 💾 One-click download functionality
- ⚡ Two modes: Edit and Generate

### Backend Improvements
- Removed unused imports (FormData)
- Added comprehensive input validation
- Implemented request timeout handling (120s)
- Added file type and size validation
- Improved error messages and logging
- Added health check endpoint with API status
- Structured error handling middleware
- Added constants for configuration
- Better API response handling

### Frontend Improvements
- Separated concerns into multiple files:
  - `api.ts` - API client functions
  - `types.ts` - TypeScript interfaces
  - `utils.ts` - Helper functions
- Added file validation before upload
- Improved error handling and user feedback
- Added API health check on startup
- Added character counter for prompts
- Added keyboard shortcuts (Ctrl/Cmd + Enter)
- Better loading states and disabled states
- Improved TypeScript type safety
- Added warning messages for API configuration

### Code Quality
- Full TypeScript type coverage
- Separated business logic from UI
- Reusable utility functions
- Better error propagation
- Consistent code style
- Comprehensive validation

### Security
- File type validation (JPEG, PNG, WebP only)
- File size limits (50MB max)
- Input sanitization
- Prompt length limits (2000 chars)
- Dimension validation for generated images

### Documentation
- Comprehensive README.md
- Quick setup guide (SETUP.md)
- API endpoint documentation
- Troubleshooting section



================================================
FILE: DEVELOPMENT.md
================================================
# Development Guide

## Project Overview

NanoBanana Studio is a full-stack AI image editor powered by Google's nanobanana API via fal.ai. The project uses a modern tech stack with strong typing and comprehensive error handling.

## Architecture

### Backend (Node.js/Express)

**File**: `backend/server.js`

**Key Features**:
- RESTful API endpoints for image editing and generation
- Multer middleware for file uploads
- Request validation and sanitization
- Timeout handling (120s)
- Comprehensive error handling
- Health check endpoint

**Endpoints**:
- `GET /api/health` - Server health and API status
- `POST /api/edit-image` - Edit images with AI
- `POST /api/generate-image` - Generate images from text

### Frontend (React/TypeScript/Vite)

**File Structure**:
- `App.tsx` - Main UI component
- `api.ts` - API client functions
- `types.ts` - TypeScript interfaces
- `utils.ts` - Helper functions
- `App.css` - Styles

**Key Features**:
- Type-safe API calls
- Input validation
- Error handling with user feedback
- Keyboard shortcuts
- Responsive UI

## Development Workflow

### 1. Initial Setup

```bash
# Install all dependencies
npm run install:all

# Set up environment
cd backend
cp env.example .env
# Edit .env and add FAL_API_KEY
```

### 2. Running Development Servers

```bash
# From root directory - runs both frontend and backend
npm run dev

# Or run separately:
cd frontend && npm run dev  # http://localhost:3000
cd backend && npm run dev   # http://localhost:3001
```

### 3. Making Changes

**Backend Changes**:
- Edit `backend/server.js`
- Server auto-restarts with `--watch` flag
- Check console for errors

**Frontend Changes**:
- Edit files in `frontend/src/`
- Vite hot-reloads changes automatically
- Check browser console for errors

## Code Style Guidelines

### Backend

```javascript
// Use async/await for async operations
async function callAPI(params) {
  try {
    const response = await fetch(url, options);
    return await response.json();
  } catch (error) {
    console.error('[CONTEXT] Error:', error.message);
    throw error;
  }
}

// Use constants for configuration
const MAX_FILE_SIZE = 50 * 1024 * 1024;

// Validate inputs
if (!prompt || !prompt.trim()) {
  return res.status(400).json({ error: 'Prompt is required' });
}
```

### Frontend

```typescript
// Define interfaces for all data structures
interface ApiResponse {
  images?: Array<{ url: string }>;
}

// Use proper typing
const [state, setState] = useState<string | null>(null);

// Extract reusable functions
async function handleApiCall() {
  try {
    const data = await apiFunction(params);
    // Handle success
  } catch (error) {
    // Handle error
  }
}
```

## Testing

### Manual Testing

1. **Edit Mode**:
   - Upload various image formats (JPEG, PNG, WebP)
   - Test file size limits (try > 50MB)
   - Test different prompts
   - Test with/without negative prompts

2. **Generate Mode**:
   - Test various prompts
   - Check image generation quality
   - Test error scenarios

3. **Error Cases**:
   - Missing API key
   - Invalid file types
   - Network errors
   - Timeout scenarios

### API Testing

```bash
# Health check
curl http://localhost:3001/api/health

# Generate image
curl -X POST http://localhost:3001/api/generate-image \
  -H "Content-Type: application/json" \
  -d '{"prompt": "a beautiful sunset"}'

# Edit image (requires multipart/form-data)
curl -X POST http://localhost:3001/api/edit-image \
  -F "image=@test.jpg" \
  -F "prompt=make it more dramatic"
```

## Common Issues & Solutions

### Issue: "FAL_API_KEY not configured"
**Solution**: Create `backend/.env` file with valid API key

### Issue: CORS errors
**Solution**: Backend already has CORS enabled; check proxy configuration in `vite.config.ts`

### Issue: File upload fails
**Solution**: 
- Check file size (max 50MB)
- Check file type (JPEG, PNG, WebP only)
- Check browser console for detailed error

### Issue: Timeout errors
**Solution**: 
- Increase timeout in `backend/server.js` (API_TIMEOUT constant)
- Check fal.ai API status
- Reduce image size

## Adding New Features

### Adding a New API Endpoint

1. Add endpoint to `backend/server.js`:
```javascript
app.post('/api/new-feature', async (req, res) => {
  try {
    // Validation
    // API call
    // Response
  } catch (error) {
    // Error handling
  }
});
```

2. Add API function to `frontend/src/api.ts`:
```typescript
export async function newFeature(params: Params): Promise<Response> {
  const response = await fetch('/api/new-feature', {
    method: 'POST',
    body: JSON.stringify(params),
  });
  return handleApiResponse(response);
}
```

3. Use in component:
```typescript
const handleNewFeature = async () => {
  try {
    const result = await newFeature(params);
    // Handle result
  } catch (error) {
    // Handle error
  }
};
```

## Performance Optimization

### Backend
- Use response compression: `npm install compression`
- Cache frequent API responses
- Implement rate limiting

### Frontend
- Lazy load components
- Optimize images before upload
- Add request debouncing
- Implement image caching

## Deployment

### Backend
1. Build frontend: `cd frontend && npm run build`
2. Set environment variables
3. Start server: `cd backend && npm start`

### Recommended Platforms
- **Backend**: Railway, Render, Heroku
- **Frontend**: Vercel, Netlify
- **Full Stack**: Railway, Render

### Environment Variables for Production
```
FAL_API_KEY=your_production_key
PORT=3001
NODE_ENV=production
```

## Resources

- [fal.ai Documentation](https://fal.ai/models)
- [React Documentation](https://react.dev)
- [Express Documentation](https://expressjs.com)
- [Vite Documentation](https://vitejs.dev)



================================================
FILE: LICENSE
================================================
MIT License

Copyright (c) 2025 NanoBanana Studio

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.



================================================
FILE: README.md
================================================
# NanoBanana Studio

A modern, AI-powered image editor alternative to Photoshop/Photopea, powered by Google's nanobanana API from [fal.ai](https://fal.ai/dashboard).

![NanoBanana Studio Screenshot](./screenshot.png)

## Features

- 🎨 **AI-Powered Image Editing**: Edit images using natural language prompts
- ✨ **Image Generation**: Generate new images from text descriptions
- 🖼️ **Intuitive UI**: Clean, modern interface inspired by professional image editors
- ⚡ **Model Switcher**: Toggle between NanoBanana and [NanoBanana Pro](https://fal.ai/models/fal-ai/nano-banana-pro/edit/api) on the fly
- 💾 **Easy Export**: Download your edited images with one click

## Prerequisites

- Node.js 18+ and npm
- A fal.ai API key ([Get one here](https://fal.ai/dashboard/keys))

## Installation

1. Clone the repository and install dependencies:

```bash
npm run install:all
```

2. Set up your environment variables:

```bash
cd backend
cp env.example .env
```

Edit `backend/.env` and add your fal.ai API key:

```
FAL_API_KEY=your_fal_ai_api_key_here
PORT=3001
```

**Note:** Get your fal.ai API key from [fal.ai](https://fal.ai). You'll need to sign up and create an API key in your dashboard.

## Running the Application

Start both frontend and backend in development mode:

```bash
npm run dev
```

- Frontend will be available at: http://localhost:3000
- Backend API will be available at: http://localhost:3001

## Usage

### Edit Mode

1. Click "Upload Image" to select an image file
2. Enter a natural language prompt describing the edits you want (e.g., "make the sky more dramatic", "add a sunset", "remove the background")
3. Optionally add a negative prompt to exclude unwanted elements
4. Click "Edit Image" and wait for processing
5. Download your edited image

## API Configuration

**Important:** The application uses fal.ai's NanoBanana APIs:

- Standard: [NanoBanana Edit](https://fal.ai/models/fal-ai/nano-banana/edit/api)
- Pro tier: [NanoBanana Pro Edit](https://fal.ai/models/fal-ai/nano-banana-pro/edit/api)

Select your model in the UI, and the backend automatically routes to the appropriate endpoint. If fal.ai updates these URLs, adjust `MODEL_ENDPOINTS` in `backend/server.js`.

To verify the correct endpoint:
1. Check the [fal.ai documentation](https://fal.ai/models)
2. Look for the nanobanana model endpoint
3. Update the fetch URL in `backend/server.js` if needed

## API Endpoints

### POST `/api/edit-image`

Edit an existing image using AI.

**Request:**
- `image` (file): Image file to edit
- `prompt` (string): Natural language editing instructions
- `negativePrompt` (string, optional): Things to avoid in the edit

**Response:**
```json
{
  "images": [{"url": "data:image/..."}]
}
```

### POST `/api/generate-image`

Generate a new image from text.

**Request:**
```json
{
  "prompt": "a beautiful landscape...",
  "negativePrompt": "blurry, low quality",
  "width": 1024,
  "height": 1024
}
```

**Response:**
```json
{
  "images": [{"url": "data:image/..."}]
}
```

## Tech Stack

- **Frontend**: React + TypeScript + Vite
- **Backend**: Node.js + Express
- **AI**: Google nanobanana via fal.ai
- **UI**: Custom CSS with modern design

## Project Structure

```
nanobanana-studio/
├── frontend/          # React frontend application
│   ├── src/
│   │   ├── App.tsx    # Main application component
│   │   ├── api.ts     # API client functions
│   │   ├── types.ts   # TypeScript type definitions
│   │   ├── utils.ts   # Utility functions
│   │   ├── App.css    # Styles
│   │   └── ...
│   └── package.json
├── backend/           # Express backend server
│   ├── server.js      # API server with validation & error handling
│   ├── env.example    # Environment variables template
│   └── package.json
├── package.json       # Root package.json
├── README.md          # Full documentation
└── SETUP.md          # Quick setup guide
```

## Code Quality Features

- **TypeScript**: Full type safety on frontend
- **Error Handling**: Comprehensive error handling on both frontend and backend
- **Validation**: Input validation for images, prompts, and parameters
- **API Timeout**: 120-second timeout for API requests
- **File Validation**: Type and size validation for uploaded images
- **Logging**: Structured logging for debugging
- **Security**: File type validation, size limits, and input sanitization

## License

MIT

## Contributing

Contributions are welcome! Please feel free to submit a Pull Request.



================================================
FILE: SETUP.md
================================================
# Quick Setup Guide

## Step 1: Install Dependencies

```bash
npm run install:all
```

This will install dependencies for:
- Root package (concurrently for running both servers)
- Frontend (React + TypeScript + Vite)
- Backend (Express + API dependencies)

## Step 2: Configure API Key

1. Get your fal.ai API key:
   - Visit [fal.ai](https://fal.ai)
   - Sign up or log in
   - Navigate to your API keys section
   - Create a new API key

2. Set up environment variables:
   ```bash
   cd backend
   cp env.example .env
   ```

3. Edit `backend/.env` and paste your API key:
   ```
   FAL_API_KEY=your_actual_api_key_here
   PORT=3001
   ```

## Step 3: Run the Application

```bash
npm run dev
```

This starts:
- Frontend on http://localhost:3000
- Backend on http://localhost:3001

## Step 4: Use the Application

1. Open http://localhost:3000 in your browser
2. **Edit Mode:**
   - Click "Upload Image"
   - Enter a prompt like "make the sky more dramatic"
   - Click "Edit Image"
   - Wait for processing
   - Download your result

3. **Generate Mode:**
   - Switch to "Generate" mode
   - Enter a prompt like "a beautiful sunset over mountains"
   - Click "Generate Image"
   - Download the generated image

## Troubleshooting

### "FAL_API_KEY not configured" error
- Make sure you created the `.env` file in the `backend` directory
- Verify the API key is correct (no extra spaces)
- Restart the backend server after adding the key

### API endpoint errors
- Check that your fal.ai API key is valid
- Verify the endpoint URL in `backend/server.js` matches fal.ai's current API
- Check the browser console and server logs for detailed error messages

### Port already in use
- Change the PORT in `backend/.env` to a different number
- Or stop the process using port 3000/3001

## Next Steps

- Customize the UI in `frontend/src/App.tsx` and `frontend/src/App.css`
- Add more editing features
- Implement image history/undo functionality
- Add batch processing capabilities



================================================
FILE: backend/env.example
================================================
FAL_API_KEY=
PORT=3001



================================================
FILE: backend/package.json
================================================
{
  "name": "nanobanana-backend",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "node --watch server.js",
    "start": "node server.js"
  },
  "dependencies": {
    "express": "^4.18.2",
    "cors": "^2.8.5",
    "dotenv": "^16.3.1",
    "multer": "^1.4.5-lts.1",
    "form-data": "^4.0.0",
    "node-fetch": "^3.3.2",
    "@fal-ai/client": "^1.0.0"
  }
}



================================================
FILE: backend/server.js
================================================
import express from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
import multer from 'multer';
import fetch from 'node-fetch';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { existsSync } from 'fs';
import { Buffer } from 'buffer';
import { fal } from '@fal-ai/client';

dotenv.config();

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

const app = express();
const PORT = process.env.PORT || 3001;
const FAL_API_KEY = process.env.FAL_API_KEY;

// Configure fal.ai client
if (FAL_API_KEY) {
  fal.config({
    credentials: FAL_API_KEY,
  });
}

// Constants
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/jpg', 'image/webp'];
const API_TIMEOUT = 120000; // 120 seconds
const MODEL_ENDPOINTS = {
  nano: 'fal-ai/nano-banana/edit',
  pro: 'fal-ai/nano-banana-pro/edit',
};

// Middleware
app.use(cors());
app.use(express.json({ limit: '10mb' }));

// Only serve static files in production (when dist folder exists)
const distPath = join(__dirname, '../frontend/dist');
if (existsSync(distPath)) {
  app.use(express.static(distPath));
}

// Configure multer for file uploads
const upload = multer({
  storage: multer.memoryStorage(),
  limits: { fileSize: MAX_FILE_SIZE },
  fileFilter: (req, file, cb) => {
    if (ALLOWED_MIME_TYPES.includes(file.mimetype)) {
      cb(null, true);
    } else {
      cb(new Error('Invalid file type. Only JPEG, PNG, and WebP images are allowed.'));
    }
  }
});

// Utility function to call fal.ai API using the official client
async function callFalAPI(inputPayload, modelId = 'nano') {
  if (!FAL_API_KEY) {
    throw new Error('FAL_API_KEY not configured');
  }

  try {
    const endpoint = MODEL_ENDPOINTS[modelId] || MODEL_ENDPOINTS.nano;
    // Use fal.subscribe which handles queue polling automatically
    const result = await fal.subscribe(endpoint, {
      input: inputPayload,
      logs: true,
      onQueueUpdate: (update) => {
        if (update.status === 'IN_PROGRESS') {
          update.logs?.map((log) => log.message).forEach((msg) => {
            console.log(`[FAL API] ${msg}`);
          });
        }
      },
    });

    console.log(`[FAL API] Request completed, request_id: ${result.requestId}`);
    // Return the data field from the result
    return result.data;
  } catch (error) {
    console.error('[FAL API] Error:', error.message);
    throw error;
  }
}

// Validate and parse integer parameters
function parseIntParam(value, defaultValue = undefined) {
  if (!value) return defaultValue;
  const parsed = parseInt(value, 10);
  return isNaN(parsed) ? defaultValue : parsed;
}

// Health check
app.get('/api/health', (req, res) => {
  const isConfigured = !!FAL_API_KEY;
  res.json({ 
    status: 'ok',
    apiConfigured: isConfigured,
    timestamp: new Date().toISOString()
  });
});

// Image editing endpoint using fal.ai nanobanana
app.post('/api/edit-image', upload.single('image'), async (req, res) => {
  try {
    // Validate image
    if (!req.file) {
      return res.status(400).json({ error: 'No image file provided' });
    }

    // Validate prompt
    const { prompt, negativePrompt, seed, numInferenceSteps, model } = req.body;
    if (!prompt || !prompt.trim()) {
      return res.status(400).json({ error: 'Prompt is required' });
    }

    if (prompt.length > 2000) {
      return res.status(400).json({ error: 'Prompt is too long (max 2000 characters)' });
    }

    // Convert image buffer to base64 data URL
    const imageBase64 = req.file.buffer.toString('base64');
    const imageDataUrl = `data:${req.file.mimetype};base64,${imageBase64}`;

    // Prepare API payload according to fal.ai API schema
    // API expects image_urls (array) and prompt
    const payload = {
      prompt: prompt.trim(),
      image_urls: [imageDataUrl], // API expects array of image URLs
    };

    // Note: The nano-banana/edit API doesn't support negative_prompt, seed, or num_inference_steps
    // These parameters are not in the API schema

    const modelId = model === 'pro' ? 'pro' : 'nano';
    console.log(`[EDIT] Processing image edit (${modelId}) with prompt: "${prompt.substring(0, 50)}..."`);

    // Call fal.ai API
    const data = await callFalAPI(payload, modelId);

    console.log('[EDIT] Successfully processed image');
    // API returns { images: [{ url, ... }], description: "..." }
    // Frontend expects this format, so return as-is
    res.json(data);
  } catch (error) {
    console.error('[EDIT] Error processing image:', error.message);
    
    const statusCode = error.message.includes('timeout') ? 504 : 
                       error.message.includes('not configured') ? 500 : 400;
    
    res.status(statusCode).json({ 
      error: error.message,
      timestamp: new Date().toISOString()
    });
  }
});

// Inpainting endpoint using fal-ai/qwen-image-edit/inpaint
app.post('/api/inpaint-image', upload.fields([{ name: 'image', maxCount: 1 }, { name: 'mask', maxCount: 1 }]), async (req, res) => {
  try {
    const { prompt, imageUrl, maskUrl } = req.body;

    // Validate prompt
    if (!prompt || !prompt.trim()) {
      return res.status(400).json({ error: 'Prompt is required' });
    }

    // Get Image URL (either from file or body)
    let finalImageUrl = imageUrl;
    if (req.files && req.files['image'] && req.files['image'][0]) {
      const imageFile = req.files['image'][0];
      const imageBase64 = imageFile.buffer.toString('base64');
      finalImageUrl = `data:${imageFile.mimetype};base64,${imageBase64}`;
    }

    // Get Mask URL (either from file or body)
    let finalMaskUrl = maskUrl;
    if (req.files && req.files['mask'] && req.files['mask'][0]) {
      const maskFile = req.files['mask'][0];
      const maskBase64 = maskFile.buffer.toString('base64');
      finalMaskUrl = `data:${maskFile.mimetype};base64,${maskBase64}`;
    }

    if (!finalImageUrl) {
      return res.status(400).json({ error: 'Image is required (file or imageUrl)' });
    }
    if (!finalMaskUrl) {
      return res.status(400).json({ error: 'Mask is required (file or maskUrl)' });
    }

    console.log(`[INPAINT] Processing inpainting with prompt: "${prompt.substring(0, 50)}..."`);
    console.log(`[INPAINT] Image source: ${finalImageUrl.substring(0, 30)}...`);
    console.log(`[INPAINT] Mask source: ${finalMaskUrl.substring(0, 30)}...`);

    // Call fal.ai API
    if (!FAL_API_KEY) {
      throw new Error('FAL_API_KEY not configured');
    }

    const result = await fal.subscribe('fal-ai/qwen-image-edit/inpaint', {
      input: {
        prompt: prompt.trim(),
        image_url: finalImageUrl,
        mask_url: finalMaskUrl,
        // Explicitly use the mask as-is, avoiding any bounding box logic if the API defaults to it
        use_mask_as_is: true
      },
      logs: true,
      onQueueUpdate: (update) => {
        if (update.status === 'IN_PROGRESS') {
          update.logs?.map((log) => log.message).forEach((msg) => {
            console.log(`[FAL INPAINT] ${msg}`);
          });
        }
      },
    });

    console.log(`[INPAINT] Request completed, request_id: ${result.requestId}`);
    res.json(result.data);

  } catch (error) {
    console.error('[INPAINT] Error processing inpainting:', error.message);
    const statusCode = error.message.includes('timeout') ? 504 : 
                       error.message.includes('not configured') ? 500 : 400;
    res.status(statusCode).json({ 
      error: error.message,
      timestamp: new Date().toISOString()
    });
  }
});

// SAM2 auto-segmentation endpoint
app.post('/api/segment-image', upload.single('image'), async (req, res) => {
  try {
    // Validate image
    if (!req.file) {
      return res.status(400).json({ error: 'No image file provided' });
    }

    // Convert image buffer to base64 data URL
    const imageBase64 = req.file.buffer.toString('base64');
    const imageDataUrl = `data:${req.file.mimetype};base64,${imageBase64}`;

    console.log(`[SEGMENT] Processing image segmentation`);

    // Call SAM2 API
    const data = await callSam2API(imageDataUrl);

    console.log('[SEGMENT] Successfully processed segmentation');
    res.json(data);
  } catch (error) {
    console.error('[SEGMENT] Error processing segmentation:', error.message);

    const statusCode = error.message.includes('timeout') ? 504 :
                       error.message.includes('not configured') ? 500 : 400;

    res.status(statusCode).json({
      error: error.message,
      timestamp: new Date().toISOString()
    });
  }
});

// Utility function to call SAM2 API
async function callSam2API(imageUrl) {
  if (!FAL_API_KEY) {
    throw new Error('FAL_API_KEY not configured');
  }

  try {
    // Use fal.subscribe which handles queue polling automatically
    const result = await fal.subscribe('fal-ai/sam2/auto-segment', {
      input: {
        image_url: imageUrl,
        output_format: 'png',
        sync_mode: true,
      },
      logs: true,
      onQueueUpdate: (update) => {
        if (update.status === 'IN_PROGRESS') {
          update.logs?.map((log) => log.message).forEach((msg) => {
            console.log(`[SAM2 API] ${msg}`);
          });
        }
      },
    });

    console.log(`[SAM2 API] Segmentation completed, request_id: ${result.requestId}`);
    const data = result.data;
    
    // Debug: Log what we received
    console.log('[SAM2 API] Response structure:', {
      hasCombinedMask: !!data?.combined_mask,
      hasIndividualMasks: !!data?.individual_masks,
      individualMasksCount: Array.isArray(data?.individual_masks) ? data.individual_masks.length : 0,
      hasSegmentedImages: !!data?.segmented_images,
      segmentedImagesCount: Array.isArray(data?.segmented_images) ? data.segmented_images.length : 0,
      keys: Object.keys(data || {})
    });

    const embedMask = async (mask) => {
      if (!mask?.url) return mask;
      try {
        const response = await fetch(mask.url);
        if (!response.ok) {
          throw new Error(`Failed to fetch mask asset: ${response.status}`);
        }
        const arrayBuffer = await response.arrayBuffer();
        const base64 = Buffer.from(arrayBuffer).toString('base64');
        const contentType = mask.content_type || 'image/png';
        return {
          ...mask,
          data_url: `data:${contentType};base64,${base64}`,
        };
      } catch (maskError) {
        console.warn('[SAM2 API] Unable to embed mask asset', maskError.message || maskError);
        return mask;
      }
    };

    if (data?.combined_mask) {
      data.combined_mask = await embedMask(data.combined_mask);
    }
    if (Array.isArray(data?.individual_masks)) {
      data.individual_masks = await Promise.all(data.individual_masks.map(embedMask));
    }
    if (Array.isArray(data?.segmented_images)) {
      data.segmented_images = await Promise.all(data.segmented_images.map(embedMask));
    }

    return data;
  } catch (error) {
    console.error('[SAM2 API] Error:', error.message);
    throw error;
  }
}

// Generate image from text
app.post('/api/generate-image', async (req, res) => {
  try {
    const { prompt, negativePrompt, seed, numInferenceSteps, width, height, model } = req.body;

    // Validate prompt
    if (!prompt || !prompt.trim()) {
      return res.status(400).json({ error: 'Prompt is required' });
    }

    if (prompt.length > 2000) {
      return res.status(400).json({ error: 'Prompt is too long (max 2000 characters)' });
    }

    // Prepare API payload
    const payload = {
      prompt: prompt.trim(),
    };

    // Add optional parameters
    if (negativePrompt && negativePrompt.trim()) {
      payload.negative_prompt = negativePrompt.trim();
    }

    const parsedSeed = parseIntParam(seed);
    if (parsedSeed !== undefined) {
      payload.seed = parsedSeed;
    }

    const parsedSteps = parseIntParam(numInferenceSteps);
    if (parsedSteps !== undefined && parsedSteps > 0 && parsedSteps <= 50) {
      payload.num_inference_steps = parsedSteps;
    }

    // Validate and add dimensions
    const parsedWidth = parseIntParam(width, 1024);
    const parsedHeight = parseIntParam(height, 1024);
    
    if (parsedWidth < 256 || parsedWidth > 2048) {
      return res.status(400).json({ error: 'Width must be between 256 and 2048' });
    }
    if (parsedHeight < 256 || parsedHeight > 2048) {
      return res.status(400).json({ error: 'Height must be between 256 and 2048' });
    }

    payload.width = parsedWidth;
    payload.height = parsedHeight;

    const modelId = model === 'pro' ? 'pro' : 'nano';
    console.log(`[GENERATE] Generating image (${modelId}) with prompt: "${prompt.substring(0, 50)}..."`);

    // Call fal.ai API
    const data = await callFalAPI(payload, modelId);

    console.log('[GENERATE] Successfully generated image');
    res.json(data);
  } catch (error) {
    console.error('[GENERATE] Error generating image:', error.message);
    
    const statusCode = error.message.includes('timeout') ? 504 : 
                       error.message.includes('not configured') ? 500 : 400;
    
    res.status(statusCode).json({ 
      error: error.message,
      timestamp: new Date().toISOString()
    });
  }
});

// Error handling middleware
app.use((err, req, res, next) => {
  console.error('Unhandled error:', err);
  
  if (err instanceof multer.MulterError) {
    if (err.code === 'LIMIT_FILE_SIZE') {
      return res.status(400).json({ error: 'File is too large (max 50MB)' });
    }
    return res.status(400).json({ error: err.message });
  }
  
  res.status(500).json({ 
    error: err.message || 'Internal server error',
    timestamp: new Date().toISOString()
  });
});

// Serve frontend in production only (must be last)
if (existsSync(distPath)) {
  app.get('*', (req, res) => {
    res.sendFile(join(__dirname, '../frontend/dist/index.html'));
  });
}

app.listen(PORT, () => {
  console.log(`🚀 Server running on http://localhost:${PORT}`);
  console.log(`📝 API Key configured: ${FAL_API_KEY ? '✓' : '✗'}`);
  if (!FAL_API_KEY) {
    console.warn('⚠️  Warning: FAL_API_KEY not set in .env file');
  }
});


================================================
FILE: frontend/index.html
================================================
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>NanoBanana Studio - AI Image Editor</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>



================================================
FILE: frontend/package.json
================================================
{
  "name": "nanobanana-frontend",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "lucide-react": "^0.294.0",
    "react-easy-crop": "^5.0.7"
  },
  "devDependencies": {
    "@types/react": "^18.2.43",
    "@types/react-dom": "^18.2.17",
    "@vitejs/plugin-react": "^4.2.1",
    "typescript": "^5.3.3",
    "vite": "^5.0.8"
  }
}



================================================
FILE: frontend/src/App.css
================================================
.app {
  display: flex;
  flex-direction: column;
  height: 100vh;
  background: #e0e7ff;
  color: #000000;
  font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}

.top-bar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 16px 32px;
  background: #ffffff;
  border-bottom: 3px solid #000000;
  z-index: 10;
  gap: 16px;
  flex-wrap: wrap;
}

.brand {
  display: flex;
  align-items: center;
  gap: 12px;
}

.brand-icon {
  color: #7c3aed;
  font-size: 32px;
  filter: drop-shadow(2px 2px 0px #000000);
}

.brand-title {
  font-weight: 900;
  font-size: 24px;
  margin: 0;
  color: #000000;
  letter-spacing: -0.5px;
  text-transform: uppercase;
}

.brand-subtitle {
  font-size: 12px;
  color: #000000;
  font-weight: 700;
  background: #a7f3d0;
  padding: 4px 8px;
  border: 2px solid #000000;
  border-radius: 6px;
  box-shadow: 2px 2px 0px #000000;
}

.menu-strip {
  display: flex;
  gap: 12px;
}

.menu-item {
  background: #ffffff;
  border: 2px solid #000000;
  color: #000000;
  font-size: 14px;
  padding: 8px 16px;
  border-radius: 8px;
  cursor: pointer;
  font-weight: 700;
  transition: all 0.15s ease;
  box-shadow: 3px 3px 0px #000000;
}

.menu-item:hover {
  transform: translate(-1px, -1px);
  box-shadow: 4px 4px 0px #000000;
  background: #fef3c7;
}

.menu-item:active {
  transform: translate(2px, 2px);
  box-shadow: 1px 1px 0px #000000;
}

.status-cluster {
  display: flex;
  align-items: center;
  gap: 16px;
  font-size: 14px;
  color: #000000;
  font-weight: 700;
}

.status-dot {
  width: 12px;
  height: 12px;
  border-radius: 50%;
  display: inline-block;
  border: 2px solid #000000;
}

.status-dot.online {
  background: #10b981;
}

.status-dot.offline {
  background: #ef4444;
}

.quick-action-nav {
  display: flex;
  gap: 8px;
  flex: 1;
  justify-content: center;
  flex-wrap: wrap;
  min-width: 220px;
}

.quick-action-icon-btn {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  border: 2px solid #000000;
  background: #ffffff;
  color: #000000;
  padding: 8px 12px;
  border-radius: 999px;
  cursor: pointer;
  font-size: 12px;
  font-weight: 800;
  text-transform: uppercase;
  transition: all 0.15s ease;
  box-shadow: 3px 3px 0px #000000;
}

.quick-action-icon-btn:disabled {
  opacity: 0.4;
  cursor: not-allowed;
  box-shadow: none;
}

.quick-action-icon-btn:not(:disabled):hover {
  background: #c4b5fd;
  transform: translate(-1px, -1px);
  box-shadow: 4px 4px 0px #000000;
}

.secondary-btn {
  display: flex;
  align-items: center;
  gap: 8px;
  border: 2px solid #000000;
  background: #ffffff;
  color: #000000;
  padding: 8px 16px;
  border-radius: 8px;
  cursor: pointer;
  font-size: 13px;
  font-weight: 700;
  transition: all 0.15s ease;
  box-shadow: 3px 3px 0px #000000;
}

.secondary-btn:hover {
  transform: translate(-1px, -1px);
  box-shadow: 4px 4px 0px #000000;
  background: #e9d5ff;
}

.workspace {
  flex: 1;
  display: grid;
  grid-template-columns: 80px 1fr 380px;
  overflow: hidden;
  background: #e0e7ff;
  gap: 0;
}

.tool-panel {
  display: flex;
  flex-direction: column;
  gap: 12px;
  padding: 24px 12px;
  background: #ffffff;
  border-right: 3px solid #000000;
  z-index: 5;
}

.tool-btn {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
  padding: 16px 8px;
  border: 2px solid #000000;
  background: #ffffff;
  color: #000000;
  font-size: 11px;
  font-weight: 800;
  border-radius: 8px;
  cursor: pointer;
  transition: all 0.15s ease;
  text-transform: uppercase;
  box-shadow: 3px 3px 0px #000000;
}

.tool-btn:disabled {
  opacity: 0.5;
  cursor: not-allowed;
  box-shadow: none;
  background: #e5e7eb;
}

.tool-btn svg {
  color: inherit;
  font-size: 24px;
}

.tool-btn.active {
  background: #fbbf24;
  color: #000000;
  transform: translate(-1px, -1px);
  box-shadow: 4px 4px 0px #000000;
}

.tool-btn:not(:disabled):not(.active):hover {
  background: #e0f2fe;
  transform: translate(-1px, -1px);
  box-shadow: 4px 4px 0px #000000;
}

.canvas-shell {
  display: flex;
  flex-direction: column;
  background: #ffffff;
  overflow: hidden;
  border-right: 3px solid #000000;
}

.options-bar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 16px 32px;
  background: #ffedd5;
  border-bottom: 3px solid #000000;
}

.document-title {
  margin: 0;
  font-size: 20px;
  font-weight: 900;
  color: #000000;
  letter-spacing: -0.5px;
  text-transform: uppercase;
}

.document-subtitle {
  font-size: 13px;
  color: #000000;
  font-weight: 700;
  opacity: 0.7;
}

.mode-toggle.chips {
  display: flex;
  gap: 8px;
  background: #ffffff;
  padding: 6px;
  border-radius: 8px;
  border: 2px solid #000000;
  box-shadow: 3px 3px 0px #000000;
}

.mode-chip {
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 8px 16px;
  border-radius: 6px;
  border: 2px solid transparent;
  background: transparent;
  color: #000000;
  cursor: pointer;
  font-size: 14px;
  font-weight: 700;
  transition: all 0.15s ease;
}

.mode-chip.active {
  background: #000000;
  color: #ffffff;
  box-shadow: 2px 2px 0px #a78bfa;
}

.mode-chip:not(.active):hover {
  background: #e5e7eb;
}

.model-toggle {
  display: flex;
  gap: 6px;
  background: #ffffff;
  padding: 6px;
  border-radius: 8px;
  border: 2px solid #000000;
  box-shadow: 3px 3px 0px #000000;
}

.model-chip {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  padding: 6px 12px;
  border-radius: 6px;
  border: 2px solid transparent;
  background: transparent;
  color: #000000;
  cursor: pointer;
  font-size: 12px;
  font-weight: 700;
  transition: all 0.15s ease;
  min-width: 140px;
}

.model-chip span {
  font-size: 10px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.5px;
  opacity: 0.7;
}

.model-chip.active {
  background: #000000;
  color: #ffffff;
  box-shadow: 2px 2px 0px #a78bfa;
}

.model-chip:not(.active):hover {
  background: #e5e7eb;
  transform: translate(-1px, -1px);
  box-shadow: 3px 3px 0px #000000;
}

.canvas-stage {
  flex: 1;
  display: grid;
  grid-template-columns: minmax(0, 1fr) 320px;
  overflow: hidden;
  gap: 0;
  background: #e0e7ff;
}

.canvas-area {
  background-color: #ffffff;
  background-image: radial-gradient(#e5e7eb 1px, transparent 1px);
  background-size: 20px 20px;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 40px;
  position: relative;
}

.canvas-area.pro {
  border-right: 3px solid #000000;
}

.empty-state {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 20px;
  color: #000000;
  text-align: center;
}

.empty-state svg {
  font-size: 80px;
  color: #a78bfa;
  opacity: 1;
  filter: drop-shadow(3px 3px 0px #000000);
}

.empty-state p {
  font-size: 24px;
  font-weight: 900;
  margin: 0;
  color: #000000;
  letter-spacing: -0.5px;
  text-transform: uppercase;
}

.hint {
  font-size: 14px;
  color: #000000;
  font-weight: 700;
  background: #ddd6fe;
  padding: 4px 12px;
  border: 2px solid #000000;
  border-radius: 6px;
}

.image-container {
  position: relative;
  max-width: 100%;
  max-height: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
}

.image-container.framed {
  padding: 20px;
  border-radius: 12px;
  background: #ffffff;
  box-shadow: 6px 6px 0px #000000;
  border: 3px solid #000000;
}

.result-image {
  max-width: 100%;
  max-height: calc(100vh - 200px);
  border-radius: 4px;
  border: 2px solid #000000;
}

.image-container.cropping {
  overflow: hidden;
}

.cropper-wrapper {
  position: relative;
  width: min(90vw, 900px);
  height: min(65vh, 520px);
  border-radius: 4px;
  overflow: hidden;
  border: 3px solid #000000;
  box-shadow: 6px 6px 0px #000000;
}

.image-actions.floating {
  position: absolute;
  right: 32px;
  bottom: 32px;
}

.download-btn {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 12px 24px;
  border-radius: 8px;
  border: 2px solid #000000;
  background: #10b981;
  color: #000000;
  cursor: pointer;
  font-size: 14px;
  font-weight: 800;
  letter-spacing: 0.5px;
  text-transform: uppercase;
  box-shadow: 4px 4px 0px #000000;
  transition: all 0.15s ease;
}

.download-btn:hover {
  transform: translate(-2px, -2px);
  box-shadow: 6px 6px 0px #000000;
  background: #34d399;
}

.stack-panels {
  display: flex;
  flex-direction: column;
  gap: 16px;
  padding: 20px;
  overflow-y: auto;
  background: #ffffff;
  border-left: 3px solid #000000;
}

/* Custom scrollbar */
.stack-panels::-webkit-scrollbar,
.control-panel::-webkit-scrollbar {
  width: 10px;
}

.stack-panels::-webkit-scrollbar-track,
.control-panel::-webkit-scrollbar-track {
  background: #f3f4f6;
  border-left: 2px solid #000000;
}

.stack-panels::-webkit-scrollbar-thumb,
.control-panel::-webkit-scrollbar-thumb {
  background: #000000;
  border: 2px solid #000000;
}

.stack-panels::-webkit-scrollbar-thumb:hover,
.control-panel::-webkit-scrollbar-thumb:hover {
  background: #7c3aed;
}

.panel-card {
  background: #ffffff;
  border-radius: 12px;
  padding: 20px;
  border: 2px solid #000000;
  box-shadow: 4px 4px 0px #000000;
}

.panel-card.subtle {
  background: #fef3c7;
  border-color: #000000;
}

.panel-header-info {
  display: flex;
  align-items: center;
  gap: 10px;
  color: #000000;
  font-weight: 800;
  text-transform: uppercase;
}

.layer-add-btn {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 8px 12px;
  border-radius: 6px;
  border: 2px solid #000000;
  background: #ffffff;
  color: #000000;
  font-size: 12px;
  font-weight: 800;
  text-transform: uppercase;
  cursor: pointer;
  transition: all 0.15s ease;
  box-shadow: 2px 2px 0px #000000;
}

.layer-add-btn:hover {
  background: #c4b5fd;
  transform: translate(-1px, -1px);
  box-shadow: 3px 3px 0px #000000;
}

.panel-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  font-size: 14px;
  color: #000000;
  margin-bottom: 16px;
}

.panel-header h2 {
  font-size: 18px;
  font-weight: 900;
  margin: 0;
  color: #000000;
  letter-spacing: -0.5px;
  text-transform: uppercase;
}

.layer-list {
  display: flex;
  flex-direction: column;
  gap: 12px;
}

.layer-item {
  width: 100%;
  text-align: left;
  padding: 12px 16px;
  border-radius: 8px;
  background: #ffffff;
  font-size: 14px;
  border: 2px solid #000000;
  color: #000000;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  transition: all 0.15s ease;
  box-shadow: 2px 2px 0px #000000;
}

.layer-item:hover {
  background: #fef3c7;
  transform: translate(-1px, -1px);
  box-shadow: 3px 3px 0px #000000;
}

.layer-item.active {
  background: #c4b5fd;
  color: #000000;
  border-color: #000000;
  box-shadow: 3px 3px 0px #000000;
  transform: translate(-1px, -1px);
}

.layer-item.muted {
  color: #6b7280;
  background: #e5e7eb;
}

.layer-item.hidden {
  opacity: 0.6;
}

.layer-meta {
  display: flex;
  flex-direction: column;
  gap: 2px;
}

.layer-meta strong {
  font-size: 14px;
  font-weight: 800;
  color: #000000;
}

.layer-meta span {
  font-size: 11px;
  color: #000000;
  font-weight: 600;
  text-transform: uppercase;
}

.layer-pill {
  padding: 4px 8px;
  border-radius: 4px;
  font-size: 10px;
  text-transform: uppercase;
  font-weight: 800;
  letter-spacing: 0.5px;
  border: 2px solid #000000;
  box-shadow: 1px 1px 0px #000000;
}

.layer-pill.source {
  background: #ffffff;
  color: #000000;
}

.layer-pill.ai {
  background: #a78bfa;
  color: #000000;
}

.layer-pill.segment {
  background: #fca5a5;
  color: #000000;
}

.layer-pill.empty {
  background: #fcd34d;
  color: #000000;
}

.layer-actions {
  display: flex;
  align-items: center;
  gap: 8px;
}

.layer-eye-btn {
  border: 2px solid #000000;
  background: #ffffff;
  color: #000000;
  padding: 6px;
  border-radius: 6px;
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  transition: all 0.15s ease;
}

.layer-eye-btn:hover {
  background: #a7f3d0;
  transform: translate(-1px, -1px);
  box-shadow: 2px 2px 0px #000000;
}

.history-list {
  list-style: none;
  padding: 0;
  margin: 0;
  display: flex;
  flex-direction: column;
  gap: 8px;
  font-size: 13px;
  color: #000000;
}

.history-list li {
  padding: 8px 12px;
  background: #ffffff;
  border-radius: 6px;
  font-weight: 600;
  border: 2px solid #000000;
  box-shadow: 2px 2px 0px #000000;
}

.control-panel {
  padding: 20px;
  overflow-y: auto;
  background: #ffffff;
  border-left: 3px solid #000000;
}

.upload-deck {
  display: flex;
  flex-direction: column;
  gap: 12px;
  margin-bottom: 20px;
}

.file-input {
  display: none;
}

.upload-btn,
.clear-btn {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 10px;
  padding: 16px 20px;
  border-radius: 8px;
  border: 2px dashed #000000;
  background: #f3f4f6;
  color: #000000;
  cursor: pointer;
  font-weight: 800;
  font-size: 14px;
  text-transform: uppercase;
  transition: all 0.15s ease;
}

.upload-btn:hover,
.clear-btn:hover {
  background: #e0e7ff;
  border-color: #7c3aed;
  border-style: solid;
  transform: translate(-2px, -2px);
  box-shadow: 4px 4px 0px #000000;
}

.clear-btn.ghost {
  background: #fee2e2;
  color: #000000;
  border: 2px solid #000000;
}

.clear-btn.ghost:hover {
  background: #ef4444;
  color: #ffffff;
}

.prompt-section {
  display: flex;
  flex-direction: column;
  gap: 10px;
  margin-bottom: 20px;
}

.prompt-header {
  display: flex;
  justify-content: space-between;
  font-size: 13px;
  color: #000000;
  font-weight: 800;
  text-transform: uppercase;
}

.char-count {
  color: #7c3aed;
  font-weight: 800;
}

.prompt-input {
  width: 100%;
  padding: 16px;
  border-radius: 8px;
  border: 2px solid #000000;
  background: #ffffff;
  color: #000000;
  resize: none;
  font-size: 16px;
  font-family: inherit;
  font-weight: 600;
  transition: all 0.15s ease;
  box-shadow: 3px 3px 0px #000000;
}

.prompt-input::placeholder {
  color: #9ca3af;
}

.prompt-input:focus {
  outline: none;
  border-color: #7c3aed;
  background: #ffffff;
  transform: translate(-1px, -1px);
  box-shadow: 5px 5px 0px #000000;
}

.crop-controls {
  display: flex;
  flex-direction: column;
  gap: 16px;
  padding: 20px;
  border-radius: 12px;
  border: 2px solid #000000;
  background: #f3f4f6;
  margin-bottom: 20px;
  box-shadow: 4px 4px 0px #000000;
}

.crop-header p {
  margin: 0;
  font-weight: 800;
  color: #000000;
  text-transform: uppercase;
}

.crop-header span {
  font-size: 12px;
  color: #000000;
  font-weight: 600;
}

.aspect-options {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
}

.aspect-chip {
  padding: 8px 16px;
  border-radius: 6px;
  border: 2px solid #000000;
  background: #ffffff;
  color: #000000;
  font-size: 13px;
  font-weight: 700;
  cursor: pointer;
  transition: all 0.15s ease;
  box-shadow: 2px 2px 0px #000000;
}

.aspect-chip.active {
  background: #000000;
  color: #ffffff;
  transform: translate(-1px, -1px);
  box-shadow: 3px 3px 0px #a78bfa;
}

.aspect-chip:not(.active):hover {
  background: #e5e7eb;
  transform: translate(-1px, -1px);
  box-shadow: 3px 3px 0px #000000;
}

.crop-label {
  font-size: 13px;
  color: #000000;
  font-weight: 800;
  text-transform: uppercase;
}

.crop-slider {
  width: 100%;
  height: 12px;
  border-radius: 6px;
  background: #ffffff;
  border: 2px solid #000000;
  outline: none;
  -webkit-appearance: none;
}

.crop-slider::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
  width: 20px;
  height: 20px;
  border-radius: 4px;
  background: #7c3aed;
  border: 2px solid #000000;
  cursor: pointer;
  box-shadow: 2px 2px 0px #000000;
}

.crop-slider::-moz-range-thumb {
  width: 20px;
  height: 20px;
  border-radius: 4px;
  background: #7c3aed;
  border: 2px solid #000000;
  cursor: pointer;
  box-shadow: 2px 2px 0px #000000;
}

.crop-actions {
  display: flex;
  justify-content: flex-end;
  gap: 12px;
}

.primary-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 12px 24px;
  border-radius: 8px;
  border: 2px solid #000000;
  background: #7c3aed;
  color: #ffffff;
  font-weight: 800;
  text-transform: uppercase;
  letter-spacing: 0.5px;
  cursor: pointer;
  box-shadow: 4px 4px 0px #000000;
  transition: all 0.15s ease;
}

.primary-btn:disabled {
  opacity: 0.5;
  cursor: not-allowed;
  background: #9ca3af;
}

.primary-btn:not(:disabled):hover {
  transform: translate(-2px, -2px);
  box-shadow: 6px 6px 0px #000000;
  background: #8b5cf6;
}

.filters-panel {
  background: #ffffff;
  border-radius: 12px;
  padding: 20px;
  border: 2px solid #000000;
  box-shadow: 4px 4px 0px #000000;
  margin-bottom: 20px;
}

.filters-grid {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 12px;
  margin-bottom: 16px;
}

@media (max-width: 520px) {
  .filters-grid {
    grid-template-columns: 1fr;
  }
}

.filter-chip {
  border: 2px solid #000000;
  background: #ffffff;
  color: #000000;
  padding: 16px 10px;
  border-radius: 8px;
  cursor: pointer;
  text-align: center;
  transition: all 0.15s ease;
  box-shadow: 3px 3px 0px #000000;
}

.filter-chip:hover {
  background: #ddd6fe;
  transform: translate(-2px, -2px);
  box-shadow: 5px 5px 0px #000000;
}

.filter-label {
  font-size: 13px;
  font-weight: 800;
  text-transform: uppercase;
  margin-bottom: 4px;
  display: block;
}

.filter-category {
  font-size: 10px;
  font-weight: 700;
  letter-spacing: 0.5px;
  color: #6b7280;
  text-transform: uppercase;
  background: #e5e7eb;
  padding: 2px 6px;
  border-radius: 4px;
  display: inline-block;
  border: 1px solid #000000;
}

.filter-instructions {
  background: #fef3c7;
  border: 2px solid #000000;
  padding: 14px;
  border-radius: 8px;
  box-shadow: 3px 3px 0px #000000;
}

.filter-instructions p {
  margin: 0;
  font-size: 13px;
  font-weight: 700;
  color: #000000;
}

.preset-grid {
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
  margin-bottom: 24px;
}

.preset-chip {
  padding: 10px 16px;
  border-radius: 6px;
  border: 2px solid #000000;
  background: #ffffff;
  color: #000000;
  font-size: 13px;
  font-weight: 700;
  text-transform: uppercase;
  cursor: pointer;
  box-shadow: 3px 3px 0px #000000;
  transition: all 0.15s ease;
}

.preset-chip:hover {
  background: #fef3c7;
  transform: translate(-1px, -1px);
  box-shadow: 4px 4px 0px #000000;
}

.edit-btn {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 12px;
  padding: 16px 32px;
  width: 100%;
  border-radius: 8px;
  border: 2px solid #000000;
  background: #7c3aed;
  color: #ffffff;
  font-weight: 900;
  font-size: 16px;
  text-transform: uppercase;
  letter-spacing: 1px;
  cursor: pointer;
  box-shadow: 4px 4px 0px #000000;
  transition: all 0.15s ease;
}

.edit-btn:hover:not(:disabled) {
  background: #8b5cf6;
  transform: translate(-2px, -2px);
  box-shadow: 6px 6px 0px #000000;
}

.edit-btn:disabled {
  opacity: 0.5;
  cursor: not-allowed;
  background: #9ca3af;
}

.warning-message,
.error-message {
  margin-top: 16px;
  padding: 14px 18px;
  border-radius: 8px;
  font-size: 14px;
  font-weight: 700;
  border: 2px solid #000000;
  box-shadow: 3px 3px 0px #000000;
}

.warning-message {
  background: #fef3c7;
  color: #000000;
}

.error-message {
  background: #fee2e2;
  color: #000000;
}

.status-bar {
  height: 40px;
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: 24px;
  padding: 0 32px;
  font-size: 13px;
  background: #ffffff;
  border-top: 3px solid #000000;
  color: #000000;
  font-weight: 700;
  text-transform: uppercase;
}

.spinner {
  animation: spin 1s linear infinite;
}

@keyframes spin {
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(360deg);
  }
}

/* Canvas and segmentation styling */
.canvas-stack {
  position: relative;
  width: 100%;
  height: 100%;
  /* Checkerboard pattern to show transparency */
  background-image:
    linear-gradient(45deg, #f8fafc 25%, #e2e8f0 25%),
    linear-gradient(-45deg, #f8fafc 25%, #e2e8f0 25%),
    linear-gradient(45deg, #e2e8f0 75%, #f8fafc 75%),
    linear-gradient(-45deg, #e2e8f0 75%, #f8fafc 75%);
  background-size: 20px 20px;
  background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
  border-radius: 12px;
  border: 1px solid rgba(148, 163, 184, 0.2);
}

.segmentation-overlay {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  pointer-events: auto;
}

.segment-mask {
  transition: all 0.2s ease;
}

.segment-mask.selected {
  box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.5);
}

.segment-mask.hidden {
  opacity: 0;
}

.segmentation-loading {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 16px;
  background: rgba(255, 255, 255, 0.95);
  backdrop-filter: blur(20px);
  padding: 24px 32px;
  border-radius: 16px;
  color: #1e293b;
  font-size: 14px;
  font-weight: 600;
  letter-spacing: -0.2px;
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
  border: 1px solid rgba(148, 163, 184, 0.2);
}

.selection-box {
  position: absolute;
  border: 2px dashed #6366f1;
  border-radius: 8px;
  pointer-events: none;
  box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
}

@keyframes dash {
  to {
    stroke-dashoffset: -24;
  }
}


================================================
FILE: frontend/src/App.tsx
================================================
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import {
  ImageIcon,
  Upload,
  Download,
  Sparkles,
  Loader2,
  X,
  Wand2,
  Layers,
  History,
  Settings2,
  MousePointer2,
  Crop,
  Plus,
  Eye,
  EyeOff,
  Eraser,
  SunMedium,
  Camera,
  Zap,
  Sparkles as SparklesIcon,
  Palette,
} from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import Cropper, { Area } from 'react-easy-crop';
import { Mode, ModelId } from './types';
import { editImage, generateImage, segmentImage, checkHealth } from './api';
import { validateImageFile, extractImageUrl, dataUrlToBlob, downloadImage, getTimestamp } from './utils';
import './App.css';
import 'react-easy-crop/react-easy-crop.css';

type LayerKind = 'source' | 'ai' | 'empty' | 'segment';

interface BoundingBox {
  x: number;
  y: number;
  width: number;
  height: number;
}

interface Layer {
  id: string;
  name: string;
  kind: LayerKind;
  preview?: string | null;
  timestamp: string;
  visible?: boolean;
  metadata?: {
    maskUrl?: string;
    boundingBox?: BoundingBox | null;
  };
}

interface QuickEditAction {
  label: string;
  description: string;
  prompt: string;
  negativePrompt?: string;
  icon: LucideIcon;
}

function App() {
  const [image, setImage] = useState<string | null>(null);
  const [editedImage, setEditedImage] = useState<string | null>(null);
  const [prompt, setPrompt] = useState('');
  const [negativePrompt, setNegativePrompt] = useState('');
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [mode, setMode] = useState<Mode>('edit');
  const [apiConfigured, setApiConfigured] = useState(true);
  const [activeTool, setActiveTool] = useState('select');
  const [layers, setLayers] = useState<Layer[]>([]);
  const [selectedLayerId, setSelectedLayerId] = useState<string | null>(null);
  const [showCropper, setShowCropper] = useState(false);
  const [cropTargetLayerId, setCropTargetLayerId] = useState<string | null>(null);
  const [crop, setCrop] = useState({ x: 0, y: 0 });
  const [zoom, setZoom] = useState(1);
  const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null);
  const [aiLayerCount, setAiLayerCount] = useState(0);
  const [adjustmentCount, setAdjustmentCount] = useState(1);
  const [aspect, setAspect] = useState<number | undefined>(3 / 2);
  const [isSegmenting, setIsSegmenting] = useState(false);
  const [model, setModel] = useState<ModelId>('nano');
  const fileInputRef = useRef<HTMLInputElement>(null);

  const menuItems = ['Export', 'Help'];
  const toolset = [
    { id: 'select', label: 'Select', icon: MousePointer2 },
    { id: 'crop', label: 'Crop', icon: Crop },
    { id: 'filters', label: 'Filters', icon: Sparkles },
    { id: 'magic', label: 'Magic', icon: Wand2 },
  ];
  const presetPrompts = [
    {
      label: 'Portrait glow',
      prompt:
        'Hyper-real studio portrait, cinematic rim lighting, polished skin texture, subtle bokeh background, warm highlights',
    },
    {
      label: 'Cinematic matte',
      prompt:
        'Moody widescreen grade, teal and amber palette, lifted blacks, film grain, dramatic contrast, cinematic atmosphere',
    },
    {
      label: 'Product polish',
      prompt:
        'Luxury product beauty lighting, reflective podium, glossy highlights, high-contrast shadows, editorial look',
    },
    {
      label: 'Golden hour',
      prompt:
        'Sunset hues, soft volumetric light, warm peach glow, long natural shadows, coastal warmth, dreamy tone',
    },
    {
      label: 'Analog film',
      prompt:
        'Kodak portra palette, soft halation, subtle film grain, gentle fades, authentic analog imperfections',
    },
  ];

  const filterPresets = [
    {
      label: 'Vintage Film',
      prompt: 'Kodak Portra 400 film, warm amber tones, subtle grain texture, vintage color grading, authentic film imperfections, cinematic look',
      category: 'Film'
    },
    {
      label: 'Black & White',
      prompt: 'High contrast black and white, dramatic shadows, rich textures, classic photography, silver gelatin print aesthetic, moody atmosphere',
      category: 'Monochrome'
    },
    {
      label: 'Sepia Tone',
      prompt: 'Warm sepia tones, vintage photograph, antique brown coloring, historical aesthetic, aged paper texture, nostalgic atmosphere',
      category: 'Vintage'
    },
    {
      label: 'High Contrast',
      prompt: 'Extreme contrast, deep blacks, bright highlights, dramatic lighting, bold shadows, punchy colors, vibrant saturation',
      category: 'Dramatic'
    },
    {
      label: 'Soft Glow',
      prompt: 'Soft dreamy glow, ethereal lighting, gentle highlights, warm ambiance, romantic atmosphere, subtle soft focus, luminous quality',
      category: 'Dreamy'
    },
    {
      label: 'Cinematic Teal',
      prompt: 'Cinematic color grading, teal and orange palette, film look, lifted blacks, subtle grain, Hollywood cinematography style',
      category: 'Cinematic'
    },
    {
      label: 'Neon Glow',
      prompt: 'Vibrant neon colors, cyberpunk aesthetic, electric blues and pinks, glowing highlights, futuristic atmosphere, high saturation',
      category: 'Modern'
    },
    {
      label: 'Matte Flat',
      prompt: 'Flat design aesthetic, minimal shadows, clean lighting, modern photography, reduced contrast, contemporary style, flat lay',
      category: 'Minimal'
    },
    {
      label: 'Golden Hour',
      prompt: 'Golden hour lighting, warm sunset tones, long dramatic shadows, magical atmosphere, romantic golden glow, evening light',
      category: 'Lighting'
    },
    {
      label: 'Vintage Polaroid',
      prompt: 'Polaroid instant film, faded colors, white border, authentic polaroid aesthetic, nostalgic snapshot, retro photography',
      category: 'Instant'
    },
    {
      label: 'Studio Lighting',
      prompt: 'Professional studio lighting, clean white background, even illumination, commercial photography, product photography style',
      category: 'Studio'
    },
    {
      label: 'Moody Noir',
      prompt: 'Film noir style, high contrast, deep shadows, dramatic lighting, black and white with blue tint, mysterious atmosphere',
      category: 'Noir'
    }
  ];
  const quickEdits: QuickEditAction[] = [
    {
      label: 'Clean Background',
      description: 'Isolate your subject on a soft studio gradient.',
      prompt:
        'Isolate the main subject, remove distractions, replace background with a soft neutral gradient backdrop, keep natural shadows, commercial studio polish',
      negativePrompt: 'busy background, clutter, extra hands, text, watermark',
      icon: Eraser,
    },
    {
      label: 'Product Pop',
      description: 'Boost contrast, reflections, and clarity.',
      prompt:
        'Create a premium e-commerce hero shot, punchy contrast, sharpened edges, controlled reflections, glossy highlights, gradient sweep backdrop',
      negativePrompt: 'noise, watermark, text overlay, harsh artifacts',
      icon: Camera,
    },
    {
      label: 'Portrait Glow',
      description: 'Retouch skin, add warm rim lighting.',
      prompt:
        'Subtle portrait retouch, even skin tone, soften blemishes, add warm golden rim light, cinematic bokeh background, high-end magazine aesthetic',
      negativePrompt: 'over-smoothing, plastic skin, distortion, vignette',
      icon: SunMedium,
    },
    {
      label: 'Cinematic Mood',
      description: 'Teal & amber film-grade look.',
      prompt:
        'Apply dramatic teal and amber cinematic grade, lifted blacks, gentle bloom, volumetric atmosphere, film grain, widescreen energy',
      negativePrompt: 'washed out, oversaturated, text overlay',
      icon: Palette,
    },
    {
      label: 'Vibrant Neon',
      description: 'Add cyberpunk neon accents.',
      prompt:
        'Introduce neon magenta and cyan rim lighting, subtle glow trails, futuristic highlights, reflective surfaces, cyberpunk energy',
      negativePrompt: 'overexposed, posterization, text badge',
      icon: Zap,
    },
    {
      label: 'Matte Vintage',
      description: 'Soft matte finish with retro tones.',
      prompt:
        'Apply vintage matte film look, muted shadows, gentle halation, warm highlights, dusted texture, analog imperfections',
      negativePrompt: 'heavy grain, scratches, frame border, text',
      icon: SparklesIcon,
    },
  ];
  const modelOptions: Array<{ id: ModelId; label: string; description: string }> = [
    {
      id: 'nano',
      label: 'Nano Banana',
      description: 'Fast & lightweight',
    },
    {
      id: 'pro',
      label: 'Nano Banana Pro',
      description: 'SOTA fidelity',
    },
  ];
  const aspectPresets = [
    { label: 'Free', ratio: undefined },
    { label: '1:1', ratio: 1 },
    { label: '4:5', ratio: 4 / 5 },
    { label: '3:2', ratio: 3 / 2 },
    { label: '16:9', ratio: 16 / 9 },
  ];

const registerLayer = useCallback(
  (
    layerData: Omit<Layer, 'id' | 'timestamp'>,
    options?: { replaceKind?: LayerKind; autoSelect?: boolean },
  ) => {
      const layer: Layer = {
        id: `layer-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
        timestamp: new Date().toLocaleTimeString(),
        ...layerData,
      visible: layerData.visible ?? true,
      };

    setLayers((prev) => {
        let base = prev;
        if (options?.replaceKind) {
          base = prev.filter((entry) => entry.kind !== options.replaceKind);
        }
        return [layer, ...base];
      });

    if (options?.autoSelect !== false) {
      setSelectedLayerId(layer.id);
    }
      return layer;
    },
    [],
  );

  const loadImageElement = useCallback((src: string) => {
    return new Promise<HTMLImageElement>((resolve, reject) => {
      const img = new Image();
      img.crossOrigin = 'anonymous';
      img.onload = () => {
        console.log(`[IMAGE LOAD] Loaded image, src start: ${src.substring(0, 60)}..., size: ${img.width}x${img.height}`);
        resolve(img);
      };
      img.onerror = (e) => {
        console.error(`[IMAGE LOAD] Failed to load image, src start: ${src.substring(0, 60)}...`, e);
        reject(e);
      };
      img.src = src;
    });
  }, []);

  const createMaskedPreview = useCallback((baseImage: HTMLImageElement, maskImage: HTMLImageElement) => {
    const width = baseImage.width;
    const height = baseImage.height;
    const canvas = document.createElement('canvas');
    const ctx = canvas.getContext('2d');

    if (!ctx) {
      throw new Error('Unable to create canvas context for segmentation preview');
    }

    canvas.width = width;
    canvas.height = height;
    
    // Clear canvas to transparent
    ctx.clearRect(0, 0, width, height);
    
    // First, check what the mask looks like
    const maskCanvas = document.createElement('canvas');
    const maskCtx = maskCanvas.getContext('2d');
    if (maskCtx) {
      maskCanvas.width = maskImage.width;
      maskCanvas.height = maskImage.height;
      maskCtx.drawImage(maskImage, 0, 0);
      const maskData = maskCtx.getImageData(0, 0, Math.min(100, maskImage.width), Math.min(100, maskImage.height));
      let whitePixels = 0;
      let blackPixels = 0;
      let transparentPixels = 0;
      let opaquePixels = 0;
      for (let i = 0; i < maskData.data.length; i += 4) {
        const r = maskData.data[i];
        const g = maskData.data[i + 1];
        const b = maskData.data[i + 2];
        const a = maskData.data[i + 3];
        if (a > 250) opaquePixels++;
        if (a < 10) transparentPixels++;
        else if (r > 200 && g > 200 && b > 200) whitePixels++;
        else if (r < 55 && g < 55 && b < 55) blackPixels++;
      }
      // Debug mask content (uncomment if needed)
      // console.log('[MASK] Mask analysis:', { 
      //   whitePixels, 
      //   blackPixels, 
      //   transparentPixels, 
      //   opaquePixels,
      //   totalSampled: maskData.data.length / 4,
      //   percentWhite: (whitePixels / (maskData.data.length / 4) * 100).toFixed(1) + '%',
      //   percentOpaque: (opaquePixels / (maskData.data.length / 4) * 100).toFixed(1) + '%'
      // });
    }
    
    // Draw the base image
    ctx.drawImage(baseImage, 0, 0, width, height);
    
    // Create a temporary canvas for the mask to analyze it
    const tempCanvas = document.createElement('canvas');
    const tempCtx = tempCanvas.getContext('2d');
    if (!tempCtx) {
      throw new Error('Unable to create temporary canvas context');
    }
    tempCanvas.width = width;
    tempCanvas.height = height;
    tempCtx.drawImage(maskImage, 0, 0, width, height);
    const maskData = tempCtx.getImageData(0, 0, width, height);
    
    // Get the base image data
    const imageData = ctx.getImageData(0, 0, width, height);
    
    // Apply mask: Check both alpha channel and RGB values
    // SAM2 masks can be: 1) Alpha channel mask, 2) RGB grayscale, or 3) Inverted
    let hasAlphaVariation = false;
    let hasRGBVariation = false;
    
    // Sample to detect mask type
    for (let i = 0; i < Math.min(1000, maskData.data.length); i += 4) {
      if (maskData.data[i + 3] < 255) hasAlphaVariation = true;
      if (maskData.data[i] !== maskData.data[i + 3]) hasRGBVariation = true;
    }
    
    console.log('[MASK] Detected mask type:', { hasAlphaVariation, hasRGBVariation });
    
    // Apply the mask and count non-transparent pixels
    let opaquePixelCount = 0;
    let semiTransparentCount = 0;
    let transparentCount = 0;
    
    for (let i = 0; i < imageData.data.length; i += 4) {
      if (hasAlphaVariation) {
        // Use alpha channel directly
        imageData.data[i + 3] = maskData.data[i + 3];
      } else {
        // Use RGB value as alpha (standard: white = keep, black = remove)
        const maskValue = maskData.data[i]; // Red channel
        imageData.data[i + 3] = maskValue; // White (255) = opaque, Black (0) = transparent
      }
      
      const alpha = imageData.data[i + 3];
      if (alpha > 200) opaquePixelCount++;
      else if (alpha > 50) semiTransparentCount++;
      else transparentCount++;
    }
    
    const totalPixels = imageData.data.length / 4;
    console.log('[MASK] Final pixel counts:', {
      opaque: opaquePixelCount,
      semiTransparent: semiTransparentCount,
      transparent: transparentCount,
      total: totalPixels,
      percentOpaque: ((opaquePixelCount / totalPixels) * 100).toFixed(1) + '%'
    });
    
    ctx.putImageData(imageData, 0, 0);

    const result = canvas.toDataURL('image/png');
    
    // Debug: Check if the result is different from the original (uncomment if needed)
    // console.log('[MASK] Created preview', {
    //   baseSize: `${baseImage.width}x${baseImage.height}`,
    //   maskSize: `${maskImage.width}x${maskImage.height}`,
    //   canvasSize: `${canvas.width}x${canvas.height}`,
    //   resultLength: result.length
    // });

    return result;
  }, []);

  const extractBoundingBox = useCallback((maskImage: HTMLImageElement): BoundingBox | null => {
    const width = maskImage.width;
    const height = maskImage.height;
    const canvas = document.createElement('canvas');
    const ctx = canvas.getContext('2d');

    if (!ctx) {
      return null;
    }

    canvas.width = width;
    canvas.height = height;
    ctx.drawImage(maskImage, 0, 0, width, height);
    const { data } = ctx.getImageData(0, 0, width, height);

    let minX = width;
    let minY = height;
    let maxX = 0;
    let maxY = 0;
    let hasPixel = false;

    for (let y = 0; y < height; y += 1) {
      for (let x = 0; x < width; x += 1) {
        const alpha = data[(y * width + x) * 4 + 3];
        if (alpha > 25) {
          hasPixel = true;
          if (x < minX) minX = x;
          if (y < minY) minY = y;
          if (x > maxX) maxX = x;
          if (y > maxY) maxY = y;
        }
      }
    }

    if (!hasPixel) {
      return null;
    }

    return {
      x: (minX / width) * 100,
      y: (minY / height) * 100,
      width: ((maxX - minX + 1) / width) * 100,
      height: ((maxY - minY + 1) / height) * 100,
    };
  }, []);

  const createSegmentLayers = useCallback(
    async (
      baseImageUrl: string,
      items: Array<{ url?: string; data_url?: string }> = [],
      isSegmentedImages: boolean = false,
      masks: Array<{ url?: string; data_url?: string }> = []
    ) => {
      if (!baseImageUrl) return;

      if (!items.length) {
        setLayers((prev) => prev.filter((layer) => layer.kind !== 'segment'));
        return;
      }

      // First, clear existing segment layers
      setLayers((prev) => prev.filter((layer) => layer.kind !== 'segment'));

      // Create or update the Background/Source layer
      registerLayer(
        {
          name: 'Background',
          kind: 'source',
          preview: baseImageUrl,
          visible: true,
          metadata: {},
        },
        {
          replaceKind: 'source',
          autoSelect: false,
        },
      );

      // Collect all segment layers first, then add them in one batch
      const newSegmentLayers: Layer[] = [];
      
      for (let i = 0; i < items.length; i += 1) {
        const item = items[i];
        const itemSource = item?.data_url || item?.url;
        
        // Try to find corresponding mask
        const maskItem = masks[i];
        const maskSource = maskItem?.data_url || maskItem?.url || (isSegmentedImages ? undefined : itemSource);
        
        console.log(`[SEGMENT ${i + 1}] Item:`, {
          hasDataUrl: !!item?.data_url,
          hasUrl: !!item?.url,
          hasMask: !!maskSource,
          dataUrlStart: item?.data_url?.substring(0, 80),
          urlStart: item?.url?.substring(0, 80)
        });
        if (!itemSource) {
          console.warn(`[SEGMENT ${i + 1}] No source found, skipping`);
          continue;
        }
        
        try {
          // If we have segmented_images, they're already the extracted objects
          // If we have individual_masks, we need to apply them to the base image
          let preview: string;
          let boundingBox: BoundingBox | null = null;
          
          if (isSegmentedImages) {
            // Already segmented - just use it directly
            preview = itemSource;
            const segmentedImage = await loadImageElement(itemSource);
            boundingBox = extractBoundingBox(segmentedImage);
          } else {
            // It's a mask - apply it to the base image
            console.log(`[SEGMENT ${i + 1}] Loading base and mask images...`);
            const baseImage = await loadImageElement(baseImageUrl);
            const maskImage = await loadImageElement(itemSource);
            console.log(`[SEGMENT ${i + 1}] Loaded base: ${baseImage.width}x${baseImage.height}, mask: ${maskImage.width}x${maskImage.height}`);
            console.log(`[SEGMENT ${i + 1}] Mask src hash:`, itemSource.substring(0, 100));
            
            // TEMP DEBUG: For the first and second segments, log details
            if (i === 0 || i === 1) {
              console.log(`[DEBUG] Segment ${i + 1} mask URL:`, itemSource.substring(0, 150));
            }
            preview = createMaskedPreview(baseImage, maskImage);
            console.log(`[SEGMENT ${i + 1}] Preview created, length: ${preview.length}, start: ${preview.substring(0, 100)}`);
            boundingBox = extractBoundingBox(maskImage);
            console.log(`[SEGMENT ${i + 1}] Created preview, boundingBox:`, boundingBox);
          }
          
          const layer: Layer = {
            id: `segment-${Date.now()}-${i}-${Math.random().toString(36).slice(2, 6)}`,
            name: `Segment ${i + 1}`,
            kind: 'segment',
            preview,
            visible: true,
            timestamp: new Date().toLocaleTimeString(),
            metadata: {
              maskUrl: maskSource,
              boundingBox,
            },
          };
          
          newSegmentLayers.push(layer);
          console.log(`[SEGMENT ${i + 1}] Layer created with preview length: ${preview.length}`);
        } catch (segmentError) {
          console.error(`Failed to create segment layer ${i + 1}`, segmentError);
        }
      }

      // Add all segment layers at once
      if (newSegmentLayers.length > 0) {
        setLayers((prev) => [...newSegmentLayers, ...prev]);
        setSelectedLayerId(newSegmentLayers[0].id);
        console.log(`[SEGMENTS] Added ${newSegmentLayers.length} segment layers in one batch`);
      } else {
        console.warn('[SEGMENTS] No segment layers were created');
      }
    },
    [createMaskedPreview, extractBoundingBox, loadImageElement, registerLayer],
  );

  const handleAddAdjustmentLayer = useCallback(() => {
    registerLayer({
      name: `Adjustment ${adjustmentCount}`,
      kind: 'empty',
      preview: null,
    });
    setAdjustmentCount((count) => count + 1);
  }, [adjustmentCount, registerLayer]);

  useEffect(() => {
    if (!layers.length) {
      setSelectedLayerId(null);
      return;
    }
    const exists = layers.some((layer) => layer.id === selectedLayerId);
    if (!exists) {
      setSelectedLayerId(layers[0].id);
    }
  }, [layers, selectedLayerId]);

  const activeLayer = selectedLayerId
    ? layers.find((layer) => layer.id === selectedLayerId) ?? layers[0]
    : layers[0];
  const displayedImage = activeLayer?.preview || editedImage || image;
  const canCrop = Boolean(displayedImage);
  const segmentLayers = layers.filter((layer) => layer.kind === 'segment');
  const baseImageForSegments = editedImage || image || null;
  const sourceLayer = layers.find((layer) => layer.kind === 'source');
  const showBaseImage = segmentLayers.length === 0 || !sourceLayer || sourceLayer.visible !== false;
  const historyEntries = [
    'Session started',
    image ? 'Image imported' : null,
    editedImage ? 'AI edit applied' : null,
  ].filter((entry): entry is string => Boolean(entry));

  const baseLayer = useMemo(() => {
    return layers.find((layer) => layer.kind === 'ai') || layers.find((layer) => layer.kind === 'source') || null;
  }, [layers]);

  const cropTargetLayer = useMemo(() => {
    if (cropTargetLayerId) {
      return layers.find((layer) => layer.id === cropTargetLayerId) || null;
    }
    if (activeLayer?.kind === 'segment') {
      return baseLayer;
    }
    return activeLayer ?? baseLayer;
  }, [activeLayer, baseLayer, cropTargetLayerId, layers]);

  const cropperImage = cropTargetLayer?.preview || baseImageForSegments || displayedImage || null;
  const hasEditableImage = Boolean(image || editedImage);

  const handleAutoSegment = useCallback(async () => {
    const baseImageSource = image || editedImage;
    if (!baseImageSource) return;

    setIsSegmenting(true);
    setError(null);

    try {
      const blob = await dataUrlToBlob(baseImageSource);
      const segmentationData = await segmentImage(blob);
      
      // Debug: Log what we received
      console.log('[SEGMENT] Received data:', {
        hasIndividualMasks: !!segmentationData?.individual_masks,
        individualMasksCount: segmentationData?.individual_masks?.length,
        hasSegmentedImages: !!segmentationData?.segmented_images,
        segmentedImagesCount: segmentationData?.segmented_images?.length,
        keys: Object.keys(segmentationData || {})
      });
      
      // Check if we should use segmented_images (pre-extracted objects) or individual_masks (need to apply to base)
      const hasSegmentedImages = segmentationData?.segmented_images && segmentationData.segmented_images.length > 0;
      const itemsToUse = hasSegmentedImages 
        ? segmentationData.segmented_images 
        : (segmentationData?.individual_masks || []);
      const masksToUse = segmentationData?.individual_masks || [];
      
      console.log('[SEGMENT] Using:', hasSegmentedImages ? 'segmented_images' : 'individual_masks', itemsToUse.length);
      
      await createSegmentLayers(baseImageSource, itemsToUse, hasSegmentedImages, masksToUse);
    } catch (err) {
      const errorMessage = err instanceof Error ? err.message : 'Segmentation failed';
      setError(errorMessage);
      console.error('Segmentation error:', err);
    } finally {
      setIsSegmenting(false);
    }
  }, [createSegmentLayers, dataUrlToBlob, image, editedImage]);

  const handleToolSelect = useCallback(
    (toolId: string) => {
      setActiveTool(toolId);
      if (toolId === 'crop' && canCrop) {
        const targetLayer =
          activeLayer && activeLayer.kind !== 'segment' ? activeLayer : baseLayer;
        if (targetLayer) {
          if (selectedLayerId !== targetLayer.id) {
            setSelectedLayerId(targetLayer.id);
          }
          setCropTargetLayerId(targetLayer.id);
        } else {
          setCropTargetLayerId(null);
        }
        setShowCropper(true);
      } else {
        setShowCropper(false);
        setCropTargetLayerId(null);
      }
      if (toolId === 'magic' && image) {
        handleAutoSegment();
      }
    },
    [activeLayer, baseLayer, canCrop, handleAutoSegment, image, selectedLayerId],
  );


  useEffect(() => {
    if (activeTool === 'crop' && canCrop) {
      setShowCropper(true);
    } else {
      setShowCropper(false);
    }
  }, [activeTool, canCrop]);

  useEffect(() => {
    if (showCropper) {
      setCrop({ x: 0, y: 0 });
      setZoom(1);
      setCroppedAreaPixels(null);
    } else {
      setCroppedAreaPixels(null);
      setCropTargetLayerId(null);
    }
  }, [showCropper]);

  const onCropComplete = useCallback((_: Area, croppedArea: Area) => {
    setCroppedAreaPixels(croppedArea);
  }, []);

  const getCroppedImage = useCallback(
    (imageSrc: string, croppedArea: Area) =>
      new Promise<string>((resolve, reject) => {
        const imageElement = new Image();
        imageElement.src = imageSrc;
        imageElement.crossOrigin = 'anonymous';
        imageElement.onload = () => {
          const canvas = document.createElement('canvas');
          canvas.width = croppedArea.width;
          canvas.height = croppedArea.height;
          const ctx = canvas.getContext('2d');
          if (!ctx) {
            reject(new Error('Canvas not supported'));
            return;
          }
          ctx.drawImage(
            imageElement,
            croppedArea.x,
            croppedArea.y,
            croppedArea.width,
            croppedArea.height,
            0,
            0,
            croppedArea.width,
            croppedArea.height,
          );
          resolve(canvas.toDataURL('image/png'));
        };
        imageElement.onerror = () => reject(new Error('Failed to load image for cropping'));
      }),
    [],
  );

  const handleApplyCrop = useCallback(async () => {
    if (!croppedAreaPixels) return;

    const targetLayer = cropTargetLayer || baseLayer;
    const targetImage = targetLayer?.preview || baseImageForSegments || displayedImage || editedImage || image;

    if (!targetImage) return;

    try {
      const croppedDataUrl = await getCroppedImage(targetImage, croppedAreaPixels);
      const targetKind: LayerKind = targetLayer?.kind ?? (editedImage ? 'ai' : 'source');
      const shouldResetSegments = targetKind === 'source' || targetKind === 'ai';

      setLayers((prev) => {
        let updated = targetLayer
          ? prev.map((layer) =>
              layer.id === targetLayer.id
                ? { ...layer, preview: croppedDataUrl, timestamp: new Date().toLocaleTimeString() }
                : layer,
            )
          : prev;

        if (shouldResetSegments) {
          updated = updated.filter((layer) => layer.kind !== 'segment');
        }

        return updated;
      });

      if (targetKind === 'source') {
        setImage(croppedDataUrl);
      } else if (targetKind === 'ai') {
        setEditedImage(croppedDataUrl);
      } else if (!targetLayer) {
        if (editedImage) {
          setEditedImage(croppedDataUrl);
        } else {
          setImage(croppedDataUrl);
        }
      }

      if (targetLayer) {
        setSelectedLayerId(targetLayer.id);
      }

      setShowCropper(false);
      setActiveTool('select');
      setCropTargetLayerId(null);
    } catch (cropError) {
      console.error(cropError);
      setError('Failed to crop image. Please try again.');
    }
  }, [
    baseImageForSegments,
    baseLayer,
    cropTargetLayer,
    croppedAreaPixels,
    displayedImage,
    editedImage,
    getCroppedImage,
    image,
  ]);

  const handleCancelCrop = () => {
    setShowCropper(false);
    setActiveTool('select');
    setCropTargetLayerId(null);
  };

  const handleLayerSelect = (layerId: string) => {
    setSelectedLayerId(layerId);
    
    // TEMP: Log which layer was selected for debugging
    const layer = layers.find(l => l.id === layerId);
    console.log('[LAYER SELECT]', layer?.name, 'preview length:', layer?.preview?.length);
    if (layer?.preview) {
      console.log('[LAYER SELECT] Preview URL (open in new tab):', layer.preview);
    }
  };

  const toggleLayerVisibility = useCallback((layerId: string, soloMode: boolean = false) => {
    setLayers((prev) => {
      if (soloMode) {
        // Solo mode: hide all segments except this one
        return prev.map((layer) => {
          if (layer.kind === 'segment') {
            return { ...layer, visible: layer.id === layerId };
          }
          return layer;
        });
      } else {
        // Normal toggle
        return prev.map((layer) =>
          layer.id === layerId ? { ...layer, visible: layer.visible === false ? true : false } : layer,
        );
      }
    });
  }, []);

  const handlePresetApply = (preset: string) => {
    setPrompt(preset);
  };

  const handleAspectPreset = (ratio: number | undefined) => {
    setAspect(ratio);
  };

  const handleApplyFilter = async (filterPrompt: string) => {
    if (!image) {
      setError('Please upload an image first');
      return;
    }

    if (!apiConfigured) {
      setError('API key not configured. Please check backend configuration.');
      return;
    }

    setIsLoading(true);
    setError(null);
    setPrompt(filterPrompt);

    try {
      const data = await editImage({
        image: await dataUrlToBlob(image),
        prompt: filterPrompt.trim(),
        negativePrompt: negativePrompt.trim() || undefined,
        model,
      });

      const imageUrl = extractImageUrl(data);
      if (imageUrl) {
        setEditedImage(imageUrl);
        registerLayer({
          name: `Filter: ${filterPrompt.trim().substring(0, 15)}...`,
          kind: 'ai',
          preview: imageUrl,
        });
        setAiLayerCount((c) => c + 1);
      }
    } catch (err) {
      const errorMessage = err instanceof Error ? err.message : 'Filter application failed';
      setError(errorMessage);
      console.error('Filter error:', err);
    } finally {
      setIsLoading(false);
    }
  };

  const handleQuickEdit = async (action: QuickEditAction) => {
    const baseSource = editedImage || image;
    if (!baseSource) {
      setError('Please upload an image first');
      return;
    }

    if (!apiConfigured) {
      setError('API key not configured. Please check backend configuration.');
      return;
    }

    setIsLoading(true);
    setError(null);
    setPrompt(action.prompt);

    try {
      const blob = await dataUrlToBlob(baseSource);
      const data = await editImage({
        image: blob,
        prompt: action.prompt.trim(),
        negativePrompt: action.negativePrompt?.trim() || undefined,
        model,
      });

      const imageUrl = extractImageUrl(data);
      if (imageUrl) {
        setEditedImage(imageUrl);
        registerLayer({
          name: `Quick: ${action.label}`,
          kind: 'ai',
          preview: imageUrl,
        });
        setAiLayerCount((count) => count + 1);
      }
    } catch (err) {
      const errorMessage = err instanceof Error ? err.message : 'Quick edit failed';
      setError(errorMessage);
      console.error('Quick edit error:', err);
    } finally {
      setIsLoading(false);
    }
  };

  const handleMenuClick = (menuItem: string) => {
    switch (menuItem) {
      case 'Export':
        handleDownload();
        break;
      case 'Help':
        window.open('https://github.com/your-repo/nanobanana-studio', '_blank');
        break;
      default:
        break;
    }
  };

  // Check API health on mount
  useEffect(() => {
    checkHealth()
      .then((health) => {
        setApiConfigured(health.apiConfigured);
        if (!health.apiConfigured) {
          setError('API key not configured. Please set FAL_API_KEY in backend/.env');
        }
      })
      .catch(() => {
        setError('Unable to connect to backend server');
      });
  }, []);

  const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (!file) return;

    // Validate file
    const validationError = validateImageFile(file);
    if (validationError) {
      setError(validationError);
      return;
    }

    // Read file
    const reader = new FileReader();
    reader.onload = (event) => {
      const dataUrl = event.target?.result as string;
      setImage(dataUrl);
      setEditedImage(null);
      setError(null);
      registerLayer(
        {
          name: 'Source Asset',
          kind: 'source',
          preview: dataUrl,
        },
        { replaceKind: 'source' },
      );
      setAiLayerCount(0);
    };
    reader.onerror = () => {
      setError('Failed to read image file');
    };
    reader.readAsDataURL(file);
  };

  const handleProcess = async () => {
    // Validate inputs
    if (!prompt.trim()) {
      setError('Please enter a prompt');
      return;
    }

    if (prompt.length > 2000) {
      setError('Prompt is too long (max 2000 characters)');
      return;
    }

    if (mode === 'edit' && !image) {
      setError('Please upload an image first');
      return;
    }

    if (!apiConfigured) {
      setError('API key not configured. Please check backend configuration.');
      return;
    }

    setIsLoading(true);
    setError(null);

    try {
      let data;

      if (mode === 'edit') {
        // Convert data URL to blob
        const blob = await dataUrlToBlob(image!);

        // Call edit API
        data = await editImage({
          image: blob,
          prompt: prompt.trim(),
          negativePrompt: negativePrompt.trim() || undefined,
          model,
        });
      } else {
        // Call generate API
        data = await generateImage({
          prompt: prompt.trim(),
          negativePrompt: negativePrompt.trim() || undefined,
          model,
        });
      }

      // Extract image URL from response
      const imageUrl = extractImageUrl(data);

      if (imageUrl) {
        setEditedImage(imageUrl);
        const layerName = mode === 'edit' ? `AI Output ${aiLayerCount + 1}` : `Generation ${aiLayerCount + 1}`;
        registerLayer({
          name: layerName,
          kind: 'ai',
          preview: imageUrl,
        });
        setAiLayerCount((count) => count + 1);
      } else {
        throw new Error('No image in response. Please try again.');
      }
    } catch (err) {
      const errorMessage = err instanceof Error ? err.message : 'An unexpected error occurred';
      setError(errorMessage);
      console.error('Error processing image:', err);
    } finally {
      setIsLoading(false);
    }
  };

  const handleDownload = () => {
    if (editedImage) {
      const filename = `nanobanana-${mode}-${getTimestamp()}.png`;
      downloadImage(editedImage, filename);
    }
  };

  const handleClearImage = () => {
    setImage(null);
    setEditedImage(null);
    setError(null);
    if (fileInputRef.current) {
      fileInputRef.current.value = '';
    }
  };

  const handleModeChange = (newMode: Mode) => {
    setMode(newMode);
    setError(null);
  };

  const handleKeyPress = (e: React.KeyboardEvent) => {
    if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
      handleProcess();
    }
  };

  return (
    <div className="app">
      <header className="top-bar">
        <div className="brand">
          <Sparkles className="brand-icon" size={20} />
          <div>
            <p className="brand-title">NanoBanana Studio</p>
            <span className="brand-subtitle">NanoBanana & Pro • fal.ai</span>
          </div>
        </div>

        <div className="quick-action-nav">
          {quickEdits.map((action) => (
            <button
              key={action.label}
              type="button"
              className="quick-action-icon-btn"
              onClick={() => handleQuickEdit(action)}
              disabled={!hasEditableImage || isLoading}
              title={action.description}
            >
              <action.icon size={16} />
              <span>{action.label}</span>
            </button>
          ))}
        </div>

        <nav className="menu-strip">
          {menuItems.map((label) => (
            <button
              key={label}
              className="menu-item"
              type="button"
              onClick={() => handleMenuClick(label)}
            >
              {label}
            </button>
          ))}
        </nav>

        <div className="status-cluster">
          <span className={`status-dot ${apiConfigured ? 'online' : 'offline'}`} />
          <span>{apiConfigured ? 'Connected' : 'API Offline'}</span>
          <button className="secondary-btn" type="button">
            <Settings2 size={16} />
            Studio prefs
          </button>
        </div>
      </header>

      <div className="workspace">
        <aside className="tool-panel">
          {toolset.map((tool) => (
            <button
              key={tool.id}
              className={`tool-btn ${activeTool === tool.id ? 'active' : ''}`}
              onClick={() => handleToolSelect(tool.id)}
              disabled={tool.id === 'crop' && !canCrop}
              type="button"
            >
              <tool.icon size={18} />
              <span>{tool.label}</span>
            </button>
          ))}
        </aside>

        <section className="canvas-shell">
          <div className="options-bar">
            <div>
              <p className="document-title">
                {image ? 'canvas.png' : 'Untitled canvas'}
              </p>
              <span className="document-subtitle">
                {mode === 'edit' ? 'Layered Edit Session' : 'Generative Session'}
              </span>
            </div>
            <div className="mode-toggle chips">
              <button
                className={`mode-chip ${mode === 'edit' ? 'active' : ''}`}
                onClick={() => handleModeChange('edit')}
                disabled={isLoading}
                type="button"
              >
                <ImageIcon size={16} />
                Edit
              </button>
              <button
                className={`mode-chip ${mode === 'generate' ? 'active' : ''}`}
                onClick={() => handleModeChange('generate')}
                disabled={isLoading}
                type="button"
              >
                <Wand2 size={16} />
                Generate
              </button>
            </div>
            <div className="model-toggle chips">
              {modelOptions.map((option) => (
                <button
                  key={option.id}
                  className={`model-chip ${model === option.id ? 'active' : ''}`}
                  onClick={() => setModel(option.id)}
                  type="button"
                  disabled={isLoading}
                >
                  <strong>{option.label}</strong>
                  <span>{option.description}</span>
                </button>
              ))}
            </div>
          </div>

          <div className="canvas-stage">
            <div className="canvas-area pro">
              {!displayedImage ? (
                mode === 'edit' ? (
                  <div className="empty-state">
                    <ImageIcon size={64} />
                    <p>Edit Images with AI</p>
                    <span className="hint">Supports JPEG, PNG, and WebP (max 50MB)</span>
                  </div>
                ) : (
                  <div className="empty-state">
                    <Wand2 size={64} />
                    <p>Describe the scene you need</p>
                    <span className="hint">Press Ctrl/Cmd + Enter to generate</span>
                  </div>
                )
              ) : (
                <div className={`image-container framed ${showCropper ? 'cropping' : ''}`}>
                  {showCropper && cropperImage ? (
                    <div className="cropper-wrapper">
                      <Cropper
                        image={cropperImage}
                        crop={crop}
                        zoom={zoom}
                        aspect={aspect}
                        onCropChange={setCrop}
                        onZoomChange={setZoom}
                        onCropComplete={onCropComplete}
                      />
                    </div>
                  ) : (
                    <div className="canvas-stack">
                      {baseImageForSegments && (
                        <img
                          src={baseImageForSegments}
                          alt="Canvas preview"
                          className="result-image"
                          style={{
                            visibility: showBaseImage ? 'visible' : 'hidden',
                          }}
                        />
                      )}
                      {segmentLayers.length > 0 && (
                        <div className="segmentation-overlay">
                          {(() => {
                            const visibleCount = segmentLayers.filter(l => l.visible !== false).length;
                            console.log(`[RENDER] Rendering ${visibleCount} visible segments out of ${segmentLayers.length} total`);
                            return null;
                          })()}
                          {segmentLayers.map((layer, idx) => {
                            if (!layer.preview) {
                              console.warn(`[RENDER] Layer ${layer.name} has no preview`);
                              return null;
                            }
                            const isVisible = layer.visible !== false;
                            const isSelected = layer.id === activeLayer?.id;
                            
                            // Debug first layer
                            if (idx === 0) {
                              console.log(`[RENDER] First segment layer:`, {
                                name: layer.name,
                                isVisible,
                                isSelected,
                                previewLength: layer.preview.length,
                                previewStart: layer.preview.substring(0, 50)
                              });
                            }
                            
                            return (
                              <div
                                key={layer.id}
                                style={{
                                  position: 'absolute',
                                  top: 0,
                                  left: 0,
                                  right: 0,
                                  bottom: 0,
                                  display: isVisible ? 'flex' : 'none',
                                  alignItems: 'center',
                                  justifyContent: 'center',
                                  opacity: !isVisible ? 0 : (isSelected ? 1 : 0.8),
                                  pointerEvents: isVisible ? 'auto' : 'none',
                                }}
                                onClick={() => isVisible && handleLayerSelect(layer.id)}
                              >
                                <img
                                  src={layer.preview}
                                  alt={layer.name}
                                  className={`segment-mask ${isSelected ? 'selected' : ''}`}
                                  style={{
                                    maxWidth: '100%',
                                    maxHeight: '100%',
                                    objectFit: 'contain',
                                  }}
                                  onLoad={(e) => {
                                    const img = e.target as HTMLImageElement;
                                    console.log(`[RENDER] ${layer.name} loaded: ${img.naturalWidth}x${img.naturalHeight}, displayed: ${img.width}x${img.height}`);
                                  }}
                                  onError={(e) => console.error(`[RENDER] Image failed to load: ${layer.name}`, e)}
                                />
                              </div>
                            );
                          })}
                          {activeLayer?.kind === 'segment' && activeLayer.metadata?.boundingBox && (
                            <div
                              className="selection-box"
                              style={{
                                left: `${activeLayer.metadata.boundingBox.x}%`,
                                top: `${activeLayer.metadata.boundingBox.y}%`,
                                width: `${activeLayer.metadata.boundingBox.width}%`,
                                height: `${activeLayer.metadata.boundingBox.height}%`,
                              }}
                            />
                          )}
                        </div>
                      )}
                      {isSegmenting && (
                        <div className="segmentation-loading">
                          <Loader2 className="spinner" size={40} />
                          <span>Segmenting objects...</span>
                        </div>
                      )}
                      {editedImage && (
                        <div className="image-actions floating">
                          <button onClick={handleDownload} className="download-btn" type="button">
                            <Download size={18} />
                            Export PNG
                          </button>
                        </div>
                      )}
                    </div>
                  )}
                </div>
              )}
            </div>

            <div className="stack-panels">
              <div className="panel-card subtle">
                <div className="panel-header">
                  <div className="panel-header-info">
                    <Layers size={16} />
                    <span>Layers</span>
                  </div>
                  <button className="layer-add-btn" type="button" onClick={handleAddAdjustmentLayer}>
                    <Plus size={14} />
                    New layer
                  </button>
                </div>
                <div className="layer-list">
                  {layers.length === 0 ? (
                    <div className="layer-item muted">Layers will appear here</div>
                  ) : (
                    layers.map((layer) => (
                      <button
                        key={layer.id}
                        type="button"
                        onClick={() => handleLayerSelect(layer.id)}
                        className={`layer-item ${
                          layer.id === activeLayer?.id ? 'active' : ''
                        } ${!layer.preview ? 'muted' : ''} ${layer.visible === false ? 'hidden' : ''}`}
                      >
                        <div className="layer-meta">
                          <strong>{layer.name}</strong>
                          <span>{layer.timestamp}</span>
                        </div>
                        <div className="layer-actions">
                          <span className={`layer-pill ${layer.kind}`}>{layer.kind}</span>
                          {layer.kind === 'segment' && layer.preview && (
                            <>
                              <button
                                type="button"
                                className="layer-eye-btn"
                                onClick={(event) => {
                                  event.stopPropagation();
                                  // Download this segment
                                  if (layer.preview) {
                                    const link = document.createElement('a');
                                    link.href = layer.preview;
                                    link.download = `${layer.name}.png`;
                                    link.click();
                                  }
                                }}
                                aria-label="Download segment"
                                title="Download segment"
                              >
                                <Download size={14} />
                              </button>
                              <button
                                type="button"
                                className="layer-eye-btn"
                                onClick={(event) => {
                                  event.stopPropagation();
                                  toggleLayerVisibility(layer.id, event.altKey);
                                }}
                                aria-label={layer.visible === false ? 'Show layer' : 'Hide layer'}
                                title={layer.visible === false ? 'Show layer (Alt+Click to solo)' : 'Hide layer (Alt+Click to solo)'}
                              >
                                {layer.visible === false ? <EyeOff size={14} /> : <Eye size={14} />}
                              </button>
                            </>
                          )}
                          {layer.kind === 'source' && (
                            <button
                              type="button"
                              className="layer-eye-btn"
                              onClick={(event) => {
                                event.stopPropagation();
                                toggleLayerVisibility(layer.id);
                              }}
                              aria-label={layer.visible === false ? 'Show background' : 'Hide background'}
                            >
                              {layer.visible === false ? <EyeOff size={14} /> : <Eye size={14} />}
                            </button>
                          )}
                        </div>
                      </button>
                    ))
                  )}
                </div>
              </div>

              <div className="panel-card subtle">
                <div className="panel-header">
                  <History size={16} />
                  <span>History</span>
                </div>
                <ul className="history-list">
                  {historyEntries.map((entry) => (
                    <li key={entry}>{entry}</li>
                  ))}
                </ul>
              </div>
            </div>
          </div>
        </section>

        <aside className="control-panel">
          <div className="panel-card">
            <div className="panel-header">
              <h2>{mode === 'edit' ? 'Edit Controls' : 'Generation Controls'}</h2>
            </div>

            {mode === 'edit' && (
              <div className="upload-deck">
                <input
                  ref={fileInputRef}
                  type="file"
                  accept="image/jpeg,image/png,image/jpg,image/webp"
                  onChange={handleImageUpload}
                  className="file-input"
                  id="file-input"
                  disabled={isLoading}
                />
                <label htmlFor="file-input" className={`upload-btn ${isLoading ? 'disabled' : ''}`}>
                  <Upload size={20} />
                  {image ? 'Replace Asset' : 'Import Image'}
                </label>
                {image && (
                  <button
                    className="clear-btn ghost"
                    onClick={handleClearImage}
                    disabled={isLoading}
                    type="button"
                  >
                    <X size={16} />
                    Remove Layer
                  </button>
                )}
              </div>
            )}

            <div className="prompt-section">
              <div className="prompt-header">
                <label htmlFor="prompt">
                  {mode === 'edit' ? 'Editing prompt' : 'Generation prompt'}
                </label>
                <span className="char-count">{prompt.length}/2000</span>
              </div>
              <textarea
                id="prompt"
                value={prompt}
                onChange={(e) => setPrompt(e.target.value)}
                onKeyDown={handleKeyPress}
                placeholder={
                  mode === 'edit'
                    ? 'Relight the subject with golden hour tones...'
                    : 'Ultra-wide hero shot of a desert city at dusk...'
                }
                rows={5}
                className="prompt-input"
                disabled={isLoading}
                maxLength={2000}
              />
            </div>

            <div className="prompt-section">
              <label htmlFor="negative-prompt">Negative prompt</label>
              <textarea
                id="negative-prompt"
                value={negativePrompt}
                onChange={(e) => setNegativePrompt(e.target.value)}
                placeholder="Artifacts to avoid (e.g., blur, watermark, distortion)"
                rows={3}
                className="prompt-input"
                disabled={isLoading}
              />
            </div>

            {activeTool === 'crop' && canCrop && (
              <div className="crop-controls">
                <div className="crop-header">
                  <div>
                    <p>Crop controls</p>
                    <span>Use handles directly on canvas</span>
                  </div>
                </div>
                <div className="aspect-options">
                  {aspectPresets.map((preset) => {
                    const isActive = preset.ratio ? aspect === preset.ratio : !aspect;
                    return (
                      <button
                        key={preset.label}
                        type="button"
                        className={`aspect-chip ${isActive ? 'active' : ''}`}
                        onClick={() => handleAspectPreset(preset.ratio)}
                      >
                        {preset.label}
                      </button>
                    );
                  })}
                </div>
                <label className="crop-label" htmlFor="crop-zoom">
                  Zoom
                </label>
                <input
                  id="crop-zoom"
                  type="range"
                  min={1}
                  max={3}
                  step={0.05}
                  value={zoom}
                  onChange={(e) => setZoom(Number(e.target.value))}
                  className="crop-slider"
                />
                <div className="crop-actions">
                  <button className="clear-btn ghost" type="button" onClick={handleCancelCrop}>
                    Cancel
                  </button>
                  <button
                    className="primary-btn"
                    type="button"
                    onClick={handleApplyCrop}
                    disabled={!croppedAreaPixels}
                  >
                    Apply crop
                  </button>
                </div>
              </div>
            )}

            {activeTool === 'filters' && (
              <div className="filters-panel">
                <div className="panel-header">
                  <h2>Photo Filters</h2>
                  <span>Apply instant AI filters to your image</span>
                </div>

                <div className="filters-grid">
                  {filterPresets.map((filter) => (
                    <button
                      key={filter.label}
                      type="button"
                      className="filter-chip"
                      onClick={() => handleApplyFilter(filter.prompt)}
                      title={filter.prompt}
                    >
                      <div className="filter-label">{filter.label}</div>
                      <div className="filter-category">{filter.category}</div>
                    </button>
                  ))}
                </div>

                <div className="filter-instructions">
                  <p>Click any filter to instantly apply it to your image using AI. Each filter uses carefully crafted prompts for professional results.</p>
                </div>
              </div>
            )}

            <div className="preset-grid">
              {presetPrompts.map((preset) => (
                <button
                  key={preset.label}
                  type="button"
                  className="preset-chip"
                  onClick={() => handlePresetApply(preset.prompt)}
                  title={preset.prompt}
                >
                  {preset.label}
                </button>
              ))}
            </div>


            <button
              onClick={handleProcess}
              disabled={isLoading || (mode === 'edit' && !image) || !prompt.trim()}
              className="edit-btn"
              title="Ctrl/Cmd + Enter"
              type="button"
            >
              {isLoading ? (
                <>
                  <Loader2 className="spinner" size={20} />
                  Processing...
                </>
              ) : (
                <>
                  <Sparkles size={20} />
                  {mode === 'edit' ? 'Run Edit' : 'Generate Image'}
                </>
              )}
            </button>

            {error && (
              <div className="error-message">
                <strong>Error:</strong> {error}
              </div>
            )}

            {!apiConfigured && !error && (
              <div className="warning-message">⚠️ API key not configured</div>
            )}
          </div>
        </aside>
      </div>

      <footer className="status-bar">
        <span>Zoom 100%</span>
        <span>Document RGB • 16-bit</span>
        <span>{isLoading ? 'Working...' : 'Idle'}</span>
      </footer>
    </div>
  );
}

export default App;


================================================
FILE: frontend/src/api.ts
================================================
import { EditResponse, EditImageParams, GenerateImageParams, InpaintImageParams } from './types';

const API_BASE = '/api';
const DEFAULT_MODEL = 'nano';

async function handleApiResponse<T>(response: Response): Promise<T> {
  if (!response.ok) {
    const errorData = await response.json().catch(() => ({ error: 'Failed to process request' }));
    throw new Error(errorData.error || `Server error: ${response.status}`);
  }
  return response.json();
}

export async function editImage(params: EditImageParams): Promise<EditResponse> {
  const formData = new FormData();
  formData.append('image', params.image, 'image.png');
  formData.append('prompt', params.prompt);
  
  if (params.negativePrompt) {
    formData.append('negativePrompt', params.negativePrompt);
  }
  formData.append('model', params.model ?? DEFAULT_MODEL);

  const response = await fetch(`${API_BASE}/edit-image`, {
    method: 'POST',
    body: formData,
  });

  return handleApiResponse<EditResponse>(response);
}

export async function inpaintImage(params: InpaintImageParams): Promise<EditResponse> {
  const formData = new FormData();
  
  if (typeof params.image === 'string') {
    formData.append('imageUrl', params.image);
  } else {
    formData.append('image', params.image, 'image.png');
  }

  if (typeof params.mask === 'string') {
    formData.append('maskUrl', params.mask);
  } else {
    formData.append('mask', params.mask, 'mask.png');
  }

  formData.append('prompt', params.prompt);

  const response = await fetch(`${API_BASE}/inpaint-image`, {
    method: 'POST',
    body: formData,
  });

  return handleApiResponse<EditResponse>(response);
}

export async function generateImage(params: GenerateImageParams): Promise<EditResponse> {
  const response = await fetch(`${API_BASE}/generate-image`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      prompt: params.prompt,
      negativePrompt: params.negativePrompt || undefined,
      width: params.width,
      height: params.height,
      model: params.model ?? DEFAULT_MODEL,
    }),
  });

  return handleApiResponse<EditResponse>(response);
}

export async function segmentImage(image: Blob): Promise<any> {
  const formData = new FormData();
  formData.append('image', image, 'image.png');

  const response = await fetch(`${API_BASE}/segment-image`, {
    method: 'POST',
    body: formData,
  });

  return handleApiResponse<any>(response);
}

export async function checkHealth(): Promise<{ status: string; apiConfigured: boolean }> {
  const response = await fetch(`${API_BASE}/health`);
  return handleApiResponse(response);
}


================================================
FILE: frontend/src/index.css
================================================
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
    'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
    sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  background: #1a1a1a;
  color: #e0e0e0;
  overflow: hidden;
}

#root {
  width: 100vw;
  height: 100vh;
}

code {
  font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
    monospace;
}



================================================
FILE: frontend/src/main.tsx
================================================
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
);



================================================
FILE: frontend/src/react-easy-crop.d.ts
================================================
declare module 'react-easy-crop' {
  import { ComponentType } from 'react';

  export interface Area {
    x: number;
    y: number;
    width: number;
    height: number;
  }

  export interface CropCoordinates {
    x: number;
    y: number;
  }

  export interface CropperProps {
    image?: string;
    crop?: CropCoordinates;
    zoom?: number;
    aspect?: number;
    onCropChange?: (value: CropCoordinates) => void;
    onZoomChange?: (value: number) => void;
    onCropComplete?: (croppedArea: Area, croppedAreaPixels: Area) => void;
    restrictPosition?: boolean;
  }

  const Cropper: ComponentType<CropperProps>;
  export default Cropper;
}



================================================
FILE: frontend/src/types.ts
================================================
export interface EditResponse {
  images?: Array<{ url: string }>;
  image?: { url: string };
}

export interface ApiError {
  error: string;
  details?: string;
  timestamp?: string;
}

export type Mode = 'edit' | 'generate';

export type ModelId = 'nano' | 'pro';

export interface EditImageParams {
  image: Blob;
  prompt: string;
  negativePrompt?: string;
  model?: ModelId;
}

export interface InpaintImageParams {
  image: Blob | string;
  mask: Blob | string;
  prompt: string;
}

export interface GenerateImageParams {
  prompt: string;
  negativePrompt?: string;
  width?: number;
  height?: number;
  model?: ModelId;
}


================================================
FILE: frontend/src/utils.ts
================================================
import { EditResponse } from './types';

const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
const ALLOWED_FILE_TYPES = ['image/jpeg', 'image/png', 'image/jpg', 'image/webp'];

export function validateImageFile(file: File): string | null {
  if (!ALLOWED_FILE_TYPES.includes(file.type)) {
    return 'Please upload a valid image file (JPEG, PNG, or WebP)';
  }

  if (file.size > MAX_FILE_SIZE) {
    return 'File size must be less than 50MB';
  }

  return null;
}

export function extractImageUrl(response: EditResponse): string | null {
  return response.images?.[0]?.url || response.image?.url || null;
}

export async function dataUrlToBlob(dataUrl: string): Promise<Blob> {
  const response = await fetch(dataUrl);
  return response.blob();
}

export function downloadImage(imageUrl: string, filename: string = 'image.png') {
  const link = document.createElement('a');
  link.href = imageUrl;
  link.download = filename;
  document.body.appendChild(link);
  link.click();
  document.body.removeChild(link);
}

export function getTimestamp(): string {
  return new Date().toISOString().replace(/[:.]/g, '-');
}



================================================
FILE: frontend/tsconfig.json
================================================
{
  "compilerOptions": {
    "target": "ES2020",
    "useDefineForClassFields": true,
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "skipLibCheck": true,
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "react-jsx",
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true
  },
  "include": ["src"],
  "references": [{ "path": "./tsconfig.node.json" }]
}



================================================
FILE: frontend/tsconfig.node.json
================================================
{
  "compilerOptions": {
    "composite": true,
    "skipLibCheck": true,
    "module": "ESNext",
    "moduleResolution": "bundler",
    "allowSyntheticDefaultImports": true
  },
  "include": ["vite.config.ts"]
}



================================================
FILE: frontend/vite.config.ts
================================================
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  server: {
    port: 3000,
    proxy: {
      '/api': {
        target: 'http://localhost:3001',
        changeOrigin: true,
      },
    },
  },
});



================================================
FILE: package.json
================================================
{
  "name": "nanobanana-studio",
  "version": "1.0.0",
  "description": "AI-powered image editor using Google's nanobanana API from fal.ai",
  "type": "module",
  "scripts": {
    "dev": "concurrently \"npm run dev:frontend\" \"npm run dev:backend\"",
    "dev:frontend": "cd frontend && npm run dev",
    "dev:backend": "cd backend && npm run dev",
    "build": "cd frontend && npm run build",
    "install:all": "npm install && cd frontend && npm install && cd ../backend && npm install"
  },
  "keywords": [
    "image-editor",
    "ai",
    "fal.ai",
    "nanobanana"
  ],
  "author": "",
  "license": "MIT",
  "devDependencies": {
    "concurrently": "^8.2.2"
  },
  "dependencies": {
    "@fal-ai/client": "^1.7.2"
  }
}
Download .txt
gitextract_ymb6w0qn/

├── .gitignore
├── CHANGELOG.md
├── DEVELOPMENT.md
├── LICENSE
├── README.md
├── SETUP.md
├── backend/
│   ├── env.example
│   ├── package.json
│   └── server.js
├── frontend/
│   ├── index.html
│   ├── package.json
│   ├── src/
│   │   ├── App.css
│   │   ├── App.tsx
│   │   ├── api.ts
│   │   ├── index.css
│   │   ├── main.tsx
│   │   ├── react-easy-crop.d.ts
│   │   ├── types.ts
│   │   └── utils.ts
│   ├── tsconfig.json
│   ├── tsconfig.node.json
│   └── vite.config.ts
└── package.json
Download .txt
SYMBOL INDEX (39 symbols across 6 files)

FILE: backend/server.js
  constant PORT (line 18) | const PORT = process.env.PORT || 3001;
  constant FAL_API_KEY (line 19) | const FAL_API_KEY = process.env.FAL_API_KEY;
  constant MAX_FILE_SIZE (line 29) | const MAX_FILE_SIZE = 50 * 1024 * 1024;
  constant ALLOWED_MIME_TYPES (line 30) | const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/jpg', 'ima...
  constant API_TIMEOUT (line 31) | const API_TIMEOUT = 120000;
  constant MODEL_ENDPOINTS (line 32) | const MODEL_ENDPOINTS = {
  function callFalAPI (line 61) | async function callFalAPI(inputPayload, modelId = 'nano') {
  function parseIntParam (line 91) | function parseIntParam(value, defaultValue = undefined) {
  function callSam2API (line 269) | async function callSam2API(imageUrl) {

FILE: frontend/src/App.tsx
  type LayerKind (line 33) | type LayerKind = 'source' | 'ai' | 'empty' | 'segment';
  type BoundingBox (line 35) | interface BoundingBox {
  type Layer (line 42) | interface Layer {
  type QuickEditAction (line 55) | interface QuickEditAction {
  function App (line 63) | function App() {

FILE: frontend/src/api.ts
  constant API_BASE (line 3) | const API_BASE = '/api';
  constant DEFAULT_MODEL (line 4) | const DEFAULT_MODEL = 'nano';
  function handleApiResponse (line 6) | async function handleApiResponse<T>(response: Response): Promise<T> {
  function editImage (line 14) | async function editImage(params: EditImageParams): Promise<EditResponse> {
  function inpaintImage (line 32) | async function inpaintImage(params: InpaintImageParams): Promise<EditRes...
  function generateImage (line 57) | async function generateImage(params: GenerateImageParams): Promise<EditR...
  function segmentImage (line 75) | async function segmentImage(image: Blob): Promise<any> {
  function checkHealth (line 87) | async function checkHealth(): Promise<{ status: string; apiConfigured: b...

FILE: frontend/src/react-easy-crop.d.ts
  type Area (line 4) | interface Area {
  type CropCoordinates (line 11) | interface CropCoordinates {
  type CropperProps (line 16) | interface CropperProps {

FILE: frontend/src/types.ts
  type EditResponse (line 1) | interface EditResponse {
  type ApiError (line 6) | interface ApiError {
  type Mode (line 12) | type Mode = 'edit' | 'generate';
  type ModelId (line 14) | type ModelId = 'nano' | 'pro';
  type EditImageParams (line 16) | interface EditImageParams {
  type InpaintImageParams (line 23) | interface InpaintImageParams {
  type GenerateImageParams (line 29) | interface GenerateImageParams {

FILE: frontend/src/utils.ts
  constant MAX_FILE_SIZE (line 3) | const MAX_FILE_SIZE = 50 * 1024 * 1024;
  constant ALLOWED_FILE_TYPES (line 4) | const ALLOWED_FILE_TYPES = ['image/jpeg', 'image/png', 'image/jpg', 'ima...
  function validateImageFile (line 6) | function validateImageFile(file: File): string | null {
  function extractImageUrl (line 18) | function extractImageUrl(response: EditResponse): string | null {
  function dataUrlToBlob (line 22) | async function dataUrlToBlob(dataUrl: string): Promise<Blob> {
  function downloadImage (line 27) | function downloadImage(imageUrl: string, filename: string = 'image.png') {
  function getTimestamp (line 36) | function getTimestamp(): string {
Condensed preview — 23 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (124K chars).
[
  {
    "path": ".gitignore",
    "chars": 65,
    "preview": "node_modules/\ndist/\nbuild/\n.env\n.DS_Store\n*.log\n.vscode/\n.idea/\n\n"
  },
  {
    "path": "CHANGELOG.md",
    "chars": 1695,
    "preview": "# Changelog\n\n## [1.0.0] - Initial Release\n\n### Features\n- ✨ AI-powered image editing with natural language prompts\n- 🎨 I"
  },
  {
    "path": "DEVELOPMENT.md",
    "chars": 5694,
    "preview": "# Development Guide\n\n## Project Overview\n\nNanoBanana Studio is a full-stack AI image editor powered by Google's nanobana"
  },
  {
    "path": "LICENSE",
    "chars": 1075,
    "preview": "MIT License\n\nCopyright (c) 2025 NanoBanana Studio\n\nPermission is hereby granted, free of charge, to any person obtaining"
  },
  {
    "path": "README.md",
    "chars": 4451,
    "preview": "# NanoBanana Studio\n\nA modern, AI-powered image editor alternative to Photoshop/Photopea, powered by Google's nanobanana"
  },
  {
    "path": "SETUP.md",
    "chars": 1982,
    "preview": "# Quick Setup Guide\n\n## Step 1: Install Dependencies\n\n```bash\nnpm run install:all\n```\n\nThis will install dependencies fo"
  },
  {
    "path": "backend/env.example",
    "chars": 24,
    "preview": "FAL_API_KEY=\nPORT=3001\n\n"
  },
  {
    "path": "backend/package.json",
    "chars": 379,
    "preview": "{\n  \"name\": \"nanobanana-backend\",\n  \"version\": \"1.0.0\",\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"node --watch serv"
  },
  {
    "path": "backend/server.js",
    "chars": 14169,
    "preview": "import express from 'express';\nimport cors from 'cors';\nimport dotenv from 'dotenv';\nimport multer from 'multer';\nimport"
  },
  {
    "path": "frontend/index.html",
    "chars": 385,
    "preview": "<!DOCTYPE html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <link rel=\"icon\" type=\"image/svg+xml\" href=\"/"
  },
  {
    "path": "frontend/package.json",
    "chars": 506,
    "preview": "{\n  \"name\": \"nanobanana-frontend\",\n  \"version\": \"1.0.0\",\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"vite\",\n    \"buil"
  },
  {
    "path": "frontend/src/App.css",
    "chars": 21349,
    "preview": ".app {\n  display: flex;\n  flex-direction: column;\n  height: 100vh;\n  background: #e0e7ff;\n  color: #000000;\n  font-famil"
  },
  {
    "path": "frontend/src/App.tsx",
    "chars": 58726,
    "preview": "import { useState, useRef, useEffect, useCallback, useMemo } from 'react';\nimport {\n  ImageIcon,\n  Upload,\n  Download,\n "
  },
  {
    "path": "frontend/src/api.ts",
    "chars": 2654,
    "preview": "import { EditResponse, EditImageParams, GenerateImageParams, InpaintImageParams } from './types';\n\nconst API_BASE = '/ap"
  },
  {
    "path": "frontend/src/index.css",
    "chars": 519,
    "preview": "* {\n  margin: 0;\n  padding: 0;\n  box-sizing: border-box;\n}\n\nbody {\n  font-family: -apple-system, BlinkMacSystemFont, 'Se"
  },
  {
    "path": "frontend/src/main.tsx",
    "chars": 238,
    "preview": "import React from 'react';\nimport ReactDOM from 'react-dom/client';\nimport App from './App';\nimport './index.css';\n\nReac"
  },
  {
    "path": "frontend/src/react-easy-crop.d.ts",
    "chars": 655,
    "preview": "declare module 'react-easy-crop' {\n  import { ComponentType } from 'react';\n\n  export interface Area {\n    x: number;\n  "
  },
  {
    "path": "frontend/src/types.ts",
    "chars": 632,
    "preview": "export interface EditResponse {\n  images?: Array<{ url: string }>;\n  image?: { url: string };\n}\n\nexport interface ApiErr"
  },
  {
    "path": "frontend/src/utils.ts",
    "chars": 1114,
    "preview": "import { EditResponse } from './types';\n\nconst MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB\nconst ALLOWED_FILE_TYPES = ['im"
  },
  {
    "path": "frontend/tsconfig.json",
    "chars": 563,
    "preview": "{\n  \"compilerOptions\": {\n    \"target\": \"ES2020\",\n    \"useDefineForClassFields\": true,\n    \"lib\": [\"ES2020\", \"DOM\", \"DOM."
  },
  {
    "path": "frontend/tsconfig.node.json",
    "chars": 214,
    "preview": "{\n  \"compilerOptions\": {\n    \"composite\": true,\n    \"skipLibCheck\": true,\n    \"module\": \"ESNext\",\n    \"moduleResolution\""
  },
  {
    "path": "frontend/vite.config.ts",
    "chars": 284,
    "preview": "import { defineConfig } from 'vite';\nimport react from '@vitejs/plugin-react';\n\nexport default defineConfig({\n  plugins:"
  },
  {
    "path": "package.json",
    "chars": 727,
    "preview": "{\n  \"name\": \"nanobanana-studio\",\n  \"version\": \"1.0.0\",\n  \"description\": \"AI-powered image editor using Google's nanobana"
  }
]

About this extraction

This page contains the full source code of the amrrs/fal-nanobanana-studio GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 23 files (115.3 KB), approximately 30.3k tokens, and a symbol index with 39 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!