This commit implements a full-stack AI photo editing application that allows users to regenerate only selected areas of images using AI. Features implemented: - Frontend (React + Fabric.js): * Interactive canvas with selection tools (rectangle, ellipse, lasso) * Real-time selection preview and editing * Mode toggle (A: patch only, B: patch + context) * Feather slider for edge blending (0-50px) * Prompt input for AI instructions * Edit history viewer with revert capability * Responsive UI with dark theme - Backend (FastAPI): * RESTful API for projects and edits * SQLite database for metadata storage * Image processing pipeline with PIL/OpenCV * AI provider interface (pluggable) * Support for OpenAI, Stability AI, and mock providers * Feathered alpha blending for smooth compositing * Complete edit history tracking * File-based storage for images and edits - Image Processing: * Patch extraction from bounding boxes * Mask generation for all selection types * Feathered edge blending * Patch compositing back to full image * No pixels modified outside selection * All edits reversible - Infrastructure: * Docker Compose orchestration * Production and development configurations * Nginx reverse proxy for frontend * Hot-reload support for development * Volume persistence for data Architecture follows specification exactly: - Only selected regions are regenerated - Full image pixels preserved outside mask - Two-mode operation (cost vs quality) - Complete edit history and reversibility - Self-hosted with external AI API calls All components are fully functional and ready for deployment.
145 lines
4.1 KiB
Python
145 lines
4.1 KiB
Python
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
|