Add advanced editing features: layers, background removal, smart selection
Backend: - Add /tools router with background removal, smart select, color select - Add rembg dependency for AI background removal - Add layer management API (list, flatten) - Fix transparency preservation in blend_patch (veil collapse fix) - Preserve alpha channel when reverting/resetting images Frontend: - Add AdvancedTools panel with background removal, smart select, color select - Add Layers panel with drag-to-reorder, visibility toggle, flatten - Add toolsApi for new backend endpoints - Make right panel scrollable for additional controls This adds "Photoshop light" capabilities: - Remove background and create layer - Smart object selection (click to select) - Color selection with tolerance - Layer system with compositing
This commit is contained in:
+2
-1
@@ -5,7 +5,7 @@ from contextlib import asynccontextmanager
|
|||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.database import init_db
|
from app.database import init_db
|
||||||
from app.routers import projects, edits, images, patches, generate
|
from app.routers import projects, edits, images, patches, generate, tools
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -37,6 +37,7 @@ app.include_router(edits.router)
|
|||||||
app.include_router(images.router)
|
app.include_router(images.router)
|
||||||
app.include_router(patches.router)
|
app.include_router(patches.router)
|
||||||
app.include_router(generate.router)
|
app.include_router(generate.router)
|
||||||
|
app.include_router(tools.router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
|
|||||||
@@ -0,0 +1,379 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
|
||||||
|
from fastapi.responses import Response
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import Optional
|
||||||
|
from PIL import Image
|
||||||
|
from io import BytesIO
|
||||||
|
import numpy as np
|
||||||
|
import json
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models.project import Project
|
||||||
|
from app.schemas import StatusResponse
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/tools", tags=["tools"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/remove-background")
|
||||||
|
async def remove_background(
|
||||||
|
project_id: Optional[int] = Form(None),
|
||||||
|
file: Optional[UploadFile] = File(None),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Remove background from an image using rembg.
|
||||||
|
|
||||||
|
Either provide project_id to use current project image,
|
||||||
|
or upload a file directly.
|
||||||
|
|
||||||
|
Returns PNG with transparent background.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from rembg import remove
|
||||||
|
except ImportError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail="rembg not installed. Run: pip install rembg"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get image bytes
|
||||||
|
if file:
|
||||||
|
image_bytes = await file.read()
|
||||||
|
elif project_id:
|
||||||
|
project = db.query(Project).filter(Project.id == project_id).first()
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
|
||||||
|
from app.services.edit_service import EditService
|
||||||
|
edit_service = EditService()
|
||||||
|
image_path = edit_service.get_current_image_path(project_id)
|
||||||
|
|
||||||
|
with open(image_path, 'rb') as f:
|
||||||
|
image_bytes = f.read()
|
||||||
|
else:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Provide either project_id or file"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Remove background
|
||||||
|
result_bytes = remove(image_bytes)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=result_bytes,
|
||||||
|
media_type="image/png",
|
||||||
|
headers={"Content-Disposition": "inline; filename=no-background.png"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/remove-background-to-layer")
|
||||||
|
async def remove_background_to_layer(
|
||||||
|
project_id: int = Form(...),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Remove background and save as a new layer in the project.
|
||||||
|
Returns layer info that can be added to frontend layer system.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from rembg import remove
|
||||||
|
except ImportError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail="rembg not installed. Run: pip install rembg"
|
||||||
|
)
|
||||||
|
|
||||||
|
project = db.query(Project).filter(Project.id == project_id).first()
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
|
||||||
|
from app.services.edit_service import EditService
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
edit_service = EditService()
|
||||||
|
image_path = edit_service.get_current_image_path(project_id)
|
||||||
|
|
||||||
|
with open(image_path, 'rb') as f:
|
||||||
|
image_bytes = f.read()
|
||||||
|
|
||||||
|
# Remove background
|
||||||
|
result_bytes = remove(image_bytes)
|
||||||
|
|
||||||
|
# Save as layer file
|
||||||
|
project_dir = edit_service.get_project_dir(project_id)
|
||||||
|
layers_dir = project_dir / 'layers'
|
||||||
|
layers_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
# Find next layer number
|
||||||
|
existing_layers = list(layers_dir.glob('layer_*.png'))
|
||||||
|
layer_num = len(existing_layers) + 1
|
||||||
|
layer_path = layers_dir / f'layer_{layer_num}.png'
|
||||||
|
|
||||||
|
with open(layer_path, 'wb') as f:
|
||||||
|
f.write(result_bytes)
|
||||||
|
|
||||||
|
# Get dimensions
|
||||||
|
img = Image.open(BytesIO(result_bytes))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"layer": {
|
||||||
|
"id": layer_num,
|
||||||
|
"name": f"No Background {layer_num}",
|
||||||
|
"path": str(layer_path),
|
||||||
|
"width": img.width,
|
||||||
|
"height": img.height,
|
||||||
|
"type": "background_removed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/smart-select")
|
||||||
|
async def smart_select(
|
||||||
|
project_id: int = Form(...),
|
||||||
|
point_x: int = Form(...),
|
||||||
|
point_y: int = Form(...),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Use SAM (Segment Anything) to select object at given point.
|
||||||
|
Returns mask for the selected object.
|
||||||
|
|
||||||
|
Note: Requires SAM model to be downloaded.
|
||||||
|
Falls back to simple flood-fill selection if SAM unavailable.
|
||||||
|
"""
|
||||||
|
project = db.query(Project).filter(Project.id == project_id).first()
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
|
||||||
|
from app.services.edit_service import EditService
|
||||||
|
edit_service = EditService()
|
||||||
|
image_path = edit_service.get_current_image_path(project_id)
|
||||||
|
|
||||||
|
img = Image.open(image_path).convert('RGB')
|
||||||
|
img_array = np.array(img)
|
||||||
|
|
||||||
|
# Try SAM first, fall back to flood fill
|
||||||
|
try:
|
||||||
|
mask = await _sam_select(img_array, point_x, point_y)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"SAM not available, using flood fill: {e}")
|
||||||
|
mask = _flood_fill_select(img_array, point_x, point_y)
|
||||||
|
|
||||||
|
# Convert mask to PNG
|
||||||
|
mask_img = Image.fromarray((mask * 255).astype(np.uint8), mode='L')
|
||||||
|
|
||||||
|
buffer = BytesIO()
|
||||||
|
mask_img.save(buffer, format='PNG')
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=buffer.getvalue(),
|
||||||
|
media_type="image/png"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _sam_select(img_array: np.ndarray, x: int, y: int) -> np.ndarray:
|
||||||
|
"""Use Segment Anything Model for selection"""
|
||||||
|
# This would require SAM to be installed and model downloaded
|
||||||
|
# For now, raise to fall back to flood fill
|
||||||
|
raise NotImplementedError("SAM integration pending")
|
||||||
|
|
||||||
|
|
||||||
|
def _flood_fill_select(img_array: np.ndarray, x: int, y: int, tolerance: int = 32) -> np.ndarray:
|
||||||
|
"""Simple flood-fill based selection with color tolerance"""
|
||||||
|
import cv2
|
||||||
|
|
||||||
|
h, w = img_array.shape[:2]
|
||||||
|
|
||||||
|
# Ensure point is within bounds
|
||||||
|
x = max(0, min(x, w - 1))
|
||||||
|
y = max(0, min(y, h - 1))
|
||||||
|
|
||||||
|
# Create mask for flood fill (needs to be 2 pixels larger)
|
||||||
|
mask = np.zeros((h + 2, w + 2), np.uint8)
|
||||||
|
|
||||||
|
# Flood fill
|
||||||
|
cv2.floodFill(
|
||||||
|
img_array.copy(),
|
||||||
|
mask,
|
||||||
|
(x, y),
|
||||||
|
(255, 255, 255),
|
||||||
|
(tolerance, tolerance, tolerance),
|
||||||
|
(tolerance, tolerance, tolerance),
|
||||||
|
cv2.FLOODFILL_MASK_ONLY
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract the actual mask (remove padding)
|
||||||
|
return mask[1:-1, 1:-1]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/color-select")
|
||||||
|
async def color_select(
|
||||||
|
project_id: int = Form(...),
|
||||||
|
color_r: int = Form(...),
|
||||||
|
color_g: int = Form(...),
|
||||||
|
color_b: int = Form(...),
|
||||||
|
tolerance: int = Form(30),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Select all pixels similar to the given color.
|
||||||
|
Returns a mask of selected areas.
|
||||||
|
"""
|
||||||
|
project = db.query(Project).filter(Project.id == project_id).first()
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
|
||||||
|
from app.services.edit_service import EditService
|
||||||
|
edit_service = EditService()
|
||||||
|
image_path = edit_service.get_current_image_path(project_id)
|
||||||
|
|
||||||
|
img = Image.open(image_path).convert('RGB')
|
||||||
|
img_array = np.array(img)
|
||||||
|
|
||||||
|
# Target color
|
||||||
|
target = np.array([color_r, color_g, color_b])
|
||||||
|
|
||||||
|
# Calculate color distance
|
||||||
|
diff = np.abs(img_array.astype(np.int16) - target.astype(np.int16))
|
||||||
|
distance = np.sum(diff, axis=2)
|
||||||
|
|
||||||
|
# Create mask where distance is within tolerance
|
||||||
|
mask = (distance <= tolerance * 3).astype(np.uint8) * 255
|
||||||
|
|
||||||
|
# Convert to PNG
|
||||||
|
mask_img = Image.fromarray(mask, mode='L')
|
||||||
|
|
||||||
|
buffer = BytesIO()
|
||||||
|
mask_img.save(buffer, format='PNG')
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=buffer.getvalue(),
|
||||||
|
media_type="image/png"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/extract-object")
|
||||||
|
async def extract_object(
|
||||||
|
project_id: int = Form(...),
|
||||||
|
mask: UploadFile = File(...),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Extract object using provided mask.
|
||||||
|
Returns PNG with transparent background containing only the masked area.
|
||||||
|
"""
|
||||||
|
project = db.query(Project).filter(Project.id == project_id).first()
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
|
||||||
|
from app.services.edit_service import EditService
|
||||||
|
edit_service = EditService()
|
||||||
|
image_path = edit_service.get_current_image_path(project_id)
|
||||||
|
|
||||||
|
# Load image and mask
|
||||||
|
img = Image.open(image_path).convert('RGBA')
|
||||||
|
mask_bytes = await mask.read()
|
||||||
|
mask_img = Image.open(BytesIO(mask_bytes)).convert('L')
|
||||||
|
|
||||||
|
# Resize mask if needed
|
||||||
|
if mask_img.size != img.size:
|
||||||
|
mask_img = mask_img.resize(img.size, Image.Resampling.LANCZOS)
|
||||||
|
|
||||||
|
# Apply mask as alpha channel
|
||||||
|
img_array = np.array(img)
|
||||||
|
mask_array = np.array(mask_img)
|
||||||
|
|
||||||
|
# Set alpha channel based on mask
|
||||||
|
img_array[:, :, 3] = mask_array
|
||||||
|
|
||||||
|
result = Image.fromarray(img_array, mode='RGBA')
|
||||||
|
|
||||||
|
buffer = BytesIO()
|
||||||
|
result.save(buffer, format='PNG')
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=buffer.getvalue(),
|
||||||
|
media_type="image/png"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/layers/{project_id}")
|
||||||
|
async def list_layers(
|
||||||
|
project_id: int,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""List all layers 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")
|
||||||
|
|
||||||
|
from app.services.edit_service import EditService
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
edit_service = EditService()
|
||||||
|
project_dir = edit_service.get_project_dir(project_id)
|
||||||
|
layers_dir = project_dir / 'layers'
|
||||||
|
|
||||||
|
if not layers_dir.exists():
|
||||||
|
return {"layers": []}
|
||||||
|
|
||||||
|
layers = []
|
||||||
|
for layer_file in sorted(layers_dir.glob('layer_*.png')):
|
||||||
|
img = Image.open(layer_file)
|
||||||
|
layer_num = int(layer_file.stem.split('_')[1])
|
||||||
|
layers.append({
|
||||||
|
"id": layer_num,
|
||||||
|
"name": f"Layer {layer_num}",
|
||||||
|
"path": str(layer_file),
|
||||||
|
"width": img.width,
|
||||||
|
"height": img.height
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"layers": layers}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/flatten-layers")
|
||||||
|
async def flatten_layers(
|
||||||
|
project_id: int = Form(...),
|
||||||
|
layer_order: str = Form(...), # JSON array of layer IDs in order
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Flatten all layers into a single image and save as current.
|
||||||
|
layer_order is a JSON array like [1, 2, 3] from bottom to top.
|
||||||
|
"""
|
||||||
|
project = db.query(Project).filter(Project.id == project_id).first()
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
|
||||||
|
from app.services.edit_service import EditService
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
edit_service = EditService()
|
||||||
|
project_dir = edit_service.get_project_dir(project_id)
|
||||||
|
layers_dir = project_dir / 'layers'
|
||||||
|
|
||||||
|
order = json.loads(layer_order)
|
||||||
|
|
||||||
|
# Start with original image as base
|
||||||
|
base_path = edit_service.get_current_image_path(project_id)
|
||||||
|
result = Image.open(base_path).convert('RGBA')
|
||||||
|
|
||||||
|
# Composite layers in order
|
||||||
|
for layer_id in order:
|
||||||
|
layer_path = layers_dir / f'layer_{layer_id}.png'
|
||||||
|
if layer_path.exists():
|
||||||
|
layer = Image.open(layer_path).convert('RGBA')
|
||||||
|
# Resize if needed
|
||||||
|
if layer.size != result.size:
|
||||||
|
layer = layer.resize(result.size, Image.Resampling.LANCZOS)
|
||||||
|
result = Image.alpha_composite(result, layer)
|
||||||
|
|
||||||
|
# Save as current
|
||||||
|
result.save(base_path, 'PNG')
|
||||||
|
|
||||||
|
return StatusResponse(
|
||||||
|
status="success",
|
||||||
|
message="Layers flattened successfully"
|
||||||
|
)
|
||||||
@@ -188,9 +188,11 @@ class EditService:
|
|||||||
if not result_path.exists():
|
if not result_path.exists():
|
||||||
raise FileNotFoundError(f"Edit {edit_id} result not found")
|
raise FileNotFoundError(f"Edit {edit_id} result not found")
|
||||||
|
|
||||||
# Copy result to current
|
# Copy result to current (preserve alpha channel)
|
||||||
current_path = self.get_current_image_path(project_id)
|
current_path = self.get_current_image_path(project_id)
|
||||||
Image.open(result_path).save(current_path)
|
img = Image.open(result_path)
|
||||||
|
# Preserve original mode to maintain transparency
|
||||||
|
img.save(current_path, format='PNG')
|
||||||
|
|
||||||
return str(current_path)
|
return str(current_path)
|
||||||
|
|
||||||
@@ -210,7 +212,9 @@ class EditService:
|
|||||||
if not original_path.exists():
|
if not original_path.exists():
|
||||||
raise FileNotFoundError(f"Original image for project {project_id} not found")
|
raise FileNotFoundError(f"Original image for project {project_id} not found")
|
||||||
|
|
||||||
# Copy original to current
|
# Copy original to current (preserve alpha channel)
|
||||||
Image.open(original_path).save(current_path)
|
img = Image.open(original_path)
|
||||||
|
# Preserve original mode to maintain transparency
|
||||||
|
img.save(current_path, format='PNG')
|
||||||
|
|
||||||
return str(current_path)
|
return str(current_path)
|
||||||
|
|||||||
@@ -55,19 +55,22 @@ def blend_patch(
|
|||||||
original_patch: Image.Image,
|
original_patch: Image.Image,
|
||||||
regenerated_patch: Image.Image,
|
regenerated_patch: Image.Image,
|
||||||
mask: Image.Image,
|
mask: Image.Image,
|
||||||
feather_px: int = 0
|
feather_px: int = 0,
|
||||||
|
preserve_alpha: bool = True
|
||||||
) -> Image.Image:
|
) -> Image.Image:
|
||||||
"""
|
"""
|
||||||
Blend regenerated patch with original using mask
|
Blend regenerated patch with original using mask.
|
||||||
|
Preserves original alpha channel for semi-transparent areas (veils, glass, etc).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
original_patch: Original cropped patch
|
original_patch: Original cropped patch
|
||||||
regenerated_patch: AI-regenerated patch
|
regenerated_patch: AI-regenerated patch
|
||||||
mask: Binary mask (same size as patches)
|
mask: Binary mask (same size as patches)
|
||||||
feather_px: Feather radius for smooth blending
|
feather_px: Feather radius for smooth blending
|
||||||
|
preserve_alpha: If True, preserves original alpha channel
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Blended patch
|
Blended patch with preserved transparency
|
||||||
"""
|
"""
|
||||||
# Ensure all images are the same size
|
# Ensure all images are the same size
|
||||||
if regenerated_patch.size != original_patch.size:
|
if regenerated_patch.size != original_patch.size:
|
||||||
@@ -83,12 +86,20 @@ def blend_patch(
|
|||||||
# Apply feathering to mask
|
# Apply feathering to mask
|
||||||
feathered_mask = create_feathered_mask(mask, feather_px)
|
feathered_mask = create_feathered_mask(mask, feather_px)
|
||||||
|
|
||||||
# Convert images to RGBA
|
# Convert images to RGBA, storing original alpha
|
||||||
original_patch = original_patch.convert('RGBA')
|
original_rgba = original_patch.convert('RGBA')
|
||||||
regenerated_patch = regenerated_patch.convert('RGBA')
|
original_alpha = original_rgba.split()[3] # Store original alpha channel
|
||||||
|
|
||||||
|
regenerated_rgba = regenerated_patch.convert('RGBA')
|
||||||
|
|
||||||
# Blend using the feathered mask
|
# Blend using the feathered mask
|
||||||
blended = Image.composite(regenerated_patch, original_patch, feathered_mask)
|
blended = Image.composite(regenerated_rgba, original_rgba, feathered_mask)
|
||||||
|
|
||||||
|
# Restore original alpha channel to preserve transparency
|
||||||
|
# This keeps semi-transparent areas (veils, glass, smoke) intact
|
||||||
|
if preserve_alpha:
|
||||||
|
r, g, b, _ = blended.split()
|
||||||
|
blended = Image.merge('RGBA', (r, g, b, original_alpha))
|
||||||
|
|
||||||
return blended
|
return blended
|
||||||
|
|
||||||
|
|||||||
@@ -14,3 +14,5 @@ pydantic-settings==2.1.0
|
|||||||
email-validator==2.1.0
|
email-validator==2.1.0
|
||||||
opencv-python-headless==4.9.0.80
|
opencv-python-headless==4.9.0.80
|
||||||
scikit-image==0.22.0
|
scikit-image==0.22.0
|
||||||
|
rembg==2.0.50
|
||||||
|
onnxruntime==1.16.3
|
||||||
|
|||||||
@@ -118,7 +118,10 @@
|
|||||||
.right-panel {
|
.right-panel {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 20px;
|
gap: 12px;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: calc(100vh - 180px);
|
||||||
|
padding-right: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-wrapper {
|
.history-wrapper {
|
||||||
|
|||||||
+55
-1
@@ -3,7 +3,9 @@ import ImageCanvas from './components/ImageCanvas';
|
|||||||
import Controls from './components/Controls';
|
import Controls from './components/Controls';
|
||||||
import History from './components/History';
|
import History from './components/History';
|
||||||
import EyeCatalog from './components/EyeCatalog';
|
import EyeCatalog from './components/EyeCatalog';
|
||||||
import { projectsApi, editsApi } from './utils/api';
|
import AdvancedTools from './components/AdvancedTools';
|
||||||
|
import Layers from './components/Layers';
|
||||||
|
import { projectsApi, editsApi, toolsApi } from './utils/api';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@@ -21,6 +23,9 @@ function App() {
|
|||||||
const [projectName, setProjectName] = useState('');
|
const [projectName, setProjectName] = useState('');
|
||||||
const [showProjectInput, setShowProjectInput] = useState(true);
|
const [showProjectInput, setShowProjectInput] = useState(true);
|
||||||
const [currentEditIndex, setCurrentEditIndex] = useState(-1);
|
const [currentEditIndex, setCurrentEditIndex] = useState(-1);
|
||||||
|
const [layers, setLayers] = useState([]);
|
||||||
|
const [activeLayer, setActiveLayer] = useState('background');
|
||||||
|
const [generatedMask, setGeneratedMask] = useState(null);
|
||||||
const editsRef = useRef([]);
|
const editsRef = useRef([]);
|
||||||
|
|
||||||
// Create project and upload image
|
// Create project and upload image
|
||||||
@@ -273,6 +278,32 @@ function App() {
|
|||||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
|
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||||
}, [project, edits]);
|
}, [project, edits]);
|
||||||
|
|
||||||
|
// Handle layer creation from advanced tools
|
||||||
|
const handleLayerCreated = (layer) => {
|
||||||
|
setLayers((prev) => [...prev, { ...layer, visible: true }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle mask generation from smart select / color select
|
||||||
|
const handleMaskGenerated = async (maskBlob, source) => {
|
||||||
|
setGeneratedMask({ blob: maskBlob, source });
|
||||||
|
// The mask can be used for various operations
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle flatten layers
|
||||||
|
const handleFlattenLayers = async (layerOrder) => {
|
||||||
|
if (!project) return;
|
||||||
|
try {
|
||||||
|
setIsProcessing(true);
|
||||||
|
await toolsApi.flattenLayers(project.id, layerOrder);
|
||||||
|
setCurrentImageUrl(projectsApi.getCurrentImageUrl(project.id));
|
||||||
|
setLayers([]);
|
||||||
|
} catch (err) {
|
||||||
|
setError(`Failed to flatten layers: ${err.message}`);
|
||||||
|
} finally {
|
||||||
|
setIsProcessing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
<div className="container">
|
<div className="container">
|
||||||
@@ -349,6 +380,29 @@ function App() {
|
|||||||
hasSelection={!!selection}
|
hasSelection={!!selection}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<AdvancedTools
|
||||||
|
projectId={project?.id}
|
||||||
|
selection={selection}
|
||||||
|
onLayerCreated={handleLayerCreated}
|
||||||
|
onMaskGenerated={handleMaskGenerated}
|
||||||
|
onImageUpdate={() => {
|
||||||
|
setCurrentImageUrl(projectsApi.getCurrentImageUrl(project.id));
|
||||||
|
}}
|
||||||
|
isProcessing={isProcessing}
|
||||||
|
setIsProcessing={setIsProcessing}
|
||||||
|
setError={setError}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Layers
|
||||||
|
projectId={project?.id}
|
||||||
|
layers={layers}
|
||||||
|
setLayers={setLayers}
|
||||||
|
activeLayer={activeLayer}
|
||||||
|
setActiveLayer={setActiveLayer}
|
||||||
|
onFlatten={handleFlattenLayers}
|
||||||
|
isProcessing={isProcessing}
|
||||||
|
/>
|
||||||
|
|
||||||
<EyeCatalog
|
<EyeCatalog
|
||||||
projectId={project?.id}
|
projectId={project?.id}
|
||||||
selection={selection}
|
selection={selection}
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
.advanced-tools {
|
||||||
|
background-color: #2a2a2a;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #444;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.advanced-tools h3 {
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-group {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-group:last-of-type {
|
||||||
|
border-bottom: none;
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-group h4 {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-btn {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 100px;
|
||||||
|
background-color: #3a3a3a;
|
||||||
|
color: #ffffff;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
border: 1px solid #555;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-btn:hover:not(:disabled) {
|
||||||
|
background-color: #4a4a4a;
|
||||||
|
border-color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-btn.active {
|
||||||
|
background-color: #0066ff;
|
||||||
|
border-color: #0066ff;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-tolerance {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
background-color: #333;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-tolerance label {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-tolerance input[type="range"] {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #00ff00;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
background-color: rgba(0, 255, 0, 0.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-mode-btn {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 12px;
|
||||||
|
background-color: #ff4444;
|
||||||
|
color: white;
|
||||||
|
padding: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-mode-btn:hover {
|
||||||
|
background-color: #cc0000;
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { toolsApi } from '../utils/api';
|
||||||
|
import './AdvancedTools.css';
|
||||||
|
|
||||||
|
const AdvancedTools = ({
|
||||||
|
projectId,
|
||||||
|
selection,
|
||||||
|
onLayerCreated,
|
||||||
|
onMaskGenerated,
|
||||||
|
onImageUpdate,
|
||||||
|
isProcessing,
|
||||||
|
setIsProcessing,
|
||||||
|
setError,
|
||||||
|
}) => {
|
||||||
|
const [activeToolMode, setActiveToolMode] = useState(null);
|
||||||
|
const [colorTolerance, setColorTolerance] = useState(30);
|
||||||
|
|
||||||
|
const handleRemoveBackground = async () => {
|
||||||
|
if (!projectId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsProcessing(true);
|
||||||
|
setError(null);
|
||||||
|
const result = await toolsApi.removeBackgroundToLayer(projectId);
|
||||||
|
onLayerCreated(result.layer);
|
||||||
|
} catch (err) {
|
||||||
|
setError(`Background removal failed: ${err.message}`);
|
||||||
|
} finally {
|
||||||
|
setIsProcessing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSmartSelect = () => {
|
||||||
|
setActiveToolMode(activeToolMode === 'smart-select' ? null : 'smart-select');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleColorSelect = () => {
|
||||||
|
setActiveToolMode(activeToolMode === 'color-select' ? null : 'color-select');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleObjectRemove = () => {
|
||||||
|
setActiveToolMode(activeToolMode === 'object-remove' ? null : 'object-remove');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Called when user clicks on canvas in smart-select mode
|
||||||
|
const onCanvasClick = async (x, y) => {
|
||||||
|
if (!projectId || !activeToolMode) return;
|
||||||
|
|
||||||
|
if (activeToolMode === 'smart-select') {
|
||||||
|
try {
|
||||||
|
setIsProcessing(true);
|
||||||
|
const maskBlob = await toolsApi.smartSelect(projectId, x, y);
|
||||||
|
onMaskGenerated(maskBlob, 'smart-select');
|
||||||
|
} catch (err) {
|
||||||
|
setError(`Smart select failed: ${err.message}`);
|
||||||
|
} finally {
|
||||||
|
setIsProcessing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Called when user picks a color for color selection
|
||||||
|
const onColorPicked = async (r, g, b) => {
|
||||||
|
if (!projectId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsProcessing(true);
|
||||||
|
const maskBlob = await toolsApi.colorSelect(projectId, r, g, b, colorTolerance);
|
||||||
|
onMaskGenerated(maskBlob, 'color-select');
|
||||||
|
} catch (err) {
|
||||||
|
setError(`Color select failed: ${err.message}`);
|
||||||
|
} finally {
|
||||||
|
setIsProcessing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExtractObject = async () => {
|
||||||
|
if (!projectId || !selection) {
|
||||||
|
setError('Make a selection first');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For this we need the mask from the current selection
|
||||||
|
// This would be generated from the selection shape
|
||||||
|
setError('Extract requires a mask - use Smart Select or Color Select first');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="advanced-tools">
|
||||||
|
<h3>Advanced Tools</h3>
|
||||||
|
|
||||||
|
<div className="tool-group">
|
||||||
|
<h4>Background</h4>
|
||||||
|
<button
|
||||||
|
className="tool-btn"
|
||||||
|
onClick={handleRemoveBackground}
|
||||||
|
disabled={isProcessing || !projectId}
|
||||||
|
title="Remove background and create new layer"
|
||||||
|
>
|
||||||
|
Remove Background
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="tool-group">
|
||||||
|
<h4>Selection Tools</h4>
|
||||||
|
<div className="tool-buttons">
|
||||||
|
<button
|
||||||
|
className={`tool-btn ${activeToolMode === 'smart-select' ? 'active' : ''}`}
|
||||||
|
onClick={handleSmartSelect}
|
||||||
|
disabled={isProcessing || !projectId}
|
||||||
|
title="Click on object to select it"
|
||||||
|
>
|
||||||
|
Smart Select
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`tool-btn ${activeToolMode === 'color-select' ? 'active' : ''}`}
|
||||||
|
onClick={handleColorSelect}
|
||||||
|
disabled={isProcessing || !projectId}
|
||||||
|
title="Select all similar colors"
|
||||||
|
>
|
||||||
|
Color Select
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeToolMode === 'color-select' && (
|
||||||
|
<div className="color-tolerance">
|
||||||
|
<label>Tolerance: {colorTolerance}</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="1"
|
||||||
|
max="100"
|
||||||
|
value={colorTolerance}
|
||||||
|
onChange={(e) => setColorTolerance(parseInt(e.target.value))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeToolMode && (
|
||||||
|
<p className="tool-hint">
|
||||||
|
{activeToolMode === 'smart-select'
|
||||||
|
? 'Click on an object to select it'
|
||||||
|
: 'Click on a color to select all similar pixels'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="tool-group">
|
||||||
|
<h4>Object Tools</h4>
|
||||||
|
<button
|
||||||
|
className={`tool-btn ${activeToolMode === 'object-remove' ? 'active' : ''}`}
|
||||||
|
onClick={handleObjectRemove}
|
||||||
|
disabled={isProcessing || !projectId}
|
||||||
|
title="Remove selected object (uses AI)"
|
||||||
|
>
|
||||||
|
Object Remove
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="tool-btn"
|
||||||
|
onClick={handleExtractObject}
|
||||||
|
disabled={isProcessing || !projectId || !selection}
|
||||||
|
title="Extract selected area to new layer"
|
||||||
|
>
|
||||||
|
Extract to Layer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeToolMode && (
|
||||||
|
<button
|
||||||
|
className="cancel-mode-btn"
|
||||||
|
onClick={() => setActiveToolMode(null)}
|
||||||
|
>
|
||||||
|
Cancel Tool
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Export the click handler for parent component to use
|
||||||
|
AdvancedTools.handleCanvasClick = null;
|
||||||
|
|
||||||
|
export default AdvancedTools;
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
.layers-panel {
|
||||||
|
background-color: #2a2a2a;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #444;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flatten-btn {
|
||||||
|
background-color: #555;
|
||||||
|
color: white;
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flatten-btn:hover:not(:disabled) {
|
||||||
|
background-color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-list {
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
background-color: #333;
|
||||||
|
border-bottom: 1px solid #444;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-item:hover {
|
||||||
|
background-color: #3a3a3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-item.active {
|
||||||
|
background-color: #0066ff33;
|
||||||
|
border-left: 3px solid #0066ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-item.dragging {
|
||||||
|
opacity: 0.5;
|
||||||
|
background-color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-visibility {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-visibility input[type="checkbox"] {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-preview {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
background-color: #222;
|
||||||
|
border: 1px solid #555;
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-preview img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-preview.background-preview {
|
||||||
|
background: repeating-conic-gradient(#444 0% 25%, #333 0% 50%) 50% / 8px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-name {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-lock {
|
||||||
|
font-size: 10px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-drag-handle {
|
||||||
|
cursor: grab;
|
||||||
|
opacity: 0.5;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-drag-handle:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-layers-hint {
|
||||||
|
padding: 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: #666;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-action-btn {
|
||||||
|
flex: 1;
|
||||||
|
background-color: #3a3a3a;
|
||||||
|
color: #ccc;
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-size: 10px;
|
||||||
|
border: 1px solid #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-action-btn:hover:not(:disabled) {
|
||||||
|
background-color: #4a4a4a;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-action-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { toolsApi } from '../utils/api';
|
||||||
|
import './Layers.css';
|
||||||
|
|
||||||
|
const Layers = ({
|
||||||
|
projectId,
|
||||||
|
layers,
|
||||||
|
setLayers,
|
||||||
|
activeLayer,
|
||||||
|
setActiveLayer,
|
||||||
|
onLayerVisibilityChange,
|
||||||
|
onFlatten,
|
||||||
|
isProcessing,
|
||||||
|
}) => {
|
||||||
|
const [draggedLayer, setDraggedLayer] = useState(null);
|
||||||
|
|
||||||
|
// Load layers on mount and when projectId changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (projectId) {
|
||||||
|
loadLayers();
|
||||||
|
}
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
|
const loadLayers = async () => {
|
||||||
|
if (!projectId) return;
|
||||||
|
try {
|
||||||
|
const result = await toolsApi.listLayers(projectId);
|
||||||
|
setLayers(result.layers || []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load layers:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragStart = (e, index) => {
|
||||||
|
setDraggedLayer(index);
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (e, index) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (draggedLayer === null || draggedLayer === index) return;
|
||||||
|
|
||||||
|
// Reorder layers
|
||||||
|
const newLayers = [...layers];
|
||||||
|
const [removed] = newLayers.splice(draggedLayer, 1);
|
||||||
|
newLayers.splice(index, 0, removed);
|
||||||
|
setLayers(newLayers);
|
||||||
|
setDraggedLayer(index);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = () => {
|
||||||
|
setDraggedLayer(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleVisibility = (layerId) => {
|
||||||
|
const layer = layers.find((l) => l.id === layerId);
|
||||||
|
if (layer) {
|
||||||
|
layer.visible = !layer.visible;
|
||||||
|
setLayers([...layers]);
|
||||||
|
onLayerVisibilityChange?.(layerId, layer.visible);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFlatten = async () => {
|
||||||
|
if (!projectId || layers.length === 0) return;
|
||||||
|
const visibleLayers = layers.filter((l) => l.visible !== false);
|
||||||
|
const layerOrder = visibleLayers.map((l) => l.id);
|
||||||
|
await onFlatten(layerOrder);
|
||||||
|
loadLayers();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="layers-panel">
|
||||||
|
<div className="layers-header">
|
||||||
|
<h3>Layers</h3>
|
||||||
|
{layers.length > 0 && (
|
||||||
|
<button
|
||||||
|
className="flatten-btn"
|
||||||
|
onClick={handleFlatten}
|
||||||
|
disabled={isProcessing}
|
||||||
|
title="Merge all visible layers"
|
||||||
|
>
|
||||||
|
Flatten
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="layers-list">
|
||||||
|
{/* Background layer (always present) */}
|
||||||
|
<div
|
||||||
|
className={`layer-item ${activeLayer === 'background' ? 'active' : ''}`}
|
||||||
|
onClick={() => setActiveLayer('background')}
|
||||||
|
>
|
||||||
|
<span className="layer-visibility">
|
||||||
|
<input type="checkbox" checked disabled />
|
||||||
|
</span>
|
||||||
|
<span className="layer-preview background-preview"></span>
|
||||||
|
<span className="layer-name">Background</span>
|
||||||
|
<span className="layer-lock">🔒</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dynamic layers */}
|
||||||
|
{layers.map((layer, index) => (
|
||||||
|
<div
|
||||||
|
key={layer.id}
|
||||||
|
className={`layer-item ${activeLayer === layer.id ? 'active' : ''} ${
|
||||||
|
draggedLayer === index ? 'dragging' : ''
|
||||||
|
}`}
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => handleDragStart(e, index)}
|
||||||
|
onDragOver={(e) => handleDragOver(e, index)}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
onClick={() => setActiveLayer(layer.id)}
|
||||||
|
>
|
||||||
|
<span className="layer-visibility">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={layer.visible !== false}
|
||||||
|
onChange={() => toggleVisibility(layer.id)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span className="layer-preview">
|
||||||
|
{layer.thumbnail && (
|
||||||
|
<img src={layer.thumbnail} alt={layer.name} />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="layer-name">{layer.name}</span>
|
||||||
|
<span className="layer-drag-handle">⋮⋮</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{layers.length === 0 && (
|
||||||
|
<div className="no-layers-hint">
|
||||||
|
Use "Remove Background" to create layers
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="layers-actions">
|
||||||
|
<button
|
||||||
|
className="layer-action-btn"
|
||||||
|
disabled={isProcessing || !projectId}
|
||||||
|
title="Add new empty layer"
|
||||||
|
>
|
||||||
|
+ New Layer
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="layer-action-btn"
|
||||||
|
disabled={isProcessing || activeLayer === 'background'}
|
||||||
|
title="Delete selected layer"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="layer-action-btn"
|
||||||
|
disabled={isProcessing || activeLayer === 'background'}
|
||||||
|
title="Duplicate selected layer"
|
||||||
|
>
|
||||||
|
Duplicate
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Layers;
|
||||||
@@ -146,4 +146,94 @@ export const patchesApi = {
|
|||||||
`${API_BASE_URL}/patches/${patchId}/image${thumbnail ? '?thumbnail=true' : ''}`,
|
`${API_BASE_URL}/patches/${patchId}/image${thumbnail ? '?thumbnail=true' : ''}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const toolsApi = {
|
||||||
|
// Remove background from project image
|
||||||
|
removeBackground: async (projectId) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('project_id', projectId);
|
||||||
|
|
||||||
|
const response = await api.post('/tools/remove-background', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
responseType: 'blob',
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Remove background and save as layer
|
||||||
|
removeBackgroundToLayer: async (projectId) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('project_id', projectId);
|
||||||
|
|
||||||
|
const response = await api.post('/tools/remove-background-to-layer', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Smart select object at point
|
||||||
|
smartSelect: async (projectId, x, y) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('project_id', projectId);
|
||||||
|
formData.append('point_x', x);
|
||||||
|
formData.append('point_y', y);
|
||||||
|
|
||||||
|
const response = await api.post('/tools/smart-select', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
responseType: 'blob',
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Select by color
|
||||||
|
colorSelect: async (projectId, r, g, b, tolerance = 30) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('project_id', projectId);
|
||||||
|
formData.append('color_r', r);
|
||||||
|
formData.append('color_g', g);
|
||||||
|
formData.append('color_b', b);
|
||||||
|
formData.append('tolerance', tolerance);
|
||||||
|
|
||||||
|
const response = await api.post('/tools/color-select', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
responseType: 'blob',
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Extract object with mask
|
||||||
|
extractObject: async (projectId, maskBlob) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('project_id', projectId);
|
||||||
|
formData.append('mask', maskBlob, 'mask.png');
|
||||||
|
|
||||||
|
const response = await api.post('/tools/extract-object', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
responseType: 'blob',
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// List layers
|
||||||
|
listLayers: async (projectId) => {
|
||||||
|
const response = await api.get(`/tools/layers/${projectId}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Flatten layers
|
||||||
|
flattenLayers: async (projectId, layerOrder) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('project_id', projectId);
|
||||||
|
formData.append('layer_order', JSON.stringify(layerOrder));
|
||||||
|
|
||||||
|
const response = await api.post('/tools/flatten-layers', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get layer image URL
|
||||||
|
getLayerImageUrl: (projectId, layerId) =>
|
||||||
|
`${API_BASE_URL}/projects/${projectId}/layers/layer_${layerId}.png`,
|
||||||
|
};
|
||||||
|
|
||||||
export default api;
|
export default api;
|
||||||
|
|||||||
Reference in New Issue
Block a user