Merge pull request #1 from outis1one/claude/ai-photo-edit-tool-c8orl
Claude/ai photo edit tool c8orl
This commit is contained in:
@@ -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
|
||||||
+70
@@ -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
|
||||||
@@ -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!
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -1 +1,354 @@
|
|||||||
# EditmaskwithAI
|
# 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 <repository-url>
|
||||||
|
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.
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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"}
|
||||||
@@ -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"]
|
||||||
@@ -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")
|
||||||
@@ -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")
|
||||||
@@ -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")
|
||||||
@@ -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")
|
||||||
@@ -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))
|
||||||
@@ -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))
|
||||||
@@ -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"}
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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}")
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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:
|
||||||
@@ -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?
|
||||||
@@ -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!
|
||||||
@@ -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
|
||||||
@@ -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;"]
|
||||||
@@ -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"]
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>AI Photo Edit</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="app">
|
||||||
|
<div className="container">
|
||||||
|
<div className="header">
|
||||||
|
<h1>AI Photo Edit</h1>
|
||||||
|
<p>Have AI regenerate only a selected area of your photo</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="error-banner">
|
||||||
|
<strong>Error:</strong> {error}
|
||||||
|
<button onClick={() => setError(null)}>✕</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showProjectInput ? (
|
||||||
|
<div className="project-setup">
|
||||||
|
<h2>Create New Project</h2>
|
||||||
|
<div className="setup-form">
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Project Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={projectName}
|
||||||
|
onChange={(e) => setProjectName(e.target.value)}
|
||||||
|
placeholder="My Photo Edit Project"
|
||||||
|
disabled={isProcessing}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Upload Image</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={(e) => setImageFile(e.target.files[0])}
|
||||||
|
disabled={isProcessing}
|
||||||
|
/>
|
||||||
|
{imageFile && (
|
||||||
|
<p className="file-name">Selected: {imageFile.name}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="create-project-btn"
|
||||||
|
onClick={handleCreateProject}
|
||||||
|
disabled={isProcessing || !projectName.trim() || !imageFile}
|
||||||
|
>
|
||||||
|
{isProcessing ? 'Creating...' : 'Create Project'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="workspace">
|
||||||
|
<div className="left-panel">
|
||||||
|
<ImageCanvas
|
||||||
|
imageUrl={currentImageUrl}
|
||||||
|
onSelectionChange={setSelection}
|
||||||
|
selectionMode={selectionMode}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="right-panel">
|
||||||
|
<Controls
|
||||||
|
selectionMode={selectionMode}
|
||||||
|
onSelectionModeChange={setSelectionMode}
|
||||||
|
mode={mode}
|
||||||
|
onModeChange={setMode}
|
||||||
|
feather={feather}
|
||||||
|
onFeatherChange={setFeather}
|
||||||
|
prompt={prompt}
|
||||||
|
onPromptChange={setPrompt}
|
||||||
|
onFix={handleFix}
|
||||||
|
onClear={() => setSelection(null)}
|
||||||
|
isProcessing={isProcessing}
|
||||||
|
hasSelection={!!selection}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="history-wrapper">
|
||||||
|
<History
|
||||||
|
edits={edits}
|
||||||
|
onRevert={handleRevert}
|
||||||
|
onReset={handleReset}
|
||||||
|
isProcessing={isProcessing}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="controls">
|
||||||
|
<div className="control-section">
|
||||||
|
<h3>Selection Tool</h3>
|
||||||
|
<div className="button-group">
|
||||||
|
<button
|
||||||
|
className={selectionMode === 'rectangle' ? 'active' : ''}
|
||||||
|
onClick={() => onSelectionModeChange('rectangle')}
|
||||||
|
disabled={isProcessing}
|
||||||
|
>
|
||||||
|
Rectangle
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={selectionMode === 'ellipse' ? 'active' : ''}
|
||||||
|
onClick={() => onSelectionModeChange('ellipse')}
|
||||||
|
disabled={isProcessing}
|
||||||
|
>
|
||||||
|
Ellipse
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={selectionMode === 'lasso' ? 'active' : ''}
|
||||||
|
onClick={() => onSelectionModeChange('lasso')}
|
||||||
|
disabled={isProcessing}
|
||||||
|
>
|
||||||
|
Lasso
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="control-section">
|
||||||
|
<h3>AI Mode</h3>
|
||||||
|
<div className="button-group">
|
||||||
|
<button
|
||||||
|
className={mode === 'A' ? 'active' : ''}
|
||||||
|
onClick={() => onModeChange('A')}
|
||||||
|
disabled={isProcessing}
|
||||||
|
title="Mode A: Send only the selected patch (faster, cheaper)"
|
||||||
|
>
|
||||||
|
Mode A (Patch Only)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={mode === 'B' ? 'active' : ''}
|
||||||
|
onClick={() => onModeChange('B')}
|
||||||
|
disabled={isProcessing}
|
||||||
|
title="Mode B: Send patch + full image for context (better style consistency)"
|
||||||
|
>
|
||||||
|
Mode B (With Context)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="mode-hint">
|
||||||
|
{mode === 'A'
|
||||||
|
? 'Faster and cheaper - sends only the selected area'
|
||||||
|
: 'Better style consistency - includes full image for context'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="control-section">
|
||||||
|
<h3>Edge Feathering</h3>
|
||||||
|
<div className="slider-group">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="50"
|
||||||
|
value={feather}
|
||||||
|
onChange={(e) => onFeatherChange(parseInt(e.target.value))}
|
||||||
|
disabled={isProcessing}
|
||||||
|
/>
|
||||||
|
<span className="slider-value">{feather}px</span>
|
||||||
|
</div>
|
||||||
|
<p className="hint">Smooth blending at selection edges</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="control-section">
|
||||||
|
<h3>Prompt</h3>
|
||||||
|
<textarea
|
||||||
|
value={prompt}
|
||||||
|
onChange={(e) => onPromptChange(e.target.value)}
|
||||||
|
placeholder="Describe what to fix or change in the selected area..."
|
||||||
|
rows={3}
|
||||||
|
disabled={isProcessing}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="control-section action-buttons">
|
||||||
|
<button
|
||||||
|
className="fix-btn"
|
||||||
|
onClick={onFix}
|
||||||
|
disabled={isProcessing || !hasSelection || !prompt.trim()}
|
||||||
|
>
|
||||||
|
{isProcessing ? 'Processing...' : 'Fix Selected Area'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="clear-btn"
|
||||||
|
onClick={onClear}
|
||||||
|
disabled={isProcessing}
|
||||||
|
>
|
||||||
|
Clear Selection
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Controls;
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
.history {
|
||||||
|
padding: 20px;
|
||||||
|
background-color: #2a2a2a;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #444;
|
||||||
|
max-height: 600px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-header h3 {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ffffff;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reset-btn {
|
||||||
|
background-color: #ff4444;
|
||||||
|
color: white;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reset-btn:hover:not(:disabled) {
|
||||||
|
background-color: #cc0000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-message {
|
||||||
|
text-align: center;
|
||||||
|
color: #666;
|
||||||
|
padding: 40px 20px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item {
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item:hover {
|
||||||
|
border-color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-id {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-status {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-prompt {
|
||||||
|
color: #cccccc;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-details {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-mode,
|
||||||
|
.edit-type,
|
||||||
|
.edit-feather {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #888;
|
||||||
|
background-color: #2a2a2a;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-date {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revert-btn {
|
||||||
|
width: 100%;
|
||||||
|
background-color: #0066ff;
|
||||||
|
color: white;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revert-btn:hover:not(:disabled) {
|
||||||
|
background-color: #0055dd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
color: #ff4444;
|
||||||
|
font-size: 11px;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 6px;
|
||||||
|
background-color: rgba(255, 68, 68, 0.1);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import './History.css';
|
||||||
|
|
||||||
|
const History = ({ edits, onRevert, onReset, isProcessing }) => {
|
||||||
|
const formatDate = (dateString) => {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed':
|
||||||
|
return '#00aa00';
|
||||||
|
case 'processing':
|
||||||
|
return '#ffaa00';
|
||||||
|
case 'failed':
|
||||||
|
return '#ff0000';
|
||||||
|
default:
|
||||||
|
return '#666';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="history">
|
||||||
|
<div className="history-header">
|
||||||
|
<h3>Edit History</h3>
|
||||||
|
{edits.length > 0 && (
|
||||||
|
<button
|
||||||
|
className="reset-btn"
|
||||||
|
onClick={onReset}
|
||||||
|
disabled={isProcessing}
|
||||||
|
>
|
||||||
|
Reset to Original
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{edits.length === 0 ? (
|
||||||
|
<p className="empty-message">No edits yet</p>
|
||||||
|
) : (
|
||||||
|
<div className="history-list">
|
||||||
|
{edits.map((edit) => (
|
||||||
|
<div key={edit.id} className="history-item">
|
||||||
|
<div className="edit-info">
|
||||||
|
<div className="edit-header">
|
||||||
|
<span className="edit-id">Edit #{edit.id}</span>
|
||||||
|
<span
|
||||||
|
className="edit-status"
|
||||||
|
style={{ color: getStatusColor(edit.status) }}
|
||||||
|
>
|
||||||
|
{edit.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="edit-prompt">{edit.prompt}</p>
|
||||||
|
<div className="edit-details">
|
||||||
|
<span className="edit-mode">Mode {edit.mode}</span>
|
||||||
|
<span className="edit-type">{edit.selection_type}</span>
|
||||||
|
<span className="edit-feather">Feather: {edit.feather_px}px</span>
|
||||||
|
</div>
|
||||||
|
<p className="edit-date">{formatDate(edit.created_at)}</p>
|
||||||
|
</div>
|
||||||
|
{edit.status === 'completed' && (
|
||||||
|
<button
|
||||||
|
className="revert-btn"
|
||||||
|
onClick={() => onRevert(edit.id)}
|
||||||
|
disabled={isProcessing}
|
||||||
|
>
|
||||||
|
Revert to This
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{edit.error_message && (
|
||||||
|
<p className="error-message">{edit.error_message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default History;
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
.canvas-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 500px;
|
||||||
|
background-color: #2a2a2a;
|
||||||
|
border: 2px solid #444;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-container canvas {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-selection-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
background-color: #ff4444;
|
||||||
|
color: white;
|
||||||
|
padding: 8px 16px;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-selection-btn:hover {
|
||||||
|
background-color: #cc0000;
|
||||||
|
}
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { fabric } from 'fabric';
|
||||||
|
import './ImageCanvas.css';
|
||||||
|
|
||||||
|
const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => {
|
||||||
|
const canvasRef = useRef(null);
|
||||||
|
const fabricCanvasRef = useRef(null);
|
||||||
|
const [currentSelection, setCurrentSelection] = useState(null);
|
||||||
|
const [isDrawing, setIsDrawing] = useState(false);
|
||||||
|
const lassoPoints = useRef([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canvasRef.current) return;
|
||||||
|
|
||||||
|
// Initialize Fabric.js canvas
|
||||||
|
const canvas = new fabric.Canvas(canvasRef.current, {
|
||||||
|
selection: false,
|
||||||
|
backgroundColor: '#2a2a2a',
|
||||||
|
});
|
||||||
|
fabricCanvasRef.current = canvas;
|
||||||
|
|
||||||
|
// Handle window resize
|
||||||
|
const handleResize = () => {
|
||||||
|
const container = canvasRef.current?.parentElement;
|
||||||
|
if (container) {
|
||||||
|
canvas.setWidth(container.clientWidth);
|
||||||
|
canvas.setHeight(Math.min(container.clientHeight, 800));
|
||||||
|
canvas.renderAll();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleResize();
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
canvas.dispose();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Load image when URL changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!fabricCanvasRef.current || !imageUrl) return;
|
||||||
|
|
||||||
|
const canvas = fabricCanvasRef.current;
|
||||||
|
|
||||||
|
// Add cache buster to force reload
|
||||||
|
const cacheBustedUrl = `${imageUrl}?t=${Date.now()}`;
|
||||||
|
|
||||||
|
fabric.Image.fromURL(cacheBustedUrl, (img) => {
|
||||||
|
canvas.clear();
|
||||||
|
|
||||||
|
// Scale image to fit canvas
|
||||||
|
const scale = Math.min(
|
||||||
|
canvas.width / img.width,
|
||||||
|
canvas.height / img.height,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
img.scale(scale);
|
||||||
|
img.set({
|
||||||
|
left: (canvas.width - img.width * scale) / 2,
|
||||||
|
top: (canvas.height - img.height * scale) / 2,
|
||||||
|
selectable: false,
|
||||||
|
evented: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
canvas.add(img);
|
||||||
|
canvas.sendToBack(img);
|
||||||
|
canvas.renderAll();
|
||||||
|
|
||||||
|
// Store image reference
|
||||||
|
canvas.backgroundImage = img;
|
||||||
|
}, { crossOrigin: 'anonymous' });
|
||||||
|
}, [imageUrl]);
|
||||||
|
|
||||||
|
// Handle selection mode changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!fabricCanvasRef.current) return;
|
||||||
|
|
||||||
|
const canvas = fabricCanvasRef.current;
|
||||||
|
|
||||||
|
// Clear previous selection
|
||||||
|
if (currentSelection) {
|
||||||
|
canvas.remove(currentSelection);
|
||||||
|
setCurrentSelection(null);
|
||||||
|
onSelectionChange(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up event handlers based on mode
|
||||||
|
canvas.off('mouse:down');
|
||||||
|
canvas.off('mouse:move');
|
||||||
|
canvas.off('mouse:up');
|
||||||
|
|
||||||
|
if (selectionMode === 'rectangle') {
|
||||||
|
setupRectangleMode(canvas);
|
||||||
|
} else if (selectionMode === 'ellipse') {
|
||||||
|
setupEllipseMode(canvas);
|
||||||
|
} else if (selectionMode === 'lasso') {
|
||||||
|
setupLassoMode(canvas);
|
||||||
|
}
|
||||||
|
}, [selectionMode]);
|
||||||
|
|
||||||
|
const setupRectangleMode = (canvas) => {
|
||||||
|
let rect, isDown, startX, startY;
|
||||||
|
|
||||||
|
canvas.on('mouse:down', (e) => {
|
||||||
|
isDown = true;
|
||||||
|
const pointer = canvas.getPointer(e.e);
|
||||||
|
startX = pointer.x;
|
||||||
|
startY = pointer.y;
|
||||||
|
|
||||||
|
rect = new fabric.Rect({
|
||||||
|
left: startX,
|
||||||
|
top: startY,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
fill: 'rgba(255, 255, 255, 0.3)',
|
||||||
|
stroke: '#00ff00',
|
||||||
|
strokeWidth: 2,
|
||||||
|
selectable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
canvas.add(rect);
|
||||||
|
setCurrentSelection(rect);
|
||||||
|
});
|
||||||
|
|
||||||
|
canvas.on('mouse:move', (e) => {
|
||||||
|
if (!isDown) return;
|
||||||
|
|
||||||
|
const pointer = canvas.getPointer(e.e);
|
||||||
|
const width = pointer.x - startX;
|
||||||
|
const height = pointer.y - startY;
|
||||||
|
|
||||||
|
rect.set({
|
||||||
|
width: Math.abs(width),
|
||||||
|
height: Math.abs(height),
|
||||||
|
left: width < 0 ? pointer.x : startX,
|
||||||
|
top: height < 0 ? pointer.y : startY,
|
||||||
|
});
|
||||||
|
|
||||||
|
canvas.renderAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
canvas.on('mouse:up', () => {
|
||||||
|
isDown = false;
|
||||||
|
updateSelection(rect, 'rectangle');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const setupEllipseMode = (canvas) => {
|
||||||
|
let ellipse, isDown, startX, startY;
|
||||||
|
|
||||||
|
canvas.on('mouse:down', (e) => {
|
||||||
|
isDown = true;
|
||||||
|
const pointer = canvas.getPointer(e.e);
|
||||||
|
startX = pointer.x;
|
||||||
|
startY = pointer.y;
|
||||||
|
|
||||||
|
ellipse = new fabric.Ellipse({
|
||||||
|
left: startX,
|
||||||
|
top: startY,
|
||||||
|
rx: 0,
|
||||||
|
ry: 0,
|
||||||
|
fill: 'rgba(255, 255, 255, 0.3)',
|
||||||
|
stroke: '#00ff00',
|
||||||
|
strokeWidth: 2,
|
||||||
|
selectable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
canvas.add(ellipse);
|
||||||
|
setCurrentSelection(ellipse);
|
||||||
|
});
|
||||||
|
|
||||||
|
canvas.on('mouse:move', (e) => {
|
||||||
|
if (!isDown) return;
|
||||||
|
|
||||||
|
const pointer = canvas.getPointer(e.e);
|
||||||
|
const rx = Math.abs(pointer.x - startX) / 2;
|
||||||
|
const ry = Math.abs(pointer.y - startY) / 2;
|
||||||
|
|
||||||
|
ellipse.set({
|
||||||
|
rx: rx,
|
||||||
|
ry: ry,
|
||||||
|
left: startX < pointer.x ? startX : pointer.x,
|
||||||
|
top: startY < pointer.y ? startY : pointer.y,
|
||||||
|
});
|
||||||
|
|
||||||
|
canvas.renderAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
canvas.on('mouse:up', () => {
|
||||||
|
isDown = false;
|
||||||
|
updateSelection(ellipse, 'ellipse');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const setupLassoMode = (canvas) => {
|
||||||
|
let line, points = [];
|
||||||
|
|
||||||
|
canvas.on('mouse:down', (e) => {
|
||||||
|
setIsDrawing(true);
|
||||||
|
const pointer = canvas.getPointer(e.e);
|
||||||
|
points = [{ x: pointer.x, y: pointer.y }];
|
||||||
|
|
||||||
|
line = new fabric.Polyline(points, {
|
||||||
|
fill: 'rgba(255, 255, 255, 0.3)',
|
||||||
|
stroke: '#00ff00',
|
||||||
|
strokeWidth: 2,
|
||||||
|
selectable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
canvas.add(line);
|
||||||
|
setCurrentSelection(line);
|
||||||
|
});
|
||||||
|
|
||||||
|
canvas.on('mouse:move', (e) => {
|
||||||
|
if (!isDrawing) return;
|
||||||
|
|
||||||
|
const pointer = canvas.getPointer(e.e);
|
||||||
|
points.push({ x: pointer.x, y: pointer.y });
|
||||||
|
|
||||||
|
line.set({ points: points });
|
||||||
|
canvas.renderAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
canvas.on('mouse:up', () => {
|
||||||
|
setIsDrawing(false);
|
||||||
|
lassoPoints.current = points;
|
||||||
|
updateSelection(line, 'lasso');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateSelection = (selection, type) => {
|
||||||
|
if (!selection || !fabricCanvasRef.current) return;
|
||||||
|
|
||||||
|
const canvas = fabricCanvasRef.current;
|
||||||
|
const bgImage = canvas.backgroundImage;
|
||||||
|
|
||||||
|
if (!bgImage) return;
|
||||||
|
|
||||||
|
// Calculate bounding box in original image coordinates
|
||||||
|
const imgScale = bgImage.scaleX;
|
||||||
|
const imgLeft = bgImage.left;
|
||||||
|
const imgTop = bgImage.top;
|
||||||
|
|
||||||
|
let bbox, selectionData = null;
|
||||||
|
|
||||||
|
if (type === 'rectangle') {
|
||||||
|
bbox = {
|
||||||
|
x: Math.round((selection.left - imgLeft) / imgScale),
|
||||||
|
y: Math.round((selection.top - imgTop) / imgScale),
|
||||||
|
width: Math.round(selection.width / imgScale),
|
||||||
|
height: Math.round(selection.height / imgScale),
|
||||||
|
};
|
||||||
|
} else if (type === 'ellipse') {
|
||||||
|
bbox = {
|
||||||
|
x: Math.round((selection.left - imgLeft) / imgScale),
|
||||||
|
y: Math.round((selection.top - imgTop) / imgScale),
|
||||||
|
width: Math.round((selection.rx * 2) / imgScale),
|
||||||
|
height: Math.round((selection.ry * 2) / imgScale),
|
||||||
|
};
|
||||||
|
} else if (type === 'lasso') {
|
||||||
|
const bounds = selection.getBoundingRect();
|
||||||
|
bbox = {
|
||||||
|
x: Math.round((bounds.left - imgLeft) / imgScale),
|
||||||
|
y: Math.round((bounds.top - imgTop) / imgScale),
|
||||||
|
width: Math.round(bounds.width / imgScale),
|
||||||
|
height: Math.round(bounds.height / imgScale),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Convert lasso points to relative coordinates within bbox
|
||||||
|
const relativePoints = lassoPoints.current.map(p => [
|
||||||
|
Math.round((p.x - bounds.left) / imgScale),
|
||||||
|
Math.round((p.y - bounds.top) / imgScale),
|
||||||
|
]);
|
||||||
|
|
||||||
|
selectionData = { points: relativePoints };
|
||||||
|
}
|
||||||
|
|
||||||
|
onSelectionChange({
|
||||||
|
type,
|
||||||
|
bbox,
|
||||||
|
selectionData,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearSelection = () => {
|
||||||
|
if (currentSelection && fabricCanvasRef.current) {
|
||||||
|
fabricCanvasRef.current.remove(currentSelection);
|
||||||
|
setCurrentSelection(null);
|
||||||
|
onSelectionChange(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="canvas-container">
|
||||||
|
<canvas ref={canvasRef} />
|
||||||
|
{currentSelection && (
|
||||||
|
<button className="clear-selection-btn" onClick={clearSelection}>
|
||||||
|
Clear Selection
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ImageCanvas;
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||||
|
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||||
|
sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||||
|
monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"],
|
||||||
|
input[type="file"],
|
||||||
|
textarea {
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: #2a2a2a;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"] {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
margin-bottom: 30px;
|
||||||
|
border-bottom: 2px solid #333;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
font-size: 32px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header p {
|
||||||
|
color: #888;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import App from './App'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000';
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: API_BASE_URL,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const projectsApi = {
|
||||||
|
// Create a new project
|
||||||
|
create: async (name) => {
|
||||||
|
const response = await api.post('/projects/', { name });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// List all projects
|
||||||
|
list: async () => {
|
||||||
|
const response = await api.get('/projects/');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get a specific project
|
||||||
|
get: async (projectId) => {
|
||||||
|
const response = await api.get(`/projects/${projectId}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Delete a project
|
||||||
|
delete: async (projectId) => {
|
||||||
|
const response = await api.delete(`/projects/${projectId}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Upload image to project
|
||||||
|
uploadImage: async (projectId, file) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
const response = await api.post(`/projects/${projectId}/upload`, formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get edits for a project
|
||||||
|
getEdits: async (projectId) => {
|
||||||
|
const response = await api.get(`/projects/${projectId}/edits`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get image URLs
|
||||||
|
getOriginalImageUrl: (projectId) => `${API_BASE_URL}/projects/${projectId}/original`,
|
||||||
|
getCurrentImageUrl: (projectId) => `${API_BASE_URL}/projects/${projectId}/current`,
|
||||||
|
getEditResultUrl: (projectId, editId) => `${API_BASE_URL}/projects/${projectId}/history/${editId}/result`,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const editsApi = {
|
||||||
|
// Create a new edit (Fix button)
|
||||||
|
create: async (projectId, editData) => {
|
||||||
|
const response = await api.post(`/edits/projects/${projectId}/fix`, editData);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get edit status
|
||||||
|
get: async (editId) => {
|
||||||
|
const response = await api.get(`/edits/${editId}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Revert to a specific edit
|
||||||
|
revert: async (projectId, editId) => {
|
||||||
|
const response = await api.post(`/edits/projects/${projectId}/revert/${editId}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Reset to original
|
||||||
|
reset: async (projectId) => {
|
||||||
|
const response = await api.post(`/edits/projects/${projectId}/reset`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default api;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/projects': {
|
||||||
|
target: 'http://backend:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/edits': {
|
||||||
|
target: 'http://backend:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user