diff --git a/.env.example b/.env.example
index eb1b3ab..aab2dce 100644
--- a/.env.example
+++ b/.env.example
@@ -26,6 +26,13 @@
AI_PROVIDER=replicate
+# Per-operation provider overrides (optional — blank means use AI_PROVIDER above)
+# Example: use OpenAI for text-to-image (best quality) but InvokeAI for everything else
+#AI_PROVIDER_TXT2IMG=openai
+#AI_PROVIDER_INPAINT=invokeai
+#AI_PROVIDER_IMG2IMG=invokeai
+#AI_PROVIDER_OUTPAINT=invokeai
+
# =============================================================================
# STEP 2: Get Your API Key
@@ -50,10 +57,27 @@ AI_PROVIDER=replicate
REPLICATE_API_KEY=r8_PASTE_YOUR_KEY_HERE
# ───────────────────────────────────────────────────────────────────────────
-# OPENAI (Alternative - not recommended, lower quality)
+# OPENAI (cloud, dall-e-3 / gpt-image-1)
# Get key at: https://platform.openai.com/api-keys
+# AI_PROVIDER=openai
# ───────────────────────────────────────────────────────────────────────────
#OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
+#OPENAI_MODEL=dall-e-3
+
+# ───────────────────────────────────────────────────────────────────────────
+# INVOKEAI (self-hosted, best for Flux/SDXL)
+# Run InvokeAI on your local machine or NAS, point URL here.
+# AI_PROVIDER=invokeai
+# ───────────────────────────────────────────────────────────────────────────
+#INVOKEAI_URL=http://192.168.1.x:9090
+#INVOKEAI_DEFAULT_MODEL=flux-dev
+
+# ───────────────────────────────────────────────────────────────────────────
+# COMFYUI (self-hosted, workflow JSON API)
+# AI_PROVIDER=comfyui
+# ───────────────────────────────────────────────────────────────────────────
+#COMFYUI_URL=http://192.168.1.x:8188
+#COMFYUI_DEFAULT_MODEL=v1-5-pruned-emaonly.ckpt
# ───────────────────────────────────────────────────────────────────────────
# STABILITY AI (Alternative)
diff --git a/backend/app/config.py b/backend/app/config.py
index c958edc..0b80ad9 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -12,13 +12,33 @@ class Settings(BaseSettings):
access_token_expire_minutes: int = 30
# AI Provider
- ai_provider: str = "mock" # Options: openai, stability, replicate, mock
+ # Local: blank or "mock" — always available, no config needed
+ # Remote default (used for any operation without a specific override):
+ # openai | invokeai | comfyui | replicate | stability
+ ai_provider: str = "mock"
+
+ # Per-operation provider overrides — blank means use ai_provider default.
+ # Operations: inpaint, txt2img, img2img, outpaint
+ # Example: AI_PROVIDER_TXT2IMG=openai (use OpenAI for text-to-image only)
+ ai_provider_inpaint: str = "" # remote inpaint / replace selection
+ ai_provider_txt2img: str = "" # text-to-image
+ ai_provider_img2img: str = "" # image-to-image
+ ai_provider_outpaint: str = "" # expand canvas
# Provider API Keys
openai_api_key: str = ""
+ openai_model: str = "dall-e-3"
stability_api_key: str = ""
replicate_api_key: str = ""
+ # InvokeAI (self-hosted)
+ invokeai_url: str = ""
+ invokeai_default_model: str = "flux-dev"
+
+ # ComfyUI (self-hosted)
+ comfyui_url: str = ""
+ comfyui_default_model: str = "v1-5-pruned-emaonly.ckpt"
+
# Model Selection (optional, provider-specific)
stability_model: str = "sdxl" # Options: sdxl, sd15, sd21
replicate_model: str = "sdxl-inpaint" # Options: sdxl-inpaint, lama, realistic-vision
diff --git a/backend/app/main.py b/backend/app/main.py
index bfbb7f2..70d9b5a 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -4,17 +4,23 @@ from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, HTMLResponse
from contextlib import asynccontextmanager
from pathlib import Path
+import asyncio
import os
from app.config import settings
from app.database import init_db
-from app.routers import projects, edits, images, patches, generate, tools
+from app.routers import projects, edits, images, patches, generate, tools, ai_tools, print_tools
@asynccontextmanager
async def lifespan(app: FastAPI):
- """Initialize database on startup"""
+ """Initialize database on startup; auto-install Real-ESRGAN NCNN in background."""
init_db()
+ # Kick off NCNN install in background if no AI upscaler detected
+ from app.services.upscale import probe_upscale_capabilities, ensure_ncnn_installed
+ caps = probe_upscale_capabilities()
+ if not caps["realesrgan_pytorch"] and not caps["realesrgan_ncnn"]:
+ asyncio.create_task(ensure_ncnn_installed())
yield
@@ -41,6 +47,8 @@ app.include_router(images.router)
app.include_router(patches.router)
app.include_router(generate.router)
app.include_router(tools.router)
+app.include_router(ai_tools.router)
+app.include_router(print_tools.router)
@app.get("/api")
diff --git a/backend/app/routers/ai_tools.py b/backend/app/routers/ai_tools.py
new file mode 100644
index 0000000..243c5ba
--- /dev/null
+++ b/backend/app/routers/ai_tools.py
@@ -0,0 +1,350 @@
+"""
+AI tools router — LaMa inpaint, background removal, remote generation, config.
+All endpoints are under /api prefix.
+"""
+
+from fastapi import APIRouter, HTTPException
+from pydantic import BaseModel
+from typing import Optional
+import base64
+import asyncio
+
+from app.services.local_inpaint import (
+ lama_inpaint, opencv_inpaint, lama_available, gpu_available, rembg_available,
+)
+
+router = APIRouter(prefix="/api", tags=["ai-tools"])
+
+
+# ─── Request / response models ───────────────────────────────────────────────
+
+class EraseRequest(BaseModel):
+ image: str # base64
+ mask: str # base64
+
+
+class InpaintRemoteRequest(BaseModel):
+ image: str
+ mask: str
+ prompt: str
+ negative_prompt: Optional[str] = ""
+ steps: Optional[int] = 30
+ cfg_scale: Optional[float] = 7.5
+ model: Optional[str] = None
+
+
+class Txt2ImgRequest(BaseModel):
+ prompt: str
+ width: Optional[int] = 1024
+ height: Optional[int] = 1024
+ negative_prompt: Optional[str] = ""
+ steps: Optional[int] = 30
+ cfg_scale: Optional[float] = 7.5
+ model: Optional[str] = None
+ seed: Optional[int] = 0
+
+
+class Img2ImgRequest(BaseModel):
+ image: str
+ prompt: str
+ strength: Optional[float] = 0.75
+ negative_prompt: Optional[str] = ""
+ steps: Optional[int] = 30
+ cfg_scale: Optional[float] = 7.5
+ model: Optional[str] = None
+
+
+class OutpaintRequest(BaseModel):
+ image: str
+ direction: str # left | right | top | bottom
+ size: Optional[int] = 256
+ prompt: Optional[str] = ""
+
+
+class BgRemoveRequest(BaseModel):
+ image: str
+
+
+# ─── Helpers ─────────────────────────────────────────────────────────────────
+
+def _decode(b64: str) -> bytes:
+ return base64.b64decode(b64)
+
+
+def _encode(data: bytes) -> str:
+ return base64.b64encode(data).decode()
+
+
+def _require_remote(operation: str = None):
+ from app.services.remote_provider import get_remote_provider
+ provider = get_remote_provider(operation)
+ if provider is None:
+ op_hint = f"AI_PROVIDER_{operation.upper()} or " if operation else ""
+ raise HTTPException(
+ status_code=503,
+ detail=f"No remote AI provider configured for '{operation or 'default'}'. "
+ f"Set {op_hint}AI_PROVIDER in .env (openai / invokeai / comfyui)."
+ )
+ return provider
+
+
+# ─── Local inpaint endpoints ─────────────────────────────────────────────────
+
+@router.post("/erase")
+async def erase(req: EraseRequest):
+ """
+ Magic eraser: remove object / fill region using LaMa (local, no API key needed).
+ Falls back to OpenCV if LaMa not installed.
+ """
+ try:
+ image_bytes = _decode(req.image)
+ mask_bytes = _decode(req.mask)
+
+ if lama_available():
+ result = await asyncio.get_event_loop().run_in_executor(
+ None, lama_inpaint, image_bytes, mask_bytes
+ )
+ method = "lama"
+ else:
+ result = await asyncio.get_event_loop().run_in_executor(
+ None, opencv_inpaint, image_bytes, mask_bytes
+ )
+ method = "opencv"
+
+ return {"result": _encode(result), "method": method}
+ except Exception as e:
+ import traceback; traceback.print_exc()
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.post("/inpaint/lama")
+async def inpaint_lama(req: EraseRequest):
+ """LaMa structural inpainting."""
+ if not lama_available():
+ raise HTTPException(status_code=503, detail="simple-lama-inpainting not installed.")
+ try:
+ result = await asyncio.get_event_loop().run_in_executor(
+ None, lama_inpaint, _decode(req.image), _decode(req.mask)
+ )
+ return {"result": _encode(result)}
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.post("/inpaint/fast")
+async def inpaint_fast(req: EraseRequest):
+ """OpenCV fast inpainting (CPU, milliseconds)."""
+ try:
+ result = await asyncio.get_event_loop().run_in_executor(
+ None, opencv_inpaint, _decode(req.image), _decode(req.mask)
+ )
+ return {"result": _encode(result)}
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.post("/background/remove")
+async def background_remove(req: BgRemoveRequest):
+ """Remove background — rembg if available, else U2Net."""
+ try:
+ image_bytes = _decode(req.image)
+
+ # Try rembg first
+ if rembg_available():
+ from app.services.local_inpaint import remove_background_rembg
+ result = await asyncio.get_event_loop().run_in_executor(
+ None, remove_background_rembg, image_bytes
+ )
+ return {"result": _encode(result), "method": "rembg"}
+
+ # Fall back to U2Net (existing implementation)
+ from PIL import Image
+ from io import BytesIO as _BytesIO
+ img = Image.open(_BytesIO(image_bytes)).convert("RGB")
+ from app.routers.tools import _remove_background_u2net
+ result = await _remove_background_u2net(img)
+ return {"result": _encode(result), "method": "u2net"}
+
+ except Exception as e:
+ import traceback; traceback.print_exc()
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+# ─── Remote provider endpoints ───────────────────────────────────────────────
+
+@router.post("/inpaint/remote")
+async def inpaint_remote(req: InpaintRemoteRequest):
+ """Inpaint via configured remote provider (InvokeAI / ComfyUI / OpenAI)."""
+ provider = _require_remote("inpaint")
+ try:
+ params = {
+ "negative_prompt": req.negative_prompt or "",
+ "steps": req.steps,
+ "cfg_scale": req.cfg_scale,
+ }
+ if req.model:
+ params["model"] = req.model
+ result = await provider.inpaint(_decode(req.image), _decode(req.mask), req.prompt, params)
+ return {"result": _encode(result)}
+ except Exception as e:
+ import traceback; traceback.print_exc()
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.post("/generate/txt2img")
+async def txt2img(req: Txt2ImgRequest):
+ """Text-to-image via configured remote provider."""
+ provider = _require_remote("txt2img")
+ try:
+ params = {
+ "negative_prompt": req.negative_prompt or "",
+ "steps": req.steps,
+ "cfg_scale": req.cfg_scale,
+ "seed": req.seed or 0,
+ }
+ if req.model:
+ params["model"] = req.model
+ result = await provider.txt2img(req.prompt, req.width, req.height, params)
+ return {"result": _encode(result)}
+ except Exception as e:
+ import traceback; traceback.print_exc()
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.post("/generate/img2img")
+async def img2img(req: Img2ImgRequest):
+ """Image-to-image via configured remote provider."""
+ provider = _require_remote("img2img")
+ try:
+ params = {
+ "negative_prompt": req.negative_prompt or "",
+ "steps": req.steps,
+ "cfg_scale": req.cfg_scale,
+ }
+ if req.model:
+ params["model"] = req.model
+ result = await provider.img2img(_decode(req.image), req.prompt, req.strength, params)
+ return {"result": _encode(result)}
+ except Exception as e:
+ import traceback; traceback.print_exc()
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.post("/generate/outpaint")
+async def outpaint(req: OutpaintRequest):
+ """Expand canvas in given direction via remote provider."""
+ provider = _require_remote("outpaint")
+ if req.direction not in ("left", "right", "top", "bottom"):
+ raise HTTPException(status_code=400, detail="direction must be left/right/top/bottom")
+ try:
+ result = await provider.outpaint(_decode(req.image), req.direction, req.size, req.prompt or "")
+ return {"result": _encode(result)}
+ except Exception as e:
+ import traceback; traceback.print_exc()
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+# ─── Config / capabilities ────────────────────────────────────────────────────
+
+class ConfigUpdateRequest(BaseModel):
+ ai_provider: Optional[str] = None
+ # Per-operation overrides (blank = use default)
+ ai_provider_inpaint: Optional[str] = None
+ ai_provider_txt2img: Optional[str] = None
+ ai_provider_img2img: Optional[str] = None
+ ai_provider_outpaint: Optional[str] = None
+ # Credentials / URLs
+ openai_api_key: Optional[str] = None
+ openai_model: Optional[str] = None
+ invokeai_url: Optional[str] = None
+ invokeai_default_model: Optional[str] = None
+ comfyui_url: Optional[str] = None
+ comfyui_default_model: Optional[str] = None
+ replicate_api_key: Optional[str] = None
+ stability_api_key: Optional[str] = None
+
+
+@router.post("/config")
+async def update_config(req: ConfigUpdateRequest):
+ """
+ Apply runtime provider settings (no restart needed).
+ Values are applied to the live settings object in-process.
+ They do NOT persist across restarts — set them in .env for permanence.
+ """
+ from app.config import settings
+
+ _str_fields = [
+ "ai_provider", "ai_provider_inpaint", "ai_provider_txt2img",
+ "ai_provider_img2img", "ai_provider_outpaint",
+ "openai_api_key", "openai_model",
+ "invokeai_url", "invokeai_default_model",
+ "comfyui_url", "comfyui_default_model",
+ "replicate_api_key", "stability_api_key",
+ ]
+ for field in _str_fields:
+ val = getattr(req, field, None)
+ if val is not None:
+ setattr(settings, field, val)
+
+ return {
+ "status": "ok",
+ "ai_provider": settings.ai_provider,
+ "overrides": {
+ "inpaint": settings.ai_provider_inpaint or None,
+ "txt2img": settings.ai_provider_txt2img or None,
+ "img2img": settings.ai_provider_img2img or None,
+ "outpaint": settings.ai_provider_outpaint or None,
+ }
+ }
+
+
+async def _check_provider(operation: str) -> dict:
+ """Health-check the provider for a specific operation."""
+ from app.services.remote_provider import get_remote_provider
+ try:
+ p = get_remote_provider(operation)
+ if p is None:
+ return {"provider": None, "healthy": False}
+ healthy = await asyncio.wait_for(p.health(), timeout=5.0)
+ return {"provider": p.__class__.__name__.replace("Provider", "").lower(), "healthy": healthy}
+ except Exception:
+ return {"provider": None, "healthy": False}
+
+
+@router.get("/config")
+async def get_config():
+ """
+ Return capability flags so the frontend can show/hide tools.
+ Includes per-operation provider assignments and health status.
+ """
+ from app.config import settings
+
+ # Run health checks for each operation concurrently
+ ops = ["inpaint", "txt2img", "img2img", "outpaint"]
+ results = await asyncio.gather(*[_check_provider(op) for op in ops])
+ op_status = dict(zip(ops, results))
+
+ # Default provider for display (used when no per-op override)
+ default_name = (settings.ai_provider or "").lower() or None
+
+ return {
+ "local": {
+ "lama": lama_available(),
+ "rembg": rembg_available(),
+ "opencv": True,
+ "gpu_detected": gpu_available(),
+ },
+ "remote": {
+ "default_provider": default_name,
+ # Legacy field kept for backwards compat with badge/capabilities checks
+ "provider": default_name,
+ "healthy": any(v["healthy"] for v in op_status.values()),
+ "operations": op_status,
+ "overrides": {
+ "inpaint": settings.ai_provider_inpaint or None,
+ "txt2img": settings.ai_provider_txt2img or None,
+ "img2img": settings.ai_provider_img2img or None,
+ "outpaint": settings.ai_provider_outpaint or None,
+ },
+ }
+ }
diff --git a/backend/app/routers/print_tools.py b/backend/app/routers/print_tools.py
new file mode 100644
index 0000000..52fe476
--- /dev/null
+++ b/backend/app/routers/print_tools.py
@@ -0,0 +1,377 @@
+"""
+Print / frame tools — frame fit and upscale.
+All endpoints under /api/print prefix.
+"""
+
+from fastapi import APIRouter, HTTPException
+from pydantic import BaseModel
+from typing import Optional, Literal
+import base64
+import asyncio
+from io import BytesIO
+from PIL import Image
+import numpy as np
+
+router = APIRouter(prefix="/api/print", tags=["print-tools"])
+
+# ── Frame size catalogue (inches) ──────────────────────────────────────────
+FRAME_SIZES = {
+ "4x6": (4, 6),
+ "5x7": (5, 7),
+ "8x10": (8, 10),
+ "11x14": (11, 14),
+ "16x20": (16, 20),
+ "20x24": (20, 24),
+ "24x36": (24, 36),
+ # Square
+ "4x4": (4, 4),
+ "8x8": (8, 8),
+ "12x12": (12, 12),
+}
+
+
+def _encode(data: bytes) -> str:
+ return base64.b64encode(data).decode()
+
+
+def _decode(b64: str) -> bytes:
+ return base64.b64decode(b64)
+
+
+def _to_png(img: Image.Image) -> bytes:
+ buf = BytesIO()
+ img.save(buf, format="PNG")
+ return buf.getvalue()
+
+
+# ── Request models ─────────────────────────────────────────────────────────
+
+class FrameFitRequest(BaseModel):
+ image: str # base64 PNG/JPEG
+ frame: str # e.g. "8x10"
+ orientation: Literal["auto", "portrait", "landscape"] = "auto"
+ mode: Literal["crop", "extend", "smart"] = "smart"
+ dpi: int = 300
+ # For extend mode: prompt passed to outpaint
+ prompt: Optional[str] = ""
+ # Smart mode threshold: extend if gap fraction < this, else crop
+ smart_threshold: float = 0.15
+
+
+class UpscaleRequest(BaseModel):
+ image: str # base64
+ scale: float = 2.0 # 1.5, 2, 3, 4
+ # auto = pick best available; lanczos = always works; realesrgan_pytorch / realesrgan_ncnn = explicit
+ method: str = "auto"
+
+
+# ── Frame sizes endpoint ───────────────────────────────────────────────────
+
+@router.get("/frame-sizes")
+def list_frame_sizes():
+ """Return the catalogue of supported frame sizes."""
+ return {
+ "sizes": list(FRAME_SIZES.keys()),
+ "catalogue": {k: {"inches": v, "pixels_300dpi": (v[0]*300, v[1]*300)}
+ for k, v in FRAME_SIZES.items()},
+ }
+
+
+# ── Frame fit ──────────────────────────────────────────────────────────────
+
+@router.post("/frame-fit")
+async def frame_fit(req: FrameFitRequest):
+ """
+ Fit an image to a print frame size.
+
+ Modes:
+ crop — center-crop to frame aspect ratio, then scale to print resolution.
+ extend — scale to fill one dimension, outpaint the gap with AI.
+ smart — extend if gap < smart_threshold of frame dimension, else crop.
+
+ Returns the fitted image plus a summary of what was done.
+ """
+ if req.frame not in FRAME_SIZES:
+ raise HTTPException(status_code=400,
+ detail=f"Unknown frame '{req.frame}'. Valid: {list(FRAME_SIZES.keys())}")
+ if not (72 <= req.dpi <= 600):
+ raise HTTPException(status_code=400, detail="dpi must be 72–600")
+
+ try:
+ image = Image.open(BytesIO(_decode(req.image))).convert("RGB")
+ except Exception as e:
+ raise HTTPException(status_code=400, detail=f"Could not decode image: {e}")
+
+ fw, fh = FRAME_SIZES[req.frame] # frame inches (w, h in portrait)
+
+ # Resolve orientation
+ img_w, img_h = image.size
+ img_landscape = img_w >= img_h
+ frame_landscape = fw >= fh
+
+ if req.orientation == "landscape":
+ fw, fh = max(fw, fh), min(fw, fh)
+ elif req.orientation == "portrait":
+ fw, fh = min(fw, fh), max(fw, fh)
+ else: # auto — match image orientation
+ if img_landscape and not frame_landscape:
+ fw, fh = fh, fw # rotate frame to landscape
+ elif not img_landscape and frame_landscape:
+ fw, fh = fh, fw # rotate frame to portrait
+
+ target_w = fw * req.dpi
+ target_h = fh * req.dpi
+ target_ratio = target_w / target_h
+ img_ratio = img_w / img_h
+
+ # Determine actual mode
+ mode = req.mode
+ if mode == "smart":
+ # Scale image to fill the frame — compute gap fraction
+ if img_ratio > target_ratio:
+ # Image wider → fits on height, gap on width
+ scaled_h = target_h
+ scaled_w = round(target_h * img_ratio)
+ gap_frac = (scaled_w - target_w) / target_w # positive = overflow (crop)
+ else:
+ scaled_w = target_w
+ scaled_h = round(target_w / img_ratio)
+ gap_frac = (scaled_h - target_h) / target_h
+
+ # gap_frac > 0 means we'd need to crop; < 0 means we'd need to extend
+ if gap_frac < 0:
+ # Need to extend — use extend if gap is small enough
+ mode = "extend" if abs(gap_frac) <= req.smart_threshold else "crop"
+ else:
+ mode = "crop"
+
+ if mode == "crop":
+ result, summary = _crop_fit(image, target_w, target_h)
+ else: # extend
+ result, summary = await _extend_fit(image, target_w, target_h, req.prompt or "")
+
+ return {
+ "result": _encode(_to_png(result)),
+ "mode_used": mode,
+ "frame": req.frame,
+ "orientation": "landscape" if fw > fh else "portrait",
+ "output_pixels": {"width": result.width, "height": result.height},
+ "output_inches": {"width": fw, "height": fh},
+ "dpi": req.dpi,
+ "summary": summary,
+ }
+
+
+def _crop_fit(image: Image.Image, target_w: int, target_h: int):
+ """Center-crop image to target aspect ratio, then Lanczos scale to target size."""
+ img_w, img_h = image.size
+ target_ratio = target_w / target_h
+ img_ratio = img_w / img_h
+
+ if img_ratio > target_ratio:
+ # Wider than target — crop sides
+ new_w = round(img_h * target_ratio)
+ x0 = (img_w - new_w) // 2
+ cropped = image.crop((x0, 0, x0 + new_w, img_h))
+ else:
+ # Taller than target — crop top/bottom
+ new_h = round(img_w / target_ratio)
+ y0 = (img_h - new_h) // 2
+ cropped = image.crop((0, y0, img_w, y0 + new_h))
+
+ result = cropped.resize((target_w, target_h), Image.Resampling.LANCZOS)
+ summary = (
+ f"Cropped from {img_w}×{img_h} to {cropped.width}×{cropped.height}, "
+ f"scaled to {target_w}×{target_h}"
+ )
+ return result, summary
+
+
+async def _extend_fit(image: Image.Image, target_w: int, target_h: int, prompt: str):
+ """
+ Scale image to fill one dimension exactly, then outpaint the gap with AI.
+ Falls back to content-aware mirror fill if no remote provider configured.
+ """
+ from app.services.remote_provider import get_remote_provider
+
+ img_w, img_h = image.size
+ target_ratio = target_w / target_h
+ img_ratio = img_w / img_h
+
+ if img_ratio > target_ratio:
+ # Image wider — scale to target width, extend height
+ scale = target_w / img_w
+ scaled_w = target_w
+ scaled_h = round(img_h * scale)
+ gap_dir = "height"
+ gap_top = (target_h - scaled_h) // 2
+ gap_bottom = target_h - scaled_h - gap_top
+ else:
+ # Image taller — scale to target height, extend width
+ scale = target_h / img_h
+ scaled_h = target_h
+ scaled_w = round(img_w * scale)
+ gap_dir = "width"
+ gap_left = (target_w - scaled_w) // 2
+ gap_right = target_w - scaled_w - gap_left
+
+ scaled = image.resize((scaled_w, scaled_h), Image.Resampling.LANCZOS)
+
+ # Place scaled image on canvas
+ canvas = Image.new("RGB", (target_w, target_h), (128, 128, 128))
+ if gap_dir == "height":
+ canvas.paste(scaled, (0, gap_top))
+ # Build mask: top and bottom strips are white (to inpaint)
+ mask = Image.new("L", (target_w, target_h), 0)
+ if gap_top > 0:
+ mask.paste(Image.new("L", (target_w, gap_top), 255), (0, 0))
+ if gap_bottom > 0:
+ mask.paste(Image.new("L", (target_w, gap_bottom), 255), (0, target_h - gap_bottom))
+ else:
+ canvas.paste(scaled, (gap_left, 0))
+ mask = Image.new("L", (target_w, target_h), 0)
+ if gap_left > 0:
+ mask.paste(Image.new("L", (gap_left, target_h), 255), (0, 0))
+ if gap_right > 0:
+ mask.paste(Image.new("L", (gap_right, target_h), 255), (target_w - gap_right, 0))
+
+ # Try AI inpaint
+ provider = get_remote_provider("inpaint")
+ if provider:
+ try:
+ canvas_bytes = _to_png(canvas)
+ mask_bytes = _to_png(mask)
+ fill_prompt = prompt or "seamlessly continue the image, natural extension"
+ result_bytes = await provider.inpaint(canvas_bytes, mask_bytes, fill_prompt, {})
+ result = Image.open(BytesIO(result_bytes)).convert("RGB")
+ summary = (
+ f"Scaled {img_w}×{img_h} → {scaled_w}×{scaled_h}, "
+ f"AI-extended {gap_dir} to {target_w}×{target_h}"
+ )
+ return result, summary
+ except Exception as e:
+ print(f"AI extend failed, using mirror fill: {e}")
+
+ # Fallback: mirror-fill the gap (looks decent for backgrounds/landscapes)
+ result = _mirror_fill(canvas, mask, scaled, gap_dir,
+ gap_top if gap_dir == "height" else gap_left,
+ gap_bottom if gap_dir == "height" else gap_right,
+ target_w, target_h)
+ summary = (
+ f"Scaled {img_w}×{img_h} → {scaled_w}×{scaled_h}, "
+ f"mirror-filled {gap_dir} to {target_w}×{target_h} (no AI provider)"
+ )
+ return result, summary
+
+
+def _mirror_fill(canvas, mask, scaled, gap_dir, gap_a, gap_b, target_w, target_h):
+ """Fill gaps by reflecting the nearest edge strip."""
+ result = canvas.copy()
+ if gap_dir == "height":
+ if gap_a > 0:
+ strip = scaled.crop((0, 0, scaled.width, min(gap_a * 2, scaled.height)))
+ strip = strip.transpose(Image.Transpose.FLIP_TOP_BOTTOM)
+ strip = strip.resize((target_w, gap_a), Image.Resampling.LANCZOS)
+ result.paste(strip, (0, 0))
+ if gap_b > 0:
+ strip = scaled.crop((0, max(0, scaled.height - gap_b * 2), scaled.width, scaled.height))
+ strip = strip.transpose(Image.Transpose.FLIP_TOP_BOTTOM)
+ strip = strip.resize((target_w, gap_b), Image.Resampling.LANCZOS)
+ result.paste(strip, (0, target_h - gap_b))
+ else:
+ if gap_a > 0:
+ strip = scaled.crop((0, 0, min(gap_a * 2, scaled.width), scaled.height))
+ strip = strip.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
+ strip = strip.resize((gap_a, target_h), Image.Resampling.LANCZOS)
+ result.paste(strip, (0, 0))
+ if gap_b > 0:
+ strip = scaled.crop((max(0, scaled.width - gap_b * 2), 0, scaled.width, scaled.height))
+ strip = strip.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
+ strip = strip.resize((gap_b, target_h), Image.Resampling.LANCZOS)
+ result.paste(strip, (target_w - gap_b, 0))
+ return result
+
+
+# ── Upscale ────────────────────────────────────────────────────────────────
+
+@router.post("/upscale/refresh-caps")
+def upscale_refresh_caps():
+ """Bust the capability cache (call after installing Real-ESRGAN without restarting)."""
+ from app.services.upscale import invalidate_caps_cache, probe_upscale_capabilities
+ invalidate_caps_cache()
+ return probe_upscale_capabilities()
+
+
+@router.get("/upscale/available")
+async def upscale_available():
+ """
+ Return capability probe: which upscale methods are available,
+ which device will be used, and which method is recommended.
+ If no AI upscaler is found, triggers background NCNN auto-install.
+ Frontend uses this to populate the method selector.
+ """
+ from app.services.upscale import probe_upscale_capabilities, ensure_ncnn_installed, get_install_status
+ caps = probe_upscale_capabilities()
+ # Auto-install NCNN if no AI upscaler is available yet
+ if not caps["realesrgan_pytorch"] and not caps["realesrgan_ncnn"]:
+ asyncio.create_task(ensure_ncnn_installed())
+ caps["ncnn_install_status"] = get_install_status()
+ return caps
+
+
+@router.get("/upscale/install-status")
+def upscale_install_status():
+ """Poll for Real-ESRGAN NCNN auto-install progress."""
+ from app.services.upscale import get_install_status, probe_upscale_capabilities, _find_ncnn_binary
+ status = get_install_status()
+ # If install just finished, refresh caps
+ if status["state"] == "done":
+ from app.services.upscale import invalidate_caps_cache
+ invalidate_caps_cache()
+ caps = probe_upscale_capabilities()
+ status["ncnn_available"] = caps["realesrgan_ncnn"]
+ else:
+ status["ncnn_available"] = False
+ return status
+
+
+@router.post("/upscale")
+async def upscale(req: UpscaleRequest):
+ """
+ Upscale image. method values:
+ auto — pick best available (recommended)
+ realesrgan_pytorch — Real-ESRGAN via PyTorch (CUDA/MPS/CPU)
+ realesrgan_ncnn — Real-ESRGAN NCNN Vulkan binary
+ lanczos — always available, instant
+ Any AI method falls back to the next best if unavailable.
+ """
+ if not (1.1 <= req.scale <= 8.0):
+ raise HTTPException(status_code=400, detail="scale must be 1.1–8.0")
+
+ valid_methods = {"auto", "realesrgan_pytorch", "realesrgan_ncnn", "lanczos"}
+ if req.method not in valid_methods:
+ raise HTTPException(status_code=400,
+ detail=f"method must be one of {sorted(valid_methods)}")
+
+ try:
+ image = Image.open(BytesIO(_decode(req.image))).convert("RGB")
+ except Exception as e:
+ raise HTTPException(status_code=400, detail=f"Could not decode image: {e}")
+
+ orig_w, orig_h = image.size
+
+ try:
+ from app.services.upscale import upscale_image
+ result_bytes, method_used = await upscale_image(image, req.scale, req.method)
+ result = Image.open(BytesIO(result_bytes))
+ except Exception as e:
+ import traceback; traceback.print_exc()
+ raise HTTPException(status_code=500, detail=str(e))
+
+ return {
+ "result": _encode(result_bytes),
+ "method": method_used,
+ "original": {"width": orig_w, "height": orig_h},
+ "output": {"width": result.width, "height": result.height},
+ "scale": req.scale,
+ }
diff --git a/backend/app/services/local_inpaint.py b/backend/app/services/local_inpaint.py
new file mode 100644
index 0000000..cd82997
--- /dev/null
+++ b/backend/app/services/local_inpaint.py
@@ -0,0 +1,82 @@
+"""
+Local inpainting operations — LaMa, OpenCV, and background removal.
+All operations use GPU automatically if PyTorch detects one, CPU otherwise.
+"""
+
+from io import BytesIO
+from PIL import Image
+import numpy as np
+import cv2
+
+# Lazy-loaded LaMa model (downloaded on first use, ~100MB)
+_lama = None
+
+
+def get_lama():
+ global _lama
+ if _lama is None:
+ from simple_lama_inpainting import SimpleLama
+ _lama = SimpleLama()
+ return _lama
+
+
+def lama_available() -> bool:
+ try:
+ import simple_lama_inpainting # noqa: F401
+ return True
+ except ImportError:
+ return False
+
+
+def lama_inpaint(image_bytes: bytes, mask_bytes: bytes) -> bytes:
+ """LaMa structural inpainting — best for object removal and large fills."""
+ lama = get_lama()
+ image = Image.open(BytesIO(image_bytes)).convert("RGB")
+ mask = Image.open(BytesIO(mask_bytes)).convert("L")
+ if mask.size != image.size:
+ mask = mask.resize(image.size, Image.Resampling.LANCZOS)
+ result = lama(image, mask)
+ buf = BytesIO()
+ result.save(buf, format="PNG")
+ return buf.getvalue()
+
+
+def opencv_inpaint(image_bytes: bytes, mask_bytes: bytes, method: str = "telea") -> bytes:
+ """OpenCV fast structural inpainting — CPU only, milliseconds."""
+ image = Image.open(BytesIO(image_bytes)).convert("RGB")
+ mask = Image.open(BytesIO(mask_bytes)).convert("L")
+ if mask.size != image.size:
+ mask = mask.resize(image.size, Image.Resampling.LANCZOS)
+
+ img_np = np.array(image)
+ mask_np = np.array(mask)
+ _, mask_bin = cv2.threshold(mask_np, 127, 255, cv2.THRESH_BINARY)
+
+ flags = cv2.INPAINT_TELEA if method == "telea" else cv2.INPAINT_NS
+ result = cv2.inpaint(img_np, mask_bin, inpaintRadius=3, flags=flags)
+
+ buf = BytesIO()
+ Image.fromarray(result).save(buf, format="PNG")
+ return buf.getvalue()
+
+
+def remove_background_rembg(image_bytes: bytes) -> bytes:
+ """Background removal using rembg."""
+ from rembg import remove
+ return remove(image_bytes)
+
+
+def rembg_available() -> bool:
+ try:
+ import rembg # noqa: F401
+ return True
+ except ImportError:
+ return False
+
+
+def gpu_available() -> bool:
+ try:
+ import torch
+ return torch.cuda.is_available()
+ except ImportError:
+ return False
diff --git a/backend/app/services/remote_provider.py b/backend/app/services/remote_provider.py
new file mode 100644
index 0000000..6fb8d31
--- /dev/null
+++ b/backend/app/services/remote_provider.py
@@ -0,0 +1,466 @@
+"""
+Remote AI provider abstraction.
+One interface, three drivers: OpenAI, InvokeAI, ComfyUI.
+Configure one provider via AI_PROVIDER in .env.
+"""
+
+from abc import ABC, abstractmethod
+from typing import Optional
+import httpx
+import base64
+import asyncio
+from io import BytesIO
+
+
+class RemoteAIProvider(ABC):
+ @abstractmethod
+ async def inpaint(self, image_bytes: bytes, mask_bytes: bytes, prompt: str, params: dict) -> bytes: ...
+ @abstractmethod
+ async def txt2img(self, prompt: str, width: int, height: int, params: dict) -> bytes: ...
+ @abstractmethod
+ async def img2img(self, image_bytes: bytes, prompt: str, strength: float, params: dict) -> bytes: ...
+ @abstractmethod
+ async def outpaint(self, image_bytes: bytes, direction: str, size: int, prompt: str) -> bytes: ...
+ @abstractmethod
+ async def health(self) -> bool: ...
+ @abstractmethod
+ def capabilities(self) -> list[str]: ...
+
+
+class OpenAIRemoteProvider(RemoteAIProvider):
+ """OpenAI image API — gpt-image-1 / dall-e-3."""
+
+ def __init__(self, api_key: str, model: str = "dall-e-3"):
+ self.api_key = api_key
+ self.model = model
+ self.base_url = "https://api.openai.com/v1"
+
+ def _headers(self):
+ return {"Authorization": f"Bearer {self.api_key}"}
+
+ async def inpaint(self, image_bytes: bytes, mask_bytes: bytes, prompt: str, params: dict) -> bytes:
+ async with httpx.AsyncClient(timeout=120.0) as client:
+ files = {
+ "image": ("image.png", image_bytes, "image/png"),
+ "mask": ("mask.png", mask_bytes, "image/png"),
+ }
+ data = {"prompt": prompt, "n": "1", "size": "1024x1024"}
+ r = await client.post(f"{self.base_url}/images/edits", files=files, data=data, headers=self._headers())
+ r.raise_for_status()
+ url = r.json()["data"][0]["url"]
+ img_r = await client.get(url)
+ img_r.raise_for_status()
+ return img_r.content
+
+ async def txt2img(self, prompt: str, width: int, height: int, params: dict) -> bytes:
+ size = f"{width}x{height}" if f"{width}x{height}" in {"256x256", "512x512", "1024x1024"} else "1024x1024"
+ async with httpx.AsyncClient(timeout=120.0) as client:
+ data = {"model": self.model, "prompt": prompt, "n": 1, "size": size}
+ r = await client.post(f"{self.base_url}/images/generations", json=data, headers=self._headers())
+ r.raise_for_status()
+ url = r.json()["data"][0]["url"]
+ img_r = await client.get(url)
+ img_r.raise_for_status()
+ return img_r.content
+
+ async def img2img(self, image_bytes: bytes, prompt: str, strength: float, params: dict) -> bytes:
+ # OpenAI doesn't have img2img natively — use edits with blank mask
+ from PIL import Image
+ import numpy as np
+ img = Image.open(BytesIO(image_bytes)).convert("RGBA")
+ mask = Image.new("RGBA", img.size, (0, 0, 0, 0))
+ mask_buf = BytesIO()
+ mask.save(mask_buf, format="PNG")
+ return await self.inpaint(image_bytes, mask_buf.getvalue(), prompt, params)
+
+ async def outpaint(self, image_bytes: bytes, direction: str, size: int, prompt: str) -> bytes:
+ from PIL import Image
+ img = Image.open(BytesIO(image_bytes)).convert("RGBA")
+ w, h = img.size
+ directions = {"left": (size, 0), "right": (size, 0), "top": (0, size), "bottom": (0, size)}
+ dw, dh = directions.get(direction, (size, 0))
+ new_w, new_h = w + dw, h + dh
+ canvas = Image.new("RGBA", (new_w, new_h), (0, 0, 0, 0))
+ offsets = {
+ "left": (size, 0), "right": (0, 0), "top": (0, size), "bottom": (0, 0)
+ }
+ ox, oy = offsets.get(direction, (0, 0))
+ canvas.paste(img, (ox, oy))
+ # mask: transparent = inpaint
+ mask = Image.new("L", (new_w, new_h), 0)
+ # fill the expanded region with white in mask
+ import numpy as np
+ mask_arr = np.zeros((new_h, new_w), dtype=np.uint8)
+ if direction == "left":
+ mask_arr[:, :size] = 255
+ elif direction == "right":
+ mask_arr[:, w:] = 255
+ elif direction == "top":
+ mask_arr[:size, :] = 255
+ else:
+ mask_arr[h:, :] = 255
+ mask = Image.fromarray(mask_arr, "L")
+
+ canvas_rgb = canvas.convert("RGB")
+ img_buf = BytesIO()
+ canvas_rgb.save(img_buf, format="PNG")
+ mask_buf = BytesIO()
+ mask.save(mask_buf, format="PNG")
+ return await self.inpaint(img_buf.getvalue(), mask_buf.getvalue(), prompt, {})
+
+ async def health(self) -> bool:
+ try:
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ r = await client.get(f"{self.base_url}/models", headers=self._headers())
+ return r.status_code == 200
+ except Exception:
+ return False
+
+ def capabilities(self) -> list[str]:
+ return ["inpaint", "txt2img", "img2img", "outpaint"]
+
+
+class InvokeAIProvider(RemoteAIProvider):
+ """InvokeAI REST API driver — supports Flux, SDXL, SD1.5 and more."""
+
+ def __init__(self, base_url: str, default_model: str = "flux-dev"):
+ self.base_url = base_url.rstrip("/")
+ self.default_model = default_model
+
+ async def _b64(self, data: bytes) -> str:
+ return base64.b64encode(data).decode()
+
+ async def _upload_image(self, client: httpx.AsyncClient, image_bytes: bytes, category: str = "general") -> str:
+ """Upload image to InvokeAI and return image_name."""
+ files = {"file": ("image.png", image_bytes, "image/png")}
+ data = {"image_category": category, "is_intermediate": "false"}
+ r = await client.post(f"{self.base_url}/api/v1/images/upload", files=files, data=data)
+ r.raise_for_status()
+ return r.json()["image_name"]
+
+ async def _run_graph(self, client: httpx.AsyncClient, graph: dict) -> bytes:
+ """Post a graph, poll for completion, return result image bytes."""
+ r = await client.post(f"{self.base_url}/api/v1/queue/default/enqueue_batch",
+ json={"prepend": False, "batch": {"graph": graph, "runs": 1}})
+ r.raise_for_status()
+ batch_id = r.json()["batch"]["batch_id"]
+
+ # Poll queue status
+ for _ in range(180):
+ await asyncio.sleep(2)
+ sr = await client.get(f"{self.base_url}/api/v1/queue/default/status")
+ sr.raise_for_status()
+ status = sr.json()
+ if status.get("queue", {}).get("completed", 0) > 0:
+ break
+ if status.get("queue", {}).get("failed", 0) > 0:
+ raise RuntimeError("InvokeAI graph failed")
+
+ # Fetch latest result image
+ lr = await client.get(f"{self.base_url}/api/v1/images/?categories=general&limit=1&is_intermediate=false")
+ lr.raise_for_status()
+ items = lr.json().get("items", [])
+ if not items:
+ raise RuntimeError("No output image from InvokeAI")
+
+ img_name = items[0]["image_name"]
+ img_r = await client.get(f"{self.base_url}/api/v1/images/i/{img_name}/full")
+ img_r.raise_for_status()
+ return img_r.content
+
+ async def inpaint(self, image_bytes: bytes, mask_bytes: bytes, prompt: str, params: dict) -> bytes:
+ async with httpx.AsyncClient(timeout=300.0) as client:
+ img_name = await self._upload_image(client, image_bytes)
+ mask_name = await self._upload_image(client, mask_bytes, "mask")
+ model = params.get("model", self.default_model)
+ graph = {
+ "id": "inpaint_graph",
+ "nodes": {
+ "img_node": {"id": "img_node", "type": "image", "image": {"image_name": img_name}},
+ "mask_node": {"id": "mask_node", "type": "image", "image": {"image_name": mask_name}},
+ "model_node": {"id": "model_node", "type": "main_model_loader", "model": {"model_name": model, "base": "any"}},
+ "clip_skip": {"id": "clip_skip", "type": "clip_skip", "skipped_layers": 0},
+ "positive": {"id": "positive", "type": "compel", "prompt": prompt},
+ "negative": {"id": "negative", "type": "compel", "prompt": params.get("negative_prompt", "")},
+ "denoise": {
+ "id": "denoise", "type": "denoise_latents",
+ "steps": params.get("steps", 30),
+ "cfg_scale": params.get("cfg_scale", 7.5),
+ "denoising_start": 0.0, "denoising_end": 1.0,
+ "scheduler": "euler", "is_intermediate": False
+ },
+ "vae_loader": {"id": "vae_loader", "type": "vae_loader", "vae_model": {"model_name": model, "base": "any"}},
+ "img_to_latents": {"id": "img_to_latents", "type": "i2l"},
+ "latents_to_img": {"id": "latents_to_img", "type": "l2i"},
+ },
+ "edges": [
+ {"source": {"node_id": "model_node", "field": "unet"}, "destination": {"node_id": "denoise", "field": "unet"}},
+ {"source": {"node_id": "model_node", "field": "clip"}, "destination": {"node_id": "clip_skip", "field": "clip"}},
+ {"source": {"node_id": "clip_skip", "field": "clip"}, "destination": {"node_id": "positive", "field": "clip"}},
+ {"source": {"node_id": "clip_skip", "field": "clip"}, "destination": {"node_id": "negative", "field": "clip"}},
+ {"source": {"node_id": "positive", "field": "conditioning"}, "destination": {"node_id": "denoise", "field": "positive_conditioning"}},
+ {"source": {"node_id": "negative", "field": "conditioning"}, "destination": {"node_id": "denoise", "field": "negative_conditioning"}},
+ {"source": {"node_id": "img_node", "field": "image"}, "destination": {"node_id": "img_to_latents", "field": "image"}},
+ {"source": {"node_id": "vae_loader", "field": "vae"}, "destination": {"node_id": "img_to_latents", "field": "vae"}},
+ {"source": {"node_id": "img_to_latents", "field": "latents"}, "destination": {"node_id": "denoise", "field": "latents"}},
+ {"source": {"node_id": "mask_node", "field": "image"}, "destination": {"node_id": "denoise", "field": "mask"}},
+ {"source": {"node_id": "denoise", "field": "latents"}, "destination": {"node_id": "latents_to_img", "field": "latents"}},
+ {"source": {"node_id": "vae_loader", "field": "vae"}, "destination": {"node_id": "latents_to_img", "field": "vae"}},
+ ]
+ }
+ return await self._run_graph(client, graph)
+
+ async def txt2img(self, prompt: str, width: int, height: int, params: dict) -> bytes:
+ async with httpx.AsyncClient(timeout=300.0) as client:
+ model = params.get("model", self.default_model)
+ graph = {
+ "id": "txt2img_graph",
+ "nodes": {
+ "model_node": {"id": "model_node", "type": "main_model_loader", "model": {"model_name": model, "base": "any"}},
+ "clip_skip": {"id": "clip_skip", "type": "clip_skip", "skipped_layers": 0},
+ "positive": {"id": "positive", "type": "compel", "prompt": prompt},
+ "negative": {"id": "negative", "type": "compel", "prompt": params.get("negative_prompt", "")},
+ "noise": {"id": "noise", "type": "noise", "width": width, "height": height, "seed": params.get("seed", 0)},
+ "denoise": {
+ "id": "denoise", "type": "denoise_latents",
+ "steps": params.get("steps", 30),
+ "cfg_scale": params.get("cfg_scale", 7.5),
+ "denoising_start": 0.0, "denoising_end": 1.0,
+ "scheduler": "euler",
+ },
+ "vae_loader": {"id": "vae_loader", "type": "vae_loader", "vae_model": {"model_name": model, "base": "any"}},
+ "latents_to_img": {"id": "latents_to_img", "type": "l2i"},
+ },
+ "edges": [
+ {"source": {"node_id": "model_node", "field": "unet"}, "destination": {"node_id": "denoise", "field": "unet"}},
+ {"source": {"node_id": "model_node", "field": "clip"}, "destination": {"node_id": "clip_skip", "field": "clip"}},
+ {"source": {"node_id": "clip_skip", "field": "clip"}, "destination": {"node_id": "positive", "field": "clip"}},
+ {"source": {"node_id": "clip_skip", "field": "clip"}, "destination": {"node_id": "negative", "field": "clip"}},
+ {"source": {"node_id": "positive", "field": "conditioning"}, "destination": {"node_id": "denoise", "field": "positive_conditioning"}},
+ {"source": {"node_id": "negative", "field": "conditioning"}, "destination": {"node_id": "denoise", "field": "negative_conditioning"}},
+ {"source": {"node_id": "noise", "field": "noise"}, "destination": {"node_id": "denoise", "field": "noise"}},
+ {"source": {"node_id": "denoise", "field": "latents"}, "destination": {"node_id": "latents_to_img", "field": "latents"}},
+ {"source": {"node_id": "vae_loader", "field": "vae"}, "destination": {"node_id": "latents_to_img", "field": "vae"}},
+ ]
+ }
+ return await self._run_graph(client, graph)
+
+ async def img2img(self, image_bytes: bytes, prompt: str, strength: float, params: dict) -> bytes:
+ # Reuse inpaint with a full-white mask at the given strength
+ from PIL import Image
+ img = Image.open(BytesIO(image_bytes))
+ mask = Image.new("L", img.size, 255)
+ mask_buf = BytesIO()
+ mask.save(mask_buf, format="PNG")
+ p = dict(params)
+ p.setdefault("denoising_start", 1.0 - strength)
+ return await self.inpaint(image_bytes, mask_buf.getvalue(), prompt, p)
+
+ async def outpaint(self, image_bytes: bytes, direction: str, size: int, prompt: str) -> bytes:
+ # Delegate to inpaint with expanded canvas
+ provider = OpenAIRemoteProvider.__new__(OpenAIRemoteProvider)
+ return await provider.outpaint(image_bytes, direction, size, prompt)
+
+ async def health(self) -> bool:
+ try:
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ r = await client.get(f"{self.base_url}/api/v1/app/version")
+ return r.status_code == 200
+ except Exception:
+ return False
+
+ def capabilities(self) -> list[str]:
+ return ["inpaint", "txt2img", "img2img", "outpaint"]
+
+
+class ComfyUIProvider(RemoteAIProvider):
+ """ComfyUI workflow JSON API driver."""
+
+ def __init__(self, base_url: str, default_model: str = "v1-5-pruned-emaonly.ckpt"):
+ self.base_url = base_url.rstrip("/")
+ self.default_model = default_model
+
+ async def _upload_image(self, client: httpx.AsyncClient, image_bytes: bytes, name: str = "image.png") -> str:
+ files = {"image": (name, image_bytes, "image/png")}
+ data = {"overwrite": "true"}
+ r = await client.post(f"{self.base_url}/upload/image", files=files, data=data)
+ r.raise_for_status()
+ j = r.json()
+ return j.get("name", name)
+
+ async def _queue_prompt(self, client: httpx.AsyncClient, workflow: dict) -> str:
+ r = await client.post(f"{self.base_url}/prompt", json={"prompt": workflow})
+ r.raise_for_status()
+ return r.json()["prompt_id"]
+
+ async def _wait_for_result(self, client: httpx.AsyncClient, prompt_id: str) -> bytes:
+ for _ in range(180):
+ await asyncio.sleep(2)
+ r = await client.get(f"{self.base_url}/history/{prompt_id}")
+ r.raise_for_status()
+ history = r.json()
+ if prompt_id in history:
+ outputs = history[prompt_id].get("outputs", {})
+ for node_output in outputs.values():
+ for img_info in node_output.get("images", []):
+ img_r = await client.get(
+ f"{self.base_url}/view",
+ params={"filename": img_info["filename"], "subfolder": img_info.get("subfolder", ""),
+ "type": img_info.get("type", "output")}
+ )
+ img_r.raise_for_status()
+ return img_r.content
+ raise RuntimeError("ComfyUI timed out waiting for result")
+
+ async def inpaint(self, image_bytes: bytes, mask_bytes: bytes, prompt: str, params: dict) -> bytes:
+ model = params.get("model", self.default_model)
+ async with httpx.AsyncClient(timeout=300.0) as client:
+ img_name = await self._upload_image(client, image_bytes, "input.png")
+ mask_name = await self._upload_image(client, mask_bytes, "mask.png")
+ workflow = {
+ "1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": model}},
+ "2": {"class_type": "CLIPTextEncode", "inputs": {"text": prompt, "clip": ["1", 1]}},
+ "3": {"class_type": "CLIPTextEncode", "inputs": {"text": params.get("negative_prompt", ""), "clip": ["1", 1]}},
+ "4": {"class_type": "LoadImage", "inputs": {"image": img_name}},
+ "5": {"class_type": "LoadImage", "inputs": {"image": mask_name}},
+ "6": {"class_type": "VAEEncode", "inputs": {"pixels": ["4", 0], "vae": ["1", 2]}},
+ "7": {"class_type": "KSampler", "inputs": {
+ "model": ["1", 0], "positive": ["2", 0], "negative": ["3", 0],
+ "latent_image": ["6", 0], "mask": ["5", 0],
+ "seed": params.get("seed", 42), "steps": params.get("steps", 20),
+ "cfg": params.get("cfg_scale", 7.0), "sampler_name": "euler",
+ "scheduler": "normal", "denoise": params.get("denoise", 1.0)
+ }},
+ "8": {"class_type": "VAEDecode", "inputs": {"samples": ["7", 0], "vae": ["1", 2]}},
+ "9": {"class_type": "SaveImage", "inputs": {"images": ["8", 0], "filename_prefix": "api_out"}},
+ }
+ pid = await self._queue_prompt(client, workflow)
+ return await self._wait_for_result(client, pid)
+
+ async def txt2img(self, prompt: str, width: int, height: int, params: dict) -> bytes:
+ model = params.get("model", self.default_model)
+ async with httpx.AsyncClient(timeout=300.0) as client:
+ workflow = {
+ "1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": model}},
+ "2": {"class_type": "CLIPTextEncode", "inputs": {"text": prompt, "clip": ["1", 1]}},
+ "3": {"class_type": "CLIPTextEncode", "inputs": {"text": params.get("negative_prompt", ""), "clip": ["1", 1]}},
+ "4": {"class_type": "EmptyLatentImage", "inputs": {"width": width, "height": height, "batch_size": 1}},
+ "5": {"class_type": "KSampler", "inputs": {
+ "model": ["1", 0], "positive": ["2", 0], "negative": ["3", 0],
+ "latent_image": ["4", 0],
+ "seed": params.get("seed", 42), "steps": params.get("steps", 20),
+ "cfg": params.get("cfg_scale", 7.0), "sampler_name": "euler",
+ "scheduler": "normal", "denoise": 1.0
+ }},
+ "6": {"class_type": "VAEDecode", "inputs": {"samples": ["5", 0], "vae": ["1", 2]}},
+ "7": {"class_type": "SaveImage", "inputs": {"images": ["6", 0], "filename_prefix": "api_out"}},
+ }
+ pid = await self._queue_prompt(client, workflow)
+ return await self._wait_for_result(client, pid)
+
+ async def img2img(self, image_bytes: bytes, prompt: str, strength: float, params: dict) -> bytes:
+ from PIL import Image
+ img = Image.open(BytesIO(image_bytes))
+ mask = Image.new("L", img.size, 255)
+ mask_buf = BytesIO()
+ mask.save(mask_buf, format="PNG")
+ p = dict(params)
+ p["denoise"] = strength
+ return await self.inpaint(image_bytes, mask_buf.getvalue(), prompt, p)
+
+ async def outpaint(self, image_bytes: bytes, direction: str, size: int, prompt: str) -> bytes:
+ # Build expanded canvas then inpaint with blank mask
+ from PIL import Image
+ import numpy as np
+ img = Image.open(BytesIO(image_bytes)).convert("RGB")
+ w, h = img.size
+ dw = size if direction in ("left", "right") else 0
+ dh = size if direction in ("top", "bottom") else 0
+ canvas = Image.new("RGB", (w + dw, h + dh), (128, 128, 128))
+ ox = size if direction == "left" else 0
+ oy = size if direction == "top" else 0
+ canvas.paste(img, (ox, oy))
+ mask_arr = np.zeros((h + dh, w + dw), dtype=np.uint8)
+ if direction == "left":
+ mask_arr[:, :size] = 255
+ elif direction == "right":
+ mask_arr[:, w:] = 255
+ elif direction == "top":
+ mask_arr[:size, :] = 255
+ else:
+ mask_arr[h:, :] = 255
+ img_buf = BytesIO()
+ canvas.save(img_buf, format="PNG")
+ mask_buf = BytesIO()
+ Image.fromarray(mask_arr, "L").save(mask_buf, format="PNG")
+ return await self.inpaint(img_buf.getvalue(), mask_buf.getvalue(), prompt, {})
+
+ async def health(self) -> bool:
+ try:
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ r = await client.get(f"{self.base_url}/system_stats")
+ return r.status_code == 200
+ except Exception:
+ return False
+
+ def capabilities(self) -> list[str]:
+ return ["inpaint", "txt2img", "img2img", "outpaint"]
+
+
+def _build_provider(name: str) -> Optional[RemoteAIProvider]:
+ """Instantiate a named provider from current settings."""
+ from app.config import settings
+
+ name = (name or "").lower().strip()
+
+ if name == "openai":
+ if not settings.openai_api_key:
+ return None
+ return OpenAIRemoteProvider(settings.openai_api_key, settings.openai_model)
+
+ if name == "invokeai":
+ if not settings.invokeai_url:
+ return None
+ return InvokeAIProvider(settings.invokeai_url, settings.invokeai_default_model)
+
+ if name == "comfyui":
+ if not settings.comfyui_url:
+ return None
+ return ComfyUIProvider(settings.comfyui_url, settings.comfyui_default_model)
+
+ return None
+
+
+# Map operation names to the settings field that holds the override
+_OP_FIELD = {
+ "inpaint": "ai_provider_inpaint",
+ "txt2img": "ai_provider_txt2img",
+ "img2img": "ai_provider_img2img",
+ "outpaint": "ai_provider_outpaint",
+}
+
+
+def get_remote_provider(operation: Optional[str] = None) -> Optional[RemoteAIProvider]:
+ """
+ Return the provider for a given operation.
+
+ Resolution order:
+ 1. Per-operation override (AI_PROVIDER_INPAINT, AI_PROVIDER_TXT2IMG, etc.)
+ 2. Global default (AI_PROVIDER)
+ 3. None (local-only mode)
+
+ Example .env for mixed setup:
+ AI_PROVIDER=invokeai # default for inpaint/img2img/outpaint
+ AI_PROVIDER_TXT2IMG=openai # use OpenAI only for text-to-image
+ """
+ from app.config import settings
+
+ if operation and operation in _OP_FIELD:
+ override = getattr(settings, _OP_FIELD[operation], "")
+ if override:
+ provider = _build_provider(override)
+ if provider is not None:
+ return provider
+ # override configured but not usable (missing key/url) — fall through to default
+
+ return _build_provider(settings.ai_provider)
diff --git a/backend/app/services/upscale.py b/backend/app/services/upscale.py
new file mode 100644
index 0000000..92d43b4
--- /dev/null
+++ b/backend/app/services/upscale.py
@@ -0,0 +1,421 @@
+"""
+Upscale service — auto-detects best available method and runs it.
+Auto-installs Real-ESRGAN NCNN Vulkan binary on first use if no AI upscaler found.
+
+Priority (auto mode):
+ 1. Real-ESRGAN PyTorch + CUDA GPU — fastest, best quality
+ 2. Real-ESRGAN PyTorch + Apple MPS — fast on Apple Silicon
+ 3. Real-ESRGAN NCNN Vulkan binary — fast on any GPU (Intel/AMD/integrated)
+ 4. Real-ESRGAN PyTorch CPU — works, slow (warn user)
+ 5. Lanczos — always available, instant
+
+Capability probe is run once at first call and cached.
+NCNN binary is auto-downloaded if no AI upscaler is found.
+"""
+
+import asyncio
+import os
+import platform
+import shutil
+import stat
+import subprocess
+import sys
+import tempfile
+import urllib.request
+import zipfile
+from dataclasses import dataclass, field
+from enum import Enum
+from io import BytesIO
+from pathlib import Path
+from typing import Optional
+
+from PIL import Image
+
+# ── NCNN auto-install ─────────────────────────────────────────────────────────
+
+NCNN_DEST_DIR = Path("/app/data/models/realesrgan")
+NCNN_VERSION = "v0.2.5.0"
+NCNN_BASE_URL = f"https://github.com/xinntao/Real-ESRGAN/releases/download/{NCNN_VERSION}"
+
+_PLATFORM_ZIP = {
+ "linux": f"realesrgan-ncnn-vulkan-{NCNN_VERSION}-ubuntu.zip",
+ "darwin": f"realesrgan-ncnn-vulkan-{NCNN_VERSION}-macos.zip",
+ "win32": f"realesrgan-ncnn-vulkan-{NCNN_VERSION}-windows.zip",
+ "windows": f"realesrgan-ncnn-vulkan-{NCNN_VERSION}-windows.zip",
+}
+
+
+class InstallState(str, Enum):
+ idle = "idle"
+ downloading = "downloading"
+ extracting = "extracting"
+ done = "done"
+ failed = "failed"
+
+
+@dataclass
+class InstallStatus:
+ state: InstallState = InstallState.idle
+ progress: int = 0 # 0-100
+ message: str = ""
+ error: str = ""
+
+
+_install_status = InstallStatus()
+_install_lock = asyncio.Lock()
+
+
+def get_install_status() -> dict:
+ s = _install_status
+ return {
+ "state": s.state.value,
+ "progress": s.progress,
+ "message": s.message,
+ "error": s.error,
+ }
+
+
+def _ncnn_binary_name() -> str:
+ plat = sys.platform.lower()
+ return "realesrgan-ncnn-vulkan.exe" if "win" in plat else "realesrgan-ncnn-vulkan"
+
+
+async def ensure_ncnn_installed() -> Optional[Path]:
+ """
+ Check if NCNN binary is present; if not, download and install it.
+ Returns the binary Path on success, None on failure.
+ Serialised via _install_lock so concurrent callers wait for a single install.
+ """
+ global _install_status
+
+ binary_path = NCNN_DEST_DIR / _ncnn_binary_name()
+ if binary_path.exists() and os.access(binary_path, os.X_OK):
+ _install_status = InstallStatus(state=InstallState.done, progress=100,
+ message="Already installed.")
+ return binary_path
+
+ async with _install_lock:
+ # Re-check after acquiring lock (another coroutine may have just finished)
+ if binary_path.exists() and os.access(binary_path, os.X_OK):
+ _install_status = InstallStatus(state=InstallState.done, progress=100,
+ message="Already installed.")
+ return binary_path
+
+ if _install_status.state == InstallState.downloading:
+ return None # install already in progress
+
+ plat = sys.platform.lower()
+ zip_name = _PLATFORM_ZIP.get(plat)
+ if not zip_name:
+ _install_status = InstallStatus(
+ state=InstallState.failed,
+ error=f"Unsupported platform: {plat}",
+ )
+ return None
+
+ url = f"{NCNN_BASE_URL}/{zip_name}"
+
+ try:
+ NCNN_DEST_DIR.mkdir(parents=True, exist_ok=True)
+ zip_path = NCNN_DEST_DIR / zip_name
+
+ # Download
+ _install_status = InstallStatus(
+ state=InstallState.downloading, progress=0,
+ message=f"Downloading Real-ESRGAN NCNN {NCNN_VERSION}…",
+ )
+
+ def _do_download():
+ def _progress(count, block, total):
+ if total > 0:
+ pct = min(90, int(count * block * 90 / total))
+ _install_status.progress = pct
+ urllib.request.urlretrieve(url, zip_path, _progress)
+
+ loop = asyncio.get_event_loop()
+ await loop.run_in_executor(None, _do_download)
+
+ # Extract
+ _install_status.state = InstallState.extracting
+ _install_status.progress = 92
+ _install_status.message = "Extracting…"
+
+ def _do_extract():
+ with zipfile.ZipFile(zip_path, "r") as zf:
+ zf.extractall(NCNN_DEST_DIR)
+ # Find binary (may be in a subdirectory)
+ found = list(NCNN_DEST_DIR.rglob(_ncnn_binary_name()))
+ if not found:
+ raise FileNotFoundError(f"Binary not found after extract: {_ncnn_binary_name()}")
+ extracted = found[0]
+ if extracted != binary_path:
+ extracted.rename(binary_path)
+ # Make executable
+ if "win" not in sys.platform.lower():
+ binary_path.chmod(
+ binary_path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH
+ )
+ zip_path.unlink(missing_ok=True)
+
+ await loop.run_in_executor(None, _do_extract)
+
+ _install_status = InstallStatus(
+ state=InstallState.done, progress=100,
+ message=f"Installed: {binary_path}",
+ )
+ # Bust caps cache so probe picks up new binary
+ invalidate_caps_cache()
+ return binary_path
+
+ except Exception as exc:
+ _install_status = InstallStatus(
+ state=InstallState.failed,
+ error=str(exc),
+ message="Installation failed.",
+ )
+ print(f"[upscale] NCNN auto-install failed: {exc}")
+ return None
+
+
+# ── Capability detection ──────────────────────────────────────────────────────
+
+_caps: Optional[dict] = None
+
+
+def probe_upscale_capabilities() -> dict:
+ """
+ Detect what upscaling hardware and software is available.
+ Result is cached after first call.
+ """
+ global _caps
+ if _caps is not None:
+ return _caps
+
+ caps = {
+ "lanczos": True,
+ "realesrgan_pytorch": False,
+ "realesrgan_pytorch_device": None,
+ "realesrgan_ncnn": False,
+ "realesrgan_ncnn_path": None,
+ "recommended": "lanczos",
+ "recommended_label": "Lanczos (no AI upscaler found)",
+ "methods": ["lanczos"],
+ "ncnn_install_status": get_install_status(),
+ }
+
+ # ── PyTorch path ──────────────────────────────────────────────────────────
+ pytorch_device = None
+ try:
+ import torch
+ if torch.cuda.is_available():
+ pytorch_device = "cuda"
+ elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
+ pytorch_device = "mps"
+ else:
+ pytorch_device = "cpu"
+ except ImportError:
+ pass
+
+ if pytorch_device:
+ try:
+ import realesrgan # noqa: F401
+ from basicsr.archs.rrdbnet_arch import RRDBNet # noqa: F401
+ caps["realesrgan_pytorch"] = True
+ caps["realesrgan_pytorch_device"] = pytorch_device
+ caps["methods"].append("realesrgan_pytorch")
+ except ImportError:
+ pass
+
+ # ── NCNN Vulkan binary ────────────────────────────────────────────────────
+ ncnn_path = _find_ncnn_binary()
+ if ncnn_path:
+ caps["realesrgan_ncnn"] = True
+ caps["realesrgan_ncnn_path"] = str(ncnn_path)
+ caps["methods"].append("realesrgan_ncnn")
+
+ # ── Pick recommended ──────────────────────────────────────────────────────
+ if caps["realesrgan_pytorch"] and pytorch_device in ("cuda", "mps"):
+ device_label = "CUDA GPU" if pytorch_device == "cuda" else "Apple Silicon"
+ caps["recommended"] = "realesrgan_pytorch"
+ caps["recommended_label"] = f"Real-ESRGAN ({device_label})"
+ elif caps["realesrgan_ncnn"]:
+ caps["recommended"] = "realesrgan_ncnn"
+ caps["recommended_label"] = "Real-ESRGAN NCNN (Vulkan)"
+ elif caps["realesrgan_pytorch"] and pytorch_device == "cpu":
+ caps["recommended"] = "realesrgan_pytorch"
+ caps["recommended_label"] = "Real-ESRGAN (CPU — may be slow)"
+ else:
+ caps["recommended"] = "lanczos"
+ caps["recommended_label"] = "Lanczos (installing Real-ESRGAN…)"
+
+ _caps = caps
+ return caps
+
+
+def _find_ncnn_binary() -> Optional[Path]:
+ """Find realesrgan-ncnn-vulkan binary on the system."""
+ found = shutil.which("realesrgan-ncnn-vulkan")
+ if found:
+ return Path(found)
+
+ candidates = [
+ NCNN_DEST_DIR / _ncnn_binary_name(),
+ Path("/usr/local/bin/realesrgan-ncnn-vulkan"),
+ Path.home() / ".local/bin/realesrgan-ncnn-vulkan",
+ Path(r"C:/realesrgan-ncnn-vulkan/realesrgan-ncnn-vulkan.exe"),
+ Path("/opt/homebrew/bin/realesrgan-ncnn-vulkan"),
+ ]
+ for p in candidates:
+ if p.exists() and os.access(p, os.X_OK):
+ return p
+ return None
+
+
+def invalidate_caps_cache():
+ """Call after installing new software so next probe picks it up."""
+ global _caps
+ _caps = None
+
+
+# ── Upscale implementations ───────────────────────────────────────────────────
+
+def _to_png_bytes(img: Image.Image) -> bytes:
+ buf = BytesIO()
+ img.save(buf, format="PNG")
+ return buf.getvalue()
+
+
+def upscale_lanczos(image: Image.Image, scale: float) -> tuple[bytes, str]:
+ """Pure Pillow Lanczos — instant, always available."""
+ new_w = round(image.width * scale)
+ new_h = round(image.height * scale)
+ result = image.resize((new_w, new_h), Image.Resampling.LANCZOS)
+ return _to_png_bytes(result), "lanczos"
+
+
+def upscale_realesrgan_pytorch(image: Image.Image, scale: float) -> tuple[bytes, str]:
+ """
+ Real-ESRGAN via PyTorch.
+ Uses CUDA > MPS > CPU automatically based on what's available.
+ """
+ import torch
+ from basicsr.archs.rrdbnet_arch import RRDBNet
+ from realesrgan import RealESRGANer
+
+ caps = probe_upscale_capabilities()
+ device = caps.get("realesrgan_pytorch_device", "cpu")
+
+ model_scale = 2 if scale <= 2.5 else 4
+ model = RRDBNet(
+ num_in_ch=3, num_out_ch=3, num_feat=64,
+ num_block=23, num_grow_ch=32, scale=model_scale
+ )
+
+ model_dir = Path("/app/data/models/realesrgan")
+ model_dir.mkdir(parents=True, exist_ok=True)
+ model_name = f"RealESRGAN_x{model_scale}plus.pth"
+ model_path = model_dir / model_name
+ if not model_path.exists():
+ model_path = None
+
+ upsampler = RealESRGANer(
+ scale=model_scale,
+ model_path=str(model_path) if model_path else None,
+ model=model,
+ tile=512,
+ tile_pad=10,
+ pre_pad=0,
+ half=(device == "cuda"),
+ device=torch.device(device),
+ )
+
+ import numpy as np
+ img_bgr = np.array(image)[:, :, ::-1].copy()
+ enhanced, _ = upsampler.enhance(img_bgr, outscale=scale)
+ result = Image.fromarray(enhanced[:, :, ::-1])
+
+ label = f"realesrgan_pytorch_{device}"
+ return _to_png_bytes(result), label
+
+
+def upscale_realesrgan_ncnn(image: Image.Image, scale: float) -> tuple[bytes, str]:
+ """
+ Real-ESRGAN via NCNN Vulkan binary — works on any GPU.
+ Runs as subprocess with temp file I/O.
+ """
+ caps = probe_upscale_capabilities()
+ binary = caps.get("realesrgan_ncnn_path")
+ if not binary:
+ raise RuntimeError("realesrgan-ncnn-vulkan binary not found")
+
+ model_scale = 4 if scale > 2.5 else 2
+ target_w = round(image.width * scale)
+ target_h = round(image.height * scale)
+
+ with tempfile.TemporaryDirectory() as tmpdir:
+ in_path = Path(tmpdir) / "input.png"
+ out_path = Path(tmpdir) / "output.png"
+
+ image.save(in_path, format="PNG")
+
+ model_name = f"realesrgan-x{model_scale}plus"
+ cmd = [
+ binary, "-i", str(in_path), "-o", str(out_path),
+ "-s", str(model_scale), "-n", model_name, "-f", "png",
+ ]
+
+ result_proc = subprocess.run(cmd, capture_output=True, timeout=300)
+ if result_proc.returncode != 0:
+ raise RuntimeError(
+ f"realesrgan-ncnn-vulkan failed: {result_proc.stderr.decode()}"
+ )
+
+ result = Image.open(out_path).convert("RGB")
+ if result.width != target_w or result.height != target_h:
+ result = result.resize((target_w, target_h), Image.Resampling.LANCZOS)
+
+ return _to_png_bytes(result), "realesrgan_ncnn"
+
+
+# ── Public entry point ────────────────────────────────────────────────────────
+
+def upscale_sync(image: Image.Image, scale: float, method: str = "auto") -> tuple[bytes, str]:
+ """Upscale image synchronously. Returns (png_bytes, method_used_label)."""
+ caps = probe_upscale_capabilities()
+
+ if method == "auto":
+ method = caps["recommended"]
+
+ if method == "realesrgan_pytorch":
+ if caps["realesrgan_pytorch"]:
+ try:
+ return upscale_realesrgan_pytorch(image, scale)
+ except Exception as e:
+ print(f"Real-ESRGAN PyTorch failed, falling back: {e}")
+ if caps["realesrgan_ncnn"]:
+ try:
+ return upscale_realesrgan_ncnn(image, scale)
+ except Exception as e:
+ print(f"Real-ESRGAN NCNN fallback failed: {e}")
+ return upscale_lanczos(image, scale)
+
+ if method == "realesrgan_ncnn":
+ if caps["realesrgan_ncnn"]:
+ try:
+ return upscale_realesrgan_ncnn(image, scale)
+ except Exception as e:
+ print(f"Real-ESRGAN NCNN failed, falling back: {e}")
+ if caps["realesrgan_pytorch"]:
+ try:
+ return upscale_realesrgan_pytorch(image, scale)
+ except Exception as e:
+ print(f"Real-ESRGAN PyTorch fallback failed: {e}")
+ return upscale_lanczos(image, scale)
+
+ return upscale_lanczos(image, scale)
+
+
+async def upscale_image(image: Image.Image, scale: float, method: str = "auto") -> tuple[bytes, str]:
+ """Async wrapper — runs upscale in thread pool to avoid blocking the event loop."""
+ loop = asyncio.get_event_loop()
+ return await loop.run_in_executor(None, upscale_sync, image, scale, method)
diff --git a/backend/requirements.txt b/backend/requirements.txt
index 9649ea5..28e4923 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -12,19 +12,14 @@ httpx==0.26.0
pydantic==2.5.3
pydantic-settings==2.1.0
email-validator==2.1.0
-opencv-python-headless==4.9.0.80
+opencv-python-headless>=4.10.0
# SAM (Segment Anything) for smart object selection - runs locally, no API needed
torch==2.1.2
torchvision==0.16.2
segment-anything @ git+https://github.com/facebookresearch/segment-anything.git
-# Note: Using OpenCV DNN instead of onnxruntime for U2Net
-# (onnxruntime has executable stack issues in some Docker environments)
+# Local AI inpainting — LaMa model (auto GPU/CPU, no API key needed)
+simple-lama-inpainting
-# NOTE: rembg (background removal) disabled due to dependency conflicts
-# rembg>=2.0.70 requires:
-# - scikit-image>=0.26.0 which requires numpy>=2.0
-# - Pillow>=12.1.0
-# But opencv-python-headless 4.9.0.80 requires numpy<2.0
-# To enable rembg, need to update opencv-python-headless to 4.10+ (numpy 2.x compatible)
-# and update all dependent packages accordingly
+# Background removal — rembg enabled now that opencv 4.10+ supports numpy 2.x
+rembg[gpu]
diff --git a/frontend/src/js/api/capabilities.js b/frontend/src/js/api/capabilities.js
new file mode 100644
index 0000000..b49b423
--- /dev/null
+++ b/frontend/src/js/api/capabilities.js
@@ -0,0 +1,65 @@
+/**
+ * Backend capabilities singleton.
+ * Fetched once on load from GET /api/config.
+ * Tools use this to decide whether to show, grey out, or show tooltips.
+ *
+ * Shape:
+ * {
+ * local: { lama, rembg, opencv, gpu_detected },
+ * remote: { provider, capabilities: string[], healthy }
+ * }
+ */
+
+import apiService from '../services/api.js';
+
+const DEFAULT_CAPS = {
+ local: { lama: false, rembg: false, opencv: true, gpu_detected: false },
+ remote: { provider: null, capabilities: [], healthy: false },
+};
+
+let _caps = null;
+let _fetchPromise = null;
+
+/**
+ * Return capabilities (fetched lazily, cached thereafter).
+ * Always resolves — falls back to DEFAULT_CAPS on network error.
+ */
+export async function getCapabilities() {
+ if (_caps) return _caps;
+ if (!_fetchPromise) {
+ _fetchPromise = apiService.getConfig()
+ .then(data => { _caps = data || DEFAULT_CAPS; return _caps; })
+ .catch(() => { _caps = DEFAULT_CAPS; return _caps; });
+ }
+ return _fetchPromise;
+}
+
+/**
+ * Synchronous check — returns cached value or DEFAULT_CAPS if not yet loaded.
+ */
+export function getCachedCapabilities() {
+ return _caps || DEFAULT_CAPS;
+}
+
+/**
+ * True if the remote provider is configured and healthy.
+ */
+export function hasRemote() {
+ return !!(_caps?.remote?.healthy);
+}
+
+/**
+ * Invalidate cache and re-fetch (call after saving provider settings).
+ */
+export async function refreshCapabilities() {
+ _caps = null;
+ _fetchPromise = null;
+ return getCapabilities();
+}
+
+/**
+ * Kick off the fetch immediately at module load time so it's ready when tools need it.
+ */
+getCapabilities();
+
+export default { getCapabilities, getCachedCapabilities, hasRemote, refreshCapabilities };
diff --git a/frontend/src/js/config-menu.js b/frontend/src/js/config-menu.js
index e99b763..b19e667 100644
--- a/frontend/src/js/config-menu.js
+++ b/frontend/src/js/config-menu.js
@@ -330,6 +330,16 @@ const menuDefinition = [
ellipsis: true,
target: 'image/remove_background.remove_background'
},
+ {
+ name: 'Fit to Frame...',
+ ellipsis: true,
+ target: 'image/frame_fit.frame_fit'
+ },
+ {
+ name: 'Upscale...',
+ ellipsis: true,
+ target: 'image/upscale.upscale'
+ },
{
divider: true
},
@@ -816,9 +826,32 @@ const menuDefinition = [
name: 'Settings',
ellipsis: true,
target: 'tools/settings.settings'
+ },
+ {
+ divider: true
+ },
+ {
+ name: 'AI Provider Settings',
+ ellipsis: true,
+ target: 'tools/ai_provider_settings.ai_provider_settings'
}
]
},
+ {
+ name: 'Generate',
+ children: [
+ {
+ name: 'Text → Image',
+ ellipsis: true,
+ target: 'generate/text_to_image.text_to_image'
+ },
+ {
+ name: 'Expand Canvas (Outpaint)',
+ ellipsis: true,
+ target: 'generate/outpaint.outpaint'
+ },
+ ]
+ },
{
name: 'Help',
children: [
diff --git a/frontend/src/js/config.js b/frontend/src/js/config.js
index 077c1f7..466112c 100644
--- a/frontend/src/js/config.js
+++ b/frontend/src/js/config.js
@@ -110,6 +110,35 @@ config.TOOLS = [
on_activate: 'on_activate',
attributes: {},
},
+ {
+ name: 'ai_lama_erase',
+ title: 'AI Magic Erase (LaMa) - Paint over to erase',
+ attributes: {
+ size: {
+ value: 30,
+ min: 5,
+ max: 200,
+ },
+ },
+ },
+ {
+ name: 'ai_smart_inpaint',
+ title: 'AI Smart Inpaint - Paint + describe replacement',
+ on_activate: 'on_activate',
+ attributes: {
+ size: {
+ value: 30,
+ min: 5,
+ max: 200,
+ },
+ },
+ },
+ {
+ name: 'ai_replace_selection',
+ title: 'AI Replace Selection - Use any selection tool first',
+ on_activate: 'on_activate',
+ attributes: {},
+ },
{
name: 'magic_wand',
title: 'Magic Wand (Color Select)',
diff --git a/frontend/src/js/core/components/provider-badge.js b/frontend/src/js/core/components/provider-badge.js
new file mode 100644
index 0000000..8724733
--- /dev/null
+++ b/frontend/src/js/core/components/provider-badge.js
@@ -0,0 +1,72 @@
+/**
+ * ProviderBadge — small DOM element showing the active AI provider.
+ * Inserted into the toolbar footer on app load.
+ *
+ * Green = remote provider healthy
+ * Yellow = provider configured but unhealthy/unreachable
+ * Grey = local only (LaMa + OpenCV)
+ */
+
+import { getCapabilities } from '../../api/capabilities.js';
+
+export async function mountProviderBadge(container) {
+ var caps = await getCapabilities();
+
+ var badge = document.createElement('div');
+ badge.id = 'provider-badge';
+ badge.style.cssText = [
+ 'display:inline-flex', 'align-items:center', 'gap:5px',
+ 'padding:3px 8px', 'border-radius:10px',
+ 'font-size:11px', 'font-family:sans-serif',
+ 'cursor:default', 'user-select:none',
+ 'margin:4px', 'opacity:0.85',
+ ].join(';');
+
+ var dot = document.createElement('span');
+ dot.style.cssText = 'width:7px;height:7px;border-radius:50%;display:inline-block;';
+
+ var label = document.createElement('span');
+
+ var remote = caps.remote || {};
+ var local = caps.local || {};
+
+ if (remote.provider && remote.healthy) {
+ dot.style.background = '#44cc44';
+ badge.style.background = '#1a2a1a';
+ badge.style.color = '#aaffaa';
+
+ // Show override summary if any operations use different providers
+ var overrides = remote.overrides || {};
+ var overrideEntries = Object.entries(overrides).filter(([, v]) => v);
+ var overrideStr = overrideEntries.length
+ ? ' · ' + overrideEntries.map(([k, v]) => `${k}→${v}`).join(', ')
+ : '';
+ label.textContent = remote.provider + overrideStr + (local.gpu_detected ? ' · GPU' : '');
+
+ var opLines = Object.entries(remote.operations || {})
+ .map(([op, s]) => `${op}: ${s.provider || remote.provider} ${s.healthy ? '✓' : '✗'}`)
+ .join('\n');
+ badge.title = opLines || ('Provider: ' + remote.provider);
+ } else if (remote.provider && !remote.healthy) {
+ dot.style.background = '#ffaa00';
+ badge.style.background = '#2a2000';
+ badge.style.color = '#ffdd88';
+ label.textContent = remote.provider + ' (offline)';
+ badge.title = remote.provider + ' is configured but not reachable. Check your .env URL.';
+ } else {
+ dot.style.background = '#888888';
+ badge.style.background = '#1a1a1a';
+ badge.style.color = '#aaaaaa';
+ label.textContent = 'Local' + (local.lama ? ' · LaMa' : '') + (local.gpu_detected ? ' · GPU' : '');
+ badge.title = 'Local only. Set AI_PROVIDER in .env to enable generative tools.';
+ }
+
+ badge.appendChild(dot);
+ badge.appendChild(label);
+
+ if (container) {
+ container.appendChild(badge);
+ }
+
+ return badge;
+}
diff --git a/frontend/src/js/main.js b/frontend/src/js/main.js
index 9d3eac0..ded0000 100644
--- a/frontend/src/js/main.js
+++ b/frontend/src/js/main.js
@@ -23,6 +23,7 @@ import Base_search_class from './core/base-search.js';
import File_open_class from './modules/file/open.js';
import File_save_class from './modules/file/save.js';
import * as Actions from './actions/index.js';
+import { mountProviderBadge } from './core/components/provider-badge.js';
window.addEventListener('load', function (e) {
// Initiate app
@@ -54,4 +55,7 @@ window.addEventListener('load', function (e) {
// Render all
GUI.init();
Layers.init();
+
+ // Mount provider badge in the tools panel footer
+ mountProviderBadge(document.getElementById('tools_container') || document.body);
}, false);
diff --git a/frontend/src/js/modules/generate/outpaint.js b/frontend/src/js/modules/generate/outpaint.js
new file mode 100644
index 0000000..cd7165a
--- /dev/null
+++ b/frontend/src/js/modules/generate/outpaint.js
@@ -0,0 +1,142 @@
+/**
+ * Outpaint / Expand Canvas — remote provider fills the new region.
+ * Menu target: generate/outpaint.outpaint
+ */
+
+import app from './../../app.js';
+import config from './../../config.js';
+import Base_layers_class from './../../core/base-layers.js';
+import Dialog_class from './../../libs/popup.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+import apiService from './../../services/api.js';
+import { getCapabilities } from './../../api/capabilities.js';
+
+var instance = null;
+
+class Generate_outpaint_class {
+
+ constructor() {
+ if (instance) return instance;
+ instance = this;
+ this.Base_layers = new Base_layers_class();
+ this.Dialog = new Dialog_class();
+ this.isProcessing = false;
+ }
+
+ async outpaint() {
+ var caps = await getCapabilities();
+ if (!caps.remote || !caps.remote.healthy) {
+ alertify.error(
+ 'Expand Canvas requires a remote AI provider. ' +
+ 'Set AI_PROVIDER (openai / invokeai / comfyui) in .env and restart.'
+ );
+ return;
+ }
+
+ var _this = this;
+
+ this.Dialog.show({
+ title: 'Expand Canvas (Outpaint)',
+ params: [
+ {
+ name: 'direction',
+ title: 'Expand direction:',
+ value: 'right',
+ values: ['right', 'left', 'bottom', 'top'],
+ },
+ {
+ name: 'size',
+ title: 'Pixels to add:',
+ type: 'range',
+ value: 256,
+ range: [64, 1024],
+ step: 64,
+ },
+ {
+ name: 'prompt',
+ title: 'Describe the expansion (optional):',
+ value: '',
+ placeholder: "e.g. 'continue the landscape', 'more sky and clouds'",
+ },
+ ],
+ on_finish: async function (params) {
+ await _this._run(params);
+ },
+ });
+ }
+
+ async _run(params) {
+ if (this.isProcessing) return;
+ if (config.layer.type !== 'image') {
+ alertify.error('Current layer must be an image.');
+ return;
+ }
+
+ this.isProcessing = true;
+ alertify.message('Expanding canvas... please wait', 0);
+
+ try {
+ var layerCanvas = document.createElement('canvas');
+ layerCanvas.width = config.layer.width_original;
+ layerCanvas.height = config.layer.height_original;
+ layerCanvas.getContext('2d').drawImage(config.layer.link, 0, 0);
+ var imageB64 = layerCanvas.toDataURL('image/png').split(',')[1];
+
+ var response = await fetch(
+ (window.API_BASE_URL || '') + '/api/generate/outpaint',
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ image: imageB64,
+ direction: params.direction,
+ size: params.size || 256,
+ prompt: params.prompt || '',
+ }),
+ }
+ );
+ if (!response.ok) {
+ var err = await response.json().catch(() => ({ detail: 'Unknown error' }));
+ throw new Error(err.detail || 'Outpaint failed');
+ }
+ var result = await response.json();
+
+ var img = new Image();
+ img.onload = () => {
+ var newW = img.naturalWidth;
+ var newH = img.naturalHeight;
+ var resultCanvas = document.createElement('canvas');
+ resultCanvas.width = newW;
+ resultCanvas.height = newH;
+ resultCanvas.getContext('2d').drawImage(img, 0, 0);
+
+ // Update canvas dimensions and replace layer
+ config.WIDTH = newW;
+ config.HEIGHT = newH;
+ app.State.do_action(
+ new app.Actions.Bundle_action('outpaint', 'Expand Canvas', [
+ new app.Actions.Resize_canvas_action(newW, newH),
+ new app.Actions.Update_layer_image_action(resultCanvas),
+ ])
+ );
+
+ alertify.dismissAll();
+ alertify.success('Canvas expanded!');
+ this.isProcessing = false;
+ };
+ img.onerror = () => {
+ alertify.dismissAll();
+ alertify.error('Failed to load expanded image.');
+ this.isProcessing = false;
+ };
+ img.src = 'data:image/png;base64,' + result.result;
+
+ } catch (err) {
+ alertify.dismissAll();
+ alertify.error('Outpaint failed: ' + (err.message || err));
+ this.isProcessing = false;
+ }
+ }
+}
+
+export default Generate_outpaint_class;
diff --git a/frontend/src/js/modules/generate/text_to_image.js b/frontend/src/js/modules/generate/text_to_image.js
new file mode 100644
index 0000000..d23ddca
--- /dev/null
+++ b/frontend/src/js/modules/generate/text_to_image.js
@@ -0,0 +1,174 @@
+/**
+ * Text → Image — opens a sidebar-style dialog, generates via remote provider,
+ * pastes result as a new layer on the current canvas.
+ *
+ * Menu target: generate/text_to_image.text_to_image
+ */
+
+import app from './../../app.js';
+import config from './../../config.js';
+import Base_layers_class from './../../core/base-layers.js';
+import Dialog_class from './../../libs/popup.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+import apiService from './../../services/api.js';
+import { getCapabilities } from './../../api/capabilities.js';
+
+var instance = null;
+
+class Generate_text_to_image_class {
+
+ constructor() {
+ if (instance) return instance;
+ instance = this;
+ this.Base_layers = new Base_layers_class();
+ this.Dialog = new Dialog_class();
+ this.isProcessing = false;
+ }
+
+ async text_to_image() {
+ var caps = await getCapabilities();
+ if (!caps.remote || !caps.remote.healthy) {
+ alertify.error(
+ 'Text → Image requires a remote AI provider. ' +
+ 'Set AI_PROVIDER (openai / invokeai / comfyui) in .env and restart.'
+ );
+ return;
+ }
+
+ var _this = this;
+ var canvasW = config.WIDTH || 1024;
+ var canvasH = config.HEIGHT || 1024;
+
+ this.Dialog.show({
+ title: 'Text → Image',
+ params: [
+ {
+ name: 'prompt',
+ title: 'Describe your image:',
+ type: 'textarea',
+ value: '',
+ placeholder: "e.g. 'a serene mountain lake at sunset, cinematic lighting'",
+ },
+ {
+ name: 'negative_prompt',
+ title: 'Avoid (optional):',
+ value: '',
+ placeholder: 'blurry, distorted, watermark',
+ },
+ {
+ name: 'width',
+ title: 'Width (px):',
+ value: Math.min(canvasW, 1024),
+ range: [256, 2048],
+ step: 64,
+ type: 'range',
+ },
+ {
+ name: 'height',
+ title: 'Height (px):',
+ value: Math.min(canvasH, 1024),
+ range: [256, 2048],
+ step: 64,
+ type: 'range',
+ },
+ {
+ name: 'placement',
+ title: 'Add as:',
+ value: 'new_layer',
+ values: ['new_layer', 'replace_canvas'],
+ },
+ {
+ name: 'steps',
+ title: 'Steps:',
+ type: 'range',
+ value: 30,
+ range: [10, 60],
+ step: 5,
+ },
+ {
+ name: 'seed',
+ title: 'Seed (0 = random):',
+ value: 0,
+ range: [0, 2147483647],
+ step: 1,
+ type: 'range',
+ },
+ ],
+ on_finish: async function (params) {
+ if (!params.prompt || !params.prompt.trim()) {
+ alertify.warning('Please enter a description.');
+ return;
+ }
+ await _this._generate(params);
+ },
+ });
+ }
+
+ async _generate(params) {
+ if (this.isProcessing) return;
+ this.isProcessing = true;
+ alertify.message('Generating image... please wait', 0);
+
+ try {
+ var result = await apiService.textToImage(params.prompt, {
+ width: params.width || 1024,
+ height: params.height || 1024,
+ negativePrompt: params.negative_prompt || '',
+ steps: params.steps || 30,
+ seed: params.seed || 0,
+ });
+
+ var img = new Image();
+ img.onload = () => {
+ if (params.placement === 'replace_canvas') {
+ // Resize canvas and replace bottom layer
+ config.WIDTH = img.naturalWidth;
+ config.HEIGHT = img.naturalHeight;
+ var resultCanvas = document.createElement('canvas');
+ resultCanvas.width = img.naturalWidth;
+ resultCanvas.height = img.naturalHeight;
+ resultCanvas.getContext('2d').drawImage(img, 0, 0);
+ app.State.do_action(
+ new app.Actions.Bundle_action('txt2img_replace', 'Text → Image', [
+ new app.Actions.Update_layer_image_action(resultCanvas)
+ ])
+ );
+ } else {
+ // Add as new layer on top
+ var dataURL = img.src;
+ app.State.do_action(
+ new app.Actions.Bundle_action('txt2img_layer', 'Text → Image Layer', [
+ new app.Actions.Insert_layer_action({
+ name: params.prompt.slice(0, 30),
+ type: 'image',
+ data: dataURL,
+ x: 0,
+ y: 0,
+ width: img.naturalWidth,
+ height: img.naturalHeight,
+ width_original: img.naturalWidth,
+ height_original: img.naturalHeight,
+ })
+ ])
+ );
+ }
+ alertify.dismissAll();
+ alertify.success('Image generated!');
+ this.isProcessing = false;
+ };
+ img.onerror = () => {
+ alertify.dismissAll();
+ alertify.error('Failed to load generated image.');
+ this.isProcessing = false;
+ };
+ img.src = 'data:image/png;base64,' + result.result;
+
+ } catch (err) {
+ alertify.dismissAll();
+ alertify.error('Generation failed: ' + (err.message || err));
+ this.isProcessing = false;
+ }
+ }
+}
+
+export default Generate_text_to_image_class;
diff --git a/frontend/src/js/modules/help/about.js b/frontend/src/js/modules/help/about.js
index 192984a..405f1f3 100644
--- a/frontend/src/js/modules/help/about.js
+++ b/frontend/src/js/modules/help/about.js
@@ -9,19 +9,23 @@ class Help_about_class {
//about
about() {
- var email = 'www.viliusl@gmail.com';
-
+ var email = 'www.viliusl@gmail.com';
+
var settings = {
title: 'About',
params: [
{title: "", html: '
'},
- {title: "Name:", html: 'miniPaint'},
+ {title: "Name:", html: 'PaintPlus'},
{title: "Version:", value: VERSION},
- {title: "Description:", value: "Online image editor."},
- {title: "Author:", value: 'ViliusL'},
- {title: "Email:", html: '' + email + ''},
- {title: "GitHub:", html: 'https://github.com/viliusle/miniPaint'},
- {title: "Website:", html: 'https://viliusle.github.io/miniPaint/'},
+ {title: "Description:", value: "Layer-based image editor with AI tools."},
+ {title: "", html: '