Merge branch 'main' into claude/automate-sam-download-mzpRs

This commit is contained in:
outis1one
2026-01-26 00:19:26 -05:00
committed by GitHub
8 changed files with 1511 additions and 721 deletions
+63
View File
@@ -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"]
+48 -4
View File
@@ -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("<h1>Frontend not built. Run npm build in frontend/</h1>")
@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("<h1>Frontend not built</h1>", status_code=404)
+5 -28
View File
@@ -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:
+10
View File
@@ -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;
+590 -68
View File
@@ -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;
}
+497 -284
View File
@@ -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 (
<div className="app">
<div className="container">
<div className="header">
<h1>AI Photo Edit</h1>
<p>Have AI regenerate only a selected area of your photo</p>
</div>
{error && (
<div className="error-banner">
<strong>Error:</strong> {error}
<button onClick={() => setError(null)}></button>
</div>
)}
{showProjectInput ? (
// Project setup overlay
if (showProjectSetup) {
return (
<div className="app">
<div className="project-setup-overlay">
<div className="project-setup">
<h2>Start Editing</h2>
<h2>Open Image</h2>
<div className="setup-form">
<div className="form-group">
<label>Upload Image</label>
<label>Select Image</label>
<input
type="file"
accept="image/*"
onChange={(e) => setImageFile(e.target.files[0])}
disabled={isProcessing}
/>
{imageFile && (
<p className="file-name">Selected: {imageFile.name}</p>
)}
{imageFile && <p className="file-name">{imageFile.name}</p>}
</div>
<div className="form-group optional-field">
<div className="form-group">
<label>Project Name (optional)</label>
<input
type="text"
value={projectName}
onChange={(e) => setProjectName(e.target.value)}
placeholder="Uses filename if left empty"
placeholder="Auto-generated from filename"
disabled={isProcessing}
/>
</div>
@@ -393,90 +395,301 @@ function App() {
onClick={handleCreateProject}
disabled={isProcessing || !imageFile}
>
{isProcessing ? 'Starting...' : 'Start Editing'}
{isProcessing ? 'Opening...' : 'Open'}
</button>
</div>
</div>
) : (
<div className="workspace">
<div className="left-panel">
<ImageCanvas
imageUrl={currentImageUrl}
onSelectionChange={setSelection}
selectionMode={selectionMode}
advancedToolMode={advancedToolMode}
onAdvancedToolClick={handleAdvancedToolClick}
zoom={canvasZoom}
onZoomChange={setCanvasZoom}
externalSelection={externalSelection}
/>
</div>
</div>
);
}
return (
<div className="app">
{/* Menu Bar */}
<div className="menu-bar">
<h1>AI Photo Edit</h1>
<div className="menu-actions">
<button className="menu-btn" onClick={() => setShowProjectSetup(true)}>New</button>
<button className="menu-btn" onClick={handleDownload}>Save</button>
<button className="menu-btn" onClick={handleReset}>Reset</button>
</div>
</div>
{/* Error Banner */}
{error && (
<div className="error-banner">
{error}
<button onClick={() => setError(null)}></button>
</div>
)}
{/* Main Workspace */}
<div className="workspace">
{/* Left Toolbar */}
<div className="toolbar">
<button
className={`tool-btn ${activeTool === 'move' ? 'active' : ''}`}
onClick={() => setActiveTool('move')}
title="Move (V)"
></button>
<div className="tool-divider" />
<button
className={`tool-btn ${activeTool === 'select' ? 'active' : ''}`}
onClick={() => setActiveTool('select')}
title="Rectangle Select (R)"
></button>
<button
className={`tool-btn ${activeTool === 'ellipse' ? 'active' : ''}`}
onClick={() => setActiveTool('ellipse')}
title="Ellipse Select (E)"
></button>
<button
className={`tool-btn ${activeTool === 'lasso' ? 'active' : ''}`}
onClick={() => setActiveTool('lasso')}
title="Free Select (F)"
></button>
<button
className={`tool-btn ${activeTool === 'magic' ? 'active' : ''}`}
onClick={() => setActiveTool('magic')}
title="Smart Select - SAM (W)"
></button>
<button
className={`tool-btn ${activeTool === 'colorPick' ? 'active' : ''}`}
onClick={() => setActiveTool('colorPick')}
title="Color Select (U)"
></button>
<div className="tool-divider" />
<button
className={`tool-btn ${activeTool === 'brush' ? 'active' : ''}`}
onClick={() => setActiveTool('brush')}
title="Brush (B)"
>🖌</button>
<button
className={`tool-btn ${activeTool === 'bucket' ? 'active' : ''}`}
onClick={() => setActiveTool('bucket')}
title="Bucket Fill (G)"
></button>
<button
className={`tool-btn ${activeTool === 'eraser' ? 'active' : ''}`}
onClick={() => setActiveTool('eraser')}
title="Eraser (Shift+E)"
></button>
<div className="tool-divider" />
<button
className={`tool-btn ${activeTool === 'eyedropper' ? 'active' : ''}`}
onClick={() => setActiveTool('eyedropper')}
title="Color Picker (O)"
>💧</button>
<button
className={`tool-btn ${activeTool === 'zoom' ? 'active' : ''}`}
onClick={() => setActiveTool('zoom')}
title="Zoom (Z)"
>🔍</button>
<button
className={`tool-btn ${activeTool === 'pan' ? 'active' : ''}`}
onClick={() => setActiveTool('pan')}
title="Pan (H)"
></button>
</div>
{/* Canvas Area */}
<div className="canvas-area">
<div className="canvas-wrapper">
<ImageCanvas
ref={canvasRef}
imageUrl={currentImageUrl}
onSelectionChange={setSelection}
selectionMode={getSelectionMode()}
activeTool={activeTool}
zoom={zoom}
onSmartSelect={handleSmartSelect}
isProcessing={isProcessing}
/>
{isProcessing && (
<div className="processing-overlay">
<div className="processing-spinner" />
</div>
)}
</div>
<div className="canvas-status">
<div className="zoom-controls">
<button className="zoom-btn" onClick={handleZoomOut}></button>
<span className="zoom-level">{zoom}%</span>
<button className="zoom-btn" onClick={handleZoomIn}>+</button>
<button className="zoom-btn" onClick={handleZoomReset}></button>
</div>
<span>Selection: {selection ? `${selection.bbox?.width || 0}×${selection.bbox?.height || 0}` : 'None'}</span>
<span>Tool: {TOOLS[activeTool]?.name || activeTool}</span>
</div>
</div>
<div className="right-panel">
<Controls
selectionMode={selectionMode}
onSelectionModeChange={setSelectionMode}
mode={mode}
onModeChange={setMode}
feather={feather}
onFeatherChange={setFeather}
prompt={prompt}
onPromptChange={setPrompt}
onFix={handleFix}
onDownload={handleDownload}
isProcessing={isProcessing}
hasSelection={!!selection}
/>
<AdvancedTools
projectId={project?.id}
selection={selection}
onLayerCreated={handleLayerCreated}
onMaskGenerated={handleMaskGenerated}
onImageUpdate={() => {
setCurrentImageUrl(projectsApi.getCurrentImageUrl(project.id));
}}
isProcessing={isProcessing}
setIsProcessing={setIsProcessing}
setError={setError}
activeToolMode={advancedToolMode}
setActiveToolMode={setAdvancedToolMode}
/>
<Layers
projectId={project?.id}
layers={layers}
setLayers={setLayers}
activeLayer={activeLayer}
setActiveLayer={setActiveLayer}
onFlatten={handleFlattenLayers}
isProcessing={isProcessing}
onError={setError}
/>
<EyeCatalog
projectId={project?.id}
selection={selection}
feather={feather}
onApply={async () => {
// Reload image after applying eye
setCurrentImageUrl(projectsApi.getCurrentImageUrl(project.id));
await loadEdits(project.id);
}}
isProcessing={isProcessing}
/>
<div className="history-wrapper">
<History
edits={edits}
onRevert={handleRevert}
onReset={handleReset}
isProcessing={isProcessing}
/>
{/* Right Sidebar */}
<div className="sidebar">
{/* Tool Options Panel */}
<div className="sidebar-panel">
<div className="panel-header" onClick={() => togglePanel('toolOptions')}>
<h3>Tool Options</h3>
<span className="panel-toggle">{collapsedPanels.toolOptions ? '▶' : '▼'}</span>
</div>
<div className={`panel-content ${collapsedPanels.toolOptions ? 'collapsed' : ''}`}>
<div className="control-group">
<label className="control-label">Feather</label>
<div className="slider-row">
<input
type="range"
min="0"
max="50"
value={feather}
onChange={(e) => setFeather(parseInt(e.target.value))}
/>
<span className="slider-value">{feather}px</span>
</div>
</div>
</div>
</div>
)}
{/* AI Edit Panel */}
<div className="sidebar-panel">
<div className="panel-header" onClick={() => togglePanel('aiEdit')}>
<h3>AI Edit</h3>
<span className="panel-toggle">{collapsedPanels.aiEdit ? '▶' : '▼'}</span>
</div>
<div className={`panel-content ${collapsedPanels.aiEdit ? 'collapsed' : ''}`}>
<div className="control-group">
<label className="control-label">Mode</label>
<div className="control-row">
<button
className={`mode-btn ${mode === 'A' ? 'active' : ''}`}
onClick={() => setMode('A')}
>Patch Only</button>
<button
className={`mode-btn ${mode === 'B' ? 'active' : ''}`}
onClick={() => setMode('B')}
>With Context</button>
</div>
</div>
<div className="control-group">
<label className="control-label">Prompt</label>
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="Describe what to fix or change..."
/>
</div>
<button
className="action-btn"
onClick={handleFix}
disabled={isProcessing || !selection || !prompt.trim()}
>
{isProcessing ? 'Processing...' : 'Apply AI Edit'}
</button>
</div>
</div>
{/* Quick Actions Panel */}
<div className="sidebar-panel">
<div className="panel-header" onClick={() => togglePanel('actions')}>
<h3>Quick Actions</h3>
<span className="panel-toggle">{collapsedPanels.actions ? '▶' : '▼'}</span>
</div>
<div className={`panel-content ${collapsedPanels.actions ? 'collapsed' : ''}`}>
<button
className="action-btn secondary"
onClick={handleRemoveBackground}
disabled={isProcessing}
>Remove Background</button>
</div>
</div>
{/* Eyes Panel */}
<div className="sidebar-panel">
<div className="panel-header" onClick={() => togglePanel('eyes')}>
<h3>Eye Catalog</h3>
<span className="panel-toggle">{collapsedPanels.eyes ? '▶' : '▼'}</span>
</div>
<div className={`panel-content ${collapsedPanels.eyes ? 'collapsed' : ''}`}>
{eyes.length > 0 ? (
<>
<div className="eye-grid">
{eyes.map(eye => (
<div
key={eye.id}
className={`eye-item ${selectedEye?.id === eye.id ? 'selected' : ''}`}
onClick={() => setSelectedEye(eye)}
>
<img src={`/patches/${eye.id}/image?thumbnail=true`} alt={eye.name} />
</div>
))}
</div>
<button
className="action-btn"
onClick={handleApplyEye}
disabled={isProcessing || !selection || !selectedEye}
style={{ marginTop: '12px' }}
>Apply Eye to Selection</button>
</>
) : (
<p style={{ fontSize: '12px', color: '#888' }}>No eyes in catalog</p>
)}
</div>
</div>
{/* Layers Panel */}
<div className="sidebar-panel">
<div className="panel-header" onClick={() => togglePanel('layers')}>
<h3>Layers</h3>
<span className="panel-toggle">{collapsedPanels.layers ? '▶' : '▼'}</span>
</div>
<div className={`panel-content ${collapsedPanels.layers ? 'collapsed' : ''}`}>
<div className="layer-list">
<div className="layer-item active">
<button className="layer-visibility visible">👁</button>
<span className="layer-name">Background</span>
</div>
{layers.map((layer) => (
<div key={layer.id} className="layer-item">
<button className="layer-visibility visible">👁</button>
<span className="layer-name">{layer.name}</span>
</div>
))}
</div>
</div>
</div>
{/* History Panel */}
<div className="sidebar-panel">
<div className="panel-header" onClick={() => togglePanel('history')}>
<h3>History</h3>
<span className="panel-toggle">{collapsedPanels.history ? '▶' : '▼'}</span>
</div>
<div className={`panel-content ${collapsedPanels.history ? 'collapsed' : ''}`}>
<div className="history-list">
<div
className={`history-item ${currentEditIndex === -1 ? 'current' : ''}`}
onClick={handleReset}
>
Original
</div>
{edits.filter(e => e.status === 'completed').map((edit, i) => (
<div
key={edit.id}
className={`history-item ${currentEditIndex === i ? 'current' : ''}`}
onClick={() => handleRevert(edit.id)}
>
{edit.prompt?.substring(0, 30) || `Edit ${i + 1}`}
</div>
))}
</div>
</div>
</div>
</div>
</div>
</div>
);
+32 -29
View File
@@ -1,47 +1,50 @@
/* 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 {
display: block;
}
/* Selection hint */
.selection-hint {
position: absolute;
bottom: 40px;
left: 50%;
transform: translateX(-50%);
background-color: rgba(0, 0, 0, 0.8);
color: #0088ff;
padding: 6px 12px;
border-radius: 4px;
font-size: 11px;
z-index: 10;
white-space: nowrap;
pointer-events: none;
}
/* Clear selection button */
.clear-selection-btn {
position: absolute;
top: 10px;
right: 10px;
background-color: #ff4444;
top: 8px;
right: 8px;
background-color: #cc3333;
color: white;
padding: 8px 16px;
padding: 6px 12px;
font-size: 11px;
border: none;
border-radius: 3px;
cursor: pointer;
z-index: 10;
}
.clear-selection-btn:hover {
background-color: #cc0000;
}
.selection-hint {
position: absolute;
bottom: 10px;
left: 50%;
transform: translateX(-50%);
background-color: rgba(0, 0, 0, 0.8);
color: #00ff00;
padding: 8px 16px;
border-radius: 4px;
font-size: 12px;
z-index: 10;
white-space: nowrap;
background-color: #dd4444;
}
/* Zoom controls */
+266 -308
View File
@@ -1,23 +1,20 @@
import React, { useEffect, useRef, useState, useCallback } from 'react';
import React, { useEffect, useRef, useState, useCallback, forwardRef, useImperativeHandle } from 'react';
import { fabric } from 'fabric';
import './ImageCanvas.css';
const ImageCanvas = ({
const ImageCanvas = forwardRef(({
imageUrl,
onSelectionChange,
selectionMode,
advancedToolMode,
onAdvancedToolClick,
zoom = 1,
onZoomChange,
externalSelection, // { polygon: [[x,y],...], bbox: {x,y,width,height} }
}) => {
activeTool,
zoom = 100,
onSmartSelect,
isProcessing
}, ref) => {
const canvasRef = useRef(null);
const fabricCanvasRef = useRef(null);
const [currentSelection, setCurrentSelection] = useState(null);
const [isDrawing, setIsDrawing] = useState(false);
const [isTransformMode, setIsTransformMode] = useState(false);
const [currentZoom, setCurrentZoom] = useState(zoom);
const currentSelectionRef = useRef(null);
const lassoPoints = useRef([]);
const onZoomChangeRef = useRef(onZoomChange);
@@ -26,17 +23,28 @@ const ImageCanvas = ({
onZoomChangeRef.current = onZoomChange;
}, [onZoomChange]);
// Expose methods to parent
useImperativeHandle(ref, () => ({
getCanvas: () => fabricCanvasRef.current,
clearSelection: () => clearSelection(),
}));
// Update selection ref when state changes
useEffect(() => {
currentSelectionRef.current = currentSelection;
}, [currentSelection]);
// Initialize canvas
useEffect(() => {
if (!canvasRef.current) return;
// Initialize Fabric.js canvas
const canvas = new fabric.Canvas(canvasRef.current, {
selection: false,
backgroundColor: '#2a2a2a',
backgroundColor: 'transparent',
preserveObjectStacking: true,
});
fabricCanvasRef.current = canvas;
// Handle window resize
const handleResize = () => {
const container = canvasRef.current?.parentElement;
if (container) {
@@ -45,19 +53,9 @@ const ImageCanvas = ({
canvas.setWidth(width);
canvas.setHeight(height);
// Re-center and rescale the image if it exists
const bgImage = canvas.backgroundImage;
if (bgImage) {
// Allow scaling up to fill the canvas
const scale = Math.min(
(width - 40) / bgImage.width,
(height - 40) / bgImage.height
);
bgImage.scale(scale);
bgImage.set({
left: (width - bgImage.width * scale) / 2,
top: (height - bgImage.height * scale) / 2,
});
// Re-center image if it exists
if (imageRef.current) {
centerImage(canvas, imageRef.current, zoom / 100);
}
canvas.renderAll();
}
@@ -102,6 +100,39 @@ const ImageCanvas = ({
};
}, []); // Empty dependency array - only run once on mount
// Center and scale image
const centerImage = (canvas, img, zoomFactor) => {
if (!img) return;
const padding = 40;
const availableWidth = canvas.width - padding;
const availableHeight = canvas.height - padding;
// Calculate base scale to fit
const fitScale = Math.min(
availableWidth / img.width,
availableHeight / img.height
);
baseScaleRef.current = fitScale;
const scale = fitScale * zoomFactor;
img.scale(scale);
img.set({
left: (canvas.width - img.width * scale) / 2,
top: (canvas.height - img.height * scale) / 2,
});
};
// Apply zoom changes
useEffect(() => {
const canvas = fabricCanvasRef.current;
if (!canvas || !imageRef.current) return;
centerImage(canvas, imageRef.current, zoom / 100);
canvas.renderAll();
}, [zoom]);
// Load image when URL changes
useEffect(() => {
if (!fabricCanvasRef.current || !imageUrl) return;
@@ -143,186 +174,155 @@ const ImageCanvas = ({
top: ((canvas.height || 600) - img.height * scale) / 2,
selectable: false,
evented: false,
hoverCursor: 'default',
});
imageRef.current = img;
canvas.add(img);
canvas.sendToBack(img);
canvas.renderAll();
// Store image reference
canvas.backgroundImage = img;
centerImage(canvas, img, zoom / 100);
canvas.renderAll();
}, { crossOrigin: 'anonymous' });
}, [imageUrl]);
// Handle advanced tool mode clicks (smart-select, color-select)
useEffect(() => {
if (!fabricCanvasRef.current || !advancedToolMode) return;
const canvas = fabricCanvasRef.current;
const bgImage = canvas.backgroundImage;
const handleAdvancedClick = async (e) => {
if (!bgImage || !onAdvancedToolClick) return;
const pointer = canvas.getPointer(e.e);
// Convert canvas coordinates to image coordinates
const imgScale = bgImage.scaleX;
const imgLeft = bgImage.left;
const imgTop = bgImage.top;
const imgX = Math.round((pointer.x - imgLeft) / imgScale);
const imgY = Math.round((pointer.y - imgTop) / imgScale);
// Check if click is within image bounds
if (imgX < 0 || imgY < 0 || imgX > bgImage.width || imgY > bgImage.height) {
return;
}
if (advancedToolMode === 'color-select') {
// Get pixel color at click position
const ctx = canvas.getContext('2d');
const canvasX = pointer.x * canvas.getZoom();
const canvasY = pointer.y * canvas.getZoom();
// For color picking, we need to get the color from the image
// Create a temporary canvas to read pixel color
const tempCanvas = document.createElement('canvas');
tempCanvas.width = bgImage.width;
tempCanvas.height = bgImage.height;
const tempCtx = tempCanvas.getContext('2d');
// Draw the image element to temp canvas
const imgElement = bgImage.getElement();
tempCtx.drawImage(imgElement, 0, 0);
const pixelData = tempCtx.getImageData(imgX, imgY, 1, 1).data;
const color = { r: pixelData[0], g: pixelData[1], b: pixelData[2] };
onAdvancedToolClick(imgX, imgY, color);
} else {
onAdvancedToolClick(imgX, imgY, null);
}
};
canvas.on('mouse:down', handleAdvancedClick);
return () => {
canvas.off('mouse:down', handleAdvancedClick);
};
}, [advancedToolMode, onAdvancedToolClick]);
// Handle external selection (from smart-select or color-select)
useEffect(() => {
if (!fabricCanvasRef.current || !externalSelection?.polygon?.length) return;
const canvas = fabricCanvasRef.current;
const bgImage = canvas.backgroundImage;
if (!bgImage) return;
// Clear previous selection
if (currentSelection) {
canvas.remove(currentSelection);
}
// Convert image coordinates to canvas coordinates
const imgScale = bgImage.scaleX;
const imgLeft = bgImage.left;
const imgTop = bgImage.top;
const canvasPoints = externalSelection.polygon.map(([x, y]) => ({
x: x * imgScale + imgLeft,
y: y * imgScale + imgTop,
}));
// Create polygon selection
const polygon = new fabric.Polygon(canvasPoints, {
fill: 'rgba(255, 255, 255, 0.3)',
stroke: '#00ff00',
strokeWidth: 2,
selectable: true,
hasControls: true,
hasBorders: true,
lockRotation: false,
cornerColor: '#00ff00',
cornerSize: 10,
transparentCorners: false,
borderColor: '#00ff00',
borderScaleFactor: 2,
});
canvas.add(polygon);
canvas.setActiveObject(polygon);
setCurrentSelection(polygon);
lassoPoints.current = canvasPoints;
// Notify parent of selection
onSelectionChange({
type: 'polygon',
bbox: externalSelection.bbox,
selectionData: { points: externalSelection.polygon },
});
canvas.renderAll();
}, [externalSelection]);
// Handle selection mode changes
// Handle tool/mode changes
useEffect(() => {
if (!fabricCanvasRef.current) return;
const canvas = fabricCanvasRef.current;
// Don't set up selection handlers if in advanced tool mode
if (advancedToolMode) return;
// Clear previous selection when changing modes
if (currentSelection) {
canvas.remove(currentSelection);
setCurrentSelection(null);
onSelectionChange(null);
}
// Reset transform mode
setIsTransformMode(false);
// Set up event handlers based on mode
// Remove all event handlers
canvas.off('mouse:down');
canvas.off('mouse:move');
canvas.off('mouse:up');
canvas.off('object:modified');
canvas.off('object:moving');
canvas.off('object:scaling');
// Set up handlers based on selection mode
if (selectionMode === 'rectangle') {
setupRectangleMode(canvas);
} else if (selectionMode === 'ellipse') {
setupEllipseMode(canvas);
} else if (selectionMode === 'lasso') {
setupLassoMode(canvas);
} else if (selectionMode === 'smart') {
setupSmartSelectMode(canvas);
} else if (selectionMode === 'color') {
setupColorSelectMode(canvas);
} else if (activeTool === 'move') {
setupMoveMode(canvas);
} else if (activeTool === 'pan') {
setupPanMode(canvas);
}
}, [selectionMode, advancedToolMode]);
}, [selectionMode, activeTool, onSmartSelect]);
const setupRectangleMode = (canvas) => {
let rect, isDown, startX, startY;
const setupMoveMode = (canvas) => {
// In move mode, allow selecting and moving selection objects
const sel = currentSelectionRef.current;
if (sel) {
sel.set({ selectable: true, evented: true });
canvas.setActiveObject(sel);
}
canvas.on('object:modified', (e) => {
if (e.target && e.target === currentSelectionRef.current) {
updateTransformedSelection(e.target);
}
});
};
const setupPanMode = (canvas) => {
let isPanning = false;
let lastPosX, lastPosY;
canvas.on('mouse:down', (e) => {
// If clicking on existing selection, enable transform mode
if (e.target && e.target === currentSelection) {
setIsTransformMode(true);
isPanning = true;
lastPosX = e.e.clientX;
lastPosY = e.e.clientY;
canvas.setCursor('grabbing');
});
canvas.on('mouse:move', (e) => {
if (!isPanning) return;
const deltaX = e.e.clientX - lastPosX;
const deltaY = e.e.clientY - lastPosY;
canvas.relativePan({ x: deltaX, y: deltaY });
lastPosX = e.e.clientX;
lastPosY = e.e.clientY;
});
canvas.on('mouse:up', () => {
isPanning = false;
canvas.setCursor('grab');
});
canvas.setCursor('grab');
};
const setupSmartSelectMode = (canvas) => {
canvas.on('mouse:down', (e) => {
if (isProcessing) return;
const pointer = canvas.getPointer(e.e);
const img = imageRef.current;
if (!img) return;
// Convert to image coordinates
const imgScale = img.scaleX;
const imgLeft = img.left;
const imgTop = img.top;
const x = Math.round((pointer.x - imgLeft) / imgScale);
const y = Math.round((pointer.y - imgTop) / imgScale);
// Check if click is within image bounds
if (x >= 0 && x < img.width && y >= 0 && y < img.height) {
onSmartSelect?.(x, y);
}
});
canvas.setCursor('crosshair');
};
const setupColorSelectMode = (canvas) => {
canvas.on('mouse:down', (e) => {
if (isProcessing) return;
// TODO: Get pixel color at click position
const pointer = canvas.getPointer(e.e);
console.log('Color select at:', pointer);
});
canvas.setCursor('crosshair');
};
const setupRectangleMode = (canvas) => {
let rect = null;
let isDown = false;
let startX, startY;
canvas.on('mouse:down', (e) => {
// Check if clicking on existing selection
const sel = currentSelectionRef.current;
if (e.target && e.target === sel) {
// Allow moving/transforming
return;
}
// If in transform mode and clicking elsewhere, exit transform mode
if (isTransformMode) {
setIsTransformMode(false);
}
// Clear previous selection if exists
if (currentSelection) {
canvas.remove(currentSelection);
// Clear previous selection
if (sel) {
canvas.remove(sel);
setCurrentSelection(null);
}
isDown = true;
isDrawingRef.current = true;
const pointer = canvas.getPointer(e.e);
startX = pointer.x;
startY = pointer.y;
@@ -332,26 +332,24 @@ const ImageCanvas = ({
top: startY,
width: 0,
height: 0,
fill: 'rgba(255, 255, 255, 0.3)',
stroke: '#00ff00',
fill: 'rgba(0, 136, 255, 0.2)',
stroke: '#0088ff',
strokeWidth: 2,
strokeDashArray: [5, 5],
selectable: true,
hasControls: true,
hasBorders: true,
lockRotation: false,
cornerColor: '#00ff00',
cornerSize: 10,
cornerColor: '#0088ff',
cornerSize: 8,
transparentCorners: false,
borderColor: '#00ff00',
borderScaleFactor: 2,
borderColor: '#0088ff',
});
canvas.add(rect);
setCurrentSelection(rect);
});
canvas.on('mouse:move', (e) => {
if (!isDown || isTransformMode) return;
if (!isDown || !rect) return;
const pointer = canvas.getPointer(e.e);
const width = pointer.x - startX;
@@ -368,39 +366,40 @@ const ImageCanvas = ({
});
canvas.on('mouse:up', () => {
if (isDown && !isTransformMode) {
if (isDown && rect && rect.width > 5 && rect.height > 5) {
isDown = false;
isDrawingRef.current = false;
setCurrentSelection(rect);
canvas.setActiveObject(rect);
updateSelection(rect, 'rectangle');
} else if (isDown && rect) {
// Selection too small, remove it
canvas.remove(rect);
isDown = false;
isDrawingRef.current = false;
}
});
// Update selection when object is modified (moved, scaled, rotated)
canvas.on('object:modified', (e) => {
if (e.target && e.target === currentSelection) {
updateTransformedSelection(e.target, 'rectangle');
if (e.target === currentSelectionRef.current) {
updateTransformedSelection(e.target);
}
});
};
const setupEllipseMode = (canvas) => {
let ellipse, isDown, startX, startY;
let ellipse = null;
let isDown = false;
let startX, startY;
canvas.on('mouse:down', (e) => {
// If clicking on existing selection, enable transform mode
if (e.target && e.target === currentSelection) {
setIsTransformMode(true);
const sel = currentSelectionRef.current;
if (e.target && e.target === sel) {
return;
}
// If in transform mode and clicking elsewhere, exit transform mode
if (isTransformMode) {
setIsTransformMode(false);
}
// Clear previous selection if exists
if (currentSelection) {
canvas.remove(currentSelection);
if (sel) {
canvas.remove(sel);
setCurrentSelection(null);
}
@@ -414,26 +413,24 @@ const ImageCanvas = ({
top: startY,
rx: 0,
ry: 0,
fill: 'rgba(255, 255, 255, 0.3)',
stroke: '#00ff00',
fill: 'rgba(0, 136, 255, 0.2)',
stroke: '#0088ff',
strokeWidth: 2,
strokeDashArray: [5, 5],
selectable: true,
hasControls: true,
hasBorders: true,
lockRotation: false,
cornerColor: '#00ff00',
cornerSize: 10,
cornerColor: '#0088ff',
cornerSize: 8,
transparentCorners: false,
borderColor: '#00ff00',
borderScaleFactor: 2,
borderColor: '#0088ff',
});
canvas.add(ellipse);
setCurrentSelection(ellipse);
});
canvas.on('mouse:move', (e) => {
if (!isDown || isTransformMode) return;
if (!isDown || !ellipse) return;
const pointer = canvas.getPointer(e.e);
const rx = Math.abs(pointer.x - startX) / 2;
@@ -442,58 +439,55 @@ const ImageCanvas = ({
ellipse.set({
rx: rx,
ry: ry,
left: startX < pointer.x ? startX : pointer.x,
top: startY < pointer.y ? startY : pointer.y,
left: Math.min(startX, pointer.x),
top: Math.min(startY, pointer.y),
});
canvas.renderAll();
});
canvas.on('mouse:up', () => {
if (isDown && !isTransformMode) {
if (isDown && ellipse && ellipse.rx > 5 && ellipse.ry > 5) {
isDown = false;
setCurrentSelection(ellipse);
canvas.setActiveObject(ellipse);
updateSelection(ellipse, 'ellipse');
} else if (isDown && ellipse) {
canvas.remove(ellipse);
isDown = false;
}
});
// Update selection when object is modified (moved, scaled, rotated)
canvas.on('object:modified', (e) => {
if (e.target && e.target === currentSelection) {
updateTransformedSelection(e.target, 'ellipse');
if (e.target === currentSelectionRef.current) {
updateTransformedSelection(e.target);
}
});
};
const setupLassoMode = (canvas) => {
let polygon, points = [], drawingLine;
let points = [];
let drawingLine = null;
let polygon = null;
canvas.on('mouse:down', (e) => {
// If clicking on existing selection, enable transform mode
if (e.target && e.target === currentSelection) {
setIsTransformMode(true);
const sel = currentSelectionRef.current;
if (e.target && e.target === sel) {
return;
}
// If in transform mode and clicking elsewhere, exit transform mode
if (isTransformMode) {
setIsTransformMode(false);
}
// Clear previous selection if exists
if (currentSelection) {
canvas.remove(currentSelection);
if (sel) {
canvas.remove(sel);
setCurrentSelection(null);
}
setIsDrawing(true);
isDrawingRef.current = true;
const pointer = canvas.getPointer(e.e);
points = [{ x: pointer.x, y: pointer.y }];
// Create a temporary line for visual feedback while drawing
drawingLine = new fabric.Polyline(points, {
fill: 'transparent',
stroke: '#00ff00',
stroke: '#0088ff',
strokeWidth: 2,
selectable: false,
evented: false,
@@ -503,16 +497,15 @@ const ImageCanvas = ({
});
canvas.on('mouse:move', (e) => {
if (!isDrawing || isTransformMode) return;
if (!isDrawingRef.current) return;
const pointer = canvas.getPointer(e.e);
points.push({ x: pointer.x, y: pointer.y });
// Remove old line and create new one with updated points
canvas.remove(drawingLine);
drawingLine = new fabric.Polyline([...points], {
fill: 'transparent',
stroke: '#00ff00',
stroke: '#0088ff',
strokeWidth: 2,
selectable: false,
evented: false,
@@ -522,59 +515,50 @@ const ImageCanvas = ({
});
canvas.on('mouse:up', () => {
if (isDrawing && !isTransformMode && points.length > 2) {
setIsDrawing(false);
if (isDrawingRef.current && points.length > 5) {
isDrawingRef.current = false;
lassoPoints.current = [...points];
// Remove drawing line
canvas.remove(drawingLine);
// Create final polygon with fill
polygon = new fabric.Polygon(points, {
fill: 'rgba(255, 255, 255, 0.3)',
stroke: '#00ff00',
fill: 'rgba(0, 136, 255, 0.2)',
stroke: '#0088ff',
strokeWidth: 2,
strokeDashArray: [5, 5],
selectable: true,
hasControls: true,
hasBorders: true,
lockRotation: false,
cornerColor: '#00ff00',
cornerSize: 10,
cornerColor: '#0088ff',
cornerSize: 8,
transparentCorners: false,
borderColor: '#00ff00',
borderScaleFactor: 2,
borderColor: '#0088ff',
});
canvas.add(polygon);
canvas.setActiveObject(polygon);
setCurrentSelection(polygon);
updateSelection(polygon, 'lasso');
} else if (isDrawing) {
setIsDrawing(false);
} else if (isDrawingRef.current) {
isDrawingRef.current = false;
canvas.remove(drawingLine);
}
});
// Update selection when object is modified (moved, scaled, rotated)
canvas.on('object:modified', (e) => {
if (e.target && e.target === currentSelection) {
updateTransformedSelection(e.target, 'lasso');
if (e.target === currentSelectionRef.current) {
updateTransformedSelection(e.target);
}
});
};
const updateSelection = (selection, type) => {
if (!selection || !fabricCanvasRef.current) return;
if (!selection || !imageRef.current) return;
const canvas = fabricCanvasRef.current;
const bgImage = canvas.backgroundImage;
if (!bgImage) return;
// Calculate bounding box in original image coordinates
const imgScale = bgImage.scaleX;
const imgLeft = bgImage.left;
const imgTop = bgImage.top;
const img = imageRef.current;
const imgScale = img.scaleX;
const imgLeft = img.left;
const imgTop = img.top;
let bbox, selectionData = null;
@@ -601,39 +585,32 @@ const ImageCanvas = ({
height: Math.round(bounds.height / imgScale),
};
// Convert lasso points to relative coordinates within bbox
const relativePoints = lassoPoints.current.map(p => [
Math.round((p.x - bounds.left) / imgScale),
Math.round((p.y - bounds.top) / imgScale),
Math.round((p.x - imgLeft) / imgScale) - bbox.x,
Math.round((p.y - imgTop) / imgScale) - bbox.y,
]);
selectionData = { points: relativePoints };
}
onSelectionChange({
onSelectionChange?.({
type,
bbox,
selectionData,
});
};
// Update selection after transformation (move, scale, rotate)
const updateTransformedSelection = (selection, type) => {
if (!selection || !fabricCanvasRef.current) return;
const updateTransformedSelection = (selection) => {
if (!selection || !imageRef.current) return;
const canvas = fabricCanvasRef.current;
const bgImage = canvas.backgroundImage;
const img = imageRef.current;
const imgScale = img.scaleX;
const imgLeft = img.left;
const imgTop = img.top;
if (!bgImage) return;
const imgScale = bgImage.scaleX;
const imgLeft = bgImage.left;
const imgTop = bgImage.top;
// Get the transformed bounding rect (accounts for scale and rotation)
const bounds = selection.getBoundingRect(true);
let bbox = {
const bbox = {
x: Math.round((bounds.left - imgLeft) / imgScale),
y: Math.round((bounds.top - imgTop) / imgScale),
width: Math.round(bounds.width / imgScale),
@@ -641,8 +618,8 @@ const ImageCanvas = ({
};
let selectionData = null;
const type = selection.type === 'polygon' ? 'lasso' : (selection.type === 'ellipse' ? 'ellipse' : 'rectangle');
// For lasso, we need to transform the points based on the object's transformation
if (type === 'lasso' && lassoPoints.current.length > 0) {
const matrix = selection.calcTransformMatrix();
const transformedPoints = lassoPoints.current.map(p => {
@@ -651,14 +628,14 @@ const ImageCanvas = ({
matrix
);
return [
Math.round((transformed.x - bounds.left) / imgScale),
Math.round((transformed.y - bounds.top) / imgScale),
Math.round((transformed.x - imgLeft) / imgScale) - bbox.x,
Math.round((transformed.y - imgTop) / imgScale) - bbox.y,
];
});
selectionData = { points: transformedPoints };
}
onSelectionChange({
onSelectionChange?.({
type,
bbox,
selectionData,
@@ -666,10 +643,12 @@ const ImageCanvas = ({
};
const clearSelection = () => {
if (currentSelection && fabricCanvasRef.current) {
fabricCanvasRef.current.remove(currentSelection);
const canvas = fabricCanvasRef.current;
const sel = currentSelectionRef.current;
if (sel && canvas) {
canvas.remove(sel);
setCurrentSelection(null);
onSelectionChange(null);
onSelectionChange?.(null);
}
};
@@ -705,36 +684,15 @@ const ImageCanvas = ({
return (
<div className="canvas-container">
<canvas ref={canvasRef} />
{/* Zoom controls */}
<div className="zoom-controls">
<button onClick={handleZoomOut} title="Zoom Out"></button>
<span className="zoom-level">{Math.round(currentZoom * 100)}%</span>
<button onClick={handleZoomIn} title="Zoom In">+</button>
<button onClick={handleZoomReset} title="Reset Zoom"></button>
</div>
{/* Advanced tool mode indicator */}
{advancedToolMode && (
<div className="tool-mode-indicator">
{advancedToolMode === 'smart-select' && 'Click on an object to select it'}
{advancedToolMode === 'color-select' && 'Click on a color to select similar pixels'}
{advancedToolMode === 'object-remove' && 'Click on an object to remove it'}
</div>
)}
{currentSelection && !advancedToolMode && (
<>
<div className="selection-hint">
Click selection to move/resize/rotate
</div>
<button className="clear-selection-btn" onClick={clearSelection}>
Clear Selection
</button>
</>
{currentSelection && (
<button className="clear-selection-btn" onClick={clearSelection}>
Clear
</button>
)}
</div>
);
};
});
ImageCanvas.displayName = 'ImageCanvas';
export default ImageCanvas;