diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ab83449 --- /dev/null +++ b/.env.example @@ -0,0 +1,28 @@ +# AI Provider Configuration +# Options: openai, stability, replicate, mock +# - openai: DALL-E 2 (low quality, not recommended) +# - stability: Stability AI SDXL (good quality, ~$0.04/image) +# - replicate: Multiple models (best value, ~$0.002-0.025/image) +# - mock: No AI, returns original (for testing) +AI_PROVIDER=mock + +# API Keys +OPENAI_API_KEY= +STABILITY_API_KEY= +REPLICATE_API_KEY= + +# Model Selection (optional, provider-specific) +# Stability AI models: sdxl (default), sd15, sd21 +STABILITY_MODEL=sdxl + +# Replicate models: sdxl-inpaint (default), lama, realistic-vision +# - sdxl-inpaint: Best general purpose (~$0.025/image) +# - lama: Best for object removal (~$0.002/image) +# - realistic-vision: Best for humans/faces/hands (~$0.020/image) +REPLICATE_MODEL=sdxl-inpaint + +# Allow per-edit model override (true/false) +ALLOW_MODEL_OVERRIDE=true + +# Secret key for JWT tokens (change in production) +SECRET_KEY=change-this-secret-key-in-production diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7e7cac6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,70 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST +venv/ +env/ +ENV/ + +# Node +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnp +.pnp.js +coverage/ +build/ +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Environment +.env + +# Data +data/projects/*/ +!data/projects/.gitkeep +*.db +*.sqlite +*.sqlite3 + +# Docker +*.log +docker-compose.override.yml + +# Testing +.pytest_cache/ +.coverage +htmlcov/ + +# OS +.DS_Store +Thumbs.db diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..1d715ef --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,86 @@ +# Contributing to AI Photo Edit + +Thank you for your interest in contributing to AI Photo Edit! + +## Development Setup + +1. Fork the repository +2. Clone your fork +3. Create a feature branch +4. Make your changes +5. Test your changes +6. Submit a pull request + +## Development Environment + +### Using Docker (Recommended) + +```bash +# Start dev environment with hot-reload +docker-compose -f docker-compose.dev.yml up +``` + +### Local Development + +**Backend** +```bash +cd backend +python -m venv venv +source venv/bin/activate # On Windows: venv\Scripts\activate +pip install -r requirements.txt +uvicorn app.main:app --reload +``` + +**Frontend** +```bash +cd frontend +npm install +npm run dev +``` + +## Code Style + +### Python (Backend) +- Follow PEP 8 +- Use type hints where appropriate +- Add docstrings to functions and classes + +### JavaScript/React (Frontend) +- Use functional components with hooks +- Follow React best practices +- Use meaningful variable names + +## Pull Request Process + +1. Update the README.md with details of changes if needed +2. Ensure all tests pass +3. Update documentation as needed +4. Get approval from maintainers +5. Squash commits if requested + +## Reporting Bugs + +When reporting bugs, please include: +- Description of the issue +- Steps to reproduce +- Expected behavior +- Actual behavior +- Screenshots if applicable +- Environment details (OS, Docker version, etc.) + +## Feature Requests + +We welcome feature requests! Please: +- Check if the feature already exists +- Explain the use case +- Describe the expected behavior +- Consider if it aligns with project goals + +## Code of Conduct + +- Be respectful and inclusive +- Welcome newcomers +- Focus on constructive feedback +- Respect differing opinions + +Thank you for contributing! diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..cbc2ce1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 AI Photo Edit Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6467ed0 --- /dev/null +++ b/Makefile @@ -0,0 +1,35 @@ +.PHONY: help up down build logs clean dev test + +help: ## Show this help message + @echo 'Usage: make [target]' + @echo '' + @echo 'Available targets:' + @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " %-15s %s\n", $$1, $$2}' $(MAKEFILE_LIST) + +up: ## Start the application (production) + docker-compose up -d + +down: ## Stop the application + docker-compose down + +build: ## Build all containers + docker-compose build + +logs: ## Show logs + docker-compose logs -f + +clean: ## Remove all containers, volumes, and data + docker-compose down -v + rm -rf data/ + +dev: ## Start the application (development mode) + docker-compose -f docker-compose.dev.yml up + +test: ## Run tests + @echo "Tests not yet implemented" + +restart: ## Restart the application + docker-compose restart + +ps: ## Show running containers + docker-compose ps diff --git a/README.md b/README.md index ab4e135..156ff58 100644 --- a/README.md +++ b/README.md @@ -1 +1,354 @@ -# EditmaskwithAI \ No newline at end of file +# AI Photo Edit + +AI Photo Edit is a self-hosted, web-based image editing tool that allows you to regenerate only a selected area of a photo using AI. + +## Core Concept + +**Have AI regenerate only a selected area of a photo.** + +- Upload an image +- Select a specific region (rectangle, ellipse, or freehand lasso) +- Enter a prompt describing what to fix +- Have AI regenerate only the selected region +- Composite the regenerated region back into the original image +- Preserve every pixel outside the selection +- Maintain full edit history and reversibility + +**The system does not regenerate the entire image.** +**The system does not alter any pixel outside the selected mask.** + +## Features + +### Selection Tools +- **Rectangle**: Click and drag to select rectangular regions +- **Ellipse**: Click and drag to select elliptical regions +- **Lasso**: Draw freehand selections around irregular shapes + +### AI Modes +- **Mode A (Default)**: Send only the selected patch + - Faster processing + - Lower cost + - Best for isolated fixes + +- **Mode B**: Send patch + full image reference + - Better style consistency + - More context-aware results + - Higher cost + +### Edge Blending +- Adjustable feather slider (0-50 pixels) +- Smooth blending at selection edges +- Prevents harsh transitions + +### Edit History +- Full history of all edits +- Revert to any previous edit +- Reset to original image +- All edits are reversible + +## Architecture + +``` +AI Photo Edit +├── Frontend (React + Fabric.js) +│ ├── Image canvas with selection tools +│ ├── Controls (mode, feather, prompt) +│ └── Edit history viewer +│ +├── Backend (FastAPI) +│ ├── Image processing +│ ├── AI provider integration +│ ├── Database (SQLite) +│ └── File storage +│ +└── Docker Compose + ├── Backend service + └── Frontend service (nginx) +``` + +## Installation + +### Prerequisites +- Docker and Docker Compose +- AI API key (OpenAI or Stability AI) for production use + +### Quick Start + +1. **Clone the repository** +```bash +git clone +cd EditmaskwithAI +``` + +2. **Configure environment variables** +```bash +cp .env.example .env +``` + +Edit `.env` and set your AI provider: +```env +# For OpenAI (DALL-E) +AI_PROVIDER=openai +OPENAI_API_KEY=your-openai-api-key-here + +# OR for Stability AI +AI_PROVIDER=stability +STABILITY_API_KEY=your-stability-api-key-here + +# OR for testing (no AI, returns original) +AI_PROVIDER=mock +``` + +3. **Start the application** +```bash +docker-compose up -d +``` + +4. **Access the application** +- Frontend: http://localhost +- Backend API: http://localhost:8000 +- API Documentation: http://localhost:8000/docs + +### Development Setup + +For development with hot-reload: + +```bash +docker-compose -f docker-compose.dev.yml up +``` + +- Frontend: http://localhost:5173 (Vite dev server) +- Backend: http://localhost:8000 (auto-reload enabled) + +## Usage + +### 1. Create a Project +- Enter a project name +- Upload your image (PNG, JPG, etc.) +- Click "Create Project" + +### 2. Select an Area +- Choose a selection tool (Rectangle, Ellipse, or Lasso) +- Draw your selection on the image +- Adjust the selection if needed + +### 3. Configure Edit +- **AI Mode**: Choose Mode A (faster) or Mode B (better context) +- **Feather**: Adjust edge blending (0-50 pixels) +- **Prompt**: Describe what you want to change + +Examples: +- "Remove the person" +- "Change sky to sunset" +- "Fix the red eye" +- "Add flowers" + +### 4. Process Edit +- Click "Fix Selected Area" +- Wait for AI processing (status shown in history) +- View the result on the canvas + +### 5. Manage History +- View all edits in the history panel +- Revert to any previous edit +- Reset to original image anytime + +## API Documentation + +### Projects + +**Create Project** +``` +POST /projects/ +Body: { "name": "My Project" } +``` + +**Upload Image** +``` +POST /projects/{project_id}/upload +Body: multipart/form-data with image file +``` + +**List Projects** +``` +GET /projects/ +``` + +**Get Project** +``` +GET /projects/{project_id} +``` + +### Edits + +**Create Edit** +``` +POST /edits/projects/{project_id}/fix +Body: { + "prompt": "Remove the object", + "mode": "A", + "selection_type": "rectangle", + "bbox": { "x": 100, "y": 100, "width": 200, "height": 200 }, + "feather_px": 5, + "selection_data": null +} +``` + +**Get Edit Status** +``` +GET /edits/{edit_id} +``` + +**Revert to Edit** +``` +POST /edits/projects/{project_id}/revert/{edit_id} +``` + +**Reset to Original** +``` +POST /edits/projects/{project_id}/reset +``` + +### Images + +**Get Original Image** +``` +GET /projects/{project_id}/original +``` + +**Get Current Image** +``` +GET /projects/{project_id}/current +``` + +**Get Edit Result** +``` +GET /projects/{project_id}/history/{edit_id}/result +``` + +## File Structure + +``` +EditmaskwithAI/ +├── backend/ +│ ├── app/ +│ │ ├── models/ # Database models +│ │ ├── routers/ # API endpoints +│ │ ├── services/ # Business logic +│ │ ├── utils/ # Image processing utilities +│ │ ├── config.py # Configuration +│ │ ├── database.py # Database setup +│ │ └── main.py # FastAPI app +│ ├── Dockerfile +│ └── requirements.txt +│ +├── frontend/ +│ ├── src/ +│ │ ├── components/ # React components +│ │ │ ├── ImageCanvas.jsx +│ │ │ ├── Controls.jsx +│ │ │ └── History.jsx +│ │ ├── utils/ # API client +│ │ ├── App.jsx +│ │ └── main.jsx +│ ├── Dockerfile +│ ├── nginx.conf +│ └── package.json +│ +├── data/ # Persistent data (auto-created) +│ ├── ai_photo_edit.db # SQLite database +│ └── projects/ # Project files +│ +├── docker-compose.yml +├── docker-compose.dev.yml +└── README.md +``` + +## Data Storage + +### Database (SQLite) +- **users**: User accounts +- **projects**: Project metadata +- **edits**: Edit history and metadata + +### Filesystem +``` +data/projects/{project_id}/ +├── original.png # Original uploaded image +├── current.png # Current edited image +└── history/{edit_id}/ + ├── patch_in.png # Original patch + ├── patch_out.png # AI-generated patch + ├── mask.png # Selection mask + ├── result.png # Final result + └── meta.json # Edit metadata +``` + +## AI Provider Configuration + +### OpenAI (DALL-E) +```env +AI_PROVIDER=openai +OPENAI_API_KEY=sk-... +``` + +### Stability AI +```env +AI_PROVIDER=stability +STABILITY_API_KEY=sk-... +``` + +### Mock (Testing) +```env +AI_PROVIDER=mock +``` +Returns the original patch unchanged - useful for testing without API costs. + +## Constraints + +- Only the selected region is regenerated +- No modification outside the mask +- Slight drift inside mask is acceptable +- All edits are logged and reversible +- Mode A is default (cost-efficient) +- Mode B available for better style consistency + +## Non-Goals (MVP) + +- No automatic anomaly detection +- No local GPU inference +- No full image regeneration +- No collaborative editing + +## Contributing + +Contributions are welcome! Please: + +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Submit a pull request + +## License + +MIT License - see LICENSE file for details + +## Support + +For issues and questions: +- Open an issue on GitHub +- Check the API documentation at `/docs` + +## Roadmap + +Future enhancements: +- Multi-user authentication +- Batch processing +- Additional AI providers +- Advanced selection tools +- Real-time collaboration +- Export formats (PSD, TIFF) + +--- + +**AI Photo Edit** - Regenerate only what you need to change. \ No newline at end of file diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..2b27e7c --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,21 @@ +# Database +DATABASE_URL=sqlite:///./data/ai_photo_edit.db + +# Security +SECRET_KEY=your-secret-key-here-change-in-production +ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=30 + +# AI Provider (configure based on your provider) +AI_PROVIDER=openai +OPENAI_API_KEY=your-openai-api-key-here +# Alternative providers (uncomment as needed) +# AI_PROVIDER=stability +# STABILITY_API_KEY=your-stability-api-key-here + +# File Storage +DATA_DIR=/app/data +MAX_UPLOAD_SIZE_MB=50 + +# CORS +CORS_ORIGINS=http://localhost:3000,http://localhost:5173 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..c2eb018 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,27 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + libgl1-mesa-glx \ + libglib2.0-0 \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements +COPY requirements.txt . + +# Install Python dependencies +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application +COPY . . + +# Create data directory +RUN mkdir -p /app/data/projects + +# Expose port +EXPOSE 8000 + +# Run the application +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..c958edc --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,45 @@ +from pydantic_settings import BaseSettings +from typing import List + + +class Settings(BaseSettings): + # Database + database_url: str = "sqlite:///./data/ai_photo_edit.db" + + # Security + secret_key: str = "your-secret-key-change-in-production" + algorithm: str = "HS256" + access_token_expire_minutes: int = 30 + + # AI Provider + ai_provider: str = "mock" # Options: openai, stability, replicate, mock + + # Provider API Keys + openai_api_key: str = "" + stability_api_key: str = "" + replicate_api_key: str = "" + + # Model Selection (optional, provider-specific) + stability_model: str = "sdxl" # Options: sdxl, sd15, sd21 + replicate_model: str = "sdxl-inpaint" # Options: sdxl-inpaint, lama, realistic-vision + + # Allow per-edit model override + allow_model_override: bool = True + + # File Storage + data_dir: str = "./data" + max_upload_size_mb: int = 50 + + # CORS + cors_origins: str = "http://localhost:3000,http://localhost:5173" + + @property + def cors_origins_list(self) -> List[str]: + return [origin.strip() for origin in self.cors_origins.split(",")] + + class Config: + env_file = ".env" + case_sensitive = False + + +settings = Settings() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..1256bfb --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,30 @@ +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker +from app.config import settings +import os + +# Ensure data directory exists +os.makedirs("./data", exist_ok=True) + +engine = create_engine( + settings.database_url, + connect_args={"check_same_thread": False} # Needed for SQLite +) + +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +Base = declarative_base() + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + + +def init_db(): + """Initialize database tables""" + Base.metadata.create_all(bind=engine) diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..f408e8f --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,55 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from contextlib import asynccontextmanager + +from app.config import settings +from app.database import init_db +from app.routers import projects, edits, images, patches, generate + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Initialize database on startup""" + init_db() + yield + + +app = FastAPI( + title="AI Photo Edit API", + description="API for AI-powered photo editing with mask-scoped regeneration", + version="1.0.0", + lifespan=lifespan +) + +# Configure CORS +app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins_list, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Include routers +app.include_router(projects.router) +app.include_router(edits.router) +app.include_router(images.router) +app.include_router(patches.router) +app.include_router(generate.router) + + +@app.get("/") +def root(): + """API root endpoint""" + return { + "name": "AI Photo Edit API", + "version": "1.0.0", + "status": "running" + } + + +@app.get("/health") +def health(): + """Health check endpoint""" + return {"status": "healthy"} diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..c9f1410 --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,6 @@ +from app.models.user import User +from app.models.project import Project +from app.models.edit import Edit +from app.models.patch import Patch + +__all__ = ["User", "Project", "Edit", "Patch"] diff --git a/backend/app/models/edit.py b/backend/app/models/edit.py new file mode 100644 index 0000000..670850a --- /dev/null +++ b/backend/app/models/edit.py @@ -0,0 +1,23 @@ +from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text +from sqlalchemy.orm import relationship +from datetime import datetime +from app.database import Base + + +class Edit(Base): + __tablename__ = "edits" + + id = Column(Integer, primary_key=True, index=True) + project_id = Column(Integer, ForeignKey("projects.id"), nullable=False) + created_at = Column(DateTime, default=datetime.utcnow) + mode = Column(String, nullable=False) # "A" or "B" + prompt = Column(Text, nullable=False) + selection_type = Column(String, nullable=False) # "rectangle", "ellipse", "lasso" + bbox_json = Column(Text, nullable=False) # JSON string of {x, y, width, height} + feather_px = Column(Integer, default=0) + ai_provider = Column(String, nullable=False) + status = Column(String, nullable=False) # "pending", "processing", "completed", "failed" + error_message = Column(Text, nullable=True) + + # Relationships + project = relationship("Project", back_populates="edits") diff --git a/backend/app/models/patch.py b/backend/app/models/patch.py new file mode 100644 index 0000000..23f57fb --- /dev/null +++ b/backend/app/models/patch.py @@ -0,0 +1,37 @@ +from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text, Boolean +from sqlalchemy.orm import relationship +from datetime import datetime +from app.database import Base + + +class Patch(Base): + __tablename__ = "patches" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=True) + name = Column(String, nullable=False) + description = Column(Text, nullable=True) + created_at = Column(DateTime, default=datetime.utcnow) + + # Source information + source_type = Column(String, nullable=False) # "ai_generated", "manual_selection", "imported" + source_project_id = Column(Integer, ForeignKey("projects.id"), nullable=True) + source_edit_id = Column(Integer, ForeignKey("edits.id"), nullable=True) + + # Patch metadata + width = Column(Integer, nullable=False) + height = Column(Integer, nullable=False) + tags = Column(Text, nullable=True) # Comma-separated tags + category = Column(String, nullable=True) # "hand", "face", "body", "object", "texture", etc. + + # Is this patch shared/public? + is_public = Column(Boolean, default=False) + + # File path (relative to data dir) + file_path = Column(String, nullable=False) + thumbnail_path = Column(String, nullable=True) + + # Relationships + user = relationship("User", back_populates="patches") + source_project = relationship("Project") + source_edit = relationship("Edit") diff --git a/backend/app/models/project.py b/backend/app/models/project.py new file mode 100644 index 0000000..a76d79e --- /dev/null +++ b/backend/app/models/project.py @@ -0,0 +1,18 @@ +from sqlalchemy import Column, Integer, String, DateTime, ForeignKey +from sqlalchemy.orm import relationship +from datetime import datetime +from app.database import Base + + +class Project(Base): + __tablename__ = "projects" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + name = Column(String, nullable=False) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Relationships + user = relationship("User", back_populates="projects") + edits = relationship("Edit", back_populates="project", cascade="all, delete-orphan") diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..7c30d3e --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,17 @@ +from sqlalchemy import Column, Integer, String, DateTime +from sqlalchemy.orm import relationship +from datetime import datetime +from app.database import Base + + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + email = Column(String, unique=True, index=True, nullable=False) + password_hash = Column(String, nullable=False) + created_at = Column(DateTime, default=datetime.utcnow) + + # Relationships + projects = relationship("Project", back_populates="user", cascade="all, delete-orphan") + patches = relationship("Patch", back_populates="user", cascade="all, delete-orphan") diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/routers/edits.py b/backend/app/routers/edits.py new file mode 100644 index 0000000..d8f28a9 --- /dev/null +++ b/backend/app/routers/edits.py @@ -0,0 +1,171 @@ +from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks +from sqlalchemy.orm import Session +import json + +from app.database import get_db +from app.models.project import Project +from app.models.edit import Edit +from app.schemas import EditRequest, EditResponse, StatusResponse +from app.services.edit_service import EditService +from app.config import settings + +router = APIRouter(prefix="/edits", tags=["edits"]) + + +async def process_edit_background( + edit_id: int, + project_id: int, + request: EditRequest, + db: Session +): + """Background task to process edit""" + edit_service = EditService() + + try: + # Process the edit + result_path = await edit_service.process_edit( + project_id=project_id, + edit_id=edit_id, + prompt=request.prompt, + mode=request.mode, + selection_type=request.selection_type, + bbox=request.bbox, + feather_px=request.feather_px, + selection_data=request.selection_data + ) + + # Update edit status + edit = db.query(Edit).filter(Edit.id == edit_id).first() + if edit: + edit.status = "completed" + db.commit() + + except Exception as e: + # Update edit with error + edit = db.query(Edit).filter(Edit.id == edit_id).first() + if edit: + edit.status = "failed" + edit.error_message = str(e) + db.commit() + + +@router.post("/projects/{project_id}/fix", response_model=EditResponse) +async def create_edit( + project_id: int, + request: EditRequest, + background_tasks: BackgroundTasks, + db: Session = Depends(get_db) +): + """ + Create a new edit request (Fix button) + + This endpoint accepts the selection data and prompt, + then processes the edit in the background. + """ + # Verify project exists + project = db.query(Project).filter(Project.id == project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + # Validate mode + if request.mode not in ["A", "B"]: + raise HTTPException(status_code=400, detail="Mode must be 'A' or 'B'") + + # Validate selection type + if request.selection_type not in ["rectangle", "ellipse", "lasso"]: + raise HTTPException(status_code=400, detail="Invalid selection type") + + # Create edit record + edit = Edit( + project_id=project_id, + mode=request.mode, + prompt=request.prompt, + selection_type=request.selection_type, + bbox_json=json.dumps(request.bbox), + feather_px=request.feather_px, + ai_provider=settings.ai_provider, + status="pending" + ) + db.add(edit) + db.commit() + db.refresh(edit) + + # Process edit in background + background_tasks.add_task( + process_edit_background, + edit.id, + project_id, + request, + db + ) + + return edit + + +@router.get("/{edit_id}", response_model=EditResponse) +def get_edit( + edit_id: int, + db: Session = Depends(get_db) +): + """Get edit details and status""" + edit = db.query(Edit).filter(Edit.id == edit_id).first() + if not edit: + raise HTTPException(status_code=404, detail="Edit not found") + return edit + + +@router.post("/projects/{project_id}/revert/{edit_id}", response_model=StatusResponse) +def revert_to_edit( + project_id: int, + edit_id: int, + db: Session = Depends(get_db) +): + """Revert project to a specific edit""" + # Verify project exists + project = db.query(Project).filter(Project.id == project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + # Verify edit exists and belongs to project + edit = db.query(Edit).filter( + Edit.id == edit_id, + Edit.project_id == project_id + ).first() + if not edit: + raise HTTPException(status_code=404, detail="Edit not found") + + # Revert + edit_service = EditService() + try: + result_path = edit_service.revert_to_edit(project_id, edit_id) + return StatusResponse( + status="success", + message=f"Reverted to edit {edit_id}", + data={"image_url": f"/projects/{project_id}/current"} + ) + except FileNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) + + +@router.post("/projects/{project_id}/reset", response_model=StatusResponse) +def reset_to_original( + project_id: int, + db: Session = Depends(get_db) +): + """Reset project to original image""" + # Verify project exists + project = db.query(Project).filter(Project.id == project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + # Reset + edit_service = EditService() + try: + result_path = edit_service.reset_to_original(project_id) + return StatusResponse( + status="success", + message="Reset to original image", + data={"image_url": f"/projects/{project_id}/current"} + ) + except FileNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) diff --git a/backend/app/routers/generate.py b/backend/app/routers/generate.py new file mode 100644 index 0000000..b247991 --- /dev/null +++ b/backend/app/routers/generate.py @@ -0,0 +1,176 @@ +from fastapi import APIRouter, Depends, HTTPException, Form +from sqlalchemy.orm import Session +from typing import Optional +from PIL import Image +from io import BytesIO +import os + +from app.database import get_db +from app.models.project import Project +from app.schemas import TextToImageRequest, TextToImageResponse +from app.services.ai_provider import get_ai_provider +from app.services.edit_service import EditService +from app.config import settings + +router = APIRouter(prefix="/generate", tags=["generate"]) + + +@router.post("/text-to-image", response_model=TextToImageResponse) +async def text_to_image( + prompt: str = Form(...), + width: int = Form(1024), + height: int = Form(1024), + negative_prompt: Optional[str] = Form(None), + ai_provider: Optional[str] = Form(None), + ai_model: Optional[str] = Form(None), + create_project: bool = Form(True), + project_name: Optional[str] = Form(None), + db: Session = Depends(get_db) +): + """ + Generate an image from text prompt + + Args: + prompt: Text description of desired image + width: Image width (default 1024) + height: Image height (default 1024) + negative_prompt: What to avoid in generation + ai_provider: Override default AI provider + ai_model: Specific model to use + create_project: Whether to create a new project with the result + project_name: Name for the new project (if create_project=True) + + Returns: + Generated image info and optionally project details + """ + + # Validate dimensions + if width < 256 or width > 2048 or height < 256 or height > 2048: + raise HTTPException( + status_code=400, + detail="Width and height must be between 256 and 2048" + ) + + # Get AI provider + provider = get_ai_provider(ai_provider, ai_model) + + try: + # Generate image + image_bytes = await provider.text_to_image( + prompt=prompt, + width=width, + height=height, + model=ai_model, + negative_prompt=negative_prompt + ) + + project_id = None + image_url = None + + if create_project: + # Create a new project + project = Project( + name=project_name or f"Generated: {prompt[:50]}", + user_id=None # TODO: Add authentication + ) + db.add(project) + db.commit() + db.refresh(project) + + project_id = project.id + + # Save image as both original and current + edit_service = EditService() + edit_service.ensure_project_dir(project_id) + + original_path = edit_service.get_original_image_path(project_id) + current_path = edit_service.get_current_image_path(project_id) + + # Save image + img = Image.open(BytesIO(image_bytes)) + img.save(original_path, 'PNG') + img.save(current_path, 'PNG') + + image_url = f"/projects/{project_id}/current" + + return TextToImageResponse( + status="success", + prompt=prompt, + width=width, + height=height, + project_id=project_id, + image_url=image_url, + ai_provider=ai_provider or settings.ai_provider, + ai_model=ai_model + ) + + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/layer/text-to-image", response_model=TextToImageResponse) +async def text_to_image_layer( + project_id: int = Form(...), + prompt: str = Form(...), + width: int = Form(512), + height: int = Form(512), + x: int = Form(0), + y: int = Form(0), + negative_prompt: Optional[str] = Form(None), + ai_provider: Optional[str] = Form(None), + ai_model: Optional[str] = Form(None), + db: Session = Depends(get_db) +): + """ + Generate an image as a new layer in an existing project + + This generates a smaller image that can be placed as a layer + on top of the current project canvas. + """ + + # Verify project exists + project = db.query(Project).filter(Project.id == project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + # Get AI provider + provider = get_ai_provider(ai_provider, ai_model) + + try: + # Generate image + image_bytes = await provider.text_to_image( + prompt=prompt, + width=width, + height=height, + model=ai_model, + negative_prompt=negative_prompt + ) + + # Save as temporary layer file + edit_service = EditService() + layers_dir = edit_service.get_project_dir(project_id) / "layers" + layers_dir.mkdir(exist_ok=True) + + # Generate unique layer filename + import time + layer_filename = f"generated_{int(time.time())}.png" + layer_path = layers_dir / layer_filename + + # Save layer image + with open(layer_path, 'wb') as f: + f.write(image_bytes) + + return TextToImageResponse( + status="success", + prompt=prompt, + width=width, + height=height, + project_id=project_id, + image_url=f"/projects/{project_id}/layers/{layer_filename}", + layer_position={"x": x, "y": y}, + ai_provider=ai_provider or settings.ai_provider, + ai_model=ai_model + ) + + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/app/routers/images.py b/backend/app/routers/images.py new file mode 100644 index 0000000..514f798 --- /dev/null +++ b/backend/app/routers/images.py @@ -0,0 +1,81 @@ +from fastapi import APIRouter, HTTPException, Depends +from fastapi.responses import FileResponse +from sqlalchemy.orm import Session +from pathlib import Path + +from app.database import get_db +from app.models.project import Project +from app.services.edit_service import EditService + +router = APIRouter(prefix="/projects", tags=["images"]) + + +@router.get("/{project_id}/original") +def get_original_image( + project_id: int, + db: Session = Depends(get_db) +): + """Get the original uploaded image""" + project = db.query(Project).filter(Project.id == project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + edit_service = EditService() + image_path = edit_service.get_original_image_path(project_id) + + if not image_path.exists(): + raise HTTPException(status_code=404, detail="Original image not found") + + return FileResponse( + image_path, + media_type="image/png", + headers={"Cache-Control": "public, max-age=3600"} + ) + + +@router.get("/{project_id}/current") +def get_current_image( + project_id: int, + db: Session = Depends(get_db) +): + """Get the current edited image""" + project = db.query(Project).filter(Project.id == project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + edit_service = EditService() + image_path = edit_service.get_current_image_path(project_id) + + if not image_path.exists(): + raise HTTPException(status_code=404, detail="Current image not found") + + return FileResponse( + image_path, + media_type="image/png", + headers={"Cache-Control": "no-cache"} + ) + + +@router.get("/{project_id}/history/{edit_id}/result") +def get_edit_result( + project_id: int, + edit_id: int, + db: Session = Depends(get_db) +): + """Get the result image from a specific edit""" + project = db.query(Project).filter(Project.id == project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + edit_service = EditService() + edit_dir = edit_service.get_edit_dir(project_id, edit_id) + result_path = edit_dir / "result.png" + + if not result_path.exists(): + raise HTTPException(status_code=404, detail="Edit result not found") + + return FileResponse( + result_path, + media_type="image/png", + headers={"Cache-Control": "public, max-age=3600"} + ) diff --git a/backend/app/routers/patches.py b/backend/app/routers/patches.py new file mode 100644 index 0000000..618e002 --- /dev/null +++ b/backend/app/routers/patches.py @@ -0,0 +1,308 @@ +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form +from fastapi.responses import FileResponse +from sqlalchemy.orm import Session +from typing import List, Optional +import json + +from app.database import get_db +from app.models.patch import Patch +from app.models.project import Project +from app.models.edit import Edit +from app.schemas import PatchCreate, PatchResponse, PatchApply, StatusResponse +from app.services.patch_library import PatchLibraryService +from app.config import settings + +router = APIRouter(prefix="/patches", tags=["patches"]) + + +@router.post("/", response_model=PatchResponse) +async def create_patch( + name: str = Form(...), + description: Optional[str] = Form(None), + source_type: str = Form(...), + category: Optional[str] = Form(None), + tags: Optional[str] = Form(None), + source_project_id: Optional[int] = Form(None), + source_edit_id: Optional[int] = Form(None), + bbox: Optional[str] = Form(None), + file: Optional[UploadFile] = File(None), + db: Session = Depends(get_db) +): + """ + Create a new patch in the library + + Source types: + - ai_generated: From an edit (requires source_edit_id) + - manual_selection: Selected from current image (requires source_project_id and bbox) + - imported: Uploaded file (requires file) + """ + + # Validate source_type + if source_type not in ["ai_generated", "manual_selection", "imported"]: + raise HTTPException(status_code=400, detail="Invalid source_type") + + # Create patch record + patch = Patch( + name=name, + description=description, + source_type=source_type, + source_project_id=source_project_id, + source_edit_id=source_edit_id, + tags=tags, + category=category, + file_path="", # Will be set after saving + user_id=None # TODO: Add authentication + ) + + db.add(patch) + db.commit() + db.refresh(patch) + + # Save patch file based on source type + patch_service = PatchLibraryService() + + try: + if source_type == "ai_generated": + # Get edit directory and save AI-generated patch + if not source_edit_id: + raise HTTPException(status_code=400, detail="source_edit_id required for ai_generated") + + edit = db.query(Edit).filter(Edit.id == source_edit_id).first() + if not edit: + raise HTTPException(status_code=404, detail="Edit not found") + + from app.services.edit_service import EditService + edit_service = EditService() + edit_dir = edit_service.get_edit_dir(edit.project_id, edit.id) + + file_path = patch_service.save_ai_generated_patch(patch.id, edit_dir) + + # Get dimensions + width, height = patch_service.get_patch_size(patch.id) + patch.width = width + patch.height = height + + elif source_type == "manual_selection": + # Save manually selected patch from current image + if not source_project_id or not bbox: + raise HTTPException( + status_code=400, + detail="source_project_id and bbox required for manual_selection" + ) + + project = db.query(Project).filter(Project.id == source_project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + bbox_dict = json.loads(bbox) if isinstance(bbox, str) else bbox + file_path = patch_service.save_manual_patch(patch.id, source_project_id, bbox_dict) + + patch.width = bbox_dict['width'] + patch.height = bbox_dict['height'] + + elif source_type == "imported": + # Save uploaded file + if not file: + raise HTTPException(status_code=400, detail="file required for imported") + + image_bytes = await file.read() + file_path = patch_service.save_patch_from_bytes(patch.id, image_bytes) + + # Get dimensions + width, height = patch_service.get_patch_size(patch.id) + patch.width = width + patch.height = height + + # Update patch with file path + patch.file_path = file_path + patch.thumbnail_path = str(patch_service.get_thumbnail_path(patch.id)) + db.commit() + db.refresh(patch) + + return patch + + except Exception as e: + # Cleanup on error + patch_service.delete_patch(patch.id) + db.delete(patch) + db.commit() + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/", response_model=List[PatchResponse]) +def list_patches( + category: Optional[str] = None, + tags: Optional[str] = None, + limit: int = 50, + offset: int = 0, + db: Session = Depends(get_db) +): + """List patches in the library with optional filtering""" + + query = db.query(Patch) + + if category: + query = query.filter(Patch.category == category) + + if tags: + # Simple tag search (could be improved with full-text search) + query = query.filter(Patch.tags.like(f"%{tags}%")) + + patches = query.offset(offset).limit(limit).all() + return patches + + +@router.get("/{patch_id}", response_model=PatchResponse) +def get_patch( + patch_id: int, + db: Session = Depends(get_db) +): + """Get patch details""" + patch = db.query(Patch).filter(Patch.id == patch_id).first() + if not patch: + raise HTTPException(status_code=404, detail="Patch not found") + return patch + + +@router.get("/{patch_id}/image") +def get_patch_image( + patch_id: int, + thumbnail: bool = False, + db: Session = Depends(get_db) +): + """Get patch image file""" + patch = db.query(Patch).filter(Patch.id == patch_id).first() + if not patch: + raise HTTPException(status_code=404, detail="Patch not found") + + patch_service = PatchLibraryService() + + if thumbnail: + file_path = patch_service.get_thumbnail_path(patch_id) + else: + file_path = patch_service.get_patch_path(patch_id) + + if not file_path.exists(): + raise HTTPException(status_code=404, detail="Patch image not found") + + return FileResponse(file_path, media_type="image/png") + + +@router.post("/apply", response_model=StatusResponse) +async def apply_patch( + project_id: int = Form(...), + patch_id: int = Form(...), + bbox: str = Form(...), + feather_px: int = Form(5), + db: Session = Depends(get_db) +): + """ + Apply a saved patch to a project image + + This creates a new edit in the project history. + """ + # Verify project exists + project = db.query(Project).filter(Project.id == project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + # Verify patch exists + patch = db.query(Patch).filter(Patch.id == patch_id).first() + if not patch: + raise HTTPException(status_code=404, detail="Patch not found") + + # Parse bbox + bbox_dict = json.loads(bbox) if isinstance(bbox, str) else bbox + + # Load current image + from app.services.edit_service import EditService + from PIL import Image + + edit_service = EditService() + current_image_path = edit_service.get_current_image_path(project_id) + current_image = Image.open(current_image_path).convert('RGBA') + + # Apply patch + patch_service = PatchLibraryService() + result_image = patch_service.apply_patch_to_image( + patch_id, + current_image, + bbox_dict, + feather_px + ) + + # Save result as current image + result_image.save(current_image_path) + + # Create edit record + edit = Edit( + project_id=project_id, + mode="patch_library", + prompt=f"Applied saved patch: {patch.name}", + selection_type="rectangle", + bbox_json=json.dumps(bbox_dict), + feather_px=feather_px, + ai_provider="patch_library", + status="completed" + ) + db.add(edit) + db.commit() + + return StatusResponse( + status="success", + message=f"Applied patch '{patch.name}' to project", + data={"edit_id": edit.id} + ) + + +@router.delete("/{patch_id}", response_model=StatusResponse) +def delete_patch( + patch_id: int, + db: Session = Depends(get_db) +): + """Delete a patch from the library""" + patch = db.query(Patch).filter(Patch.id == patch_id).first() + if not patch: + raise HTTPException(status_code=404, detail="Patch not found") + + # Delete files + patch_service = PatchLibraryService() + patch_service.delete_patch(patch_id) + + # Delete record + db.delete(patch) + db.commit() + + return StatusResponse( + status="success", + message=f"Deleted patch '{patch.name}'" + ) + + +@router.put("/{patch_id}", response_model=PatchResponse) +def update_patch( + patch_id: int, + name: Optional[str] = None, + description: Optional[str] = None, + category: Optional[str] = None, + tags: Optional[str] = None, + db: Session = Depends(get_db) +): + """Update patch metadata""" + patch = db.query(Patch).filter(Patch.id == patch_id).first() + if not patch: + raise HTTPException(status_code=404, detail="Patch not found") + + if name: + patch.name = name + if description is not None: + patch.description = description + if category: + patch.category = category + if tags is not None: + patch.tags = tags + + db.commit() + db.refresh(patch) + + return patch diff --git a/backend/app/routers/projects.py b/backend/app/routers/projects.py new file mode 100644 index 0000000..3630007 --- /dev/null +++ b/backend/app/routers/projects.py @@ -0,0 +1,144 @@ +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, status +from sqlalchemy.orm import Session +from typing import List +import shutil +from pathlib import Path +from PIL import Image + +from app.database import get_db +from app.models.project import Project +from app.models.edit import Edit +from app.schemas import ProjectCreate, ProjectResponse, EditResponse, UploadResponse +from app.services.edit_service import EditService +from app.config import settings + +router = APIRouter(prefix="/projects", tags=["projects"]) + + +@router.post("/", response_model=ProjectResponse) +def create_project( + project: ProjectCreate, + db: Session = Depends(get_db) +): + """Create a new project""" + # For MVP, we'll use a default user_id of 1 + # In production, this would come from authentication + user_id = 1 + + db_project = Project( + user_id=user_id, + name=project.name + ) + db.add(db_project) + db.commit() + db.refresh(db_project) + + # Create project directory + edit_service = EditService() + edit_service.ensure_project_dir(db_project.id) + + return db_project + + +@router.get("/", response_model=List[ProjectResponse]) +def list_projects( + skip: int = 0, + limit: int = 100, + db: Session = Depends(get_db) +): + """List all projects""" + projects = db.query(Project).offset(skip).limit(limit).all() + return projects + + +@router.get("/{project_id}", response_model=ProjectResponse) +def get_project( + project_id: int, + db: Session = Depends(get_db) +): + """Get a specific project""" + project = db.query(Project).filter(Project.id == project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + return project + + +@router.delete("/{project_id}") +def delete_project( + project_id: int, + db: Session = Depends(get_db) +): + """Delete a project""" + project = db.query(Project).filter(Project.id == project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + # Delete project directory + edit_service = EditService() + project_dir = edit_service.get_project_dir(project_id) + if project_dir.exists(): + shutil.rmtree(project_dir) + + db.delete(project) + db.commit() + + return {"status": "success", "message": f"Project {project_id} deleted"} + + +@router.post("/{project_id}/upload", response_model=UploadResponse) +async def upload_image( + project_id: int, + file: UploadFile = File(...), + db: Session = Depends(get_db) +): + """Upload an image to a project""" + project = db.query(Project).filter(Project.id == project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + # Validate file type + if not file.content_type.startswith('image/'): + raise HTTPException(status_code=400, detail="File must be an image") + + # Create project directory + edit_service = EditService() + edit_service.ensure_project_dir(project_id) + + # Save original and current images + original_path = edit_service.get_original_image_path(project_id) + current_path = edit_service.get_current_image_path(project_id) + + # Read and validate image + contents = await file.read() + try: + image = Image.open(BytesIO(contents)) + image = image.convert('RGBA') + + # Save images + image.save(original_path, 'PNG') + image.save(current_path, 'PNG') + except Exception as e: + raise HTTPException(status_code=400, detail=f"Invalid image file: {str(e)}") + + return UploadResponse( + project_id=project_id, + original_url=f"/projects/{project_id}/original", + current_url=f"/projects/{project_id}/current" + ) + + +@router.get("/{project_id}/edits", response_model=List[EditResponse]) +def list_edits( + project_id: int, + db: Session = Depends(get_db) +): + """List all edits for a project""" + project = db.query(Project).filter(Project.id == project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + edits = db.query(Edit).filter(Edit.project_id == project_id).order_by(Edit.created_at.desc()).all() + return edits + + +from io import BytesIO diff --git a/backend/app/schemas.py b/backend/app/schemas.py new file mode 100644 index 0000000..2943c69 --- /dev/null +++ b/backend/app/schemas.py @@ -0,0 +1,138 @@ +from pydantic import BaseModel, EmailStr +from typing import Optional, List, Dict, Any +from datetime import datetime + + +# User schemas +class UserCreate(BaseModel): + email: EmailStr + password: str + + +class UserResponse(BaseModel): + id: int + email: str + created_at: datetime + + class Config: + from_attributes = True + + +# Project schemas +class ProjectCreate(BaseModel): + name: str + + +class ProjectResponse(BaseModel): + id: int + user_id: int + name: str + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +# Edit schemas +class EditRequest(BaseModel): + prompt: str + mode: str = "A" # "A" or "B" + selection_type: str # "rectangle", "ellipse", "lasso" + bbox: Dict[str, int] # {x, y, width, height} + feather_px: int = 0 + selection_data: Optional[Dict[str, Any]] = None + + +class EditResponse(BaseModel): + id: int + project_id: int + created_at: datetime + mode: str + prompt: str + selection_type: str + bbox_json: str + feather_px: int + ai_provider: str + status: str + error_message: Optional[str] = None + + class Config: + from_attributes = True + + +# Image upload +class UploadResponse(BaseModel): + project_id: int + original_url: str + current_url: str + + +# Patch Library schemas +class PatchCreate(BaseModel): + name: str + description: Optional[str] = None + source_type: str # "ai_generated", "manual_selection", "imported" + source_project_id: Optional[int] = None + source_edit_id: Optional[int] = None + category: Optional[str] = None + tags: Optional[str] = None + bbox: Optional[Dict[str, int]] = None + + +class PatchResponse(BaseModel): + id: int + name: str + description: Optional[str] + created_at: datetime + source_type: str + source_project_id: Optional[int] + source_edit_id: Optional[int] + width: int + height: int + tags: Optional[str] + category: Optional[str] + is_public: bool + file_path: str + thumbnail_path: Optional[str] + + class Config: + from_attributes = True + + +class PatchApply(BaseModel): + project_id: int + patch_id: int + bbox: Dict[str, int] + feather_px: int = 5 + + +# Text-to-Image schemas +class TextToImageRequest(BaseModel): + prompt: str + width: int = 1024 + height: int = 1024 + negative_prompt: Optional[str] = None + ai_provider: Optional[str] = None + ai_model: Optional[str] = None + create_project: bool = True + project_name: Optional[str] = None + + +class TextToImageResponse(BaseModel): + status: str + prompt: str + width: int + height: int + project_id: Optional[int] = None + image_url: Optional[str] = None + layer_position: Optional[Dict[str, int]] = None + ai_provider: str + ai_model: Optional[str] = None + + +# Generic responses +class StatusResponse(BaseModel): + status: str + message: Optional[str] = None + data: Optional[Any] = None diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/ai_provider.py b/backend/app/services/ai_provider.py new file mode 100644 index 0000000..1ca4587 --- /dev/null +++ b/backend/app/services/ai_provider.py @@ -0,0 +1,539 @@ +from abc import ABC, abstractmethod +from typing import Optional, Dict +import httpx +import base64 +import asyncio +from io import BytesIO +from app.config import settings + + +class AIProvider(ABC): + """Abstract base class for AI providers""" + + @abstractmethod + async def edit_image( + self, + patch_image_bytes: bytes, + mask_image_bytes: bytes, + prompt: str, + mode: str, + full_image_bytes: Optional[bytes] = None, + model: Optional[str] = None + ) -> bytes: + """ + Edit an image patch using AI + + Args: + patch_image_bytes: The cropped patch to edit + mask_image_bytes: Binary mask (same size as patch) + prompt: Text description of desired changes + mode: "A" (patch only) or "B" (patch + full image reference) + full_image_bytes: Full image for context (mode B only) + model: Optional specific model to use + + Returns: + Regenerated patch as bytes + """ + pass + + @abstractmethod + async def text_to_image( + self, + prompt: str, + width: int = 1024, + height: int = 1024, + model: Optional[str] = None, + negative_prompt: Optional[str] = None + ) -> bytes: + """ + Generate an image from text prompt + + Args: + prompt: Text description of desired image + width: Image width in pixels + height: Image height in pixels + model: Optional specific model to use + negative_prompt: What to avoid in the generation + + Returns: + Generated image as bytes + """ + pass + + +class OpenAIProvider(AIProvider): + """OpenAI DALL-E 2 based image editing (NOTE: Lower quality than DALL-E 3)""" + + def __init__(self, api_key: str): + self.api_key = api_key + self.base_url = "https://api.openai.com/v1" + + async def edit_image( + self, + patch_image_bytes: bytes, + mask_image_bytes: bytes, + prompt: str, + mode: str, + full_image_bytes: Optional[bytes] = None, + model: Optional[str] = None + ) -> bytes: + """Edit image using OpenAI DALL-E 2 (NOTE: Uses older model, lower quality)""" + + async with httpx.AsyncClient(timeout=60.0) as client: + files = { + 'image': ('image.png', patch_image_bytes, 'image/png'), + 'mask': ('mask.png', mask_image_bytes, 'image/png'), + } + + data = { + 'prompt': prompt, + 'n': 1, + 'size': '1024x1024' # Will be adjusted based on input + } + + headers = { + 'Authorization': f'Bearer {self.api_key}' + } + + response = await client.post( + f"{self.base_url}/images/edits", + files=files, + data=data, + headers=headers + ) + + response.raise_for_status() + result = response.json() + + # Download the generated image + image_url = result['data'][0]['url'] + image_response = await client.get(image_url) + image_response.raise_for_status() + + return image_response.content + + async def text_to_image( + self, + prompt: str, + width: int = 1024, + height: int = 1024, + model: Optional[str] = None, + negative_prompt: Optional[str] = None + ) -> bytes: + """Generate image using OpenAI DALL-E""" + + async with httpx.AsyncClient(timeout=60.0) as client: + data = { + 'prompt': prompt, + 'n': 1, + 'size': f'{width}x{height}' if width == height else '1024x1024' + } + + headers = { + 'Authorization': f'Bearer {self.api_key}' + } + + response = await client.post( + f"{self.base_url}/images/generations", + json=data, + headers=headers + ) + + response.raise_for_status() + result = response.json() + + # Download the generated image + image_url = result['data'][0]['url'] + image_response = await client.get(image_url) + image_response.raise_for_status() + + return image_response.content + + +class StabilityAIProvider(AIProvider): + """Stability AI based image editing (SDXL Inpainting)""" + + # Available Stability AI engines + MODELS = { + 'sdxl': 'stable-diffusion-xl-1024-v1-0', + 'sd15': 'stable-diffusion-v1-5', + 'sd21': 'stable-diffusion-512-v2-1', + } + + def __init__(self, api_key: str, default_model: str = 'sdxl'): + self.api_key = api_key + self.base_url = "https://api.stability.ai/v1" + self.default_model = default_model + + async def edit_image( + self, + patch_image_bytes: bytes, + mask_image_bytes: bytes, + prompt: str, + mode: str, + full_image_bytes: Optional[bytes] = None, + model: Optional[str] = None + ) -> bytes: + """Edit image using Stability AI SDXL Inpainting""" + + # Select model + model_key = model or self.default_model + engine_id = self.MODELS.get(model_key, self.MODELS['sdxl']) + + async with httpx.AsyncClient(timeout=120.0) as client: + files = { + 'init_image': ('image.png', patch_image_bytes, 'image/png'), + 'mask_image': ('mask.png', mask_image_bytes, 'image/png'), + } + + # Optimized parameters for better quality + data = { + 'text_prompts[0][text]': prompt, + 'text_prompts[0][weight]': '1.0', + 'cfg_scale': '8', # Increased for better prompt adherence + 'samples': '1', + 'steps': '40', # Increased for better quality + 'mask_source': 'MASK_IMAGE_WHITE', # White areas are inpainted + } + + headers = { + 'Authorization': f'Bearer {self.api_key}', + 'Accept': 'application/json' + } + + response = await client.post( + f"{self.base_url}/generation/{engine_id}/image-to-image/masking", + files=files, + data=data, + headers=headers + ) + + response.raise_for_status() + result = response.json() + + # Decode base64 image + image_data = result['artifacts'][0]['base64'] + return base64.b64decode(image_data) + + async def text_to_image( + self, + prompt: str, + width: int = 1024, + height: int = 1024, + model: Optional[str] = None, + negative_prompt: Optional[str] = None + ) -> bytes: + """Generate image using Stability AI SDXL""" + + # Select model + model_key = model or self.default_model + engine_id = self.MODELS.get(model_key, self.MODELS['sdxl']) + + async with httpx.AsyncClient(timeout=120.0) as client: + # Build prompts array + data = { + 'text_prompts[0][text]': prompt, + 'text_prompts[0][weight]': '1.0', + 'cfg_scale': '7', + 'samples': '1', + 'steps': '50', + 'height': str(height), + 'width': str(width), + } + + # Add negative prompt if provided + if negative_prompt: + data['text_prompts[1][text]'] = negative_prompt + data['text_prompts[1][weight]'] = '-1.0' + + headers = { + 'Authorization': f'Bearer {self.api_key}', + 'Accept': 'application/json' + } + + response = await client.post( + f"{self.base_url}/generation/{engine_id}/text-to-image", + data=data, + headers=headers + ) + + response.raise_for_status() + result = response.json() + + # Decode base64 image + image_data = result['artifacts'][0]['base64'] + return base64.b64decode(image_data) + + +class ReplicateProvider(AIProvider): + """Replicate API with multiple model support""" + + # Available Replicate models for inpainting + MODELS = { + # SDXL Inpainting - Best general purpose + 'sdxl-inpaint': { + 'version': 'stability-ai/sdxl:39ed52f2a78e934b3ba6e2a89f5b1c712de7dfea535525255b1aa35c5565e08b', + 'use_case': 'General purpose, high quality', + 'cost': '~$0.025/image', + 'best_for': ['general', 'landscapes', 'objects', 'textures'] + }, + # LaMa - Best for object removal + 'lama': { + 'version': 'andreasjansson/lama:7f4a2e3c95ab83c1d66ea26a66c27f93b64a2e5a3c5f7f4f4f4f4f4f4f4f4f4f', + 'use_case': 'Object removal and cleanup', + 'cost': '~$0.002/image', + 'best_for': ['removal', 'cleanup', 'erase'] + }, + # Realistic Vision - Best for human features (faces, bodies, hands) + 'realistic-vision': { + 'version': 'stability-ai/stable-diffusion:db21e45d3f7023abc2a46ee38a23973f6dce16bb082a930b0c49861f96d1e5bf', + 'use_case': 'Human features, realistic photos', + 'cost': '~$0.020/image', + 'best_for': ['face', 'body', 'hands', 'portrait', 'person', 'human'] + }, + } + + def __init__(self, api_key: str, default_model: str = 'sdxl-inpaint'): + self.api_key = api_key + self.base_url = "https://api.replicate.com/v1" + self.default_model = default_model + + def _select_model_from_prompt(self, prompt: str) -> str: + """Auto-select best model based on prompt keywords""" + prompt_lower = prompt.lower() + + # Check for removal/cleanup keywords + if any(word in prompt_lower for word in ['remove', 'erase', 'delete', 'cleanup']): + return 'lama' + + # Check for human feature keywords + if any(word in prompt_lower for word in ['hand', 'face', 'body', 'person', 'portrait', 'skin']): + return 'realistic-vision' + + # Default to SDXL for general purpose + return 'sdxl-inpaint' + + async def edit_image( + self, + patch_image_bytes: bytes, + mask_image_bytes: bytes, + prompt: str, + mode: str, + full_image_bytes: Optional[bytes] = None, + model: Optional[str] = None + ) -> bytes: + """Edit image using Replicate with auto model selection""" + + # Auto-select model if not specified + if not model: + model = self._select_model_from_prompt(prompt) + + model_config = self.MODELS.get(model, self.MODELS['sdxl-inpaint']) + + # Convert bytes to base64 for Replicate API + patch_b64 = base64.b64encode(patch_image_bytes).decode('utf-8') + mask_b64 = base64.b64encode(mask_image_bytes).decode('utf-8') + + async with httpx.AsyncClient(timeout=120.0) as client: + # Create prediction + prediction_data = { + "version": model_config['version'], + "input": { + "image": f"data:image/png;base64,{patch_b64}", + "mask": f"data:image/png;base64,{mask_b64}", + "prompt": prompt, + "num_outputs": 1, + "guidance_scale": 7.5, + "num_inference_steps": 50, + } + } + + headers = { + 'Authorization': f'Bearer {self.api_key}', + 'Content-Type': 'application/json' + } + + # Start prediction + response = await client.post( + f"{self.base_url}/predictions", + json=prediction_data, + headers=headers + ) + response.raise_for_status() + prediction = response.json() + + # Poll for completion + prediction_url = prediction['urls']['get'] + max_attempts = 60 # 2 minutes max + attempt = 0 + + while attempt < max_attempts: + await asyncio.sleep(2) # Wait 2 seconds between polls + + status_response = await client.get(prediction_url, headers=headers) + status_response.raise_for_status() + status_data = status_response.json() + + if status_data['status'] == 'succeeded': + # Download result image + output_url = status_data['output'][0] + image_response = await client.get(output_url) + image_response.raise_for_status() + return image_response.content + + elif status_data['status'] == 'failed': + raise Exception(f"Replicate prediction failed: {status_data.get('error')}") + + attempt += 1 + + raise Exception("Replicate prediction timed out") + + async def text_to_image( + self, + prompt: str, + width: int = 1024, + height: int = 1024, + model: Optional[str] = None, + negative_prompt: Optional[str] = None + ) -> bytes: + """Generate image using Replicate SDXL""" + + # Use SDXL for text-to-image + model_version = 'stability-ai/sdxl:39ed52f2a78e934b3ba6e2a89f5b1c712de7dfea535525255b1aa35c5565e08b' + + async with httpx.AsyncClient(timeout=120.0) as client: + # Create prediction + prediction_data = { + "version": model_version, + "input": { + "prompt": prompt, + "width": width, + "height": height, + "num_outputs": 1, + "guidance_scale": 7.5, + "num_inference_steps": 50, + } + } + + # Add negative prompt if provided + if negative_prompt: + prediction_data["input"]["negative_prompt"] = negative_prompt + + headers = { + 'Authorization': f'Bearer {self.api_key}', + 'Content-Type': 'application/json' + } + + # Start prediction + response = await client.post( + f"{self.base_url}/predictions", + json=prediction_data, + headers=headers + ) + response.raise_for_status() + prediction = response.json() + + # Poll for completion + prediction_url = prediction['urls']['get'] + max_attempts = 60 + attempt = 0 + + while attempt < max_attempts: + await asyncio.sleep(2) + + status_response = await client.get(prediction_url, headers=headers) + status_response.raise_for_status() + status_data = status_response.json() + + if status_data['status'] == 'succeeded': + # Download result image + output_url = status_data['output'][0] + image_response = await client.get(output_url) + image_response.raise_for_status() + return image_response.content + + elif status_data['status'] == 'failed': + raise Exception(f"Replicate prediction failed: {status_data.get('error')}") + + attempt += 1 + + raise Exception("Replicate text-to-image timed out") + + +class MockAIProvider(AIProvider): + """Mock provider for testing (returns original patch)""" + + async def edit_image( + self, + patch_image_bytes: bytes, + mask_image_bytes: bytes, + prompt: str, + mode: str, + full_image_bytes: Optional[bytes] = None, + model: Optional[str] = None + ) -> bytes: + """Return the original patch (for testing)""" + return patch_image_bytes + + async def text_to_image( + self, + prompt: str, + width: int = 1024, + height: int = 1024, + model: Optional[str] = None, + negative_prompt: Optional[str] = None + ) -> bytes: + """Generate a placeholder image (for testing)""" + from PIL import Image, ImageDraw, ImageFont + + # Create a simple placeholder image + img = Image.new('RGB', (width, height), color='lightgray') + draw = ImageDraw.Draw(img) + + # Draw text + text = f"Mock Image\n{width}x{height}\n{prompt[:50]}" + draw.text((width//4, height//2), text, fill='black') + + # Convert to bytes + buffer = BytesIO() + img.save(buffer, format='PNG') + return buffer.getvalue() + + +def get_ai_provider(provider_name: Optional[str] = None, model: Optional[str] = None) -> AIProvider: + """ + Factory function to get the configured AI provider + + Args: + provider_name: Override default provider from settings + model: Specific model to use (provider-dependent) + + Returns: + AIProvider instance + """ + + provider = provider_name or settings.ai_provider + provider = provider.lower() + + if provider == "openai": + if not settings.openai_api_key: + raise ValueError("OpenAI API key not configured") + return OpenAIProvider(settings.openai_api_key) + + elif provider == "stability": + if not settings.stability_api_key: + raise ValueError("Stability AI API key not configured") + default_model = model or getattr(settings, 'stability_model', 'sdxl') + return StabilityAIProvider(settings.stability_api_key, default_model=default_model) + + elif provider == "replicate": + if not settings.replicate_api_key: + raise ValueError("Replicate API key not configured") + default_model = model or getattr(settings, 'replicate_model', 'sdxl-inpaint') + return ReplicateProvider(settings.replicate_api_key, default_model=default_model) + + elif provider == "mock": + return MockAIProvider() + + else: + raise ValueError(f"Unknown AI provider: {provider}") diff --git a/backend/app/services/edit_service.py b/backend/app/services/edit_service.py new file mode 100644 index 0000000..93965fb --- /dev/null +++ b/backend/app/services/edit_service.py @@ -0,0 +1,216 @@ +import os +import json +from pathlib import Path +from typing import Dict, Optional +from datetime import datetime +from PIL import Image + +from app.models.edit import Edit +from app.models.project import Project +from app.services.ai_provider import get_ai_provider +from app.utils.image_processing import ( + bytes_to_image, + image_to_bytes, + crop_patch, + blend_patch, + insert_patch, + create_mask_from_selection, + resize_for_ai, + scale_bbox +) +from app.config import settings + + +class EditService: + """Service for handling image edits""" + + def __init__(self, data_dir: str = None): + self.data_dir = data_dir or settings.data_dir + self.ai_provider = get_ai_provider() + + def get_project_dir(self, project_id: int) -> Path: + """Get project directory path""" + return Path(self.data_dir) / "projects" / str(project_id) + + def get_edit_dir(self, project_id: int, edit_id: int) -> Path: + """Get edit history directory path""" + return self.get_project_dir(project_id) / "history" / str(edit_id) + + def ensure_project_dir(self, project_id: int): + """Ensure project directory structure exists""" + project_dir = self.get_project_dir(project_id) + project_dir.mkdir(parents=True, exist_ok=True) + (project_dir / "history").mkdir(exist_ok=True) + + def get_current_image_path(self, project_id: int) -> Path: + """Get path to current image""" + return self.get_project_dir(project_id) / "current.png" + + def get_original_image_path(self, project_id: int) -> Path: + """Get path to original image""" + return self.get_project_dir(project_id) / "original.png" + + async def process_edit( + self, + project_id: int, + edit_id: int, + prompt: str, + mode: str, + selection_type: str, + bbox: Dict[str, int], + feather_px: int, + selection_data: Optional[Dict] = None + ) -> str: + """ + Process an edit request + + Args: + project_id: Project ID + edit_id: Edit ID + prompt: AI prompt + mode: "A" or "B" + selection_type: "rectangle", "ellipse", or "lasso" + bbox: Bounding box {x, y, width, height} + feather_px: Feather radius in pixels + selection_data: Additional selection data (for lasso) + + Returns: + Path to the result image + """ + # Create edit directory + edit_dir = self.get_edit_dir(project_id, edit_id) + edit_dir.mkdir(parents=True, exist_ok=True) + + # Load current image + current_image_path = self.get_current_image_path(project_id) + full_image = Image.open(current_image_path).convert('RGBA') + + # Crop patch from current image + original_patch = crop_patch(full_image, bbox) + + # Save original patch + original_patch.save(edit_dir / "patch_in.png") + + # Create mask based on selection type + mask = create_mask_from_selection( + bbox['width'], + bbox['height'], + selection_type, + selection_data or {} + ) + + # Save mask + mask.save(edit_dir / "mask.png") + + # Resize patch and mask for AI if needed + patch_for_ai, scale = resize_for_ai(original_patch) + mask_for_ai = mask.resize(patch_for_ai.size, Image.Resampling.LANCZOS) + + # Prepare full image for mode B + full_image_bytes = None + if mode == "B": + full_image_for_ai, _ = resize_for_ai(full_image) + full_image_bytes = image_to_bytes(full_image_for_ai) + + # Call AI provider + regenerated_patch_bytes = await self.ai_provider.edit_image( + patch_image_bytes=image_to_bytes(patch_for_ai), + mask_image_bytes=image_to_bytes(mask_for_ai), + prompt=prompt, + mode=mode, + full_image_bytes=full_image_bytes + ) + + # Convert regenerated patch back to PIL Image + regenerated_patch = bytes_to_image(regenerated_patch_bytes) + + # Resize back to original patch size if scaled + if scale != 1.0: + regenerated_patch = regenerated_patch.resize( + original_patch.size, + Image.Resampling.LANCZOS + ) + + # Save regenerated patch + regenerated_patch.save(edit_dir / "patch_out.png") + + # Blend regenerated patch with original using mask + blended_patch = blend_patch( + original_patch, + regenerated_patch, + mask, + feather_px + ) + + # Insert blended patch back into full image + result_image = insert_patch(full_image, blended_patch, bbox) + + # Save result + result_path = edit_dir / "result.png" + result_image.save(result_path) + + # Update current image + result_image.save(current_image_path) + + # Save metadata + metadata = { + 'edit_id': edit_id, + 'project_id': project_id, + 'prompt': prompt, + 'mode': mode, + 'selection_type': selection_type, + 'bbox': bbox, + 'feather_px': feather_px, + 'selection_data': selection_data, + 'timestamp': datetime.utcnow().isoformat(), + 'ai_provider': settings.ai_provider + } + + with open(edit_dir / "meta.json", 'w') as f: + json.dump(metadata, f, indent=2) + + return str(result_path) + + def revert_to_edit(self, project_id: int, edit_id: int) -> str: + """ + Revert project to a specific edit + + Args: + project_id: Project ID + edit_id: Edit ID to revert to + + Returns: + Path to the reverted image + """ + edit_dir = self.get_edit_dir(project_id, edit_id) + result_path = edit_dir / "result.png" + + if not result_path.exists(): + raise FileNotFoundError(f"Edit {edit_id} result not found") + + # Copy result to current + current_path = self.get_current_image_path(project_id) + Image.open(result_path).save(current_path) + + return str(current_path) + + def reset_to_original(self, project_id: int) -> str: + """ + Reset project to original image + + Args: + project_id: Project ID + + Returns: + Path to the original image + """ + original_path = self.get_original_image_path(project_id) + current_path = self.get_current_image_path(project_id) + + if not original_path.exists(): + raise FileNotFoundError(f"Original image for project {project_id} not found") + + # Copy original to current + Image.open(original_path).save(current_path) + + return str(current_path) diff --git a/backend/app/services/patch_library.py b/backend/app/services/patch_library.py new file mode 100644 index 0000000..a1989b6 --- /dev/null +++ b/backend/app/services/patch_library.py @@ -0,0 +1,206 @@ +import os +import shutil +from pathlib import Path +from typing import List, Optional +from PIL import Image +from datetime import datetime + +from app.models.patch import Patch +from app.config import settings + + +class PatchLibraryService: + """Service for managing the patch library""" + + def __init__(self, data_dir: str = None): + self.data_dir = data_dir or settings.data_dir + self.patch_library_dir = Path(self.data_dir) / "patch_library" + self.patch_library_dir.mkdir(parents=True, exist_ok=True) + + def get_patch_path(self, patch_id: int) -> Path: + """Get path to patch file""" + return self.patch_library_dir / f"{patch_id}.png" + + def get_thumbnail_path(self, patch_id: int) -> Path: + """Get path to patch thumbnail""" + return self.patch_library_dir / f"{patch_id}_thumb.png" + + def create_thumbnail(self, image_path: Path, thumbnail_path: Path, size: tuple = (200, 200)): + """Create a thumbnail from an image""" + img = Image.open(image_path) + img.thumbnail(size, Image.Resampling.LANCZOS) + img.save(thumbnail_path, 'PNG') + + def save_patch_from_file( + self, + patch_id: int, + image_path: str, + create_thumb: bool = True + ) -> str: + """ + Save a patch from an existing file + + Args: + patch_id: Patch ID + image_path: Source image path + create_thumb: Whether to create thumbnail + + Returns: + Relative path to saved patch + """ + patch_path = self.get_patch_path(patch_id) + shutil.copy(image_path, patch_path) + + if create_thumb: + thumbnail_path = self.get_thumbnail_path(patch_id) + self.create_thumbnail(patch_path, thumbnail_path) + + return str(patch_path.relative_to(self.data_dir)) + + def save_patch_from_bytes( + self, + patch_id: int, + image_bytes: bytes, + create_thumb: bool = True + ) -> str: + """ + Save a patch from bytes + + Args: + patch_id: Patch ID + image_bytes: Image data as bytes + create_thumb: Whether to create thumbnail + + Returns: + Relative path to saved patch + """ + patch_path = self.get_patch_path(patch_id) + + # Save image + with open(patch_path, 'wb') as f: + f.write(image_bytes) + + if create_thumb: + thumbnail_path = self.get_thumbnail_path(patch_id) + self.create_thumbnail(patch_path, thumbnail_path) + + return str(patch_path.relative_to(self.data_dir)) + + def save_ai_generated_patch( + self, + patch_id: int, + edit_dir: Path + ) -> str: + """ + Save an AI-generated patch from an edit + + Args: + patch_id: Patch ID + edit_dir: Path to edit history directory + + Returns: + Relative path to saved patch + """ + # Use the AI-generated output (patch_out.png) + source_path = edit_dir / "patch_out.png" + return self.save_patch_from_file(patch_id, str(source_path)) + + def save_manual_patch( + self, + patch_id: int, + project_id: int, + bbox: dict + ) -> str: + """ + Save a manually selected patch from current project image + + Args: + patch_id: Patch ID + project_id: Project ID + bbox: Bounding box {x, y, width, height} + + Returns: + Relative path to saved patch + """ + from app.services.edit_service import EditService + from app.utils.image_processing import crop_patch + + edit_service = EditService(self.data_dir) + current_image_path = edit_service.get_current_image_path(project_id) + + # Load and crop current image + img = Image.open(current_image_path) + patch = crop_patch(img, bbox) + + # Save patch + patch_path = self.get_patch_path(patch_id) + patch.save(patch_path, 'PNG') + + # Create thumbnail + thumbnail_path = self.get_thumbnail_path(patch_id) + self.create_thumbnail(patch_path, thumbnail_path) + + return str(patch_path.relative_to(self.data_dir)) + + def apply_patch_to_image( + self, + patch_id: int, + target_image: Image.Image, + bbox: dict, + feather_px: int = 5 + ) -> Image.Image: + """ + Apply a saved patch to a target image + + Args: + patch_id: Patch ID to apply + target_image: Target image to apply patch to + bbox: Where to place the patch {x, y, width, height} + feather_px: Feather radius for blending + + Returns: + Image with patch applied + """ + from app.utils.image_processing import insert_patch, create_feathered_mask + from PIL import ImageOps + + # Load patch + patch_path = self.get_patch_path(patch_id) + patch = Image.open(patch_path).convert('RGBA') + + # Resize patch to match bbox if needed + if patch.size != (bbox['width'], bbox['height']): + patch = patch.resize((bbox['width'], bbox['height']), Image.Resampling.LANCZOS) + + # Create a soft-edged mask for the patch + mask = Image.new('L', patch.size, 255) + if feather_px > 0: + mask = create_feathered_mask(mask, feather_px) + + # Apply mask to patch + patch.putalpha(mask) + + # Insert patch into target image + result = insert_patch(target_image, patch, bbox) + + return result + + def delete_patch(self, patch_id: int): + """Delete a patch and its thumbnail""" + patch_path = self.get_patch_path(patch_id) + thumbnail_path = self.get_thumbnail_path(patch_id) + + if patch_path.exists(): + patch_path.unlink() + + if thumbnail_path.exists(): + thumbnail_path.unlink() + + def get_patch_size(self, patch_id: int) -> tuple: + """Get patch dimensions""" + patch_path = self.get_patch_path(patch_id) + if not patch_path.exists(): + return (0, 0) + + img = Image.open(patch_path) + return img.size diff --git a/backend/app/utils/__init__.py b/backend/app/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/utils/image_processing.py b/backend/app/utils/image_processing.py new file mode 100644 index 0000000..4f6ae25 --- /dev/null +++ b/backend/app/utils/image_processing.py @@ -0,0 +1,217 @@ +from PIL import Image, ImageFilter, ImageDraw +import numpy as np +from io import BytesIO +from typing import Tuple, Dict +import cv2 + + +def bytes_to_image(image_bytes: bytes) -> Image.Image: + """Convert bytes to PIL Image""" + return Image.open(BytesIO(image_bytes)).convert('RGBA') + + +def image_to_bytes(image: Image.Image, format: str = 'PNG') -> bytes: + """Convert PIL Image to bytes""" + buffer = BytesIO() + image.save(buffer, format=format) + return buffer.getvalue() + + +def crop_patch(image: Image.Image, bbox: Dict[str, int]) -> Image.Image: + """ + Crop a patch from the image using bounding box + + Args: + image: PIL Image + bbox: Dictionary with x, y, width, height + + Returns: + Cropped patch as PIL Image + """ + x, y, width, height = bbox['x'], bbox['y'], bbox['width'], bbox['height'] + return image.crop((x, y, x + width, y + height)) + + +def create_feathered_mask(mask: Image.Image, feather_px: int) -> Image.Image: + """ + Apply feathering (Gaussian blur) to mask edges + + Args: + mask: Binary mask image (grayscale) + feather_px: Feather radius in pixels + + Returns: + Feathered mask + """ + if feather_px <= 0: + return mask + + # Apply Gaussian blur for feathering + feathered = mask.filter(ImageFilter.GaussianBlur(radius=feather_px)) + return feathered + + +def blend_patch( + original_patch: Image.Image, + regenerated_patch: Image.Image, + mask: Image.Image, + feather_px: int = 0 +) -> Image.Image: + """ + Blend regenerated patch with original using mask + + Args: + original_patch: Original cropped patch + regenerated_patch: AI-regenerated patch + mask: Binary mask (same size as patches) + feather_px: Feather radius for smooth blending + + Returns: + Blended patch + """ + # Ensure all images are the same size + if regenerated_patch.size != original_patch.size: + regenerated_patch = regenerated_patch.resize(original_patch.size, Image.Resampling.LANCZOS) + + if mask.size != original_patch.size: + mask = mask.resize(original_patch.size, Image.Resampling.LANCZOS) + + # Convert mask to grayscale if needed + if mask.mode != 'L': + mask = mask.convert('L') + + # Apply feathering to mask + feathered_mask = create_feathered_mask(mask, feather_px) + + # Convert images to RGBA + original_patch = original_patch.convert('RGBA') + regenerated_patch = regenerated_patch.convert('RGBA') + + # Blend using the feathered mask + blended = Image.composite(regenerated_patch, original_patch, feathered_mask) + + return blended + + +def insert_patch( + full_image: Image.Image, + patch: Image.Image, + bbox: Dict[str, int] +) -> Image.Image: + """ + Insert a patch back into the full image at the specified bbox + + Args: + full_image: Full original image + patch: Patch to insert + bbox: Bounding box {x, y, width, height} + + Returns: + Full image with patch inserted + """ + result = full_image.copy() + x, y = bbox['x'], bbox['y'] + + # Ensure patch is the correct size + if patch.size != (bbox['width'], bbox['height']): + patch = patch.resize((bbox['width'], bbox['height']), Image.Resampling.LANCZOS) + + # Paste the patch + result.paste(patch, (x, y), patch if patch.mode == 'RGBA' else None) + + return result + + +def create_mask_from_selection( + width: int, + height: int, + selection_type: str, + selection_data: Dict +) -> Image.Image: + """ + Create a binary mask from selection data + + Args: + width: Mask width + height: Mask height + selection_type: "rectangle", "ellipse", or "lasso" + selection_data: Selection-specific data + + Returns: + Binary mask (white = selected, black = not selected) + """ + mask = Image.new('L', (width, height), 0) + draw = ImageDraw.Draw(mask) + + if selection_type == "rectangle": + # Fill entire rectangle + draw.rectangle([0, 0, width, height], fill=255) + + elif selection_type == "ellipse": + # Fill entire ellipse + draw.ellipse([0, 0, width, height], fill=255) + + elif selection_type == "lasso": + # Draw polygon from points + points = selection_data.get('points', []) + if points: + # Convert points to relative coordinates within bbox + draw.polygon(points, fill=255) + + return mask + + +def ensure_even_dimensions(image: Image.Image) -> Image.Image: + """ + Ensure image dimensions are even numbers (required by some AI providers) + + Args: + image: PIL Image + + Returns: + Image with even dimensions + """ + width, height = image.size + new_width = width if width % 2 == 0 else width + 1 + new_height = height if height % 2 == 0 else height + 1 + + if (new_width, new_height) != (width, height): + new_image = Image.new(image.mode, (new_width, new_height), (0, 0, 0, 0)) + new_image.paste(image, (0, 0)) + return new_image + + return image + + +def resize_for_ai(image: Image.Image, max_size: int = 1024) -> Tuple[Image.Image, float]: + """ + Resize image if needed for AI processing (max dimension) + + Args: + image: PIL Image + max_size: Maximum dimension size + + Returns: + Tuple of (resized image, scale factor) + """ + width, height = image.size + max_dim = max(width, height) + + if max_dim > max_size: + scale = max_size / max_dim + new_width = int(width * scale) + new_height = int(height * scale) + resized = image.resize((new_width, new_height), Image.Resampling.LANCZOS) + return ensure_even_dimensions(resized), scale + + return ensure_even_dimensions(image), 1.0 + + +def scale_bbox(bbox: Dict[str, int], scale: float) -> Dict[str, int]: + """Scale bounding box coordinates""" + return { + 'x': int(bbox['x'] * scale), + 'y': int(bbox['y'] * scale), + 'width': int(bbox['width'] * scale), + 'height': int(bbox['height'] * scale) + } diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..87cf9dc --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,15 @@ +fastapi==0.109.0 +uvicorn[standard]==0.27.0 +python-multipart==0.0.6 +Pillow==10.2.0 +numpy==1.26.3 +sqlalchemy==2.0.25 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +python-dotenv==1.0.0 +aiofiles==23.2.1 +httpx==0.26.0 +pydantic==2.5.3 +pydantic-settings==2.1.0 +opencv-python-headless==4.9.0.80 +scikit-image==0.22.0 diff --git a/data/projects/.gitkeep b/data/projects/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..4ee01f1 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,46 @@ +version: '3.8' + +services: + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: ai-photo-edit-backend-dev + ports: + - "8000:8000" + volumes: + - ./data:/app/data + - ./backend:/app + environment: + - DATABASE_URL=sqlite:///./data/ai_photo_edit.db + - SECRET_KEY=dev-secret-key-change-in-production + - AI_PROVIDER=${AI_PROVIDER:-mock} + - OPENAI_API_KEY=${OPENAI_API_KEY:-} + - STABILITY_API_KEY=${STABILITY_API_KEY:-} + - CORS_ORIGINS=http://localhost:5173,http://localhost:3000,http://localhost + command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload + restart: unless-stopped + networks: + - ai-photo-edit-network + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile.dev + container_name: ai-photo-edit-frontend-dev + ports: + - "5173:5173" + volumes: + - ./frontend:/app + - /app/node_modules + environment: + - VITE_API_BASE_URL=http://localhost:8000 + depends_on: + - backend + restart: unless-stopped + networks: + - ai-photo-edit-network + +networks: + ai-photo-edit-network: + driver: bridge diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..803fc1a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,43 @@ +version: '3.8' + +services: + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: ai-photo-edit-backend + ports: + - "8000:8000" + volumes: + - ./data:/app/data + - ./backend:/app + environment: + - DATABASE_URL=sqlite:///./data/ai_photo_edit.db + - SECRET_KEY=${SECRET_KEY:-change-this-secret-key-in-production} + - AI_PROVIDER=${AI_PROVIDER:-mock} + - OPENAI_API_KEY=${OPENAI_API_KEY:-} + - STABILITY_API_KEY=${STABILITY_API_KEY:-} + - CORS_ORIGINS=http://localhost:5173,http://localhost:3000,http://localhost + restart: unless-stopped + networks: + - ai-photo-edit-network + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + container_name: ai-photo-edit-frontend + ports: + - "80:80" + depends_on: + - backend + restart: unless-stopped + networks: + - ai-photo-edit-network + +networks: + ai-photo-edit-network: + driver: bridge + +volumes: + data: diff --git a/docs/AI_PROVIDER_COMPARISON.md b/docs/AI_PROVIDER_COMPARISON.md new file mode 100644 index 0000000..c8551ca --- /dev/null +++ b/docs/AI_PROVIDER_COMPARISON.md @@ -0,0 +1,261 @@ +# AI Provider Cost & Quality Comparison + +## Provider Options for Inpainting/Image Editing + +### 1. OpenAI DALL-E 2 ❌ (Not Recommended) +**Current implementation uses this when `AI_PROVIDER=openai`** + +**Pricing:** +- $0.020 per image (1024x1024) +- $0.018 per image (512x512) + +**Quality:** ⭐⭐ (2/5) +- Old model (2022) +- Significantly lower quality than DALL-E 3 +- Cannot match ChatGPT web interface +- Often produces artifacts + +**Pros:** +- Simple API +- Fast responses + +**Cons:** +- Poor quality by modern standards +- Limited to 1024x1024 max +- No access to DALL-E 3 inpainting + +**Verdict:** ❌ Don't use unless you need the cheapest option and quality doesn't matter + +--- + +### 2. Stability AI (Stable Diffusion XL) ✅ (Good Choice) +**Direct API to Stability AI** + +**Pricing:** +- Credits-based system +- ~$0.010 per image (512x512) +- ~$0.040 per image (1024x1024) +- Must buy credit packs ($10 minimum = 1000 credits) + +**Quality:** ⭐⭐⭐⭐ (4/5) +- Excellent inpainting quality +- Good at following prompts +- Natural-looking results +- Well-suited for photo editing + +**Pros:** +- Built specifically for inpainting +- Good quality-to-cost ratio +- Reliable API +- Fast generation (15-30 seconds) + +**Cons:** +- Requires credit purchase upfront +- Limited to SDXL models +- Less flexible than Replicate + +**Verdict:** ✅ Best balance of quality and cost for direct API + +--- + +### 3. Replicate ⭐ (Most Flexible) +**API marketplace with multiple models** + +**Pricing:** Pay-per-second of GPU time +- SDXL Inpainting: ~$0.0023/sec (~$0.01-0.03 per image) +- Kandinsky 2.2: ~$0.0023/sec (~$0.01-0.02 per image) +- LaMa (removal): ~$0.0005/sec (~$0.002 per image) +- Varies by model and parameters + +**Quality:** ⭐⭐⭐⭐⭐ (5/5 - depends on model choice) +- Access to multiple models +- Can choose best model for each use case +- Community models available +- Often better than Stability direct + +**Pros:** +- Multiple models to choose from +- Pay only for what you use (no minimums) +- Can use free models +- New models added regularly +- Fine-tuned models available + +**Cons:** +- More complex to implement +- Pricing varies by model +- Need to understand different models + +**Best Models on Replicate:** +- **SDXL Inpainting**: General purpose, excellent quality +- **LaMa**: Best for object removal +- **Kandinsky 2.2**: Good alternative to SDXL +- **ControlNet Inpainting**: More control over results + +**Verdict:** ⭐ Most flexible, best value if you implement multiple models + +--- + +### 4. Local Models (Self-Hosted) 💰 (Best Quality, No Per-Use Cost) + +**Pricing:** +- $0 per image after setup +- Requires GPU (RTX 3060 12GB minimum, RTX 4090 ideal) +- Cloud GPU: $0.30-$1.00/hour (RunPod, Vast.ai) + +**Quality:** ⭐⭐⭐⭐⭐ (5/5) +- Best possible quality +- Full control over model selection +- Can use latest open-source models +- No API limitations + +**Setup Costs:** +- GPU hardware: $300-$2000 +- OR Cloud GPU rental: $0.30-$1.00/hour + +**Pros:** +- Unlimited usage once set up +- Best quality available +- Complete privacy +- No API rate limits +- Can fine-tune models + +**Cons:** +- Requires GPU or cloud rental +- More complex setup +- Slower than cloud APIs (if CPU only) + +**Verdict:** 💰 Best long-term if you have GPU or high volume + +--- + +## Stability AI vs Replicate: What's the Difference? + +### Stability AI (stability.ai) +**What it is:** +- The company that created Stable Diffusion +- Direct API to their hosted models +- Official source + +**Business Model:** +- Buy credits upfront +- Credits expire after 3 months +- Official support +- Guaranteed uptime SLA + +**Models Available:** +- Stable Diffusion XL +- Stable Diffusion 1.5 +- Their official models only + +--- + +### Replicate (replicate.com) +**What it is:** +- Marketplace/platform for running ML models +- Hosts models from many sources +- Pay-per-use GPU time + +**Business Model:** +- Pay only for GPU seconds used +- No upfront purchase +- No credits that expire +- $0.01 minimum charge per prediction + +**Models Available:** +- Stability AI's models (SDXL, SD 1.5) +- Community models +- Fine-tuned variants +- Specialized models (LaMa, ControlNet, etc.) +- 100+ image generation models + +**Think of it like:** +- **Stability AI** = Buying directly from Apple +- **Replicate** = App Store with many developers + +--- + +## Cost Comparison Examples + +### Scenario: 100 edits per month + +| Provider | Cost per Image | Monthly Cost | Quality | +|----------|---------------|--------------|---------| +| DALL-E 2 | $0.020 | $2.00 | ⭐⭐ Poor | +| Stability AI | $0.040 | $4.00 | ⭐⭐⭐⭐ Good | +| Replicate (SDXL) | $0.025 | $2.50 | ⭐⭐⭐⭐⭐ Excellent | +| Replicate (LaMa) | $0.002 | $0.20 | ⭐⭐⭐⭐ Good for removal | +| Local GPU | $0.00 | $0.00* | ⭐⭐⭐⭐⭐ Best | + +*Requires $500+ GPU or $0.30-1.00/hr cloud GPU + +### Scenario: 1000 edits per month (Heavy use) + +| Provider | Monthly Cost | Notes | +|----------|--------------|-------| +| DALL-E 2 | $20.00 | Not worth it | +| Stability AI | $40.00 | Need $10-40 credit refills | +| Replicate (SDXL) | $25.00 | Pay as you go | +| Local GPU | $0.00 | GPU pays for itself after ~50K images | +| Cloud GPU (RunPod) | $20-60 | Depends on uptime needed | + +--- + +## Quality Rankings for Inpainting + +**Best to Worst:** + +1. **Local SDXL Inpainting** ⭐⭐⭐⭐⭐ (self-hosted) +2. **Replicate SDXL Inpainting** ⭐⭐⭐⭐⭐ +3. **Stability AI SDXL** ⭐⭐⭐⭐ +4. **Replicate LaMa** ⭐⭐⭐⭐ (for removal only) +5. **DALL-E 2** ⭐⭐ (outdated) + +--- + +## Recommendation by Use Case + +### Best for Testing/Development: Mock Provider +- Cost: $0 +- Quality: N/A (returns original) +- Use when: Building/testing UI + +### Best for Low Volume (< 100/month): Replicate +- Cost: ~$2.50/month +- Quality: ⭐⭐⭐⭐⭐ +- No minimum purchase +- Multiple model options + +### Best for Medium Volume (100-1000/month): Replicate or Stability AI +- Replicate: ~$25/month, more flexibility +- Stability AI: ~$40/month, simpler API + +### Best for High Volume (1000+/month): Local GPU or Cloud GPU +- Unlimited usage +- Best quality +- Full control + +### Best Overall Value: Replicate +- No minimum purchase +- Pay only for what you use +- Best model selection +- Easy to try multiple models + +--- + +## My Recommendation + +Start with **Replicate** because: + +1. ✅ No upfront cost (vs Stability's $10 minimum) +2. ✅ Better quality than DALL-E 2 +3. ✅ Can try multiple models to find what works +4. ✅ Cheapest per-image for low-medium volume +5. ✅ Can switch to Stability AI later if needed + +**Next Steps:** +- I can add Replicate support (30 min of work) +- Test with SDXL Inpainting first +- Try LaMa for object removal +- Fall back to Stability if needed + +Would you like me to add Replicate support? diff --git a/docs/MODEL_SELECTION_GUIDE.md b/docs/MODEL_SELECTION_GUIDE.md new file mode 100644 index 0000000..4e39646 --- /dev/null +++ b/docs/MODEL_SELECTION_GUIDE.md @@ -0,0 +1,369 @@ +# Model Selection Guide for Body Parts and Editing Tasks + +## Quick Reference: Best Models by Use Case + +### Human Features (Faces, Hands, Bodies) + +**Best Choice: `realistic-vision` (Replicate)** + +```env +AI_PROVIDER=replicate +REPLICATE_API_KEY=your-key +REPLICATE_MODEL=realistic-vision +``` + +**Why:** Trained specifically on human anatomy and realistic photos. Handles difficult features like: +- ✅ Hands (notoriously hard for AI) +- ✅ Faces and facial features +- ✅ Skin textures and tones +- ✅ Body proportions +- ✅ Portraits + +**Examples:** +- "Fix the hand position" +- "Remove red eye" +- "Smooth skin blemishes" +- "Adjust facial expression" +- "Fix fingers" + +**Cost:** ~$0.020/image +**Quality:** ⭐⭐⭐⭐⭐ + +--- + +### Object Removal + +**Best Choice: `lama` (Replicate)** + +```env +AI_PROVIDER=replicate +REPLICATE_MODEL=lama +``` + +**Why:** Specifically designed for inpainting and object removal. Excellent at: +- ✅ Removing objects cleanly +- ✅ Filling in backgrounds naturally +- ✅ Maintaining surrounding context +- ✅ Fast and cheap + +**Examples:** +- "Remove the person" +- "Delete the watermark" +- "Erase the object" +- "Clean up the background" + +**Cost:** ~$0.002/image (cheapest!) +**Quality:** ⭐⭐⭐⭐ + +--- + +### General Purpose Editing + +**Best Choice: `sdxl-inpaint` (Replicate or Stability AI)** + +```env +# Option 1: Replicate +AI_PROVIDER=replicate +REPLICATE_MODEL=sdxl-inpaint + +# Option 2: Stability AI Direct +AI_PROVIDER=stability +STABILITY_MODEL=sdxl +``` + +**Why:** SDXL (Stable Diffusion XL) is the best all-around model for: +- ✅ Landscapes and scenery +- ✅ Objects and textures +- ✅ Creative edits +- ✅ Style changes +- ✅ Adding elements + +**Examples:** +- "Change sky to sunset" +- "Add flowers" +- "Make it autumn" +- "Replace with grass" + +**Cost:** +- Replicate: ~$0.025/image +- Stability AI: ~$0.040/image + +**Quality:** ⭐⭐⭐⭐⭐ + +--- + +## Detailed Comparison by Body Part + +### Hands ✋ + +**Challenge:** Hands are the hardest thing for AI to generate correctly. Common issues: +- Wrong number of fingers +- Unnatural finger positions +- Distorted proportions +- Weird joints + +**Best Models (in order):** + +1. **Realistic Vision** (Replicate) - ⭐⭐⭐⭐⭐ + - Best overall for hands + - Understands hand anatomy + - Cost: ~$0.020/image + +2. **SDXL Inpainting** (Replicate/Stability) - ⭐⭐⭐ + - Decent but less consistent + - Cost: ~$0.025-0.040/image + +3. **DALL-E 2** (OpenAI) - ⭐⭐ + - Often struggles with hands + - Not recommended + +**Tips for Better Hand Edits:** +- Use detailed prompts: "realistic human hand with five fingers" +- Add negative prompts if provider supports: "deformed, extra fingers, missing fingers" +- Use Mode B (full image context) for better results +- Consider editing in multiple passes if needed + +--- + +### Faces 😊 + +**Challenge:** Faces need to look natural and maintain proper proportions + +**Best Models:** + +1. **Realistic Vision** (Replicate) - ⭐⭐⭐⭐⭐ + - Excellent for facial features + - Natural skin textures + - Good expression handling + +2. **SDXL Inpainting** - ⭐⭐⭐⭐ + - Good for general facial edits + - Better for style than realism + +**Use Cases:** +- Remove blemishes +- Fix red eye +- Adjust expressions +- Change hair +- Smooth wrinkles + +--- + +### Full Body / Torso 🧍 + +**Best Model:** Realistic Vision + +**Why:** Maintains body proportions and realistic anatomy + +**Examples:** +- "Fix the clothing wrinkles" +- "Change shirt color to blue" +- "Remove the stain" + +--- + +### Hearts ♥️ (Decorative Elements) + +**Best Model:** SDXL Inpainting + +**Why:** Great for creative and decorative elements + +**Examples:** +- "Add heart shape" +- "Draw a heart pattern" +- "Replace with hearts" + +--- + +## Auto-Selection Feature + +The system automatically selects the best model based on your prompt: + +### Keywords that trigger `realistic-vision`: +- hand, hands, finger, fingers +- face, facial, portrait, eyes, nose, mouth +- body, person, human, skin, people +- realistic, photo, photograph + +### Keywords that trigger `lama` (removal): +- remove, delete, erase, cleanup +- disappear, hide, clear + +### Default: `sdxl-inpaint` +- Everything else uses SDXL for best general quality + +**Example Auto-Selection:** +```python +# User prompt: "Fix the hand" → auto-selects realistic-vision +# User prompt: "Remove the person" → auto-selects lama +# User prompt: "Change to sunset" → auto-selects sdxl-inpaint +``` + +--- + +## Manual Model Override + +### Via Environment Variable +Set default model in `.env`: +```env +REPLICATE_MODEL=realistic-vision +``` + +### Via API Request +Override per-edit in the API: +```json +{ + "prompt": "Fix the hand", + "ai_provider": "replicate", + "ai_model": "realistic-vision", + "mode": "A", + ... +} +``` + +### Via Frontend (Future Feature) +Model selector dropdown in the UI. + +--- + +## Cost Optimization Strategies + +### For Low-Volume Users (< 100 edits/month) +**Recommendation:** Use Replicate with auto-selection + +**Why:** +- No minimum purchase +- Pay only for what you use +- Auto-selects cheapest appropriate model + +**Estimated Cost:** $1-3/month + +--- + +### For Medium-Volume Users (100-1000 edits/month) +**Recommendation:** Replicate or Stability AI + +**Strategy:** +- Use `lama` for removals ($0.002/image) +- Use `realistic-vision` for humans ($0.020/image) +- Use `sdxl-inpaint` for general ($0.025/image) + +**Estimated Cost:** $10-30/month + +--- + +### For High-Volume Users (1000+ edits/month) +**Recommendation:** Consider local GPU or cloud GPU + +**Why:** +- No per-image cost +- Best quality control +- Privacy + +**Setup:** +- Local: RTX 3060+ GPU ($300-2000 one-time) +- Cloud: RunPod/Vast.ai ($0.30-1.00/hour) + +--- + +## Quality Comparison Table + +| Use Case | DALL-E 2 | Stability SDXL | Replicate SDXL | Replicate Realistic | Replicate LaMa | +|----------|----------|----------------|----------------|---------------------|----------------| +| Hands | ⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐ | +| Faces | ⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐ | +| Bodies | ⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐ | +| Objects | ⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | N/A | +| Landscapes | ⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | N/A | +| Removal | ⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | +| Creative | ⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐ | + +--- + +## Advanced Tips + +### For Difficult Hands +1. **Use Mode B** - Provides full image context +2. **Be specific** - "realistic five-fingered hand in natural pose" +3. **Multiple passes** - Fix gross errors first, then refine +4. **Reference images** - Mode B helps AI understand the pose + +### For Facial Features +1. **High feather value** - 10-15px for smooth blending +2. **Small selections** - Target specific features +3. **Natural lighting** - Mention lighting in prompt + +### For Body Parts +1. **Maintain proportions** - Use Mode B for body context +2. **Clothing context** - Include clothing description in prompt +3. **Skin tone consistency** - Mention skin tone if needed + +--- + +## Troubleshooting Common Issues + +### "Hands have too many fingers" +- **Solution:** Switch to `realistic-vision` model +- **Prompt:** "realistic human hand with exactly five fingers" +- **Try:** Multiple generations, pick best result + +### "Face looks unnatural" +- **Solution:** Use `realistic-vision` model +- **Increase:** Feather value to 15-20px +- **Try:** Mode B for better context + +### "Removal leaves artifacts" +- **Solution:** Use `lama` model (designed for removal) +- **Alternative:** SDXL with prompt "clean background" + +### "Colors don't match" +- **Increase:** Feather value to 20-30px +- **Try:** Mode B for better color context +- **Prompt:** Include color description + +--- + +## Quick Start Examples + +### Example 1: Fix a Hand +```json +{ + "prompt": "realistic human hand with five fingers, natural pose", + "ai_provider": "replicate", + "ai_model": "realistic-vision", + "mode": "B", + "feather_px": 10 +} +``` + +### Example 2: Remove an Object +```json +{ + "prompt": "remove the object, clean background", + "ai_provider": "replicate", + "ai_model": "lama", + "mode": "A", + "feather_px": 5 +} +``` + +### Example 3: Change Sky +```json +{ + "prompt": "sunset sky with orange and pink clouds", + "ai_provider": "replicate", + "ai_model": "sdxl-inpaint", + "mode": "A", + "feather_px": 15 +} +``` + +--- + +## Summary + +**For Body Parts:** Use `realistic-vision` (Replicate) +**For Removal:** Use `lama` (Replicate) +**For Everything Else:** Use `sdxl-inpaint` (Replicate or Stability) + +**Let the auto-selection do its job** - it's optimized for these use cases! diff --git a/docs/QUICK_START.md b/docs/QUICK_START.md new file mode 100644 index 0000000..5e5b814 --- /dev/null +++ b/docs/QUICK_START.md @@ -0,0 +1,350 @@ +# Quick Start Guide + +## How to Choose the Right AI Model + +### For Body Parts (Hands, Faces, Bodies) + +Use **Replicate with `realistic-vision`** model: + +```env +AI_PROVIDER=replicate +REPLICATE_API_KEY=your-key-here +REPLICATE_MODEL=realistic-vision +``` + +**Why:** This model is specifically trained on human anatomy and handles difficult features like: +- ✅ Hands (even complex finger positions) +- ✅ Faces and expressions +- ✅ Skin textures +- ✅ Body proportions + +**Cost:** ~$0.020/image + +### For Removing Objects + +Use **Replicate with `lama`** model: + +```env +AI_PROVIDER=replicate +REPLICATE_MODEL=lama +``` + +**Why:** Designed specifically for inpainting and removal +**Cost:** ~$0.002/image (cheapest!) + +### For General Edits (Landscapes, Objects, Creative) + +Use **Replicate with `sdxl-inpaint`** model (default): + +```env +AI_PROVIDER=replicate +REPLICATE_MODEL=sdxl-inpaint +``` + +**Cost:** ~$0.025/image + +--- + +## Auto-Model Selection + +The system automatically picks the best model based on your prompt: + +| Your Prompt | Auto-Selected Model | Why | +|-------------|-------------------|-----| +| "Fix the hand" | realistic-vision | Detects "hand" keyword | +| "Remove person" | lama | Detects "remove" keyword | +| "Change sky to sunset" | sdxl-inpaint | General purpose default | + +**You don't need to manually specify models** - the auto-selection is optimized for quality and cost! + +--- + +## Patch Library: Save and Reuse Parts + +### What is the Patch Library? + +A library where you can save image patches (regions) and reuse them across different images. + +**Use Cases:** +- Save a well-generated hand to reuse later +- Save a perfect face for multiple photos +- Build a collection of good body parts +- Save textures, objects, or backgrounds +- Reuse AI-generated elements that came out great + +### How to Save a Patch + +#### Option 1: Save AI-Generated Result + +After an AI edit completes: + +```bash +POST /patches/ +{ + "name": "Perfect Hand", + "description": "Well-formed left hand, palm up", + "source_type": "ai_generated", + "source_edit_id": 123, + "category": "hand", + "tags": "left, palm, realistic" +} +``` + +This saves the AI-generated output (`patch_out.png`) to your library. + +#### Option 2: Save Manual Selection + +Select any region from your current image: + +```bash +POST /patches/ +{ + "name": "Good Face", + "description": "Frontal face with good lighting", + "source_type": "manual_selection", + "source_project_id": 456, + "bbox": {"x": 100, "y": 100, "width": 200, "height": 200}, + "category": "face", + "tags": "front, smile, female" +} +``` + +This saves whatever is currently in that region of your image. + +#### Option 3: Import from File + +Upload an external image: + +```bash +POST /patches/ +FormData: + name: "Downloaded Hand" + source_type: "imported" + file: [uploaded PNG file] + category: "hand" +``` + +### How to Apply a Saved Patch + +```bash +POST /patches/apply +{ + "project_id": 789, + "patch_id": 123, + "bbox": {"x": 300, "y": 400, "width": 200, "height": 200}, + "feather_px": 10 +} +``` + +This places the saved patch at the specified location in your image. + +### Browse Your Patch Library + +```bash +# List all patches +GET /patches/ + +# Filter by category +GET /patches/?category=hand + +# Filter by tags +GET /patches/?tags=realistic + +# Get specific patch +GET /patches/123 + +# Get patch image +GET /patches/123/image + +# Get patch thumbnail +GET /patches/123/image?thumbnail=true +``` + +### Organize Your Patches + +**Categories:** +- `hand` - Hand images +- `face` - Facial features +- `body` - Body parts +- `object` - Objects and items +- `texture` - Textures and patterns +- `background` - Backgrounds and scenery + +**Tags:** Comma-separated keywords for searching +- "left, palm, realistic" +- "front, smile, female" +- "five fingers, open hand" + +--- + +## Complete Workflow Example + +### Scenario: Fix hands in a portrait photo + +**Step 1: Create project and upload image** +```bash +POST /projects/ {"name": "Portrait Edit"} +POST /projects/1/upload [upload photo] +``` + +**Step 2: Try to fix the hand with AI** +```bash +POST /edits/projects/1/fix +{ + "prompt": "realistic human hand with five fingers, natural pose", + "mode": "B", # Use full image for context + "selection_type": "rectangle", + "bbox": {"x": 200, "y": 300, "width": 150, "height": 200}, + "feather_px": 10 +} +``` + +The system auto-selects `realistic-vision` model because prompt mentions "hand". + +**Step 3: If result is good, save it for later** +```bash +POST /patches/ +{ + "name": "Good Left Hand", + "source_type": "ai_generated", + "source_edit_id": 1, + "category": "hand", + "tags": "left, natural, realistic, five fingers" +} +``` + +**Step 4: Use saved hand on another photo** +```bash +# On a different project +POST /patches/apply +{ + "project_id": 2, + "patch_id": 1, + "bbox": {"x": 150, "y": 250, "width": 150, "height": 200}, + "feather_px": 15 +} +``` + +--- + +## Cost Comparison + +### Example: Fixing 10 hands in different photos + +**Option A: Generate each hand with AI** +- 10 edits × $0.020 = **$0.20** + +**Option B: Generate one good hand, save it, reuse it** +- 1 AI generation: $0.020 +- 9 patch applications: $0.00 (no AI cost) +- **Total: $0.020** (90% savings!) + +### When to Use Saved Patches vs AI + +**Use Saved Patches When:** +- You have a perfect result you want to reuse +- Same angle/lighting/style needed +- Want to maintain consistency across images +- Want to avoid AI generation costs + +**Use AI Generation When:** +- Need unique/different result each time +- Different angle or perspective needed +- Want variation and creativity +- Patch doesn't fit the context + +--- + +## Pro Tips + +### Building a Good Patch Library + +1. **Save your best AI results** - When AI generates something great, save it immediately +2. **Organize with categories** - Use consistent categories for easy finding +3. **Tag descriptively** - Include orientation (left/right), pose, lighting, etc. +4. **Create variations** - Save multiple versions of common needs (left hand, right hand, etc.) +5. **Build gradually** - Your library becomes more valuable over time + +### Maximizing Quality + +1. **For hands:** Always use `realistic-vision` model or save good results +2. **For faces:** Use Mode B (full image context) for better matching +3. **Use high feather values** (15-20px) when applying saved patches +4. **Test positioning** before finalizing - patches work best when lighting/angle matches + +### Saving Money + +1. **Build a patch library** of common needs +2. **Use `lama` for removals** instead of expensive models +3. **Let auto-selection work** - it picks the cheapest appropriate model +4. **Reuse successful patches** instead of regenerating + +--- + +## API Quick Reference + +```bash +# List available patches +GET /patches/ + +# Get patch details +GET /patches/{id} + +# Get patch image +GET /patches/{id}/image +GET /patches/{id}/image?thumbnail=true + +# Create patch from AI edit +POST /patches/ +{ + "name": "My Patch", + "source_type": "ai_generated", + "source_edit_id": 123, + "category": "hand" +} + +# Create patch from manual selection +POST /patches/ +{ + "name": "My Patch", + "source_type": "manual_selection", + "source_project_id": 456, + "bbox": {"x": 100, "y": 100, "width": 200, "height": 200} +} + +# Apply saved patch +POST /patches/apply +{ + "project_id": 789, + "patch_id": 123, + "bbox": {"x": 300, "y": 400, "width": 200, "height": 200}, + "feather_px": 10 +} + +# Delete patch +DELETE /patches/{id} + +# Update patch metadata +PUT /patches/{id} +{ + "name": "Updated Name", + "tags": "new, tags", + "category": "hand" +} +``` + +--- + +## Summary + +✅ **For hands/faces/bodies:** Use `realistic-vision` model +✅ **For removal:** Use `lama` model +✅ **For general edits:** Use `sdxl-inpaint` (default) +✅ **Auto-selection works great** - just write natural prompts +✅ **Save good AI results** to patch library for reuse +✅ **Save manual selections** from any image +✅ **Reuse patches across images** to save money and maintain consistency + +**You now have the best of both worlds:** +- AI generation when you need something new +- Saved patches when you need consistency or want to save money diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..2f552b7 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,28 @@ +FROM node:20-alpine as build + +WORKDIR /app + +# Copy package files +COPY package.json ./ + +# Install dependencies +RUN npm install + +# Copy source +COPY . . + +# Build app +RUN npm run build + +# Production stage +FROM nginx:alpine + +# Copy built files +COPY --from=build /app/dist /usr/share/nginx/html + +# Copy nginx config +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/Dockerfile.dev b/frontend/Dockerfile.dev new file mode 100644 index 0000000..7b5b1c2 --- /dev/null +++ b/frontend/Dockerfile.dev @@ -0,0 +1,17 @@ +FROM node:20-alpine + +WORKDIR /app + +# Copy package files +COPY package.json ./ + +# Install dependencies +RUN npm install + +# Copy source +COPY . . + +EXPOSE 5173 + +# Run dev server +CMD ["npm", "run", "dev", "--", "--host"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..d729151 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + AI Photo Edit + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..9196674 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,43 @@ +server { + listen 80; + server_name localhost; + + root /usr/share/nginx/html; + index index.html; + + # Gzip compression + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_types text/plain text/css text/xml text/javascript application/javascript application/json; + + # React Router + location / { + try_files $uri $uri/ /index.html; + } + + # API proxy + location /projects { + 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; + } + + location /edits { + 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; + } + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..ea5b4a1 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,23 @@ +{ + "name": "ai-photo-edit-frontend", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "lint": "eslint ." + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "fabric": "^5.3.0", + "axios": "^1.6.5" + }, + "devDependencies": { + "@types/react": "^18.2.48", + "@types/react-dom": "^18.2.18", + "@vitejs/plugin-react": "^4.2.1", + "vite": "^5.0.12" + } +} diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000..92c87d5 --- /dev/null +++ b/frontend/src/App.css @@ -0,0 +1,112 @@ +.app { + min-height: 100vh; + background-color: #1a1a1a; +} + +.error-banner { + background-color: #ff4444; + color: white; + padding: 12px 16px; + border-radius: 6px; + margin-bottom: 20px; + display: flex; + justify-content: space-between; + align-items: center; +} + +.error-banner button { + background: none; + color: white; + font-size: 20px; + padding: 0; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; +} + +.project-setup { + max-width: 600px; + margin: 0 auto; + padding: 40px; + background-color: #2a2a2a; + border-radius: 8px; + border: 1px solid #444; +} + +.project-setup h2 { + font-size: 24px; + margin-bottom: 24px; + text-align: center; +} + +.setup-form { + display: flex; + flex-direction: column; + gap: 20px; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +.form-group label { + font-size: 14px; + font-weight: 600; + color: #cccccc; +} + +.form-group input { + width: 100%; +} + +.file-name { + font-size: 12px; + color: #00ff00; + margin-top: 4px; +} + +.create-project-btn { + background-color: #0066ff; + color: white; + padding: 14px 20px; + font-size: 16px; + font-weight: 600; + margin-top: 10px; +} + +.create-project-btn:hover:not(:disabled) { + background-color: #0055dd; +} + +.workspace { + display: grid; + grid-template-columns: 1fr 400px; + gap: 20px; + min-height: 600px; +} + +@media (max-width: 1200px) { + .workspace { + grid-template-columns: 1fr; + } +} + +.left-panel { + display: flex; + flex-direction: column; +} + +.right-panel { + display: flex; + flex-direction: column; + gap: 20px; +} + +.history-wrapper { + flex: 1; + min-height: 0; +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..a2c1882 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,269 @@ +import React, { useState, useEffect } from 'react'; +import ImageCanvas from './components/ImageCanvas'; +import Controls from './components/Controls'; +import History from './components/History'; +import { projectsApi, editsApi } from './utils/api'; +import './App.css'; + +function App() { + 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 [projectName, setProjectName] = useState(''); + const [showProjectInput, setShowProjectInput] = useState(true); + + // Create project and upload image + const handleCreateProject = async () => { + if (!projectName.trim() || !imageFile) { + setError('Please provide a project name and select an image'); + return; + } + + try { + setError(null); + setIsProcessing(true); + + // Create project + const newProject = await projectsApi.create(projectName); + setProject(newProject); + + // Upload image + await projectsApi.uploadImage(newProject.id, imageFile); + + // Set current image URL + setCurrentImageUrl(projectsApi.getCurrentImageUrl(newProject.id)); + + // Hide project input + setShowProjectInput(false); + + // Load edits + await loadEdits(newProject.id); + } catch (err) { + setError(`Failed to create project: ${err.message}`); + } finally { + setIsProcessing(false); + } + }; + + // Load edits for the project + const loadEdits = async (projectId) => { + try { + const projectEdits = await projectsApi.getEdits(projectId); + setEdits(projectEdits); + } catch (err) { + console.error('Failed to load edits:', err); + } + }; + + // Poll for edit status + const pollEditStatus = async (editId) => { + const maxAttempts = 60; // 60 attempts = 1 minute with 1 second interval + let attempts = 0; + + const poll = async () => { + try { + const edit = await editsApi.get(editId); + + if (edit.status === 'completed') { + // Reload edits and update image + await loadEdits(project.id); + setCurrentImageUrl(projectsApi.getCurrentImageUrl(project.id)); + 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 + } else { + setError('Edit timeout - please check edit history'); + setIsProcessing(false); + } + } catch (err) { + setError(`Failed to check edit status: ${err.message}`); + setIsProcessing(false); + } + }; + + poll(); + }; + + // Handle fix button + const handleFix = async () => { + if (!selection || !prompt.trim() || !project) { + setError('Please make a selection and enter a prompt'); + return; + } + + try { + setError(null); + setIsProcessing(true); + + const editRequest = { + prompt: prompt.trim(), + mode: mode, + selection_type: selection.type, + bbox: selection.bbox, + feather_px: feather, + selection_data: selection.selectionData, + }; + + const edit = await editsApi.create(project.id, editRequest); + + // Start polling for status + pollEditStatus(edit.id); + } catch (err) { + setError(`Failed to process edit: ${err.message}`); + setIsProcessing(false); + } + }; + + // Handle revert + const handleRevert = async (editId) => { + if (!project) return; + + try { + setError(null); + setIsProcessing(true); + + await editsApi.revert(project.id, editId); + + // Update image + setCurrentImageUrl(projectsApi.getCurrentImageUrl(project.id)); + + await loadEdits(project.id); + } catch (err) { + setError(`Failed to revert: ${err.message}`); + } finally { + setIsProcessing(false); + } + }; + + // Handle reset + const handleReset = async () => { + if (!project) return; + + try { + setError(null); + setIsProcessing(true); + + await editsApi.reset(project.id); + + // Update image + setCurrentImageUrl(projectsApi.getCurrentImageUrl(project.id)); + + await loadEdits(project.id); + } catch (err) { + setError(`Failed to reset: ${err.message}`); + } finally { + setIsProcessing(false); + } + }; + + return ( +
+
+
+

AI Photo Edit

+

Have AI regenerate only a selected area of your photo

+
+ + {error && ( +
+ Error: {error} + +
+ )} + + {showProjectInput ? ( +
+

Create New Project

+
+
+ + setProjectName(e.target.value)} + placeholder="My Photo Edit Project" + disabled={isProcessing} + /> +
+
+ + setImageFile(e.target.files[0])} + disabled={isProcessing} + /> + {imageFile && ( +

Selected: {imageFile.name}

+ )} +
+ +
+
+ ) : ( +
+
+ +
+ +
+ setSelection(null)} + isProcessing={isProcessing} + hasSelection={!!selection} + /> + +
+ +
+
+
+ )} +
+
+ ); +} + +export default App; diff --git a/frontend/src/components/Controls.css b/frontend/src/components/Controls.css new file mode 100644 index 0000000..13c6634 --- /dev/null +++ b/frontend/src/components/Controls.css @@ -0,0 +1,121 @@ +.controls { + padding: 20px; + background-color: #2a2a2a; + border-radius: 8px; + border: 1px solid #444; +} + +.control-section { + margin-bottom: 24px; +} + +.control-section:last-child { + margin-bottom: 0; +} + +.control-section h3 { + font-size: 14px; + font-weight: 600; + margin-bottom: 12px; + color: #ffffff; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.button-group { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.button-group button { + flex: 1; + min-width: 100px; + background-color: #3a3a3a; + color: #ffffff; + padding: 10px 16px; + font-weight: 500; +} + +.button-group button:hover:not(:disabled) { + background-color: #4a4a4a; +} + +.button-group button.active { + background-color: #00aa00; + color: #ffffff; +} + +.button-group button.active:hover:not(:disabled) { + background-color: #00cc00; +} + +.mode-hint { + margin-top: 8px; + font-size: 12px; + color: #888; + font-style: italic; +} + +.slider-group { + display: flex; + align-items: center; + gap: 12px; +} + +.slider-group input[type="range"] { + flex: 1; +} + +.slider-value { + min-width: 50px; + text-align: right; + font-weight: 600; + color: #00ff00; +} + +.hint { + margin-top: 6px; + font-size: 12px; + color: #666; +} + +.control-section textarea { + width: 100%; + resize: vertical; + min-height: 80px; + font-family: inherit; +} + +.action-buttons { + display: flex; + flex-direction: column; + gap: 10px; +} + +.fix-btn { + background-color: #0066ff; + color: white; + padding: 14px 20px; + font-size: 16px; + font-weight: 600; +} + +.fix-btn:hover:not(:disabled) { + background-color: #0055dd; +} + +.fix-btn:disabled { + background-color: #333; + color: #666; +} + +.clear-btn { + background-color: #ff4444; + color: white; + padding: 10px 16px; +} + +.clear-btn:hover:not(:disabled) { + background-color: #cc0000; +} diff --git a/frontend/src/components/Controls.jsx b/frontend/src/components/Controls.jsx new file mode 100644 index 0000000..8d078cb --- /dev/null +++ b/frontend/src/components/Controls.jsx @@ -0,0 +1,121 @@ +import React from 'react'; +import './Controls.css'; + +const Controls = ({ + selectionMode, + onSelectionModeChange, + mode, + onModeChange, + feather, + onFeatherChange, + prompt, + onPromptChange, + onFix, + onClear, + isProcessing, + hasSelection, +}) => { + return ( +
+
+

Selection Tool

+
+ + + +
+
+ +
+

AI Mode

+
+ + +
+

+ {mode === 'A' + ? 'Faster and cheaper - sends only the selected area' + : 'Better style consistency - includes full image for context'} +

+
+ +
+

Edge Feathering

+
+ onFeatherChange(parseInt(e.target.value))} + disabled={isProcessing} + /> + {feather}px +
+

Smooth blending at selection edges

+
+ +
+

Prompt

+