diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..2e7a629
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,63 @@
+# ==============================================================================
+# AI Photo Edit - Unified Container
+# Builds React frontend and serves it alongside FastAPI backend
+# ==============================================================================
+
+# Stage 1: Build React frontend
+FROM node:20-alpine AS frontend-build
+
+WORKDIR /frontend
+
+# Copy package files and install dependencies
+COPY frontend/package.json frontend/package-lock.json* ./
+RUN npm install
+
+# Copy frontend source and build
+COPY frontend/ ./
+RUN npm run build
+
+# Stage 2: Python backend with frontend static files
+FROM python:3.11-slim
+
+WORKDIR /app
+
+# Install system dependencies for OpenCV, rembg, SAM, and image processing
+RUN apt-get update && apt-get install -y \
+ libgl1 \
+ libglib2.0-0 \
+ libsm6 \
+ libxext6 \
+ libxrender-dev \
+ libgomp1 \
+ wget \
+ git \
+ && rm -rf /var/lib/apt/lists/*
+
+# Copy requirements and install Python dependencies
+COPY backend/requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+# Pre-download rembg model (u2net) to avoid first-run delay
+RUN python -c "from rembg import remove; print('rembg model downloaded')" || true
+
+# Copy backend application
+COPY backend/ .
+
+# Copy entrypoint script
+COPY backend/entrypoint.sh /entrypoint.sh
+RUN chmod +x /entrypoint.sh
+
+# Copy scripts
+COPY scripts/ /scripts/
+
+# Copy built frontend from stage 1
+COPY --from=frontend-build /frontend/dist /app/static
+
+# Create data directories
+RUN mkdir -p /app/data/projects /app/data/patches /app/data/models
+
+# Expose port
+EXPOSE 8000
+
+# Use entrypoint script
+ENTRYPOINT ["/entrypoint.sh"]
diff --git a/backend/app/main.py b/backend/app/main.py
index f6a95fd..540f490 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -1,7 +1,10 @@
-from fastapi import FastAPI
+from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
+from fastapi.responses import FileResponse, HTMLResponse
from contextlib import asynccontextmanager
+from pathlib import Path
+import os
from app.config import settings
from app.database import init_db
@@ -40,9 +43,9 @@ app.include_router(generate.router)
app.include_router(tools.router)
-@app.get("/")
-def root():
- """API root endpoint"""
+@app.get("/api")
+def api_root():
+ """API info endpoint"""
return {
"name": "AI Photo Edit API",
"version": "1.0.0",
@@ -54,3 +57,44 @@ def root():
def health():
"""Health check endpoint"""
return {"status": "healthy"}
+
+
+# Static files directory
+STATIC_DIR = Path("/app/static")
+
+
+# Serve static assets (JS, CSS, images)
+if STATIC_DIR.exists():
+ app.mount("/assets", StaticFiles(directory=STATIC_DIR / "assets"), name="assets")
+
+
+@app.get("/", response_class=HTMLResponse)
+async def serve_spa():
+ """Serve React SPA index.html"""
+ index_path = STATIC_DIR / "index.html"
+ if index_path.exists():
+ return FileResponse(index_path)
+ return HTMLResponse("
Frontend not built. Run npm build in frontend/
")
+
+
+@app.get("/{full_path:path}")
+async def serve_spa_routes(request: Request, full_path: str):
+ """
+ Catch-all route for React Router (SPA).
+ Serves static files if they exist, otherwise returns index.html.
+ """
+ # Don't catch API routes
+ if full_path.startswith(("projects", "edits", "patches", "tools", "generate", "health", "docs", "openapi.json", "api")):
+ return {"detail": "Not Found"}
+
+ # Check if it's a static file
+ static_file = STATIC_DIR / full_path
+ if static_file.exists() and static_file.is_file():
+ return FileResponse(static_file)
+
+ # Otherwise serve index.html for React Router
+ index_path = STATIC_DIR / "index.html"
+ if index_path.exists():
+ return FileResponse(index_path)
+
+ return HTMLResponse("Frontend not built
", status_code=404)
diff --git a/docker-compose.yml b/docker-compose.yml
index 25c9539..0c31dbe 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,16 +1,13 @@
-version: '3.8'
-
services:
- backend:
+ app:
build:
- context: ./backend
+ context: .
dockerfile: Dockerfile
- container_name: ai-photo-edit-backend
+ container_name: ai-photo-edit
ports:
- - "8101:8000" # External 8101, internal 8000
+ - "3080:8000"
volumes:
- ./data:/app/data
- - ./backend:/app
- ./scripts:/scripts
environment:
- DATABASE_URL=sqlite:///./data/ai_photo_edit.db
@@ -19,28 +16,8 @@ services:
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
- STABILITY_API_KEY=${STABILITY_API_KEY:-}
- REPLICATE_API_KEY=${REPLICATE_API_KEY:-}
- - CORS_ORIGINS=http://localhost:5173,http://localhost:3000,http://localhost
- - AUTO_DOWNLOAD_SAM=${AUTO_DOWNLOAD_SAM:-true}
+ - CORS_ORIGINS=*
restart: unless-stopped
- networks:
- - ai-photo-edit-network
-
- frontend:
- build:
- context: ./frontend
- dockerfile: Dockerfile
- container_name: ai-photo-edit-frontend
- ports:
- - "3080:80" # Frontend on port 3080
- depends_on:
- - backend
- restart: unless-stopped
- networks:
- - ai-photo-edit-network
-
-networks:
- ai-photo-edit-network:
- driver: bridge
volumes:
data:
diff --git a/frontend/nginx.conf b/frontend/nginx.conf
index a12f248..530bfeb 100644
--- a/frontend/nginx.conf
+++ b/frontend/nginx.conf
@@ -47,6 +47,16 @@ server {
proxy_cache_bypass $http_upgrade;
}
+ location /tools {
+ proxy_pass http://backend:8000;
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection 'upgrade';
+ proxy_set_header Host $host;
+ proxy_cache_bypass $http_upgrade;
+ proxy_read_timeout 300s;
+ }
+
location /generate {
proxy_pass http://backend:8000;
proxy_http_version 1.1;
diff --git a/frontend/src/App.css b/frontend/src/App.css
index 86634e7..a0b9eeb 100644
--- a/frontend/src/App.css
+++ b/frontend/src/App.css
@@ -1,14 +1,366 @@
-.app {
- min-height: 100vh;
- background-color: #1a1a1a;
+/* GIMP-like layout */
+* {
+ box-sizing: border-box;
}
-.error-banner {
- background-color: #ff4444;
+html, body, #root {
+ margin: 0;
+ padding: 0;
+ height: 100%;
+ overflow: hidden;
+}
+
+.app {
+ height: 100vh;
+ display: flex;
+ flex-direction: column;
+ background-color: #1a1a1a;
+ color: #e0e0e0;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+}
+
+/* Header / Menu bar */
+.menu-bar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ background-color: #2d2d2d;
+ border-bottom: 1px solid #404040;
+ padding: 4px 12px;
+ height: 36px;
+ flex-shrink: 0;
+}
+
+.menu-bar h1 {
+ font-size: 14px;
+ font-weight: 600;
+ margin: 0;
+ color: #fff;
+}
+
+.menu-actions {
+ display: flex;
+ gap: 8px;
+}
+
+.menu-btn {
+ background: #404040;
+ border: none;
+ color: #e0e0e0;
+ padding: 4px 12px;
+ font-size: 12px;
+ border-radius: 3px;
+ cursor: pointer;
+}
+
+.menu-btn:hover {
+ background: #505050;
+}
+
+.menu-btn.primary {
+ background: #0066cc;
+}
+
+.menu-btn.primary:hover {
+ background: #0077ee;
+}
+
+/* Main workspace */
+.workspace {
+ display: flex;
+ flex: 1;
+ min-height: 0;
+ overflow: hidden;
+}
+
+/* Left toolbar - GIMP style vertical tools */
+.toolbar {
+ width: 48px;
+ background-color: #2d2d2d;
+ border-right: 1px solid #404040;
+ display: flex;
+ flex-direction: column;
+ padding: 4px;
+ gap: 2px;
+ flex-shrink: 0;
+}
+
+.tool-btn {
+ width: 40px;
+ height: 40px;
+ background: transparent;
+ border: 2px solid transparent;
+ border-radius: 4px;
+ color: #b0b0b0;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 18px;
+ transition: all 0.15s;
+}
+
+.tool-btn:hover {
+ background: #404040;
+ color: #fff;
+}
+
+.tool-btn.active {
+ background: #0066cc;
+ border-color: #0088ff;
+ color: #fff;
+}
+
+.tool-btn[title]:hover::after {
+ content: attr(title);
+ position: absolute;
+ left: 52px;
+ background: #000;
+ color: #fff;
+ padding: 4px 8px;
+ border-radius: 3px;
+ font-size: 11px;
+ white-space: nowrap;
+ z-index: 100;
+}
+
+.tool-divider {
+ height: 1px;
+ background: #404040;
+ margin: 4px 0;
+}
+
+/* Canvas area - main editing space */
+.canvas-area {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ background: #1a1a1a;
+ position: relative;
+}
+
+.canvas-wrapper {
+ flex: 1;
+ position: relative;
+ overflow: hidden;
+ background:
+ linear-gradient(45deg, #252525 25%, transparent 25%),
+ linear-gradient(-45deg, #252525 25%, transparent 25%),
+ linear-gradient(45deg, transparent 75%, #252525 75%),
+ linear-gradient(-45deg, transparent 75%, #252525 75%);
+ background-size: 20px 20px;
+ background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
+ background-color: #1e1e1e;
+}
+
+/* Canvas status bar */
+.canvas-status {
+ height: 24px;
+ background: #2d2d2d;
+ border-top: 1px solid #404040;
+ display: flex;
+ align-items: center;
+ padding: 0 12px;
+ font-size: 11px;
+ color: #888;
+ gap: 20px;
+}
+
+.zoom-controls {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.zoom-btn {
+ background: #404040;
+ border: none;
+ color: #e0e0e0;
+ width: 24px;
+ height: 20px;
+ border-radius: 3px;
+ cursor: pointer;
+ font-size: 14px;
+}
+
+.zoom-btn:hover {
+ background: #505050;
+}
+
+.zoom-level {
+ min-width: 50px;
+ text-align: center;
+}
+
+/* Right sidebar */
+.sidebar {
+ width: 280px;
+ background-color: #2d2d2d;
+ border-left: 1px solid #404040;
+ display: flex;
+ flex-direction: column;
+ overflow-y: auto;
+ flex-shrink: 0;
+}
+
+.sidebar-panel {
+ border-bottom: 1px solid #404040;
+}
+
+.panel-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 12px;
+ background: #353535;
+ cursor: pointer;
+ user-select: none;
+}
+
+.panel-header h3 {
+ font-size: 12px;
+ font-weight: 600;
+ margin: 0;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.panel-toggle {
+ font-size: 10px;
+ color: #888;
+}
+
+.panel-content {
+ padding: 12px;
+}
+
+.panel-content.collapsed {
+ display: none;
+}
+
+/* Form controls */
+.control-group {
+ margin-bottom: 12px;
+}
+
+.control-group:last-child {
+ margin-bottom: 0;
+}
+
+.control-label {
+ display: block;
+ font-size: 11px;
+ color: #888;
+ margin-bottom: 4px;
+ text-transform: uppercase;
+}
+
+.control-row {
+ display: flex;
+ gap: 4px;
+}
+
+.mode-btn {
+ flex: 1;
+ padding: 6px 8px;
+ background: #404040;
+ border: 1px solid #505050;
+ color: #e0e0e0;
+ font-size: 11px;
+ border-radius: 3px;
+ cursor: pointer;
+}
+
+.mode-btn:hover {
+ background: #505050;
+}
+
+.mode-btn.active {
+ background: #0066cc;
+ border-color: #0088ff;
+}
+
+input[type="range"] {
+ width: 100%;
+ height: 4px;
+ background: #404040;
+ border-radius: 2px;
+ outline: none;
+ -webkit-appearance: none;
+}
+
+input[type="range"]::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ width: 14px;
+ height: 14px;
+ background: #0088ff;
+ border-radius: 50%;
+ cursor: pointer;
+}
+
+textarea {
+ width: 100%;
+ background: #1a1a1a;
+ border: 1px solid #404040;
+ border-radius: 3px;
+ color: #e0e0e0;
+ padding: 8px;
+ font-size: 12px;
+ resize: vertical;
+ min-height: 60px;
+}
+
+textarea:focus {
+ border-color: #0088ff;
+ outline: none;
+}
+
+.action-btn {
+ width: 100%;
+ padding: 10px;
+ background: #0066cc;
+ border: none;
color: white;
- padding: 12px 16px;
- border-radius: 6px;
- margin-bottom: 20px;
+ font-size: 12px;
+ font-weight: 600;
+ border-radius: 3px;
+ cursor: pointer;
+ text-transform: uppercase;
+}
+
+.action-btn:hover:not(:disabled) {
+ background: #0077ee;
+}
+
+.action-btn:disabled {
+ background: #404040;
+ color: #666;
+ cursor: not-allowed;
+}
+
+.action-btn.secondary {
+ background: #404040;
+}
+
+.action-btn.secondary:hover:not(:disabled) {
+ background: #505050;
+}
+
+.action-btn.danger {
+ background: #cc3333;
+}
+
+.action-btn.danger:hover:not(:disabled) {
+ background: #dd4444;
+}
+
+/* Error banner */
+.error-banner {
+ background-color: #cc3333;
+ color: white;
+ padding: 8px 12px;
+ font-size: 12px;
display: flex;
justify-content: space-between;
align-items: center;
@@ -16,115 +368,285 @@
.error-banner button {
background: none;
+ border: none;
color: white;
- font-size: 20px;
+ font-size: 16px;
+ cursor: pointer;
padding: 0;
- width: 24px;
- height: 24px;
+}
+
+/* Project setup modal */
+.project-setup-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
+ z-index: 1000;
}
.project-setup {
- max-width: 600px;
- margin: 0 auto;
- padding: 40px;
- background-color: #2a2a2a;
+ background: #2d2d2d;
+ border: 1px solid #404040;
border-radius: 8px;
- border: 1px solid #444;
+ padding: 24px;
+ width: 400px;
+ max-width: 90vw;
}
.project-setup h2 {
- font-size: 24px;
- margin-bottom: 24px;
+ font-size: 18px;
+ margin: 0 0 20px 0;
text-align: center;
}
.setup-form {
display: flex;
flex-direction: column;
- gap: 20px;
+ gap: 16px;
}
.form-group {
display: flex;
flex-direction: column;
- gap: 8px;
+ gap: 6px;
}
.form-group label {
- font-size: 14px;
- font-weight: 600;
- color: #cccccc;
+ font-size: 12px;
+ color: #888;
+ text-transform: uppercase;
}
-.form-group input {
- width: 100%;
+.form-group input[type="text"],
+.form-group input[type="file"] {
+ background: #1a1a1a;
+ border: 1px solid #404040;
+ border-radius: 3px;
+ color: #e0e0e0;
+ padding: 10px;
+ font-size: 14px;
+}
+
+.form-group input[type="text"]:focus {
+ border-color: #0088ff;
+ outline: none;
}
.file-name {
- font-size: 12px;
- color: #00ff00;
- margin-top: 4px;
-}
-
-.optional-field label::after {
- content: '';
-}
-
-.optional-field {
- opacity: 0.7;
-}
-
-.optional-field:focus-within {
- opacity: 1;
+ font-size: 11px;
+ color: #0088ff;
}
.create-project-btn {
- background-color: #0066ff;
+ background: #0066cc;
+ border: none;
color: white;
- padding: 14px 20px;
- font-size: 16px;
+ padding: 12px;
+ font-size: 14px;
font-weight: 600;
- margin-top: 10px;
+ border-radius: 3px;
+ cursor: pointer;
+ margin-top: 8px;
}
.create-project-btn:hover:not(:disabled) {
- background-color: #0055dd;
+ background: #0077ee;
}
-.workspace {
- display: grid;
- grid-template-columns: 1fr 400px;
- gap: 20px;
- min-height: calc(100vh - 180px);
- height: calc(100vh - 180px);
+.create-project-btn:disabled {
+ background: #404040;
+ color: #666;
}
-@media (max-width: 1200px) {
- .workspace {
- grid-template-columns: 1fr;
- }
-}
-
-.left-panel {
+/* Layer list */
+.layer-list {
display: flex;
flex-direction: column;
- height: 100%;
- min-height: 0;
+ gap: 4px;
}
-.right-panel {
+.layer-item {
display: flex;
- flex-direction: column;
- gap: 12px;
- overflow-y: auto;
- max-height: calc(100vh - 180px);
- padding-right: 8px;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 8px;
+ background: #353535;
+ border-radius: 3px;
+ cursor: pointer;
}
-.history-wrapper {
+.layer-item:hover {
+ background: #404040;
+}
+
+.layer-item.active {
+ background: #0066cc;
+}
+
+.layer-visibility {
+ width: 16px;
+ height: 16px;
+ background: none;
+ border: none;
+ color: #888;
+ cursor: pointer;
+ padding: 0;
+ font-size: 12px;
+}
+
+.layer-visibility.visible {
+ color: #0088ff;
+}
+
+.layer-name {
flex: 1;
- min-height: 0;
+ font-size: 12px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+/* History list */
+.history-list {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ max-height: 200px;
+ overflow-y: auto;
+}
+
+.history-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 6px 8px;
+ background: #353535;
+ border-radius: 3px;
+ font-size: 11px;
+}
+
+.history-item:hover {
+ background: #404040;
+}
+
+.history-item.current {
+ background: #0066cc;
+}
+
+.history-item-actions {
+ display: flex;
+ gap: 4px;
+}
+
+.history-btn {
+ background: #505050;
+ border: none;
+ color: #e0e0e0;
+ padding: 2px 8px;
+ font-size: 10px;
+ border-radius: 2px;
+ cursor: pointer;
+}
+
+.history-btn:hover {
+ background: #606060;
+}
+
+/* Eye catalog */
+.eye-grid {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 8px;
+}
+
+.eye-item {
+ aspect-ratio: 1;
+ background: #353535;
+ border: 2px solid transparent;
+ border-radius: 4px;
+ cursor: pointer;
+ overflow: hidden;
+}
+
+.eye-item:hover {
+ border-color: #0088ff;
+}
+
+.eye-item.selected {
+ border-color: #0088ff;
+}
+
+.eye-item img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+/* Processing overlay */
+.processing-overlay {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.7);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 100;
+}
+
+.processing-spinner {
+ width: 40px;
+ height: 40px;
+ border: 3px solid #404040;
+ border-top-color: #0088ff;
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
+}
+
+@keyframes spin {
+ to { transform: rotate(360deg); }
+}
+
+/* Slider with value */
+.slider-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.slider-row input[type="range"] {
+ flex: 1;
+}
+
+.slider-value {
+ font-size: 11px;
+ color: #0088ff;
+ min-width: 35px;
+ text-align: right;
+}
+
+/* Scrollbar styling */
+::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+}
+
+::-webkit-scrollbar-track {
+ background: #1a1a1a;
+}
+
+::-webkit-scrollbar-thumb {
+ background: #404040;
+ border-radius: 4px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: #505050;
}
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 67a0a67..8dcfe65 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -1,37 +1,84 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import ImageCanvas from './components/ImageCanvas';
-import Controls from './components/Controls';
-import History from './components/History';
-import EyeCatalog from './components/EyeCatalog';
-import AdvancedTools from './components/AdvancedTools';
-import Layers from './components/Layers';
import { projectsApi, editsApi, toolsApi } from './utils/api';
import './App.css';
+// Tool definitions
+const TOOLS = {
+ move: { icon: '✥', name: 'Move', shortcut: 'V' },
+ select: { icon: '▢', name: 'Rectangle Select', shortcut: 'R' },
+ ellipse: { icon: '○', name: 'Ellipse Select', shortcut: 'E' },
+ lasso: { icon: '✎', name: 'Free Select (Lasso)', shortcut: 'F' },
+ magic: { icon: '✨', name: 'Smart Select (SAM)', shortcut: 'W' },
+ colorPick: { icon: '◉', name: 'Color Select', shortcut: 'U' },
+ brush: { icon: '🖌', name: 'Brush', shortcut: 'B' },
+ bucket: { icon: '◧', name: 'Bucket Fill', shortcut: 'G' },
+ eraser: { icon: '◫', name: 'Eraser', shortcut: 'Shift+E' },
+ eyedropper: { icon: '💧', name: 'Color Picker', shortcut: 'O' },
+ zoom: { icon: '🔍', name: 'Zoom', shortcut: 'Z' },
+ pan: { icon: '✋', name: 'Pan', shortcut: 'H' },
+};
+
function App() {
+ // Project state
const [project, setProject] = useState(null);
const [imageFile, setImageFile] = useState(null);
const [currentImageUrl, setCurrentImageUrl] = useState(null);
- const [selection, setSelection] = useState(null);
- const [selectionMode, setSelectionMode] = useState('rectangle');
- const [mode, setMode] = useState('A');
- const [feather, setFeather] = useState(5);
- const [prompt, setPrompt] = useState('');
- const [edits, setEdits] = useState([]);
- const [isProcessing, setIsProcessing] = useState(false);
- const [error, setError] = useState(null);
+ const [showProjectSetup, setShowProjectSetup] = useState(true);
const [projectName, setProjectName] = useState('');
- const [showProjectInput, setShowProjectInput] = useState(true);
+
+ // Tool state
+ const [activeTool, setActiveTool] = useState('select');
+ const [selection, setSelection] = useState(null);
+
+ // Edit state
+ const [prompt, setPrompt] = useState('');
+ const [feather, setFeather] = useState(5);
+ const [mode, setMode] = useState('A');
+ const [edits, setEdits] = useState([]);
const [currentEditIndex, setCurrentEditIndex] = useState(-1);
- const [layers, setLayers] = useState([]);
- const [activeLayer, setActiveLayer] = useState('background');
- const [generatedMask, setGeneratedMask] = useState(null);
- const [advancedToolMode, setAdvancedToolMode] = useState(null); // 'smart-select', 'color-select', 'object-remove'
- const [canvasZoom, setCanvasZoom] = useState(1);
- const [externalSelection, setExternalSelection] = useState(null); // For smart-select/color-select polygon results
const editsRef = useRef([]);
- // Create project and upload image
+ // Layer state
+ const [layers, setLayers] = useState([]);
+
+ // UI state
+ const [isProcessing, setIsProcessing] = useState(false);
+ const [error, setError] = useState(null);
+ const [zoom, setZoom] = useState(100);
+ const [collapsedPanels, setCollapsedPanels] = useState({});
+ const [eyes, setEyes] = useState([]);
+ const [selectedEye, setSelectedEye] = useState(null);
+
+ // Canvas ref for tool interactions
+ const canvasRef = useRef(null);
+
+ // Map tool to selection mode
+ const getSelectionMode = () => {
+ switch (activeTool) {
+ case 'select': return 'rectangle';
+ case 'ellipse': return 'ellipse';
+ case 'lasso': return 'lasso';
+ case 'magic': return 'smart';
+ case 'colorPick': return 'color';
+ default: return null;
+ }
+ };
+
+ // Load eyes catalog
+ useEffect(() => {
+ const loadEyes = async () => {
+ try {
+ const patches = await fetch('/patches/?category=eyes').then(r => r.json()).catch(() => []);
+ setEyes(patches);
+ } catch (err) {
+ console.error('Failed to load eyes:', err);
+ }
+ };
+ if (project) loadEyes();
+ }, [project]);
+
+ // Create project
const handleCreateProject = async () => {
if (!imageFile) {
setError('Please select an image');
@@ -42,25 +89,17 @@ function App() {
setError(null);
setIsProcessing(true);
- // Generate default project name from file name or timestamp
const defaultName = projectName.trim() ||
imageFile.name.replace(/\.[^/.]+$/, '') ||
`Project ${Date.now()}`;
- // Create project
const newProject = await projectsApi.create(defaultName);
setProject(newProject);
- // Upload image
await projectsApi.uploadImage(newProject.id, imageFile);
-
- // Set current image URL
setCurrentImageUrl(projectsApi.getCurrentImageUrl(newProject.id));
+ setShowProjectSetup(false);
- // Hide project input
- setShowProjectInput(false);
-
- // Load edits
await loadEdits(newProject.id);
} catch (err) {
setError(`Failed to create project: ${err.message}`);
@@ -69,13 +108,12 @@ function App() {
}
};
- // Load edits for the project
+ // Load edits
const loadEdits = async (projectId) => {
try {
const projectEdits = await projectsApi.getEdits(projectId);
setEdits(projectEdits);
editsRef.current = projectEdits;
- // Set index to latest completed edit
const completedEdits = projectEdits.filter(e => e.status === 'completed');
setCurrentEditIndex(completedEdits.length - 1);
} catch (err) {
@@ -83,9 +121,9 @@ function App() {
}
};
- // Poll for edit status
+ // Poll edit status
const pollEditStatus = async (editId) => {
- const maxAttempts = 60; // 60 attempts = 1 minute with 1 second interval
+ const maxAttempts = 120;
let attempts = 0;
const poll = async () => {
@@ -93,28 +131,26 @@ function App() {
const edit = await editsApi.get(editId);
if (edit.status === 'completed') {
- // Reload edits and update image
await loadEdits(project.id);
- setCurrentImageUrl(projectsApi.getCurrentImageUrl(project.id));
+ setCurrentImageUrl(projectsApi.getCurrentImageUrl(project.id) + `?t=${Date.now()}`);
setIsProcessing(false);
setSelection(null);
return;
} else if (edit.status === 'failed') {
setError(`Edit failed: ${edit.error_message}`);
setIsProcessing(false);
- await loadEdits(project.id);
return;
}
attempts++;
if (attempts < maxAttempts) {
- setTimeout(poll, 1000); // Poll every 1 second
+ setTimeout(poll, 1000);
} else {
- setError('Edit timeout - please check edit history');
+ setError('Edit timeout');
setIsProcessing(false);
}
} catch (err) {
- setError(`Failed to check edit status: ${err.message}`);
+ setError(`Status check failed: ${err.message}`);
setIsProcessing(false);
}
};
@@ -122,10 +158,10 @@ function App() {
poll();
};
- // Handle fix button
+ // Handle AI fix
const handleFix = async () => {
if (!selection || !prompt.trim() || !project) {
- setError('Please make a selection and enter a prompt');
+ setError('Make a selection and enter a prompt');
return;
}
@@ -143,248 +179,214 @@ function App() {
};
const edit = await editsApi.create(project.id, editRequest);
-
- // Start polling for status
pollEditStatus(edit.id);
} catch (err) {
- setError(`Failed to process edit: ${err.message}`);
+ setError(`Edit failed: ${err.message}`);
setIsProcessing(false);
}
};
- // Handle revert
+ // Undo/Redo
const handleRevert = useCallback(async (editId) => {
if (!project) return;
-
try {
- setError(null);
setIsProcessing(true);
-
await editsApi.revert(project.id, editId);
-
- // Update image
- setCurrentImageUrl(projectsApi.getCurrentImageUrl(project.id));
-
+ setCurrentImageUrl(projectsApi.getCurrentImageUrl(project.id) + `?t=${Date.now()}`);
await loadEdits(project.id);
} catch (err) {
- setError(`Failed to revert: ${err.message}`);
+ setError(`Revert failed: ${err.message}`);
} finally {
setIsProcessing(false);
}
}, [project]);
- // Handle reset
const handleReset = useCallback(async () => {
if (!project) return;
-
try {
- setError(null);
setIsProcessing(true);
-
await editsApi.reset(project.id);
-
- // Update image
- setCurrentImageUrl(projectsApi.getCurrentImageUrl(project.id));
-
+ setCurrentImageUrl(projectsApi.getCurrentImageUrl(project.id) + `?t=${Date.now()}`);
await loadEdits(project.id);
} catch (err) {
- setError(`Failed to reset: ${err.message}`);
+ setError(`Reset failed: ${err.message}`);
} finally {
setIsProcessing(false);
}
}, [project]);
- // Handle download
+ // Download
const handleDownload = async () => {
if (!currentImageUrl) return;
-
try {
- // Fetch the current image
- const response = await fetch(`${currentImageUrl}?t=${Date.now()}`);
+ const response = await fetch(`${currentImageUrl}&download=1`);
const blob = await response.blob();
-
- // Create download link
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
- link.download = `edited-image-${Date.now()}.png`;
- document.body.appendChild(link);
+ link.download = `${project?.name || 'image'}-${Date.now()}.png`;
link.click();
- document.body.removeChild(link);
window.URL.revokeObjectURL(url);
} catch (err) {
- setError(`Failed to download: ${err.message}`);
+ setError(`Download failed: ${err.message}`);
}
};
- // Handle undo (Ctrl+Z)
- const handleUndo = useCallback(async () => {
- if (!project || isProcessing) return;
-
- const completedEdits = editsRef.current.filter(e => e.status === 'completed');
- if (completedEdits.length === 0) return;
-
- if (currentEditIndex <= 0) {
- // Revert to original
- await handleReset();
- setCurrentEditIndex(-1);
- } else {
- // Revert to previous edit
- const previousEdit = completedEdits[currentEditIndex - 1];
- await handleRevert(previousEdit.id);
- setCurrentEditIndex(currentEditIndex - 1);
+ // Smart Select (SAM)
+ const handleSmartSelect = async (x, y) => {
+ if (!project) return;
+ try {
+ setIsProcessing(true);
+ const maskBlob = await toolsApi.smartSelect(project.id, x, y);
+ // TODO: Display mask on canvas
+ console.log('Smart select mask:', maskBlob);
+ } catch (err) {
+ setError(`Smart select failed: ${err.message}`);
+ } finally {
+ setIsProcessing(false);
}
- }, [project, isProcessing, currentEditIndex, handleReset, handleRevert]);
+ };
- // Handle redo (Ctrl+Y)
- const handleRedo = useCallback(async () => {
- if (!project || isProcessing) return;
+ // Remove background
+ const handleRemoveBackground = async () => {
+ if (!project) return;
+ try {
+ setIsProcessing(true);
+ await toolsApi.removeBackgroundToLayer(project.id);
+ setCurrentImageUrl(projectsApi.getCurrentImageUrl(project.id) + `?t=${Date.now()}`);
+ } catch (err) {
+ setError(`Background removal failed: ${err.message}`);
+ } finally {
+ setIsProcessing(false);
+ }
+ };
- const completedEdits = editsRef.current.filter(e => e.status === 'completed');
- if (currentEditIndex >= completedEdits.length - 1) return;
+ // Apply eye
+ const handleApplyEye = async () => {
+ if (!project || !selection || !selectedEye) {
+ setError('Select an area and an eye to apply');
+ return;
+ }
+ try {
+ setIsProcessing(true);
+ const formData = new FormData();
+ formData.append('project_id', project.id);
+ formData.append('patch_id', selectedEye.id);
+ formData.append('bbox', JSON.stringify(selection.bbox));
+ formData.append('feather_px', feather);
- const nextEdit = completedEdits[currentEditIndex + 1];
- await handleRevert(nextEdit.id);
- setCurrentEditIndex(currentEditIndex + 1);
- }, [project, isProcessing, currentEditIndex, handleRevert]);
+ await fetch('/patches/apply', {
+ method: 'POST',
+ body: formData,
+ });
- // Keyboard shortcut handler
+ setCurrentImageUrl(projectsApi.getCurrentImageUrl(project.id) + `?t=${Date.now()}`);
+ await loadEdits(project.id);
+ } catch (err) {
+ setError(`Apply eye failed: ${err.message}`);
+ } finally {
+ setIsProcessing(false);
+ }
+ };
+
+ // Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e) => {
- // Don't trigger shortcuts when typing in input fields
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
- if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
- e.preventDefault();
- handleUndo();
- } else if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) {
- e.preventDefault();
- handleRedo();
+ // Tool shortcuts
+ if (!e.ctrlKey && !e.metaKey) {
+ switch (e.key.toLowerCase()) {
+ case 'v': setActiveTool('move'); break;
+ case 'r': setActiveTool('select'); break;
+ case 'e': setActiveTool('ellipse'); break;
+ case 'f': setActiveTool('lasso'); break;
+ case 'w': setActiveTool('magic'); break;
+ case 'b': setActiveTool('brush'); break;
+ case 'g': setActiveTool('bucket'); break;
+ case 'z': setActiveTool('zoom'); break;
+ case 'h': setActiveTool('pan'); break;
+ case 'delete':
+ case 'backspace':
+ if (selection) {
+ // TODO: Delete selected area
+ }
+ break;
+ }
+ }
+
+ // Ctrl shortcuts
+ if (e.ctrlKey || e.metaKey) {
+ switch (e.key.toLowerCase()) {
+ case 'z':
+ e.preventDefault();
+ if (e.shiftKey) {
+ // Redo
+ const completed = editsRef.current.filter(ed => ed.status === 'completed');
+ if (currentEditIndex < completed.length - 1) {
+ handleRevert(completed[currentEditIndex + 1].id);
+ setCurrentEditIndex(i => i + 1);
+ }
+ } else {
+ // Undo
+ if (currentEditIndex >= 0) {
+ if (currentEditIndex === 0) {
+ handleReset();
+ } else {
+ const completed = editsRef.current.filter(ed => ed.status === 'completed');
+ handleRevert(completed[currentEditIndex - 1].id);
+ }
+ setCurrentEditIndex(i => i - 1);
+ }
+ }
+ break;
+ case 's':
+ e.preventDefault();
+ handleDownload();
+ break;
+ }
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
- }, [handleUndo, handleRedo]);
+ }, [selection, currentEditIndex, handleRevert, handleReset]);
- // Warn before leaving page when there are unsaved changes
- useEffect(() => {
- const handleBeforeUnload = (e) => {
- if (project && edits.length > 0) {
- e.preventDefault();
- e.returnValue = 'You have unsaved changes. Are you sure you want to leave?';
- return e.returnValue;
- }
- };
+ // Zoom controls
+ const handleZoomIn = () => setZoom(z => Math.min(z + 25, 400));
+ const handleZoomOut = () => setZoom(z => Math.max(z - 25, 25));
+ const handleZoomReset = () => setZoom(100);
- window.addEventListener('beforeunload', handleBeforeUnload);
- return () => window.removeEventListener('beforeunload', handleBeforeUnload);
- }, [project, edits]);
-
- // Handle layer creation from advanced tools
- const handleLayerCreated = (layer) => {
- setLayers((prev) => [...prev, { ...layer, visible: true }]);
+ // Panel toggle
+ const togglePanel = (panel) => {
+ setCollapsedPanels(prev => ({ ...prev, [panel]: !prev[panel] }));
};
- // Handle mask generation from smart select / color select
- const handleMaskGenerated = async (maskData, source) => {
- setGeneratedMask({ data: maskData, source });
- // Convert mask to selection if it contains polygon data
- if (maskData && maskData.polygon && maskData.polygon.length > 0) {
- // Set external selection for canvas to draw
- setExternalSelection({
- polygon: maskData.polygon,
- bbox: maskData.bbox,
- });
- // Also set selection state for fix button
- setSelection({
- type: 'polygon',
- bbox: maskData.bbox,
- selectionData: { points: maskData.polygon },
- });
- }
- // Reset tool mode after selection
- setAdvancedToolMode(null);
- };
-
- // Handle canvas click for advanced tools (smart select, color select)
- const handleAdvancedToolClick = async (x, y, color) => {
- if (!project || !advancedToolMode) return;
-
- try {
- setIsProcessing(true);
- setError(null);
-
- if (advancedToolMode === 'smart-select') {
- const result = await toolsApi.smartSelect(project.id, x, y);
- handleMaskGenerated(result, 'smart-select');
- } else if (advancedToolMode === 'color-select') {
- // Color is passed from canvas click
- if (color) {
- const result = await toolsApi.colorSelect(project.id, color.r, color.g, color.b, 30);
- handleMaskGenerated(result, 'color-select');
- }
- }
- } catch (err) {
- setError(`${advancedToolMode} failed: ${err.message}`);
- } finally {
- setIsProcessing(false);
- }
- };
-
- // Handle flatten layers
- const handleFlattenLayers = async (layerOrder) => {
- if (!project) return;
- try {
- setIsProcessing(true);
- await toolsApi.flattenLayers(project.id, layerOrder);
- setCurrentImageUrl(projectsApi.getCurrentImageUrl(project.id));
- setLayers([]);
- } catch (err) {
- setError(`Failed to flatten layers: ${err.message}`);
- } finally {
- setIsProcessing(false);
- }
- };
-
- return (
-
-
-
-
AI Photo Edit
-
Have AI regenerate only a selected area of your photo
-
-
- {error && (
-
- Error: {error}
-
-
- )}
-
- {showProjectInput ? (
+ // Project setup overlay
+ if (showProjectSetup) {
+ return (
+
+
-
Start Editing
+
Open Image
-
+
setImageFile(e.target.files[0])}
disabled={isProcessing}
/>
- {imageFile && (
-
Selected: {imageFile.name}
- )}
+ {imageFile &&
{imageFile.name}
}
-
+
setProjectName(e.target.value)}
- placeholder="Uses filename if left empty"
+ placeholder="Auto-generated from filename"
disabled={isProcessing}
/>
@@ -393,90 +395,301 @@ function App() {
onClick={handleCreateProject}
disabled={isProcessing || !imageFile}
>
- {isProcessing ? 'Starting...' : 'Start Editing'}
+ {isProcessing ? 'Opening...' : 'Open'}
- ) : (
-
+ );
+ }
+
+ return (
+
+ {/* Menu Bar */}
+
+
AI Photo Edit
+
+
+
+
+
+
+
+ {/* Error Banner */}
+ {error && (
+
+ {error}
+
+
+ )}
+
+ {/* Main Workspace */}
+
+ {/* Left Toolbar */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Canvas Area */}
+
+
+
+ {isProcessing && (
+
+ )}
+
+
+
+
+ {zoom}%
+
+
+
Selection: {selection ? `${selection.bbox?.width || 0}×${selection.bbox?.height || 0}` : 'None'}
+
Tool: {TOOLS[activeTool]?.name || activeTool}
+
+
-
-
-
-
{
- setCurrentImageUrl(projectsApi.getCurrentImageUrl(project.id));
- }}
- isProcessing={isProcessing}
- setIsProcessing={setIsProcessing}
- setError={setError}
- activeToolMode={advancedToolMode}
- setActiveToolMode={setAdvancedToolMode}
- />
-
-
-
- {
- // Reload image after applying eye
- setCurrentImageUrl(projectsApi.getCurrentImageUrl(project.id));
- await loadEdits(project.id);
- }}
- isProcessing={isProcessing}
- />
-
-
-
+ {/* Right Sidebar */}
+
+ {/* Tool Options Panel */}
+
+
togglePanel('toolOptions')}>
+
Tool Options
+ {collapsedPanels.toolOptions ? '▶' : '▼'}
+
+
+
+
+
+ setFeather(parseInt(e.target.value))}
+ />
+ {feather}px
+
- )}
+
+ {/* AI Edit Panel */}
+
+
togglePanel('aiEdit')}>
+
AI Edit
+ {collapsedPanels.aiEdit ? '▶' : '▼'}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Quick Actions Panel */}
+
+
togglePanel('actions')}>
+
Quick Actions
+ {collapsedPanels.actions ? '▶' : '▼'}
+
+
+
+
+
+
+ {/* Eyes Panel */}
+
+
togglePanel('eyes')}>
+
Eye Catalog
+ {collapsedPanels.eyes ? '▶' : '▼'}
+
+
+ {eyes.length > 0 ? (
+ <>
+
+ {eyes.map(eye => (
+
setSelectedEye(eye)}
+ >
+

+
+ ))}
+
+
+ >
+ ) : (
+
No eyes in catalog
+ )}
+
+
+
+ {/* Layers Panel */}
+
+
togglePanel('layers')}>
+
Layers
+ {collapsedPanels.layers ? '▶' : '▼'}
+
+
+
+
+
+ Background
+
+ {layers.map((layer) => (
+
+
+ {layer.name}
+
+ ))}
+
+
+
+
+ {/* History Panel */}
+
+
togglePanel('history')}>
+
History
+ {collapsedPanels.history ? '▶' : '▼'}
+
+
+
+
+ Original
+
+ {edits.filter(e => e.status === 'completed').map((edit, i) => (
+
handleRevert(edit.id)}
+ >
+ {edit.prompt?.substring(0, 30) || `Edit ${i + 1}`}
+
+ ))}
+
+
+
+
);
diff --git a/frontend/src/components/ImageCanvas.css b/frontend/src/components/ImageCanvas.css
index 5ec2dd1..3259a07 100644
--- a/frontend/src/components/ImageCanvas.css
+++ b/frontend/src/components/ImageCanvas.css
@@ -1,15 +1,11 @@
+/* ImageCanvas fills the entire canvas wrapper */
.canvas-container {
- position: relative;
- width: 100%;
- height: 100%;
- min-height: calc(100vh - 180px);
- background-color: #2a2a2a;
- border: 2px solid #444;
- border-radius: 8px;
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
overflow: hidden;
- display: flex;
- align-items: center;
- justify-content: center;
}
.canvas-container canvas {
@@ -45,16 +41,36 @@
.selection-hint {
position: absolute;
- bottom: 10px;
+ bottom: 40px;
left: 50%;
transform: translateX(-50%);
background-color: rgba(0, 0, 0, 0.8);
- color: #00ff00;
- padding: 8px 16px;
+ color: #0088ff;
+ padding: 6px 12px;
border-radius: 4px;
- font-size: 12px;
+ font-size: 11px;
z-index: 10;
white-space: nowrap;
+ pointer-events: none;
+}
+
+/* Clear selection button */
+.clear-selection-btn {
+ position: absolute;
+ top: 8px;
+ right: 8px;
+ background-color: #cc3333;
+ color: white;
+ padding: 6px 12px;
+ font-size: 11px;
+ border: none;
+ border-radius: 3px;
+ cursor: pointer;
+ z-index: 10;
+}
+
+.clear-selection-btn:hover {
+ background-color: #dd4444;
}
/* Zoom controls */