Merge pull request #53 from outis1one/claude/fervent-dirac-ldwaki
Claude/fervent dirac ldwaki
This commit is contained in:
+25
-6
@@ -14,18 +14,37 @@
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
# STEP 1: Choose AI Provider
|
# STEP 1: Choose AI Provider
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Options: mock, openai, stability, replicate
|
# Options: local_gpu, mock, openai, stability, replicate, invokeai, comfyui
|
||||||
#
|
#
|
||||||
# mock = Free, but returns original image unchanged (for testing UI)
|
# local_gpu = FREE, runs on YOUR GPU — best option if you have an NVIDIA card
|
||||||
# openai = DALL-E 2 inpainting (~$0.02/image) - lower quality
|
# (use docker-compose.gpu.yml — models auto-download on first use)
|
||||||
# stability = Stability AI SDXL (~$0.01/image) - good quality
|
# mock = Free, returns original image unchanged (UI testing only)
|
||||||
# replicate = Multiple models (~$0.002-0.03/image) - RECOMMENDED
|
# openai = DALL-E 3 / gpt-image-1 (~$0.02-0.04/image)
|
||||||
|
# stability = Stability AI SDXL (~$0.01/image)
|
||||||
|
# replicate = Multiple models (~$0.002-0.03/image)
|
||||||
|
# invokeai = Self-hosted InvokeAI running on another machine
|
||||||
|
# comfyui = Self-hosted ComfyUI running on another machine
|
||||||
#
|
#
|
||||||
# RECOMMENDED: Use "replicate" for best quality and model variety
|
# GPU QUICK-START:
|
||||||
|
# docker compose -f docker-compose.gpu.yml up --build
|
||||||
|
# (AI_PROVIDER defaults to local_gpu in that compose file)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
AI_PROVIDER=replicate
|
AI_PROVIDER=replicate
|
||||||
|
|
||||||
|
# ── Local GPU settings (only relevant when AI_PROVIDER=local_gpu) ────────────
|
||||||
|
# Auto-download HuggingFace models on first request (true/false)
|
||||||
|
AUTO_DOWNLOAD_MODELS=true
|
||||||
|
# Max diffusion pipelines to keep loaded in GPU memory (each is 2–7 GB)
|
||||||
|
LOCAL_GPU_MAX_PIPELINES=2
|
||||||
|
# HuggingFace token — only needed for gated/private models
|
||||||
|
#HF_TOKEN=hf_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
|
# Override auto-selected model for any operation (leave blank = auto by VRAM tier)
|
||||||
|
#HF_MODEL_INPAINT=your-org/your-inpaint-model
|
||||||
|
#HF_MODEL_TXT2IMG=your-org/your-txt2img-model
|
||||||
|
#HF_MODEL_IMG2IMG=your-org/your-img2img-model
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
# Per-operation provider overrides (optional — blank means use AI_PROVIDER above)
|
# 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
|
# Example: use OpenAI for text-to-image (best quality) but InvokeAI for everything else
|
||||||
#AI_PROVIDER_TXT2IMG=openai
|
#AI_PROVIDER_TXT2IMG=openai
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# EditmaskwithAI — GPU Container (NVIDIA CUDA)
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# docker compose -f docker-compose.gpu.yml up --build
|
||||||
|
#
|
||||||
|
# Requirements on host:
|
||||||
|
# - NVIDIA driver ≥ 525 (for CUDA 12.x)
|
||||||
|
# - nvidia-container-toolkit installed and configured
|
||||||
|
# - docker compose v2 (or docker-compose with GPU device support)
|
||||||
|
#
|
||||||
|
# AMD ROCm users: replace the pytorch base image with a ROCm variant, e.g.
|
||||||
|
# rocm/pytorch:latest (and remove the nvidia-smi check below)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# ── Stage 1: Build miniPaint frontend ────────────────────────────────────────
|
||||||
|
FROM node:20-alpine AS frontend-build
|
||||||
|
|
||||||
|
WORKDIR /frontend
|
||||||
|
COPY frontend/package.json frontend/package-lock.json* ./
|
||||||
|
RUN npm install
|
||||||
|
COPY frontend/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ── Stage 2: PyTorch CUDA runtime ────────────────────────────────────────────
|
||||||
|
# pytorch/pytorch already includes torch + torchvision built for CUDA 12.1.
|
||||||
|
# Using the runtime (not devel) image keeps the layer lean.
|
||||||
|
FROM pytorch/pytorch:2.1.0-cuda12.1-cudnn8-runtime
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# System dependencies
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libgl1 \
|
||||||
|
libglib2.0-0 \
|
||||||
|
libsm6 \
|
||||||
|
libxext6 \
|
||||||
|
libxrender-dev \
|
||||||
|
libgomp1 \
|
||||||
|
wget \
|
||||||
|
git \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install Python dependencies — base + GPU extras
|
||||||
|
COPY backend/requirements.txt .
|
||||||
|
COPY backend/requirements.gpu.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
RUN pip install --no-cache-dir -r requirements.gpu.txt
|
||||||
|
|
||||||
|
# Smoke-test rembg (model downloads on first use)
|
||||||
|
RUN python -c "from rembg import remove; print('rembg OK')" \
|
||||||
|
|| echo "WARNING: rembg unavailable — Remove Background disabled"
|
||||||
|
|
||||||
|
# Copy backend application
|
||||||
|
COPY backend/ .
|
||||||
|
|
||||||
|
# Entrypoint
|
||||||
|
COPY backend/entrypoint.sh /entrypoint.sh
|
||||||
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
|
# Scripts (SAM download, DB init, GPU setup, etc.)
|
||||||
|
COPY scripts/ /scripts/
|
||||||
|
RUN chmod +x /scripts/*.py 2>/dev/null || true
|
||||||
|
|
||||||
|
# Copy built frontend from Stage 1
|
||||||
|
COPY --from=frontend-build /frontend/index.html /app/static/
|
||||||
|
COPY --from=frontend-build /frontend/dist /app/static/dist
|
||||||
|
COPY --from=frontend-build /frontend/images /app/static/images
|
||||||
|
COPY --from=frontend-build /frontend/src/css /app/static/src/css
|
||||||
|
|
||||||
|
# Persistent data directories
|
||||||
|
RUN mkdir -p /app/data/projects /app/data/patches /app/data/models
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
@@ -46,6 +46,15 @@ class Settings(BaseSettings):
|
|||||||
# Allow per-edit model override
|
# Allow per-edit model override
|
||||||
allow_model_override: bool = True
|
allow_model_override: bool = True
|
||||||
|
|
||||||
|
# Local GPU diffusion (AI_PROVIDER=local_gpu)
|
||||||
|
auto_download_models: bool = True # download HF models on first use
|
||||||
|
local_gpu_max_pipelines: int = 2 # max diffusion pipelines kept in GPU memory
|
||||||
|
hf_token: str = "" # HuggingFace token (only needed for gated models)
|
||||||
|
# Override auto-selected models per operation (leave blank = auto-pick by VRAM tier)
|
||||||
|
hf_model_inpaint: str = ""
|
||||||
|
hf_model_txt2img: str = ""
|
||||||
|
hf_model_img2img: str = ""
|
||||||
|
|
||||||
# File Storage
|
# File Storage
|
||||||
data_dir: str = "./data"
|
data_dir: str = "./data"
|
||||||
max_upload_size_mb: int = 50
|
max_upload_size_mb: int = 50
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import os
|
|||||||
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, tools, ai_tools, print_tools
|
from app.routers import projects, edits, images, patches, generate, tools, ai_tools, print_tools
|
||||||
|
from app.routers import gpu_status
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -24,6 +25,33 @@ async def lifespan(app: FastAPI):
|
|||||||
# Pre-download SAM model in background so first click is fast
|
# Pre-download SAM model in background so first click is fast
|
||||||
from app.services.sam_service import ensure_sam_installed
|
from app.services.sam_service import ensure_sam_installed
|
||||||
asyncio.create_task(ensure_sam_installed())
|
asyncio.create_task(ensure_sam_installed())
|
||||||
|
|
||||||
|
# If local GPU provider is active, log GPU info at startup
|
||||||
|
if settings.ai_provider.lower() == "local_gpu" or any(
|
||||||
|
v.lower() == "local_gpu"
|
||||||
|
for v in [
|
||||||
|
settings.ai_provider_inpaint,
|
||||||
|
settings.ai_provider_txt2img,
|
||||||
|
settings.ai_provider_img2img,
|
||||||
|
settings.ai_provider_outpaint,
|
||||||
|
]
|
||||||
|
if v
|
||||||
|
):
|
||||||
|
from app.services.gpu_detect import get_cached_gpu_info
|
||||||
|
info = get_cached_gpu_info()
|
||||||
|
cc_str = f" | CC={info.compute_capability}" if info.compute_capability else ""
|
||||||
|
print(
|
||||||
|
f"[gpu] {info.device_name} | {info.vram_gb:.1f} GB{cc_str} | "
|
||||||
|
f"tier={info.tier} | fp16={info.fp16}"
|
||||||
|
)
|
||||||
|
for w in info.warnings:
|
||||||
|
print(f"[gpu] ⚠ {w}")
|
||||||
|
if settings.auto_download_models:
|
||||||
|
# Download model weight files to disk cache in background so first
|
||||||
|
# user request loads from local disk instead of the internet.
|
||||||
|
from app.services.local_diffusion import prefetch_model_files
|
||||||
|
asyncio.create_task(prefetch_model_files())
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
@@ -52,6 +80,7 @@ app.include_router(generate.router)
|
|||||||
app.include_router(tools.router)
|
app.include_router(tools.router)
|
||||||
app.include_router(ai_tools.router)
|
app.include_router(ai_tools.router)
|
||||||
app.include_router(print_tools.router)
|
app.include_router(print_tools.router)
|
||||||
|
app.include_router(gpu_status.router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api")
|
@app.get("/api")
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ All endpoints are under /api prefix.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
import base64
|
import base64
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
|
|
||||||
from app.services.local_inpaint import (
|
from app.services.local_inpaint import (
|
||||||
lama_inpaint, opencv_inpaint, lama_available, gpu_available, rembg_available,
|
lama_inpaint, opencv_inpaint, lama_available, gpu_available, rembg_available,
|
||||||
@@ -191,6 +193,37 @@ async def inpaint_remote(req: InpaintRemoteRequest):
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/generate/progress")
|
||||||
|
async def generation_progress_stream():
|
||||||
|
"""
|
||||||
|
SSE stream of local GPU pipeline inference progress.
|
||||||
|
Events are JSON arrays of pipeline state objects, emitted every 200 ms.
|
||||||
|
Each object: {pipeline, state, step, total_steps, progress, message, model_id, …}
|
||||||
|
Clients open this with EventSource before firing a generation POST,
|
||||||
|
then close it when the POST resolves.
|
||||||
|
"""
|
||||||
|
from app.services.local_diffusion import get_all_model_states
|
||||||
|
|
||||||
|
async def event_gen():
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
states = get_all_model_states()
|
||||||
|
yield f"data: {json.dumps(states)}\n\n"
|
||||||
|
await asyncio.sleep(0.2)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
event_gen(),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/generate/txt2img")
|
@router.post("/generate/txt2img")
|
||||||
async def txt2img(req: Txt2ImgRequest):
|
async def txt2img(req: Txt2ImgRequest):
|
||||||
"""Text-to-image via configured remote provider."""
|
"""Text-to-image via configured remote provider."""
|
||||||
@@ -327,12 +360,29 @@ async def get_config():
|
|||||||
# Default provider for display (used when no per-op override)
|
# Default provider for display (used when no per-op override)
|
||||||
default_name = (settings.ai_provider or "").lower() or None
|
default_name = (settings.ai_provider or "").lower() or None
|
||||||
|
|
||||||
|
from app.services.gpu_detect import get_cached_gpu_info
|
||||||
|
gpu_info = get_cached_gpu_info()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"local": {
|
"local": {
|
||||||
"lama": lama_available(),
|
"lama": lama_available(),
|
||||||
"rembg": rembg_available(),
|
"rembg": rembg_available(),
|
||||||
"opencv": True,
|
"opencv": True,
|
||||||
"gpu_detected": gpu_available(),
|
"gpu_detected": gpu_available(),
|
||||||
|
"gpu_backend": gpu_info.backend,
|
||||||
|
"gpu_device": gpu_info.device_name,
|
||||||
|
"gpu_vram_total": gpu_info.vram_total_gb,
|
||||||
|
"gpu_vram_free": gpu_info.vram_free_gb,
|
||||||
|
"gpu_cc": gpu_info.compute_capability,
|
||||||
|
"gpu_fp16": gpu_info.fp16,
|
||||||
|
"gpu_bf16": gpu_info.bf16,
|
||||||
|
"gpu_fp8": gpu_info.fp8,
|
||||||
|
"gpu_tensor_cores": gpu_info.tensor_cores,
|
||||||
|
"gpu_tier": gpu_info.tier,
|
||||||
|
"gpu_eff_vram": gpu_info.effective_vram_gb,
|
||||||
|
"local_gpu_available": gpu_info.backend in ("cuda", "mps"),
|
||||||
|
"local_gpu_capabilities": gpu_info.capabilities,
|
||||||
|
"local_gpu_warnings": gpu_info.warnings,
|
||||||
},
|
},
|
||||||
"remote": {
|
"remote": {
|
||||||
"default_provider": default_name,
|
"default_provider": default_name,
|
||||||
@@ -350,6 +400,164 @@ async def get_config():
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Selection image operations ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
class ScaleSelectionRequest(BaseModel):
|
||||||
|
image: str # base64 full canvas
|
||||||
|
mask: str # base64 selection mask (white = object)
|
||||||
|
scale_pct: float = 103.0 # 103 = 3% bigger, 95 = 5% smaller
|
||||||
|
|
||||||
|
|
||||||
|
class AiEditRegionRequest(BaseModel):
|
||||||
|
image: str
|
||||||
|
mask: str
|
||||||
|
instruction: str
|
||||||
|
negative_prompt: str = ""
|
||||||
|
steps: int = 30
|
||||||
|
cfg_scale: float = 7.5
|
||||||
|
|
||||||
|
|
||||||
|
class PasteIntoSelectionRequest(BaseModel):
|
||||||
|
image: str # base64 target canvas
|
||||||
|
mask: str # base64 selection mask
|
||||||
|
paste_image: str # base64 image to paste
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/image/scale-selection")
|
||||||
|
async def scale_selection(req: ScaleSelectionRequest):
|
||||||
|
"""
|
||||||
|
Scale the object selected by mask by scale_pct%, AI-fill the exposed gap.
|
||||||
|
Works purely with local tools (LaMa/OpenCV) — no remote provider needed.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image, ImageFilter
|
||||||
|
except ImportError:
|
||||||
|
raise HTTPException(status_code=500, detail="PIL/numpy not available")
|
||||||
|
|
||||||
|
img = Image.open(BytesIO(_decode(req.image))).convert("RGB")
|
||||||
|
mask = Image.open(BytesIO(_decode(req.mask))).convert("L")
|
||||||
|
if img.size != mask.size:
|
||||||
|
mask = mask.resize(img.size, Image.LANCZOS)
|
||||||
|
|
||||||
|
mask_arr = np.array(mask)
|
||||||
|
ys, xs = np.where(mask_arr > 128)
|
||||||
|
if len(xs) == 0:
|
||||||
|
raise HTTPException(status_code=400, detail="Empty mask — nothing to scale")
|
||||||
|
|
||||||
|
minx, maxx = int(xs.min()), int(xs.max())
|
||||||
|
miny, maxy = int(ys.min()), int(ys.max())
|
||||||
|
cx, cy = (minx + maxx) / 2.0, (miny + maxy) / 2.0
|
||||||
|
obj_w, obj_h = maxx - minx + 1, maxy - miny + 1
|
||||||
|
|
||||||
|
scale = req.scale_pct / 100.0
|
||||||
|
new_w = max(1, round(obj_w * scale))
|
||||||
|
new_h = max(1, round(obj_h * scale))
|
||||||
|
|
||||||
|
# Extract masked object crop (RGBA with mask as alpha)
|
||||||
|
img_rgba = img.convert("RGBA")
|
||||||
|
obj_crop = img_rgba.crop((minx, miny, maxx + 1, maxy + 1))
|
||||||
|
mask_crop = mask.crop((minx, miny, maxx + 1, maxy + 1))
|
||||||
|
r, g, b, _ = obj_crop.split()
|
||||||
|
obj_masked = Image.merge("RGBA", (r, g, b, mask_crop))
|
||||||
|
scaled_obj = obj_masked.resize((new_w, new_h), Image.LANCZOS)
|
||||||
|
|
||||||
|
# AI-fill the original mask area (gap) with LaMa/OpenCV
|
||||||
|
gap_mask = mask.filter(ImageFilter.MaxFilter(9)) # expand ~4px for clean seam
|
||||||
|
gap_bytes = BytesIO()
|
||||||
|
img.save(gap_bytes, format="PNG")
|
||||||
|
gap_mask_bytes = BytesIO()
|
||||||
|
gap_mask.save(gap_mask_bytes, format="PNG")
|
||||||
|
|
||||||
|
try:
|
||||||
|
if lama_available():
|
||||||
|
filled_bytes = await asyncio.get_event_loop().run_in_executor(
|
||||||
|
None, lama_inpaint, gap_bytes.getvalue(), gap_mask_bytes.getvalue()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
filled_bytes = await asyncio.get_event_loop().run_in_executor(
|
||||||
|
None, opencv_inpaint, gap_bytes.getvalue(), gap_mask_bytes.getvalue()
|
||||||
|
)
|
||||||
|
filled = Image.open(BytesIO(filled_bytes)).convert("RGBA")
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[scale-selection] fill fallback: {exc}")
|
||||||
|
filled = img.convert("RGBA")
|
||||||
|
|
||||||
|
# Paste scaled object centered on original centroid
|
||||||
|
px = round(cx - new_w / 2)
|
||||||
|
py = round(cy - new_h / 2)
|
||||||
|
result = filled.copy()
|
||||||
|
result.paste(scaled_obj, (px, py), scaled_obj.split()[3])
|
||||||
|
|
||||||
|
out = BytesIO()
|
||||||
|
result.convert("RGB").save(out, format="PNG")
|
||||||
|
return {"result": _encode(out.getvalue())}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/image/ai-edit-region")
|
||||||
|
async def ai_edit_region(req: AiEditRegionRequest):
|
||||||
|
"""
|
||||||
|
AI-edit the selected region using the configured inpaint provider.
|
||||||
|
Works with local_gpu, InvokeAI, ComfyUI, or OpenAI.
|
||||||
|
"""
|
||||||
|
provider = _require_remote("inpaint")
|
||||||
|
result_bytes = await provider.inpaint(
|
||||||
|
_decode(req.image),
|
||||||
|
_decode(req.mask),
|
||||||
|
req.instruction,
|
||||||
|
{"negative_prompt": req.negative_prompt, "steps": req.steps, "cfg_scale": req.cfg_scale},
|
||||||
|
)
|
||||||
|
return {"result": _encode(result_bytes)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/image/paste-into-selection")
|
||||||
|
async def paste_into_selection(req: PasteIntoSelectionRequest):
|
||||||
|
"""
|
||||||
|
Scale a clipboard image to the selection bounding box, mask it to the
|
||||||
|
selection shape, and composite it over the original canvas.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image
|
||||||
|
except ImportError:
|
||||||
|
raise HTTPException(status_code=500, detail="PIL/numpy not available")
|
||||||
|
|
||||||
|
img = Image.open(BytesIO(_decode(req.image))).convert("RGBA")
|
||||||
|
mask = Image.open(BytesIO(_decode(req.mask))).convert("L")
|
||||||
|
paste_img = Image.open(BytesIO(_decode(req.paste_image))).convert("RGBA")
|
||||||
|
|
||||||
|
if img.size != mask.size:
|
||||||
|
mask = mask.resize(img.size, Image.LANCZOS)
|
||||||
|
|
||||||
|
mask_arr = np.array(mask)
|
||||||
|
ys, xs = np.where(mask_arr > 128)
|
||||||
|
if len(xs) == 0:
|
||||||
|
raise HTTPException(status_code=400, detail="Empty mask")
|
||||||
|
|
||||||
|
minx, maxx = int(xs.min()), int(xs.max())
|
||||||
|
miny, maxy = int(ys.min()), int(ys.max())
|
||||||
|
target_w = maxx - minx + 1
|
||||||
|
target_h = maxy - miny + 1
|
||||||
|
|
||||||
|
# Scale clipboard image to fit the selection bounding box
|
||||||
|
paste_scaled = paste_img.resize((target_w, target_h), Image.LANCZOS)
|
||||||
|
|
||||||
|
# Clip paste to selection shape using mask
|
||||||
|
mask_crop = mask.crop((minx, miny, maxx + 1, maxy + 1))
|
||||||
|
r, g, b, a = paste_scaled.split()
|
||||||
|
mask_np = np.array(mask_crop)
|
||||||
|
alpha_np = np.array(a)
|
||||||
|
combined = (alpha_np.astype(np.uint16) * mask_np.astype(np.uint16) // 255).astype(np.uint8)
|
||||||
|
paste_final = Image.merge("RGBA", (r, g, b, Image.fromarray(combined)))
|
||||||
|
|
||||||
|
result = img.copy()
|
||||||
|
result.paste(paste_final, (minx, miny), paste_final.split()[3])
|
||||||
|
|
||||||
|
out = BytesIO()
|
||||||
|
result.convert("RGB").save(out, format="PNG")
|
||||||
|
return {"result": _encode(out.getvalue())}
|
||||||
|
|
||||||
|
|
||||||
# ─── SAM (Segment Anything) ──────────────────────────────────────────────────
|
# ─── SAM (Segment Anything) ──────────────────────────────────────────────────
|
||||||
|
|
||||||
class SegmentPointRequest(BaseModel):
|
class SegmentPointRequest(BaseModel):
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""
|
||||||
|
GPU status and model management endpoints.
|
||||||
|
All under /api/gpu prefix.
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional, List
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/gpu", tags=["gpu"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status")
|
||||||
|
async def gpu_status():
|
||||||
|
"""
|
||||||
|
Full GPU capability report: hardware, feature flags, VRAM budget,
|
||||||
|
and which model was selected for each operation.
|
||||||
|
Frontend polls this to show GPU badge and tool availability.
|
||||||
|
"""
|
||||||
|
from app.services.gpu_detect import get_cached_gpu_info
|
||||||
|
from app.services.local_diffusion import get_all_model_states
|
||||||
|
|
||||||
|
info = get_cached_gpu_info()
|
||||||
|
|
||||||
|
return {
|
||||||
|
# Hardware
|
||||||
|
"backend": info.backend,
|
||||||
|
"device_name": info.device_name,
|
||||||
|
"vram_total_gb": info.vram_total_gb,
|
||||||
|
"vram_free_gb": info.vram_free_gb,
|
||||||
|
"compute_capability": info.compute_capability,
|
||||||
|
# Feature flags
|
||||||
|
"fp16": info.fp16,
|
||||||
|
"bf16": info.bf16,
|
||||||
|
"fp8": info.fp8,
|
||||||
|
"int8": info.int8,
|
||||||
|
"tensor_cores": info.tensor_cores,
|
||||||
|
"xformers": info.xformers,
|
||||||
|
# Derived
|
||||||
|
"effective_vram_gb": info.effective_vram_gb,
|
||||||
|
"tier": info.tier,
|
||||||
|
# Selected models per operation
|
||||||
|
"recommended": {
|
||||||
|
op: (
|
||||||
|
{
|
||||||
|
"model_id": spec.model_id,
|
||||||
|
"family": spec.family,
|
||||||
|
"memory_opt": spec.memory_opt,
|
||||||
|
"native_res": spec.native_res,
|
||||||
|
"vram_fp16_gb": spec.vram_fp16_gb,
|
||||||
|
}
|
||||||
|
if spec else None
|
||||||
|
)
|
||||||
|
for op, spec in info.recommended.items()
|
||||||
|
},
|
||||||
|
"pipeline_states": get_all_model_states(),
|
||||||
|
"warnings": info.warnings,
|
||||||
|
"capabilities": info.capabilities,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class PrefetchRequest(BaseModel):
|
||||||
|
operations: Optional[List[str]] = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/prefetch")
|
||||||
|
async def prefetch_models(req: PrefetchRequest = PrefetchRequest()):
|
||||||
|
"""
|
||||||
|
Eagerly load pipelines into GPU memory for the requested operations.
|
||||||
|
Returns immediately; poll /api/gpu/prefetch-status for progress.
|
||||||
|
Default: inpaint, txt2img, img2img.
|
||||||
|
"""
|
||||||
|
ops = req.operations or ["inpaint", "txt2img", "img2img"]
|
||||||
|
valid = {"inpaint", "txt2img", "img2img", "outpaint", "upscale"}
|
||||||
|
ops = [op for op in ops if op in valid]
|
||||||
|
|
||||||
|
from app.services.local_diffusion import get_local_diffusion_provider
|
||||||
|
provider = get_local_diffusion_provider()
|
||||||
|
|
||||||
|
async def _prefetch():
|
||||||
|
for op in ops:
|
||||||
|
try:
|
||||||
|
await provider._get_pipeline(op)
|
||||||
|
print(f"[gpu] Prefetch complete: {op}")
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[gpu] Prefetch failed for {op}: {exc}")
|
||||||
|
|
||||||
|
asyncio.create_task(_prefetch())
|
||||||
|
return {"status": "prefetch_started", "operations": ops}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/prefetch-status")
|
||||||
|
async def prefetch_status():
|
||||||
|
"""Poll model download / load progress."""
|
||||||
|
from app.services.local_diffusion import get_all_model_states
|
||||||
|
return {"models": get_all_model_states()}
|
||||||
@@ -21,6 +21,7 @@ FRAME_SIZES = {
|
|||||||
"8x10": (8, 10),
|
"8x10": (8, 10),
|
||||||
"11x14": (11, 14),
|
"11x14": (11, 14),
|
||||||
"16x20": (16, 20),
|
"16x20": (16, 20),
|
||||||
|
"18x24": (18, 24),
|
||||||
"20x24": (20, 24),
|
"20x24": (20, 24),
|
||||||
"24x36": (24, 36),
|
"24x36": (24, 36),
|
||||||
# Square
|
# Square
|
||||||
@@ -65,6 +66,16 @@ class UpscaleRequest(BaseModel):
|
|||||||
method: str = "auto"
|
method: str = "auto"
|
||||||
|
|
||||||
|
|
||||||
|
class PrepareRequest(BaseModel):
|
||||||
|
image: str # base64
|
||||||
|
frame: str # e.g. "8x10"
|
||||||
|
orientation: Literal["auto", "portrait", "landscape"] = "auto"
|
||||||
|
target_dpi: int = 300
|
||||||
|
upscale_method: str = "auto" # auto / realesrgan_pytorch / realesrgan_ncnn / lanczos
|
||||||
|
mode: Literal["crop", "extend", "smart"] = "smart"
|
||||||
|
prompt: Optional[str] = ""
|
||||||
|
|
||||||
|
|
||||||
# ── Frame sizes endpoint ───────────────────────────────────────────────────
|
# ── Frame sizes endpoint ───────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.get("/frame-sizes")
|
@router.get("/frame-sizes")
|
||||||
@@ -335,6 +346,105 @@ def upscale_install_status():
|
|||||||
return status
|
return status
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/prepare")
|
||||||
|
async def prepare_for_print(req: PrepareRequest):
|
||||||
|
"""
|
||||||
|
One-shot Prepare for Print: AI upscale to reach target DPI, then fit to frame.
|
||||||
|
|
||||||
|
Steps:
|
||||||
|
1. Resolve target pixel dimensions (frame × target_dpi, orientation-adjusted)
|
||||||
|
2. Calculate needed upscale factor so the image meets the target resolution
|
||||||
|
3. Run Real-ESRGAN if scale > 1.05 (else skip — already large enough)
|
||||||
|
4. Run frame-fit (crop / extend / smart) to exact target dimensions
|
||||||
|
5. Return the print-ready image and a quality report
|
||||||
|
"""
|
||||||
|
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.target_dpi <= 600):
|
||||||
|
raise HTTPException(status_code=400, detail="target_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]
|
||||||
|
img_w, img_h = image.size
|
||||||
|
|
||||||
|
# Resolve orientation (same logic as frame_fit)
|
||||||
|
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:
|
||||||
|
if img_landscape and not frame_landscape:
|
||||||
|
fw, fh = fh, fw
|
||||||
|
elif not img_landscape and frame_landscape:
|
||||||
|
fw, fh = fh, fw
|
||||||
|
|
||||||
|
target_w = fw * req.target_dpi
|
||||||
|
target_h = fh * req.target_dpi
|
||||||
|
|
||||||
|
# Scale factor needed so the shorter dimension fills the frame
|
||||||
|
scale_w = target_w / img_w
|
||||||
|
scale_h = target_h / img_h
|
||||||
|
needed_scale = min(scale_w, scale_h) # fill-to-fit (extend) baseline
|
||||||
|
# For crop mode we need max; use the larger to be safe and let frame-fit crop
|
||||||
|
needed_scale_crop = max(scale_w, scale_h)
|
||||||
|
|
||||||
|
# Use the smaller (extend) scale as the upscale target; frame-fit handles the rest
|
||||||
|
upscale_factor = max(1.0, needed_scale)
|
||||||
|
upscale_applied = False
|
||||||
|
method_used = "none"
|
||||||
|
|
||||||
|
upscaled = image
|
||||||
|
if upscale_factor > 1.05:
|
||||||
|
# Cap per-pass at 4× (Real-ESRGAN works best at 2–4×)
|
||||||
|
remaining = upscale_factor
|
||||||
|
while remaining > 1.05:
|
||||||
|
pass_scale = min(remaining, 4.0)
|
||||||
|
# Round to one decimal to keep scale in 1.1–8.0 range accepted by upscale service
|
||||||
|
pass_scale = round(pass_scale, 1)
|
||||||
|
if pass_scale < 1.1:
|
||||||
|
break
|
||||||
|
from app.services.upscale import upscale_image
|
||||||
|
result_bytes, method_used = await upscale_image(upscaled, pass_scale, req.upscale_method)
|
||||||
|
upscaled = Image.open(BytesIO(result_bytes)).convert("RGB")
|
||||||
|
remaining /= pass_scale
|
||||||
|
upscale_applied = True
|
||||||
|
|
||||||
|
# Encode upscaled image and run frame-fit
|
||||||
|
upscaled_b64 = _encode(_to_png(upscaled))
|
||||||
|
|
||||||
|
fit_req = FrameFitRequest(
|
||||||
|
image=upscaled_b64,
|
||||||
|
frame=req.frame,
|
||||||
|
orientation=req.orientation,
|
||||||
|
mode=req.mode,
|
||||||
|
dpi=req.target_dpi,
|
||||||
|
prompt=req.prompt or "",
|
||||||
|
)
|
||||||
|
# Re-use the existing frame_fit logic inline
|
||||||
|
fit_response = await frame_fit(fit_req)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"result": fit_response["result"],
|
||||||
|
"frame": req.frame,
|
||||||
|
"orientation": fit_response["orientation"],
|
||||||
|
"output_pixels": fit_response["output_pixels"],
|
||||||
|
"output_inches": fit_response["output_inches"],
|
||||||
|
"dpi": req.target_dpi,
|
||||||
|
"mode_used": fit_response["mode_used"],
|
||||||
|
"upscale_applied": upscale_applied,
|
||||||
|
"upscale_factor": round(upscale_factor, 2),
|
||||||
|
"upscale_method": method_used,
|
||||||
|
"summary": fit_response["summary"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/upscale")
|
@router.post("/upscale")
|
||||||
async def upscale(req: UpscaleRequest):
|
async def upscale(req: UpscaleRequest):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -0,0 +1,391 @@
|
|||||||
|
"""
|
||||||
|
GPU capability detection and per-operation model selection.
|
||||||
|
|
||||||
|
Probes the actual GPU — VRAM (total + free), CUDA compute capability, and
|
||||||
|
feature flags (fp16, bf16, fp8, int8, tensor cores) — then selects the
|
||||||
|
highest-quality model that fits for each operation.
|
||||||
|
|
||||||
|
Model selection ladder (txt2img):
|
||||||
|
eff_vram ≥ 20 GB → FLUX.1-schnell (no offload)
|
||||||
|
eff_vram ≥ 10 GB → FLUX.1-schnell (model_cpu_offload, 2–3× slower but fits)
|
||||||
|
eff_vram ≥ 7.5 GB → SDXL base
|
||||||
|
eff_vram ≥ 5.5 GB → SDXL base + attention slicing
|
||||||
|
eff_vram ≥ 4.0 GB → SDXL + model_cpu_offload (GTX 1060 6 GB, Quadro 6 GB)
|
||||||
|
eff_vram ≥ 3.5 GB → Stable Diffusion 2.1
|
||||||
|
eff_vram ≥ 2.5 GB → SD 2.1-base + attention slicing
|
||||||
|
eff_vram ≥ 1.7 GB → Stable Diffusion 1.5
|
||||||
|
otherwise → SD 1.5 + sequential CPU offload
|
||||||
|
|
||||||
|
Inpaint always uses SDXL/SD-family (no FLUX inpaint pipeline yet).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
# ── Model specification ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ModelSpec:
|
||||||
|
"""Everything needed to load and run one diffusion pipeline."""
|
||||||
|
model_id: str
|
||||||
|
family: str # sd15 | sd2x | sdxl | flux
|
||||||
|
memory_opt: str # none | attention_slicing | model_cpu_offload | sequential_cpu_offload
|
||||||
|
native_res: int # 512 | 768 | 1024
|
||||||
|
vram_fp16_gb: float # approx VRAM needed in fp16, no memory opts
|
||||||
|
|
||||||
|
|
||||||
|
# ── GPU capability record ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GpuCapabilities:
|
||||||
|
# Hardware
|
||||||
|
backend: str # cuda | mps | cpu
|
||||||
|
device_name: str
|
||||||
|
vram_total_gb: float
|
||||||
|
vram_free_gb: float
|
||||||
|
compute_capability: str # "8.6", "7.5", "6.1" …
|
||||||
|
cc_major: int
|
||||||
|
cc_minor: int
|
||||||
|
|
||||||
|
# Feature flags derived from compute capability
|
||||||
|
fp16: bool # reliable fp16 (CC ≥ 6.0; CC 5.x works but slower)
|
||||||
|
bf16: bool # native bf16 (CC ≥ 8.0)
|
||||||
|
fp8: bool # native fp8 (CC ≥ 8.9, Ada / Hopper)
|
||||||
|
int8: bool # efficient int8 (CC ≥ 7.0, needed for bitsandbytes)
|
||||||
|
tensor_cores: bool # tensor cores (CC ≥ 7.0, Volta+)
|
||||||
|
xformers: bool # xformers installed (reduces attention VRAM ~20-30%)
|
||||||
|
|
||||||
|
# Derived budget
|
||||||
|
effective_vram_gb: float # free VRAM after overhead, halved if fp32-only
|
||||||
|
|
||||||
|
# Human-readable tier label
|
||||||
|
tier: str # flux_full | flux_offload | sdxl | sdxl_low | sdxl_offload | sd2x | sd2x_low | sd15 | minimal
|
||||||
|
|
||||||
|
# Best model per operation
|
||||||
|
recommended: dict[str, Optional[ModelSpec]]
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
warnings: list[str]
|
||||||
|
capabilities: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Detection ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def detect_gpu() -> GpuCapabilities:
|
||||||
|
"""Probe the GPU, return a fully populated GpuCapabilities."""
|
||||||
|
try:
|
||||||
|
import torch
|
||||||
|
|
||||||
|
if torch.cuda.is_available():
|
||||||
|
props = torch.cuda.get_device_properties(0)
|
||||||
|
free_bytes, total_bytes = torch.cuda.mem_get_info(0)
|
||||||
|
vram_total = total_bytes / (1024 ** 3)
|
||||||
|
vram_free = free_bytes / (1024 ** 3)
|
||||||
|
cc = f"{props.major}.{props.minor}"
|
||||||
|
major, minor = props.major, props.minor
|
||||||
|
|
||||||
|
fp16 = major >= 6 # Pascal and newer have good fp16
|
||||||
|
bf16 = major >= 8 # Ampere A100 / RTX 3000+
|
||||||
|
fp8 = major > 8 or (major == 8 and minor >= 9) # Ada / Hopper
|
||||||
|
int8 = major >= 7 # Volta+
|
||||||
|
tensor_cores = major >= 7
|
||||||
|
|
||||||
|
# Pre-Pascal (Maxwell CC 5.x): fp16 works but throughput is lower than fp32
|
||||||
|
# on some Maxwell cards. Flag it so memory opt logic can account for it.
|
||||||
|
xf = _xformers_available()
|
||||||
|
|
||||||
|
# Subtract driver/CUDA context overhead from free VRAM
|
||||||
|
overhead_gb = 0.4
|
||||||
|
eff = max(0.0, vram_free - overhead_gb)
|
||||||
|
if not fp16:
|
||||||
|
eff /= 2.0 # fp32 weights are 2× larger
|
||||||
|
|
||||||
|
tier = _tier_label(eff)
|
||||||
|
warnings = _build_warnings(
|
||||||
|
tier, vram_total, vram_free, cc, major, minor, fp16, bf16, fp8, xf
|
||||||
|
)
|
||||||
|
|
||||||
|
return GpuCapabilities(
|
||||||
|
backend="cuda",
|
||||||
|
device_name=props.name,
|
||||||
|
vram_total_gb=round(vram_total, 1),
|
||||||
|
vram_free_gb=round(vram_free, 1),
|
||||||
|
compute_capability=cc,
|
||||||
|
cc_major=major,
|
||||||
|
cc_minor=minor,
|
||||||
|
fp16=fp16,
|
||||||
|
bf16=bf16,
|
||||||
|
fp8=fp8,
|
||||||
|
int8=int8,
|
||||||
|
tensor_cores=tensor_cores,
|
||||||
|
xformers=xf,
|
||||||
|
effective_vram_gb=round(eff, 1),
|
||||||
|
tier=tier,
|
||||||
|
recommended=_select_all_models(eff),
|
||||||
|
warnings=warnings,
|
||||||
|
capabilities=_caps(tier),
|
||||||
|
)
|
||||||
|
|
||||||
|
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
||||||
|
usable_gb = _apple_usable_gb()
|
||||||
|
eff = max(0.0, usable_gb - 0.5)
|
||||||
|
tier = _tier_label(eff)
|
||||||
|
return GpuCapabilities(
|
||||||
|
backend="mps",
|
||||||
|
device_name="Apple Silicon",
|
||||||
|
vram_total_gb=round(usable_gb, 1),
|
||||||
|
vram_free_gb=round(usable_gb, 1),
|
||||||
|
compute_capability="mps",
|
||||||
|
cc_major=0,
|
||||||
|
cc_minor=0,
|
||||||
|
fp16=False, # MPS diffusion more stable in fp32
|
||||||
|
bf16=False,
|
||||||
|
fp8=False,
|
||||||
|
int8=False,
|
||||||
|
tensor_cores=False,
|
||||||
|
xformers=False,
|
||||||
|
effective_vram_gb=round(eff / 2, 1), # fp32 on MPS
|
||||||
|
tier=tier,
|
||||||
|
recommended=_select_all_models(eff / 2),
|
||||||
|
warnings=["Apple MPS: using fp32 (fp16 less stable). Models load slower."],
|
||||||
|
capabilities=_caps(tier),
|
||||||
|
)
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# CPU fallback
|
||||||
|
return GpuCapabilities(
|
||||||
|
backend="cpu",
|
||||||
|
device_name="CPU (no GPU)",
|
||||||
|
vram_total_gb=0.0,
|
||||||
|
vram_free_gb=0.0,
|
||||||
|
compute_capability="",
|
||||||
|
cc_major=0, cc_minor=0,
|
||||||
|
fp16=False, bf16=False, fp8=False, int8=False,
|
||||||
|
tensor_cores=False, xformers=False,
|
||||||
|
effective_vram_gb=0.0,
|
||||||
|
tier="minimal",
|
||||||
|
recommended=_select_all_models(0.0),
|
||||||
|
warnings=[
|
||||||
|
"No GPU found. Running on CPU — expect 5–30 minutes per image. "
|
||||||
|
"Consider setting AI_PROVIDER to a remote/cloud provider instead."
|
||||||
|
],
|
||||||
|
capabilities=["txt2img", "inpaint", "img2img", "outpaint"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Model selection ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _select_all_models(eff_vram: float) -> dict[str, Optional[ModelSpec]]:
|
||||||
|
return {
|
||||||
|
"txt2img": _select_txt2img(eff_vram),
|
||||||
|
"img2img": _select_img2img(eff_vram),
|
||||||
|
"inpaint": _select_inpaint(eff_vram),
|
||||||
|
"outpaint": _select_inpaint(eff_vram), # shares inpaint pipeline
|
||||||
|
"upscale": _select_upscale(eff_vram),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _select_txt2img(eff: float) -> ModelSpec:
|
||||||
|
# FLUX.1-schnell (Apache 2.0, 4-step distilled)
|
||||||
|
if eff >= 20.0:
|
||||||
|
return ModelSpec("black-forest-labs/FLUX.1-schnell", "flux", "none", 1024, 20.0)
|
||||||
|
if eff >= 10.0:
|
||||||
|
return ModelSpec("black-forest-labs/FLUX.1-schnell", "flux", "model_cpu_offload", 1024, 20.0)
|
||||||
|
# SDXL base
|
||||||
|
if eff >= 7.5:
|
||||||
|
return ModelSpec("stabilityai/stable-diffusion-xl-base-1.0", "sdxl", "none", 1024, 6.5)
|
||||||
|
if eff >= 5.5:
|
||||||
|
return ModelSpec("stabilityai/stable-diffusion-xl-base-1.0", "sdxl", "attention_slicing", 1024, 6.5)
|
||||||
|
if eff >= 4.0:
|
||||||
|
return ModelSpec("stabilityai/stable-diffusion-xl-base-1.0", "sdxl", "model_cpu_offload", 1024, 6.5)
|
||||||
|
# SD 2.x
|
||||||
|
if eff >= 3.5:
|
||||||
|
return ModelSpec("stabilityai/stable-diffusion-2-1", "sd2x", "none", 768, 3.5)
|
||||||
|
if eff >= 2.5:
|
||||||
|
return ModelSpec("stabilityai/stable-diffusion-2-1-base", "sd2x", "attention_slicing", 512, 3.2)
|
||||||
|
# SD 1.5
|
||||||
|
if eff >= 1.7:
|
||||||
|
return ModelSpec("stable-diffusion-v1-5/stable-diffusion-v1-5", "sd15", "attention_slicing", 512, 1.7)
|
||||||
|
return ModelSpec("stable-diffusion-v1-5/stable-diffusion-v1-5", "sd15", "sequential_cpu_offload", 512, 1.7)
|
||||||
|
|
||||||
|
|
||||||
|
def _select_img2img(eff: float) -> ModelSpec:
|
||||||
|
# img2img uses the same model family as txt2img
|
||||||
|
s = _select_txt2img(eff)
|
||||||
|
# FLUX img2img uses a different pipeline class but same model weights
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def _select_inpaint(eff: float) -> ModelSpec:
|
||||||
|
# No FLUX inpaint pipeline available yet — SDXL is the ceiling
|
||||||
|
if eff >= 7.5:
|
||||||
|
return ModelSpec("diffusers/stable-diffusion-xl-1.0-inpainting-0.1", "sdxl", "none", 1024, 6.5)
|
||||||
|
if eff >= 5.5:
|
||||||
|
return ModelSpec("diffusers/stable-diffusion-xl-1.0-inpainting-0.1", "sdxl", "attention_slicing", 1024, 6.5)
|
||||||
|
if eff >= 4.0:
|
||||||
|
return ModelSpec("diffusers/stable-diffusion-xl-1.0-inpainting-0.1", "sdxl", "model_cpu_offload", 1024, 6.5)
|
||||||
|
if eff >= 3.5:
|
||||||
|
return ModelSpec("stabilityai/stable-diffusion-2-inpainting", "sd2x", "none", 512, 3.5)
|
||||||
|
if eff >= 2.5:
|
||||||
|
return ModelSpec("stabilityai/stable-diffusion-2-inpainting", "sd2x", "attention_slicing", 512, 3.5)
|
||||||
|
if eff >= 1.7:
|
||||||
|
return ModelSpec("runwayml/stable-diffusion-inpainting", "sd15", "attention_slicing", 512, 1.7)
|
||||||
|
return ModelSpec("runwayml/stable-diffusion-inpainting", "sd15", "sequential_cpu_offload", 512, 1.7)
|
||||||
|
|
||||||
|
|
||||||
|
def _select_upscale(eff: float) -> Optional[ModelSpec]:
|
||||||
|
# SD x4 upscaler — needs ~2 GB fp16 PLUS headroom for the loaded inpaint/txt2img model.
|
||||||
|
# Only enable if eff_vram suggests room for it as a secondary pipeline.
|
||||||
|
if eff >= 6.0:
|
||||||
|
return ModelSpec("stabilityai/stable-diffusion-x4-upscaler", "sd2x", "attention_slicing", 512, 2.0)
|
||||||
|
return None # fall through to Real-ESRGAN
|
||||||
|
|
||||||
|
|
||||||
|
# ── Tier label (display only) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _tier_label(eff_vram: float) -> str:
|
||||||
|
if eff_vram >= 20: return "flux_full"
|
||||||
|
if eff_vram >= 10: return "flux_offload"
|
||||||
|
if eff_vram >= 7.5: return "sdxl"
|
||||||
|
if eff_vram >= 5.5: return "sdxl_low"
|
||||||
|
if eff_vram >= 4.0: return "sdxl_offload"
|
||||||
|
if eff_vram >= 3.5: return "sd2x"
|
||||||
|
if eff_vram >= 2.5: return "sd2x_low"
|
||||||
|
if eff_vram >= 1.7: return "sd15"
|
||||||
|
return "minimal"
|
||||||
|
|
||||||
|
|
||||||
|
def _caps(tier: str) -> list[str]:
|
||||||
|
base = ["txt2img", "inpaint", "img2img", "outpaint"]
|
||||||
|
if tier in ("flux_full", "flux_offload", "sdxl", "sdxl_low", "sdxl_offload"):
|
||||||
|
return base + ["upscale_diffusion"]
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
# ── Warnings ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _build_warnings(
|
||||||
|
tier: str, vram_total: float, vram_free: float,
|
||||||
|
cc: str, major: int, minor: int,
|
||||||
|
fp16: bool, bf16: bool, fp8: bool, xf: bool,
|
||||||
|
) -> list[str]:
|
||||||
|
w = []
|
||||||
|
|
||||||
|
if major < 5:
|
||||||
|
w.append(
|
||||||
|
f"GPU compute capability {cc} is not supported by PyTorch 2.x. "
|
||||||
|
"Upgrade to a Kepler/Maxwell-era or newer GPU (CC ≥ 5.0)."
|
||||||
|
)
|
||||||
|
elif major < 6:
|
||||||
|
w.append(
|
||||||
|
f"GPU is Maxwell-era (CC {cc}). fp32 mode — models need 2× VRAM. "
|
||||||
|
"A Pascal GTX 1000-series or newer card enables fp16."
|
||||||
|
)
|
||||||
|
elif not bf16 and tier in ("flux_full", "flux_offload"):
|
||||||
|
w.append(
|
||||||
|
f"GPU CC {cc}: FLUX runs in fp16 (bf16 needs CC ≥ 8.0). "
|
||||||
|
"Results are still good but Ampere/Ada GPUs are faster here."
|
||||||
|
)
|
||||||
|
|
||||||
|
if fp8 and tier in ("flux_full", "flux_offload"):
|
||||||
|
w.append(
|
||||||
|
"FP8 native support detected (Ada Lovelace / Hopper). "
|
||||||
|
"Set HF_MODEL_TXT2IMG=flux-community/flux.1-schnell-fp8 for ~40% VRAM reduction."
|
||||||
|
)
|
||||||
|
|
||||||
|
if tier == "minimal":
|
||||||
|
w.append(
|
||||||
|
f"Very low effective VRAM ({vram_free:.1f} GB free). "
|
||||||
|
"Sequential CPU offload will be used — expect 10–30 min per image."
|
||||||
|
)
|
||||||
|
elif tier == "sdxl_offload":
|
||||||
|
w.append(
|
||||||
|
f"Limited VRAM ({vram_free:.1f} GB free). "
|
||||||
|
"Using SDXL with model_cpu_offload — better quality than SD 2.x, ~30% slower. "
|
||||||
|
"Install xformers or upgrade to ≥5.5 GB effective VRAM for full-speed SDXL."
|
||||||
|
)
|
||||||
|
elif tier in ("sd15", "sd2x_low"):
|
||||||
|
w.append(
|
||||||
|
f"Limited VRAM ({vram_free:.1f} GB free). "
|
||||||
|
"Using SD 1.5/2.x. Upgrade to ≥5.5 GB free for SDXL quality."
|
||||||
|
)
|
||||||
|
|
||||||
|
if xf:
|
||||||
|
w.append(
|
||||||
|
"xformers detected — attention VRAM reduced ~20-30%. "
|
||||||
|
"You may be able to run a higher-tier model than listed."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if tier in ("sdxl_low", "sdxl_offload", "sd2x"):
|
||||||
|
w.append(
|
||||||
|
"xformers not installed. Install it (pip install xformers) to reduce "
|
||||||
|
"VRAM usage ~20-30% and potentially unlock the next model tier."
|
||||||
|
)
|
||||||
|
|
||||||
|
return w
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _xformers_available() -> bool:
|
||||||
|
try:
|
||||||
|
import xformers # noqa: F401
|
||||||
|
return True
|
||||||
|
except ImportError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _apple_usable_gb() -> float:
|
||||||
|
"""Estimate GPU-usable unified memory (≈ half of total RAM)."""
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
["sysctl", "-n", "hw.memsize"], capture_output=True, text=True, timeout=5
|
||||||
|
)
|
||||||
|
if r.returncode == 0:
|
||||||
|
return int(r.stdout.strip()) / (1024 ** 3) / 2
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return 8.0
|
||||||
|
|
||||||
|
|
||||||
|
def infer_spec_from_model_id(model_id: str) -> ModelSpec:
|
||||||
|
"""
|
||||||
|
When the user supplies HF_MODEL_* overrides, infer the pipeline family
|
||||||
|
from naming conventions so the correct diffusers class is chosen.
|
||||||
|
"""
|
||||||
|
mid = model_id.lower()
|
||||||
|
if "flux" in mid:
|
||||||
|
return ModelSpec(model_id, "flux", "model_cpu_offload", 1024, 20.0)
|
||||||
|
if "xl" in mid or "sdxl" in mid:
|
||||||
|
return ModelSpec(model_id, "sdxl", "attention_slicing", 1024, 6.5)
|
||||||
|
if any(x in mid for x in ["sd-2", "sd2", "stable-diffusion-2", "-2-", "-2inpaint"]):
|
||||||
|
res = 512 if "base" in mid else 768
|
||||||
|
return ModelSpec(model_id, "sd2x", "attention_slicing", res, 3.5)
|
||||||
|
return ModelSpec(model_id, "sd15", "attention_slicing", 512, 1.7)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Singleton ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_cached: Optional[GpuCapabilities] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_cached_gpu_info() -> GpuCapabilities:
|
||||||
|
global _cached
|
||||||
|
if _cached is None:
|
||||||
|
_cached = detect_gpu()
|
||||||
|
return _cached
|
||||||
|
|
||||||
|
|
||||||
|
# Alias kept for any callers still using the old name
|
||||||
|
def get_model_ids(tier: str) -> dict:
|
||||||
|
"""Compatibility shim — returns model_id strings keyed by operation."""
|
||||||
|
info = get_cached_gpu_info()
|
||||||
|
return {
|
||||||
|
op: (spec.model_id if spec else None)
|
||||||
|
for op, spec in info.recommended.items()
|
||||||
|
}
|
||||||
@@ -0,0 +1,584 @@
|
|||||||
|
"""
|
||||||
|
Local GPU diffusion provider — HuggingFace Diffusers backend.
|
||||||
|
|
||||||
|
Implements RemoteAIProvider so all existing routes work unchanged.
|
||||||
|
Pipelines are lazy-loaded, cached in an LRU store, and memory-optimised
|
||||||
|
per the ModelSpec chosen by gpu_detect.
|
||||||
|
|
||||||
|
Supported model families:
|
||||||
|
flux → FluxPipeline / FluxImg2ImgPipeline (FLUX.1-schnell)
|
||||||
|
sdxl → StableDiffusionXL*Pipeline (SDXL base + SDXL Inpaint)
|
||||||
|
sd2x → StableDiffusion2*Pipeline (SD 2.x)
|
||||||
|
sd15 → StableDiffusionPipeline (SD 1.5)
|
||||||
|
|
||||||
|
Requires: diffusers>=0.29.0, transformers, accelerate, safetensors
|
||||||
|
(all in requirements.gpu.txt)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import threading
|
||||||
|
from collections import OrderedDict
|
||||||
|
from io import BytesIO
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from app.services.gpu_detect import (
|
||||||
|
GpuCapabilities,
|
||||||
|
ModelSpec,
|
||||||
|
get_cached_gpu_info,
|
||||||
|
infer_spec_from_model_id,
|
||||||
|
)
|
||||||
|
from app.services.remote_provider import RemoteAIProvider
|
||||||
|
|
||||||
|
# ── Model state tracking ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_states: dict[str, dict] = {}
|
||||||
|
_states_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _set_state(key: str, **kw):
|
||||||
|
with _states_lock:
|
||||||
|
_states.setdefault(key, {}).update(kw)
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_model_states() -> list[dict]:
|
||||||
|
with _states_lock:
|
||||||
|
return list(_states.values())
|
||||||
|
|
||||||
|
|
||||||
|
def _make_step_cb(pipe_type: str, total_steps: int):
|
||||||
|
"""
|
||||||
|
Returns a diffusers callback_on_step_end that writes per-step progress
|
||||||
|
into _states so the SSE /api/generate/progress endpoint can stream it.
|
||||||
|
Called from a thread executor — _set_state is thread-safe.
|
||||||
|
"""
|
||||||
|
def cb(pipe, step_index: int, timestep, callback_kwargs: dict) -> dict:
|
||||||
|
done = step_index + 1
|
||||||
|
_set_state(pipe_type,
|
||||||
|
state="running",
|
||||||
|
step=done,
|
||||||
|
total_steps=total_steps,
|
||||||
|
progress=round(done / total_steps * 85, 1),
|
||||||
|
message=f"Step {done} / {total_steps}")
|
||||||
|
return callback_kwargs
|
||||||
|
return cb
|
||||||
|
|
||||||
|
|
||||||
|
# ── LRU pipeline cache ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class _PipelineCache:
|
||||||
|
def __init__(self, maxsize: int = 2):
|
||||||
|
self._cache: OrderedDict[str, object] = OrderedDict()
|
||||||
|
self._maxsize = maxsize
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
|
async def get(self, key: str):
|
||||||
|
async with self._lock:
|
||||||
|
if key in self._cache:
|
||||||
|
self._cache.move_to_end(key)
|
||||||
|
return self._cache[key]
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def put(self, key: str, pipe: object):
|
||||||
|
async with self._lock:
|
||||||
|
if key in self._cache:
|
||||||
|
self._cache.move_to_end(key)
|
||||||
|
else:
|
||||||
|
if len(self._cache) >= self._maxsize:
|
||||||
|
evicted_key, evicted = self._cache.popitem(last=False)
|
||||||
|
_evict(evicted, evicted_key)
|
||||||
|
self._cache[key] = pipe
|
||||||
|
|
||||||
|
|
||||||
|
def _evict(pipe, key: str):
|
||||||
|
try:
|
||||||
|
import torch
|
||||||
|
pipe.to("cpu")
|
||||||
|
torch.cuda.empty_cache()
|
||||||
|
print(f"[local_gpu] Evicted '{key}' from GPU cache")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ── Pipeline loading helpers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _apply_hf_token():
|
||||||
|
try:
|
||||||
|
from app.config import settings
|
||||||
|
if settings.hf_token:
|
||||||
|
import huggingface_hub
|
||||||
|
huggingface_hub.login(token=settings.hf_token, add_to_git_credential=False)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _get_spec(pipe_type: str, info: GpuCapabilities) -> ModelSpec:
|
||||||
|
"""Return the ModelSpec for a pipeline type, respecting user overrides."""
|
||||||
|
# Map outpaint to inpaint (same pipeline)
|
||||||
|
op_key = "inpaint" if pipe_type == "outpaint" else pipe_type
|
||||||
|
# img2img uses same family/model as txt2img for FLUX/SDXL
|
||||||
|
if pipe_type == "img2img" and op_key not in info.recommended:
|
||||||
|
op_key = "txt2img"
|
||||||
|
|
||||||
|
# User config override
|
||||||
|
try:
|
||||||
|
from app.config import settings
|
||||||
|
override_map = {
|
||||||
|
"inpaint": settings.hf_model_inpaint,
|
||||||
|
"outpaint": settings.hf_model_inpaint,
|
||||||
|
"txt2img": settings.hf_model_txt2img,
|
||||||
|
"img2img": settings.hf_model_img2img,
|
||||||
|
}
|
||||||
|
override_id = override_map.get(pipe_type, "") or ""
|
||||||
|
if override_id:
|
||||||
|
return infer_spec_from_model_id(override_id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
spec = info.recommended.get(op_key)
|
||||||
|
if spec is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"No model available for '{pipe_type}' at effective VRAM "
|
||||||
|
f"{info.effective_vram_gb:.1f} GB. GPU may not have enough memory."
|
||||||
|
)
|
||||||
|
return spec
|
||||||
|
|
||||||
|
|
||||||
|
def _load_sd_pipeline(pipe_type: str, spec: ModelSpec, info: GpuCapabilities) -> object:
|
||||||
|
"""Load a Stable Diffusion (1.5 / 2.x / XL) pipeline."""
|
||||||
|
import torch
|
||||||
|
from diffusers import (
|
||||||
|
StableDiffusionPipeline,
|
||||||
|
StableDiffusionImg2ImgPipeline,
|
||||||
|
StableDiffusionInpaintPipeline,
|
||||||
|
StableDiffusionUpscalePipeline,
|
||||||
|
StableDiffusionXLPipeline,
|
||||||
|
StableDiffusionXLImg2ImgPipeline,
|
||||||
|
StableDiffusionXLInpaintPipeline,
|
||||||
|
)
|
||||||
|
|
||||||
|
dtype = torch.float16 if info.fp16 else torch.float32
|
||||||
|
is_xl = spec.family == "sdxl"
|
||||||
|
kwargs: dict = {"torch_dtype": dtype}
|
||||||
|
if not is_xl:
|
||||||
|
kwargs["safety_checker"] = None
|
||||||
|
kwargs["requires_safety_checker"] = False
|
||||||
|
|
||||||
|
op_key = "inpaint" if pipe_type in ("inpaint", "outpaint") else pipe_type
|
||||||
|
|
||||||
|
if op_key == "inpaint":
|
||||||
|
cls = StableDiffusionXLInpaintPipeline if is_xl else StableDiffusionInpaintPipeline
|
||||||
|
elif op_key == "txt2img":
|
||||||
|
cls = StableDiffusionXLPipeline if is_xl else StableDiffusionPipeline
|
||||||
|
elif op_key == "img2img":
|
||||||
|
cls = StableDiffusionXLImg2ImgPipeline if is_xl else StableDiffusionImg2ImgPipeline
|
||||||
|
elif op_key == "upscale":
|
||||||
|
cls = StableDiffusionUpscalePipeline
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unknown SD operation: {op_key}")
|
||||||
|
|
||||||
|
pipe = cls.from_pretrained(spec.model_id, **kwargs)
|
||||||
|
return _apply_mem_opts(pipe, spec, info)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_flux_pipeline(pipe_type: str, spec: ModelSpec, info: GpuCapabilities) -> object:
|
||||||
|
"""Load a FLUX pipeline (txt2img or img2img)."""
|
||||||
|
import torch
|
||||||
|
from diffusers import FluxPipeline, FluxImg2ImgPipeline
|
||||||
|
|
||||||
|
# FLUX works best in bf16 on Ampere+; fp16 on older Turing/Pascal
|
||||||
|
dtype = torch.bfloat16 if info.bf16 else torch.float16
|
||||||
|
|
||||||
|
op_key = "img2img" if pipe_type == "img2img" else "txt2img"
|
||||||
|
cls = FluxImg2ImgPipeline if op_key == "img2img" else FluxPipeline
|
||||||
|
|
||||||
|
pipe = cls.from_pretrained(spec.model_id, torch_dtype=dtype)
|
||||||
|
return _apply_mem_opts(pipe, spec, info)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_mem_opts(pipe, spec: ModelSpec, info: GpuCapabilities) -> object:
|
||||||
|
"""Apply memory optimisations then move pipeline to device."""
|
||||||
|
device = info.backend
|
||||||
|
opt = spec.memory_opt
|
||||||
|
|
||||||
|
# VAE slicing is always beneficial (reduces VRAM for decoding large images)
|
||||||
|
try:
|
||||||
|
pipe.enable_vae_slicing()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# xformers memory-efficient attention
|
||||||
|
if info.xformers and spec.family != "flux":
|
||||||
|
try:
|
||||||
|
pipe.enable_xformers_memory_efficient_attention()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if opt == "sequential_cpu_offload":
|
||||||
|
# Each layer moved to GPU only during its forward pass — very VRAM-efficient
|
||||||
|
# enable_sequential_cpu_offload() also calls .to(device) internally
|
||||||
|
try:
|
||||||
|
pipe.enable_sequential_cpu_offload()
|
||||||
|
except Exception:
|
||||||
|
pipe.to("cpu")
|
||||||
|
|
||||||
|
elif opt == "model_cpu_offload":
|
||||||
|
# Entire sub-models (text encoder, unet/transformer, VAE) moved between CPU/GPU
|
||||||
|
# Faster than sequential but needs ~3-4 GB free to hold the active module
|
||||||
|
try:
|
||||||
|
pipe.enable_model_cpu_offload()
|
||||||
|
except Exception:
|
||||||
|
pipe.to(device)
|
||||||
|
|
||||||
|
elif opt == "attention_slicing":
|
||||||
|
try:
|
||||||
|
pipe.enable_attention_slicing(1)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
pipe.to(device)
|
||||||
|
|
||||||
|
else: # "none"
|
||||||
|
pipe.to(device)
|
||||||
|
|
||||||
|
return pipe
|
||||||
|
|
||||||
|
|
||||||
|
# ── Provider ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class LocalDiffusionProvider(RemoteAIProvider):
|
||||||
|
def __init__(self, max_cached_pipelines: int = 2):
|
||||||
|
self._cache = _PipelineCache(maxsize=max_cached_pipelines)
|
||||||
|
self._load_locks: dict[str, asyncio.Lock] = {}
|
||||||
|
self._meta_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _info(self) -> GpuCapabilities:
|
||||||
|
return get_cached_gpu_info()
|
||||||
|
|
||||||
|
async def _lock_for(self, key: str) -> asyncio.Lock:
|
||||||
|
async with self._meta_lock:
|
||||||
|
if key not in self._load_locks:
|
||||||
|
self._load_locks[key] = asyncio.Lock()
|
||||||
|
return self._load_locks[key]
|
||||||
|
|
||||||
|
def _load_pipeline_sync(self, pipe_type: str) -> object:
|
||||||
|
info = self._info
|
||||||
|
spec = _get_spec(pipe_type, info)
|
||||||
|
|
||||||
|
_apply_hf_token()
|
||||||
|
_set_state(pipe_type, pipeline=pipe_type, model_id=spec.model_id,
|
||||||
|
family=spec.family, memory_opt=spec.memory_opt,
|
||||||
|
state="downloading", progress=0.0,
|
||||||
|
message=f"Downloading {spec.model_id}…", error="")
|
||||||
|
try:
|
||||||
|
if spec.family == "flux":
|
||||||
|
pipe = _load_flux_pipeline(pipe_type, spec, info)
|
||||||
|
else:
|
||||||
|
pipe = _load_sd_pipeline(pipe_type, spec, info)
|
||||||
|
|
||||||
|
_set_state(pipe_type, state="ready", progress=100.0, message="Ready")
|
||||||
|
return pipe
|
||||||
|
except Exception as exc:
|
||||||
|
_set_state(pipe_type, state="failed", error=str(exc), message="Load failed")
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def _get_pipeline(self, pipe_type: str) -> object:
|
||||||
|
cached = await self._cache.get(pipe_type)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
lock = await self._lock_for(pipe_type)
|
||||||
|
async with lock:
|
||||||
|
cached = await self._cache.get(pipe_type)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
pipe = await loop.run_in_executor(None, self._load_pipeline_sync, pipe_type)
|
||||||
|
await self._cache.put(pipe_type, pipe)
|
||||||
|
return pipe
|
||||||
|
|
||||||
|
# ── RemoteAIProvider ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def inpaint(self, image_bytes: bytes, mask_bytes: bytes, prompt: str, params: dict) -> bytes:
|
||||||
|
pipe = await self._get_pipeline("inpaint")
|
||||||
|
spec = _get_spec("inpaint", self._info)
|
||||||
|
|
||||||
|
img = Image.open(BytesIO(image_bytes)).convert("RGB")
|
||||||
|
mask = Image.open(BytesIO(mask_bytes)).convert("L")
|
||||||
|
orig = img.size
|
||||||
|
img_r, mask_r = _resize_pair(img, mask, spec.native_res)
|
||||||
|
|
||||||
|
steps = int(params.get("steps", 30))
|
||||||
|
cfg = float(params.get("cfg_scale", 7.5))
|
||||||
|
neg = params.get("negative_prompt", "") or None
|
||||||
|
step_cb = _make_step_cb("inpaint", steps)
|
||||||
|
|
||||||
|
_set_state("inpaint", state="running", step=0, total_steps=steps, progress=0, message="Starting…")
|
||||||
|
|
||||||
|
def _run():
|
||||||
|
try:
|
||||||
|
return pipe(
|
||||||
|
prompt=prompt,
|
||||||
|
negative_prompt=neg,
|
||||||
|
image=img_r,
|
||||||
|
mask_image=mask_r,
|
||||||
|
num_inference_steps=steps,
|
||||||
|
guidance_scale=cfg,
|
||||||
|
callback_on_step_end=step_cb,
|
||||||
|
callback_on_step_end_tensor_inputs=["latents"],
|
||||||
|
).images[0].resize(orig, Image.LANCZOS)
|
||||||
|
except TypeError:
|
||||||
|
return pipe(
|
||||||
|
prompt=prompt,
|
||||||
|
negative_prompt=neg,
|
||||||
|
image=img_r,
|
||||||
|
mask_image=mask_r,
|
||||||
|
num_inference_steps=steps,
|
||||||
|
guidance_scale=cfg,
|
||||||
|
).images[0].resize(orig, Image.LANCZOS)
|
||||||
|
|
||||||
|
result = _to_png(await asyncio.get_event_loop().run_in_executor(None, _run))
|
||||||
|
_set_state("inpaint", state="ready", step=None, total_steps=None, progress=100, message="Ready")
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def txt2img(self, prompt: str, width: int, height: int, params: dict) -> bytes:
|
||||||
|
pipe = await self._get_pipeline("txt2img")
|
||||||
|
spec = _get_spec("txt2img", self._info)
|
||||||
|
|
||||||
|
max_dim = spec.native_res
|
||||||
|
w = min(width, max_dim) // 8 * 8
|
||||||
|
h = min(height, max_dim) // 8 * 8
|
||||||
|
seed = int(params.get("seed", 0))
|
||||||
|
is_flux = spec.family == "flux"
|
||||||
|
steps = 4 if is_flux else int(params.get("steps", 30))
|
||||||
|
step_cb = _make_step_cb("txt2img", steps)
|
||||||
|
|
||||||
|
_set_state("txt2img", state="running", step=0, total_steps=steps, progress=0, message="Starting…")
|
||||||
|
|
||||||
|
def _run():
|
||||||
|
import torch
|
||||||
|
device = self._info.backend
|
||||||
|
gen = torch.Generator(device=device).manual_seed(seed) if seed else None
|
||||||
|
|
||||||
|
try:
|
||||||
|
if is_flux:
|
||||||
|
return pipe(
|
||||||
|
prompt=prompt,
|
||||||
|
width=w, height=h,
|
||||||
|
num_inference_steps=steps,
|
||||||
|
guidance_scale=0.0,
|
||||||
|
max_sequence_length=256,
|
||||||
|
generator=gen,
|
||||||
|
callback_on_step_end=step_cb,
|
||||||
|
callback_on_step_end_tensor_inputs=["latents"],
|
||||||
|
).images[0]
|
||||||
|
else:
|
||||||
|
return pipe(
|
||||||
|
prompt=prompt,
|
||||||
|
negative_prompt=params.get("negative_prompt", "") or None,
|
||||||
|
width=w, height=h,
|
||||||
|
num_inference_steps=steps,
|
||||||
|
guidance_scale=float(params.get("cfg_scale", 7.5)),
|
||||||
|
generator=gen,
|
||||||
|
callback_on_step_end=step_cb,
|
||||||
|
callback_on_step_end_tensor_inputs=["latents"],
|
||||||
|
).images[0]
|
||||||
|
except TypeError:
|
||||||
|
# Older diffusers without callback_on_step_end
|
||||||
|
if is_flux:
|
||||||
|
return pipe(
|
||||||
|
prompt=prompt, width=w, height=h,
|
||||||
|
num_inference_steps=steps, guidance_scale=0.0,
|
||||||
|
max_sequence_length=256, generator=gen,
|
||||||
|
).images[0]
|
||||||
|
else:
|
||||||
|
return pipe(
|
||||||
|
prompt=prompt,
|
||||||
|
negative_prompt=params.get("negative_prompt", "") or None,
|
||||||
|
width=w, height=h,
|
||||||
|
num_inference_steps=steps,
|
||||||
|
guidance_scale=float(params.get("cfg_scale", 7.5)),
|
||||||
|
generator=gen,
|
||||||
|
).images[0]
|
||||||
|
|
||||||
|
result = _to_png(await asyncio.get_event_loop().run_in_executor(None, _run))
|
||||||
|
_set_state("txt2img", state="ready", step=None, total_steps=None, progress=100, message="Ready")
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def img2img(self, image_bytes: bytes, prompt: str, strength: float, params: dict) -> bytes:
|
||||||
|
pipe = await self._get_pipeline("img2img")
|
||||||
|
spec = _get_spec("img2img", self._info)
|
||||||
|
|
||||||
|
img = Image.open(BytesIO(image_bytes)).convert("RGB")
|
||||||
|
orig = img.size
|
||||||
|
img_r = _resize_square(img, spec.native_res)
|
||||||
|
is_flux = spec.family == "flux"
|
||||||
|
steps = 4 if is_flux else int(params.get("steps", 30))
|
||||||
|
step_cb = _make_step_cb("img2img", steps)
|
||||||
|
|
||||||
|
_set_state("img2img", state="running", step=0, total_steps=steps, progress=0, message="Starting…")
|
||||||
|
|
||||||
|
def _run():
|
||||||
|
try:
|
||||||
|
if is_flux:
|
||||||
|
result = pipe(
|
||||||
|
prompt=prompt, image=img_r, strength=strength,
|
||||||
|
num_inference_steps=steps, guidance_scale=0.0,
|
||||||
|
callback_on_step_end=step_cb,
|
||||||
|
callback_on_step_end_tensor_inputs=["latents"],
|
||||||
|
).images[0]
|
||||||
|
else:
|
||||||
|
result = pipe(
|
||||||
|
prompt=prompt,
|
||||||
|
negative_prompt=params.get("negative_prompt", "") or None,
|
||||||
|
image=img_r, strength=strength,
|
||||||
|
num_inference_steps=steps,
|
||||||
|
guidance_scale=float(params.get("cfg_scale", 7.5)),
|
||||||
|
callback_on_step_end=step_cb,
|
||||||
|
callback_on_step_end_tensor_inputs=["latents"],
|
||||||
|
).images[0]
|
||||||
|
except TypeError:
|
||||||
|
if is_flux:
|
||||||
|
result = pipe(
|
||||||
|
prompt=prompt, image=img_r, strength=strength,
|
||||||
|
num_inference_steps=steps, guidance_scale=0.0,
|
||||||
|
).images[0]
|
||||||
|
else:
|
||||||
|
result = pipe(
|
||||||
|
prompt=prompt,
|
||||||
|
negative_prompt=params.get("negative_prompt", "") or None,
|
||||||
|
image=img_r, strength=strength,
|
||||||
|
num_inference_steps=steps,
|
||||||
|
guidance_scale=float(params.get("cfg_scale", 7.5)),
|
||||||
|
).images[0]
|
||||||
|
return result.resize(orig, Image.LANCZOS)
|
||||||
|
|
||||||
|
result = _to_png(await asyncio.get_event_loop().run_in_executor(None, _run))
|
||||||
|
_set_state("img2img", state="ready", step=None, total_steps=None, progress=100, message="Ready")
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def outpaint(self, image_bytes: bytes, direction: str, size: int, prompt: str) -> bytes:
|
||||||
|
from PIL import ImageDraw
|
||||||
|
|
||||||
|
img = Image.open(BytesIO(image_bytes)).convert("RGB")
|
||||||
|
w, h = img.size
|
||||||
|
|
||||||
|
positions = {
|
||||||
|
"right": ((w + size, h), (0, 0), (w, 0, w + size, h)),
|
||||||
|
"left": ((w + size, h), (size, 0), (0, 0, size, h)),
|
||||||
|
"bottom": ((w, h + size), (0, 0), (0, h, w, h + size)),
|
||||||
|
"top": ((w, h + size), (0, size), (0, 0, w, size)),
|
||||||
|
}
|
||||||
|
new_size, paste_at, mask_box = positions[direction]
|
||||||
|
|
||||||
|
expanded = Image.new("RGB", new_size, (127, 127, 127))
|
||||||
|
expanded.paste(img, paste_at)
|
||||||
|
mask = Image.new("L", new_size, 0)
|
||||||
|
ImageDraw.Draw(mask).rectangle(mask_box, fill=255)
|
||||||
|
|
||||||
|
fill_prompt = prompt or "seamless natural continuation of the scene"
|
||||||
|
return await self.inpaint(_to_png(expanded), _to_png(mask), fill_prompt, {})
|
||||||
|
|
||||||
|
async def health(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
def capabilities(self) -> list[str]:
|
||||||
|
return self._info.capabilities
|
||||||
|
|
||||||
|
|
||||||
|
# ── Image utilities ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _resize_pair(img: Image.Image, mask: Image.Image, target: int):
|
||||||
|
w, h = img.size
|
||||||
|
scale = target / max(w, h)
|
||||||
|
nw = max(8, int(w * scale) // 8 * 8)
|
||||||
|
nh = max(8, int(h * scale) // 8 * 8)
|
||||||
|
return img.resize((nw, nh), Image.LANCZOS), mask.resize((nw, nh), Image.NEAREST)
|
||||||
|
|
||||||
|
|
||||||
|
def _resize_square(img: Image.Image, target: int) -> Image.Image:
|
||||||
|
w, h = img.size
|
||||||
|
scale = target / max(w, h)
|
||||||
|
nw = max(8, int(w * scale) // 8 * 8)
|
||||||
|
nh = max(8, int(h * scale) // 8 * 8)
|
||||||
|
return img.resize((nw, nh), Image.LANCZOS)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_png(img: Image.Image) -> bytes:
|
||||||
|
buf = BytesIO()
|
||||||
|
img.save(buf, format="PNG")
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Singleton ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_provider: Optional[LocalDiffusionProvider] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_local_diffusion_provider(max_pipelines: int = 2) -> LocalDiffusionProvider:
|
||||||
|
global _provider
|
||||||
|
if _provider is None:
|
||||||
|
_provider = LocalDiffusionProvider(max_cached_pipelines=max_pipelines)
|
||||||
|
return _provider
|
||||||
|
|
||||||
|
|
||||||
|
async def prefetch_model_files() -> None:
|
||||||
|
"""
|
||||||
|
Download model weight files to HuggingFace disk cache without loading into GPU.
|
||||||
|
Called at container startup so the first request loads from disk (fast).
|
||||||
|
"""
|
||||||
|
from app.services.gpu_detect import get_cached_gpu_info
|
||||||
|
try:
|
||||||
|
from huggingface_hub import snapshot_download
|
||||||
|
except ImportError:
|
||||||
|
print("[local_gpu] huggingface_hub not installed — skipping model prefetch")
|
||||||
|
return
|
||||||
|
|
||||||
|
info = get_cached_gpu_info()
|
||||||
|
_apply_hf_token()
|
||||||
|
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
seen: set[str] = set()
|
||||||
|
|
||||||
|
for op, spec in info.recommended.items():
|
||||||
|
if spec is None or spec.model_id in seen:
|
||||||
|
continue
|
||||||
|
seen.add(spec.model_id)
|
||||||
|
|
||||||
|
# Apply user override if set
|
||||||
|
try:
|
||||||
|
from app.config import settings
|
||||||
|
override_map = {
|
||||||
|
"inpaint": settings.hf_model_inpaint,
|
||||||
|
"txt2img": settings.hf_model_txt2img,
|
||||||
|
"img2img": settings.hf_model_img2img,
|
||||||
|
}
|
||||||
|
override = override_map.get(op, "") or ""
|
||||||
|
if override and override not in seen:
|
||||||
|
seen.add(override)
|
||||||
|
spec = infer_spec_from_model_id(override)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
_set_state(op, pipeline=op, model_id=spec.model_id, family=spec.family,
|
||||||
|
memory_opt=spec.memory_opt, state="downloading", progress=0.0,
|
||||||
|
message=f"Downloading {spec.model_id}…", error="")
|
||||||
|
print(f"[local_gpu] Prefetching: {spec.model_id}")
|
||||||
|
|
||||||
|
def _dl(model_id=spec.model_id):
|
||||||
|
snapshot_download(
|
||||||
|
repo_id=model_id,
|
||||||
|
ignore_patterns=["*.msgpack", "flax_*", "tf_*", "rust_model*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await loop.run_in_executor(None, _dl)
|
||||||
|
_set_state(op, state="cached", progress=100.0,
|
||||||
|
message="Files cached — loads into GPU on first request")
|
||||||
|
print(f"[local_gpu] ✓ Cached: {spec.model_id}")
|
||||||
|
except Exception as exc:
|
||||||
|
_set_state(op, state="download_failed", error=str(exc),
|
||||||
|
message="Download failed — will retry on first request")
|
||||||
|
print(f"[local_gpu] Prefetch failed for {spec.model_id}: {exc}")
|
||||||
@@ -428,6 +428,13 @@ def _build_provider(name: str) -> Optional[RemoteAIProvider]:
|
|||||||
return None
|
return None
|
||||||
return ComfyUIProvider(settings.comfyui_url, settings.comfyui_default_model)
|
return ComfyUIProvider(settings.comfyui_url, settings.comfyui_default_model)
|
||||||
|
|
||||||
|
if name == "local_gpu":
|
||||||
|
try:
|
||||||
|
from app.services.local_diffusion import get_local_diffusion_provider
|
||||||
|
return get_local_diffusion_provider(max_pipelines=settings.local_gpu_max_pipelines)
|
||||||
|
except ImportError:
|
||||||
|
return None
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,11 @@ else
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Checking GPU capabilities..."
|
||||||
|
echo "------------------------------------------"
|
||||||
|
python /scripts/gpu_setup.py || echo "Warning: GPU detection failed (non-fatal)"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
echo "Starting FastAPI server..."
|
echo "Starting FastAPI server..."
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# GPU / Local Diffusion dependencies
|
||||||
|
# Install alongside requirements.txt when running with AI_PROVIDER=local_gpu
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# pip install -r requirements.txt -r requirements.gpu.txt
|
||||||
|
#
|
||||||
|
# These are pre-installed in Dockerfile.gpu; optional in the standard image.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# HuggingFace Diffusers ecosystem
|
||||||
|
# 0.29.0+ required for FLUX pipeline support
|
||||||
|
diffusers>=0.29.0
|
||||||
|
transformers>=4.40.0
|
||||||
|
accelerate>=0.27.0
|
||||||
|
huggingface-hub>=0.23.0
|
||||||
|
safetensors>=0.4.0
|
||||||
|
|
||||||
|
# Required by SDXL pipelines
|
||||||
|
invisible-watermark>=0.2.0
|
||||||
|
omegaconf>=2.3.0
|
||||||
|
|
||||||
|
# Required by FLUX (T5 text encoder tokenizer)
|
||||||
|
sentencepiece>=0.2.0
|
||||||
|
|
||||||
|
# xformers — reduces attention VRAM ~20-30%, often unlocks the next model tier
|
||||||
|
# Must match your PyTorch+CUDA version; leave out if unsure.
|
||||||
|
# Install post-container-start if needed:
|
||||||
|
# pip install xformers --index-url https://download.pytorch.org/whl/cu121
|
||||||
|
# xformers
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# EditmaskwithAI — GPU Docker Compose (NVIDIA CUDA)
|
||||||
|
#
|
||||||
|
# ── PREREQUISITES ─────────────────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# 1. NVIDIA driver ≥ 525 installed on the host
|
||||||
|
# Check: nvidia-smi
|
||||||
|
#
|
||||||
|
# 2. nvidia-container-toolkit installed and configured:
|
||||||
|
# (Ubuntu/Debian)
|
||||||
|
# curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey \
|
||||||
|
# | sudo gpg --dearmor -o /usr/share/keyrings/nvidia-ctk.gpg
|
||||||
|
# curl -s -L https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list \
|
||||||
|
# | sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-ctk.gpg] https://#g' \
|
||||||
|
# | sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list
|
||||||
|
# sudo apt-get update && sudo apt-get install -y nvidia-container-toolkit
|
||||||
|
# sudo nvidia-ctk runtime configure --runtime=docker
|
||||||
|
# sudo systemctl restart docker
|
||||||
|
#
|
||||||
|
# (RHEL/Fedora/Rocky)
|
||||||
|
# sudo dnf install -y nvidia-container-toolkit
|
||||||
|
# sudo nvidia-ctk runtime configure --runtime=docker
|
||||||
|
# sudo systemctl restart docker
|
||||||
|
#
|
||||||
|
# 3. Verify GPU access in Docker:
|
||||||
|
# docker run --rm --gpus all nvidia/cuda:12.1.0-base-ubuntu22.04 nvidia-smi
|
||||||
|
#
|
||||||
|
# ── QUICK START ───────────────────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# docker compose -f docker-compose.gpu.yml up --build
|
||||||
|
# Then open: http://localhost:3080
|
||||||
|
#
|
||||||
|
# ── OLDER DOCKER SETUPS (docker-compose v1 / nvidia-docker2) ─────────────────
|
||||||
|
#
|
||||||
|
# If you installed nvidia-docker2 (older approach) instead of nvidia-container-toolkit,
|
||||||
|
# replace the 'deploy:' block below with:
|
||||||
|
#
|
||||||
|
# runtime: nvidia
|
||||||
|
# environment:
|
||||||
|
# - NVIDIA_VISIBLE_DEVICES=all
|
||||||
|
# - NVIDIA_DRIVER_CAPABILITIES=compute,utility
|
||||||
|
#
|
||||||
|
# ── GPU TIER AUTO-SELECTION ───────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# ≥16 GB VRAM → SDXL (best quality)
|
||||||
|
# 8–16 GB → SDXL
|
||||||
|
# 4–8 GB → Stable Diffusion 2.x
|
||||||
|
# 2–4 GB → Stable Diffusion 1.5 (older GPUs: GTX 970/1060/RX 580)
|
||||||
|
# <2 GB → SD 1.5 + CPU offload (very slow — consider a remote provider)
|
||||||
|
#
|
||||||
|
# ── AMD ROCm ──────────────────────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# Swap the base image in Dockerfile.gpu:
|
||||||
|
# FROM pytorch/pytorch:2.1.0-cuda12.1-cudnn8-runtime
|
||||||
|
# → FROM rocm/pytorch:rocm6.0_ubuntu22.04_py3.9_pytorch_2.1.0
|
||||||
|
# Remove the 'driver: nvidia' line and add: device_ids: ['0']
|
||||||
|
#
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.gpu
|
||||||
|
container_name: editmaskwithai-gpu
|
||||||
|
ports:
|
||||||
|
- "${PORT:-3080}:8000"
|
||||||
|
volumes:
|
||||||
|
# Persistent project data
|
||||||
|
- ./data:/app/data
|
||||||
|
# HuggingFace model cache — keeps downloaded models across rebuilds (~5-20 GB)
|
||||||
|
- hf_model_cache:/root/.cache/huggingface
|
||||||
|
# Scripts (for exec access)
|
||||||
|
- ./scripts:/scripts
|
||||||
|
environment:
|
||||||
|
# ── Local GPU (default for this compose) ────────────────────────────────
|
||||||
|
- AI_PROVIDER=${AI_PROVIDER:-local_gpu}
|
||||||
|
- AUTO_DOWNLOAD_MODELS=${AUTO_DOWNLOAD_MODELS:-true}
|
||||||
|
|
||||||
|
# ── Per-operation overrides (optional) ──────────────────────────────────
|
||||||
|
# Leave blank to use AI_PROVIDER for all operations.
|
||||||
|
# Example: use InvokeAI for inpaint, local GPU for everything else:
|
||||||
|
# AI_PROVIDER_INPAINT=invokeai
|
||||||
|
- AI_PROVIDER_INPAINT=${AI_PROVIDER_INPAINT:-}
|
||||||
|
- AI_PROVIDER_TXT2IMG=${AI_PROVIDER_TXT2IMG:-}
|
||||||
|
- AI_PROVIDER_IMG2IMG=${AI_PROVIDER_IMG2IMG:-}
|
||||||
|
- AI_PROVIDER_OUTPAINT=${AI_PROVIDER_OUTPAINT:-}
|
||||||
|
|
||||||
|
# ── Remote/cloud providers (all optional) ────────────────────────────────
|
||||||
|
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
|
||||||
|
- OPENAI_MODEL=${OPENAI_MODEL:-dall-e-3}
|
||||||
|
- REPLICATE_API_KEY=${REPLICATE_API_KEY:-}
|
||||||
|
- STABILITY_API_KEY=${STABILITY_API_KEY:-}
|
||||||
|
|
||||||
|
# ── InvokeAI / ComfyUI (running on another machine or container) ────────
|
||||||
|
- INVOKEAI_URL=${INVOKEAI_URL:-}
|
||||||
|
- INVOKEAI_DEFAULT_MODEL=${INVOKEAI_DEFAULT_MODEL:-flux-dev}
|
||||||
|
- COMFYUI_URL=${COMFYUI_URL:-}
|
||||||
|
- COMFYUI_DEFAULT_MODEL=${COMFYUI_DEFAULT_MODEL:-v1-5-pruned-emaonly.ckpt}
|
||||||
|
|
||||||
|
# ── HuggingFace model overrides (optional) ───────────────────────────────
|
||||||
|
# Override the auto-selected model for any operation:
|
||||||
|
# HF_MODEL_INPAINT=your-org/your-model
|
||||||
|
- HF_MODEL_INPAINT=${HF_MODEL_INPAINT:-}
|
||||||
|
- HF_MODEL_TXT2IMG=${HF_MODEL_TXT2IMG:-}
|
||||||
|
- HF_MODEL_IMG2IMG=${HF_MODEL_IMG2IMG:-}
|
||||||
|
- HF_TOKEN=${HF_TOKEN:-}
|
||||||
|
|
||||||
|
# ── App settings ─────────────────────────────────────────────────────────
|
||||||
|
- DATABASE_URL=sqlite:///./data/ai_photo_edit.db
|
||||||
|
- SECRET_KEY=${SECRET_KEY:-change-this-secret-key-in-production}
|
||||||
|
- CORS_ORIGINS=*
|
||||||
|
- AUTO_DOWNLOAD_SAM=${AUTO_DOWNLOAD_SAM:-true}
|
||||||
|
|
||||||
|
# ── NVIDIA GPU passthrough ────────────────────────────────────────────────
|
||||||
|
# Requires nvidia-container-toolkit; see prerequisites at top of this file.
|
||||||
|
# For older nvidia-docker2 setups, replace this block with:
|
||||||
|
# runtime: nvidia
|
||||||
|
# environment:
|
||||||
|
# - NVIDIA_VISIBLE_DEVICES=all
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
reservations:
|
||||||
|
devices:
|
||||||
|
- driver: nvidia
|
||||||
|
count: 1
|
||||||
|
capabilities: [gpu]
|
||||||
|
|
||||||
|
# Reliable DNS for HuggingFace Hub downloads and external API calls
|
||||||
|
dns:
|
||||||
|
- 8.8.8.8
|
||||||
|
- 8.8.4.4
|
||||||
|
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
hf_model_cache:
|
||||||
|
# Survives docker compose down; delete manually to free disk space:
|
||||||
|
# docker volume rm editmaskwithai_hf_model_cache
|
||||||
@@ -19,6 +19,8 @@ const DEFAULT_CAPS = {
|
|||||||
|
|
||||||
let _caps = null;
|
let _caps = null;
|
||||||
let _fetchPromise = null;
|
let _fetchPromise = null;
|
||||||
|
let _gpuStatus = null;
|
||||||
|
let _gpuFetchPromise = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return capabilities (fetched lazily, cached thereafter).
|
* Return capabilities (fetched lazily, cached thereafter).
|
||||||
@@ -48,12 +50,29 @@ export function hasRemote() {
|
|||||||
return !!(_caps?.remote?.healthy);
|
return !!(_caps?.remote?.healthy);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch and cache detailed GPU status (hardware, feature flags, model selection per op).
|
||||||
|
* Calls /api/gpu/status — only meaningful when AI_PROVIDER=local_gpu.
|
||||||
|
* Returns null on error.
|
||||||
|
*/
|
||||||
|
export async function getGpuStatus() {
|
||||||
|
if (_gpuStatus !== null) return _gpuStatus;
|
||||||
|
if (!_gpuFetchPromise) {
|
||||||
|
_gpuFetchPromise = apiService.getGpuStatus()
|
||||||
|
.then(data => { _gpuStatus = data; return _gpuStatus; })
|
||||||
|
.catch(() => { _gpuStatus = null; return null; });
|
||||||
|
}
|
||||||
|
return _gpuFetchPromise;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Invalidate cache and re-fetch (call after saving provider settings).
|
* Invalidate cache and re-fetch (call after saving provider settings).
|
||||||
*/
|
*/
|
||||||
export async function refreshCapabilities() {
|
export async function refreshCapabilities() {
|
||||||
_caps = null;
|
_caps = null;
|
||||||
_fetchPromise = null;
|
_fetchPromise = null;
|
||||||
|
_gpuStatus = null;
|
||||||
|
_gpuFetchPromise = null;
|
||||||
return getCapabilities();
|
return getCapabilities();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,4 +81,4 @@ export async function refreshCapabilities() {
|
|||||||
*/
|
*/
|
||||||
getCapabilities();
|
getCapabilities();
|
||||||
|
|
||||||
export default { getCapabilities, getCachedCapabilities, hasRemote, refreshCapabilities };
|
export default { getCapabilities, getCachedCapabilities, hasRemote, refreshCapabilities, getGpuStatus };
|
||||||
|
|||||||
@@ -344,6 +344,11 @@ const menuDefinition = [
|
|||||||
ellipsis: true,
|
ellipsis: true,
|
||||||
target: 'image/remove_background.remove_background'
|
target: 'image/remove_background.remove_background'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Prepare for Print...',
|
||||||
|
ellipsis: true,
|
||||||
|
target: 'image/print_prepare.print_prepare'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'Fit to Frame...',
|
name: 'Fit to Frame...',
|
||||||
ellipsis: true,
|
ellipsis: true,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* ProviderBadge — small DOM element showing the active AI provider.
|
* ProviderBadge — small DOM element showing the active AI provider.
|
||||||
* Inserted into the toolbar footer on app load.
|
* Inserted into the toolbar footer on app load.
|
||||||
*
|
*
|
||||||
* Green = remote provider healthy
|
* Green = remote provider healthy (or local_gpu active)
|
||||||
* Yellow = provider configured but unhealthy/unreachable
|
* Yellow = provider configured but unhealthy/unreachable
|
||||||
* Grey = local only (LaMa + OpenCV)
|
* Grey = local only (LaMa + OpenCV)
|
||||||
*/
|
*/
|
||||||
@@ -30,21 +30,55 @@ export async function mountProviderBadge(container) {
|
|||||||
var remote = caps.remote || {};
|
var remote = caps.remote || {};
|
||||||
var local = caps.local || {};
|
var local = caps.local || {};
|
||||||
|
|
||||||
if (remote.provider && remote.healthy) {
|
if (remote.provider === 'local_gpu') {
|
||||||
|
// Local GPU provider — show GPU name and tier from /api/config local fields
|
||||||
|
var gpuName = _shortGpuName(local.gpu_device);
|
||||||
|
var tier = local.gpu_tier || '';
|
||||||
|
|
||||||
|
if (remote.healthy) {
|
||||||
|
dot.style.background = '#44cc44';
|
||||||
|
badge.style.background = '#1a2a1a';
|
||||||
|
badge.style.color = '#aaffaa';
|
||||||
|
label.textContent = 'GPU · ' + tier + ' · ' + gpuName;
|
||||||
|
|
||||||
|
var flagList = [
|
||||||
|
local.gpu_fp16 && 'fp16',
|
||||||
|
local.gpu_bf16 && 'bf16',
|
||||||
|
local.gpu_fp8 && 'fp8',
|
||||||
|
local.gpu_tensor_cores && 'tensor-cores',
|
||||||
|
].filter(Boolean).join(' ');
|
||||||
|
|
||||||
|
badge.title = [
|
||||||
|
local.gpu_device || gpuName,
|
||||||
|
'VRAM: ' + local.gpu_vram_total + ' GB total ' + local.gpu_vram_free + ' GB free',
|
||||||
|
'Compute: CC ' + local.gpu_cc + ' Eff: ' + local.gpu_eff_vram + ' GB',
|
||||||
|
flagList ? 'Features: ' + flagList : '',
|
||||||
|
'Capabilities: ' + (local.local_gpu_capabilities || []).join(', '),
|
||||||
|
(local.local_gpu_warnings || []).length
|
||||||
|
? '\nWarnings:\n' + local.local_gpu_warnings.join('\n')
|
||||||
|
: '',
|
||||||
|
].filter(Boolean).join('\n');
|
||||||
|
} else {
|
||||||
|
dot.style.background = '#ffaa00';
|
||||||
|
badge.style.background = '#2a2000';
|
||||||
|
badge.style.color = '#ffdd88';
|
||||||
|
label.textContent = 'Local GPU (not ready)';
|
||||||
|
badge.title = 'local_gpu is configured but the diffusers library may not be installed.\nCheck container logs for details.';
|
||||||
|
}
|
||||||
|
} else if (remote.provider && remote.healthy) {
|
||||||
dot.style.background = '#44cc44';
|
dot.style.background = '#44cc44';
|
||||||
badge.style.background = '#1a2a1a';
|
badge.style.background = '#1a2a1a';
|
||||||
badge.style.color = '#aaffaa';
|
badge.style.color = '#aaffaa';
|
||||||
|
|
||||||
// Show override summary if any operations use different providers
|
|
||||||
var overrides = remote.overrides || {};
|
var overrides = remote.overrides || {};
|
||||||
var overrideEntries = Object.entries(overrides).filter(([, v]) => v);
|
var overrideEntries = Object.entries(overrides).filter(([, v]) => v);
|
||||||
var overrideStr = overrideEntries.length
|
var overrideStr = overrideEntries.length
|
||||||
? ' · ' + overrideEntries.map(([k, v]) => `${k}→${v}`).join(', ')
|
? ' · ' + overrideEntries.map(([k, v]) => k + '→' + v).join(', ')
|
||||||
: '';
|
: '';
|
||||||
label.textContent = remote.provider + overrideStr + (local.gpu_detected ? ' · GPU' : '');
|
label.textContent = remote.provider + overrideStr + (local.gpu_detected ? ' · GPU' : '');
|
||||||
|
|
||||||
var opLines = Object.entries(remote.operations || {})
|
var opLines = Object.entries(remote.operations || {})
|
||||||
.map(([op, s]) => `${op}: ${s.provider || remote.provider} ${s.healthy ? '✓' : '✗'}`)
|
.map(([op, s]) => op + ': ' + (s.provider || remote.provider) + ' ' + (s.healthy ? '✓' : '✗'))
|
||||||
.join('\n');
|
.join('\n');
|
||||||
badge.title = opLines || ('Provider: ' + remote.provider);
|
badge.title = opLines || ('Provider: ' + remote.provider);
|
||||||
} else if (remote.provider && !remote.healthy) {
|
} else if (remote.provider && !remote.healthy) {
|
||||||
@@ -70,3 +104,10 @@ export async function mountProviderBadge(container) {
|
|||||||
|
|
||||||
return badge;
|
return badge;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _shortGpuName(name) {
|
||||||
|
return (name || 'GPU')
|
||||||
|
.replace(/^NVIDIA GeForce\s+/i, '')
|
||||||
|
.replace(/^NVIDIA\s+/i, '')
|
||||||
|
.replace(/^AMD Radeon\s+/i, '');
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
/**
|
||||||
|
* ProgressOverlay — shared animated progress indicator for long AI operations.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* import { showProgress, updateProgress, hideProgress } from './progress_overlay.js';
|
||||||
|
*
|
||||||
|
* showProgress('Generating image…');
|
||||||
|
* updateProgress(50, 'Denoising step 15/30…'); // optional step updates
|
||||||
|
* hideProgress();
|
||||||
|
*
|
||||||
|
* When you don't have real step counts, call showProgress() and hideProgress() only —
|
||||||
|
* the bar animates automatically with a shimmer to signal activity.
|
||||||
|
*/
|
||||||
|
|
||||||
|
var _overlay = null;
|
||||||
|
var _bar = null;
|
||||||
|
var _label = null;
|
||||||
|
var _shimmerAnim = null;
|
||||||
|
var _fakeTimer = null;
|
||||||
|
var _currentPct = 0;
|
||||||
|
|
||||||
|
// ── SSE progress connection ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
var _sse = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open an EventSource to /api/generate/progress and drive the bar with real
|
||||||
|
* denoising step counts from the local GPU pipeline.
|
||||||
|
*
|
||||||
|
* @param {string} pipeType - 'txt2img' | 'inpaint' | 'img2img'
|
||||||
|
* @param {string} baseUrl - window.API_BASE_URL or ''
|
||||||
|
*/
|
||||||
|
export function connectProgressSSE(pipeType, baseUrl) {
|
||||||
|
disconnectProgressSSE();
|
||||||
|
try {
|
||||||
|
var url = (baseUrl || '') + '/api/generate/progress';
|
||||||
|
_sse = new EventSource(url);
|
||||||
|
_sse.onmessage = (e) => {
|
||||||
|
try {
|
||||||
|
var states = JSON.parse(e.data);
|
||||||
|
var s = Array.isArray(states)
|
||||||
|
? states.find(st => st.pipeline === pipeType)
|
||||||
|
: null;
|
||||||
|
if (s && s.state === 'running' && s.total_steps) {
|
||||||
|
var pct = Math.round(s.step / s.total_steps * 85);
|
||||||
|
updateProgress(pct, s.message || `Step ${s.step} / ${s.total_steps}`);
|
||||||
|
}
|
||||||
|
} catch { /* malformed event — ignore */ }
|
||||||
|
};
|
||||||
|
_sse.onerror = () => disconnectProgressSSE();
|
||||||
|
} catch { /* SSE not supported */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function disconnectProgressSSE() {
|
||||||
|
if (_sse) { _sse.close(); _sse = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Progress overlay ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function showProgress(message, estimatedSeconds) {
|
||||||
|
hideProgress();
|
||||||
|
|
||||||
|
_currentPct = 0;
|
||||||
|
|
||||||
|
// ── Backdrop ──────────────────────────────────────────────────────────────
|
||||||
|
_overlay = document.createElement('div');
|
||||||
|
_overlay.id = 'ai-progress-overlay';
|
||||||
|
_overlay.style.cssText = [
|
||||||
|
'position:fixed', 'inset:0', 'z-index:99999',
|
||||||
|
'display:flex', 'flex-direction:column',
|
||||||
|
'align-items:center', 'justify-content:center',
|
||||||
|
'background:rgba(0,0,0,0.55)',
|
||||||
|
'backdrop-filter:blur(2px)',
|
||||||
|
'-webkit-backdrop-filter:blur(2px)',
|
||||||
|
].join(';');
|
||||||
|
|
||||||
|
// ── Card ──────────────────────────────────────────────────────────────────
|
||||||
|
var card = document.createElement('div');
|
||||||
|
card.style.cssText = [
|
||||||
|
'background:#1a1a2e',
|
||||||
|
'border:1px solid #3a3a6a',
|
||||||
|
'border-radius:14px',
|
||||||
|
'padding:28px 36px',
|
||||||
|
'min-width:320px', 'max-width:480px',
|
||||||
|
'box-shadow:0 12px 48px rgba(0,0,0,0.8)',
|
||||||
|
'display:flex', 'flex-direction:column', 'gap:14px',
|
||||||
|
'text-align:center',
|
||||||
|
].join(';');
|
||||||
|
|
||||||
|
// ── Label ─────────────────────────────────────────────────────────────────
|
||||||
|
_label = document.createElement('div');
|
||||||
|
_label.textContent = message || 'Processing…';
|
||||||
|
_label.style.cssText = 'font-family:sans-serif;font-size:13px;color:#c0c0e0;line-height:1.4;min-height:2.8em';
|
||||||
|
|
||||||
|
// ── Track ─────────────────────────────────────────────────────────────────
|
||||||
|
var track = document.createElement('div');
|
||||||
|
track.style.cssText = [
|
||||||
|
'width:100%', 'height:6px',
|
||||||
|
'background:#0f0f2a',
|
||||||
|
'border-radius:3px',
|
||||||
|
'overflow:hidden',
|
||||||
|
'position:relative',
|
||||||
|
].join(';');
|
||||||
|
|
||||||
|
// ── Shimmer (indeterminate stripe) ────────────────────────────────────────
|
||||||
|
var shimmer = document.createElement('div');
|
||||||
|
shimmer.style.cssText = [
|
||||||
|
'position:absolute', 'inset:0',
|
||||||
|
'background:linear-gradient(90deg,transparent 0%,rgba(120,120,255,0.25) 50%,transparent 100%)',
|
||||||
|
'transform:translateX(-100%)',
|
||||||
|
'will-change:transform',
|
||||||
|
].join(';');
|
||||||
|
|
||||||
|
// ── Filled bar ────────────────────────────────────────────────────────────
|
||||||
|
_bar = document.createElement('div');
|
||||||
|
_bar.style.cssText = [
|
||||||
|
'position:absolute', 'inset-block:0', 'left:0',
|
||||||
|
'width:0%',
|
||||||
|
'background:linear-gradient(90deg,#5577ff,#88aaff)',
|
||||||
|
'border-radius:3px',
|
||||||
|
'transition:width 0.35s ease',
|
||||||
|
].join(';');
|
||||||
|
|
||||||
|
// ── Cancel hint ───────────────────────────────────────────────────────────
|
||||||
|
var hint = document.createElement('div');
|
||||||
|
hint.textContent = 'Press Esc to cancel';
|
||||||
|
hint.style.cssText = 'font-family:sans-serif;font-size:10px;color:#444;margin-top:2px';
|
||||||
|
|
||||||
|
track.appendChild(shimmer);
|
||||||
|
track.appendChild(_bar);
|
||||||
|
card.appendChild(_label);
|
||||||
|
card.appendChild(track);
|
||||||
|
card.appendChild(hint);
|
||||||
|
_overlay.appendChild(card);
|
||||||
|
document.body.appendChild(_overlay);
|
||||||
|
|
||||||
|
// Animate shimmer
|
||||||
|
var pos = -100;
|
||||||
|
_shimmerAnim = setInterval(() => {
|
||||||
|
pos += 2.5;
|
||||||
|
if (pos > 200) pos = -100;
|
||||||
|
shimmer.style.transform = `translateX(${pos}%)`;
|
||||||
|
}, 16);
|
||||||
|
|
||||||
|
// Fake progress that creeps toward 90% if no real steps given
|
||||||
|
if (estimatedSeconds) {
|
||||||
|
var totalMs = estimatedSeconds * 1000;
|
||||||
|
var step = 90 / (totalMs / 200);
|
||||||
|
_fakeTimer = setInterval(() => {
|
||||||
|
if (_currentPct < 90) {
|
||||||
|
_currentPct = Math.min(90, _currentPct + step);
|
||||||
|
_bar.style.width = _currentPct + '%';
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Esc to cancel
|
||||||
|
_overlay._escHandler = (e) => { if (e.key === 'Escape') hideProgress(); };
|
||||||
|
document.addEventListener('keydown', _overlay._escHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateProgress(pct, message) {
|
||||||
|
if (!_overlay) return;
|
||||||
|
_currentPct = Math.max(_currentPct, Math.min(100, pct));
|
||||||
|
if (_bar) _bar.style.width = _currentPct + '%';
|
||||||
|
if (_label && message) _label.textContent = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hideProgress() {
|
||||||
|
if (_shimmerAnim) { clearInterval(_shimmerAnim); _shimmerAnim = null; }
|
||||||
|
if (_fakeTimer) { clearInterval(_fakeTimer); _fakeTimer = null; }
|
||||||
|
if (_overlay) {
|
||||||
|
document.removeEventListener('keydown', _overlay._escHandler);
|
||||||
|
_overlay.remove();
|
||||||
|
_overlay = null;
|
||||||
|
}
|
||||||
|
_bar = null;
|
||||||
|
_label = null;
|
||||||
|
_currentPct = 0;
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Text → Image — opens a sidebar-style dialog, generates via remote provider,
|
* Text → Image — generates via remote or local-GPU provider,
|
||||||
* pastes result as a new layer on the current canvas.
|
* pastes result as a new layer on the current canvas.
|
||||||
*
|
*
|
||||||
* Menu target: generate/text_to_image.text_to_image
|
* Menu target: generate/text_to_image.text_to_image
|
||||||
@@ -12,6 +12,7 @@ import Dialog_class from './../../libs/popup.js';
|
|||||||
import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
|
import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
|
||||||
import apiService from './../../services/api.js';
|
import apiService from './../../services/api.js';
|
||||||
import { getCapabilities } from './../../api/capabilities.js';
|
import { getCapabilities } from './../../api/capabilities.js';
|
||||||
|
import { showProgress, updateProgress, hideProgress, connectProgressSSE, disconnectProgressSSE } from './../../libs/progress_overlay.js';
|
||||||
|
|
||||||
var instance = null;
|
var instance = null;
|
||||||
|
|
||||||
@@ -27,10 +28,14 @@ class Generate_text_to_image_class {
|
|||||||
|
|
||||||
async text_to_image() {
|
async text_to_image() {
|
||||||
var caps = await getCapabilities();
|
var caps = await getCapabilities();
|
||||||
if (!caps.remote || !caps.remote.healthy) {
|
var hasRemote = caps.remote && caps.remote.healthy;
|
||||||
|
var hasLocal = caps.local && caps.local.local_gpu_available;
|
||||||
|
|
||||||
|
if (!hasRemote && !hasLocal) {
|
||||||
alertify.error(
|
alertify.error(
|
||||||
'Text → Image requires a remote AI provider. ' +
|
'Text → Image requires an AI provider. ' +
|
||||||
'Set AI_PROVIDER (openai / invokeai / comfyui) in .env and restart.'
|
'Set AI_PROVIDER=openai / invokeai / comfyui / local_gpu in .env and restart, ' +
|
||||||
|
'or configure one in Image → AI Provider Settings.'
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -39,9 +44,38 @@ class Generate_text_to_image_class {
|
|||||||
var canvasW = config.WIDTH || 1024;
|
var canvasW = config.WIDTH || 1024;
|
||||||
var canvasH = config.HEIGHT || 1024;
|
var canvasH = config.HEIGHT || 1024;
|
||||||
|
|
||||||
|
// Build provider info line
|
||||||
|
var providerHtml = hasRemote
|
||||||
|
? `<span style="color:#44cc44">● ${caps.remote.provider}</span>`
|
||||||
|
: `<span style="color:#44cc44">● local GPU · ${caps.local.gpu_tier || ''} · ${_shortGpu(caps.local.gpu_device)}</span>`;
|
||||||
|
|
||||||
|
// Model note for local GPU
|
||||||
|
var modelNote = '';
|
||||||
|
if (hasLocal && !hasRemote) {
|
||||||
|
var rec = caps.local.local_gpu_capabilities && caps.local.local_gpu_capabilities.recommended;
|
||||||
|
var m = rec && rec.txt2img;
|
||||||
|
if (m) {
|
||||||
|
modelNote = `Model: <span style="color:#ddd">${m.model_id.split('/').pop()}</span>`;
|
||||||
|
if (m.memory_opt && m.memory_opt !== 'none') modelNote += ` · <span style="color:#aaa">${m.memory_opt}</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Estimate generation time (rough guide for the progress bar)
|
||||||
|
var estSec = hasLocal ? 60 : 15; // local GPU ~1 min; OpenAI ~15s
|
||||||
|
|
||||||
|
var defaultW = Math.min(canvasW, hasLocal ? (caps.local.local_gpu_capabilities?.recommended?.txt2img?.native_res || 1024) : 1024);
|
||||||
|
var defaultH = Math.min(canvasH, defaultW);
|
||||||
|
|
||||||
this.Dialog.show({
|
this.Dialog.show({
|
||||||
title: 'Text → Image',
|
title: 'Text → Image',
|
||||||
params: [
|
params: [
|
||||||
|
{
|
||||||
|
title: '',
|
||||||
|
html: `<div style="font-size:11px;margin:0 0 8px">
|
||||||
|
Provider: ${providerHtml}${modelNote ? ' · ' + modelNote : ''}<br>
|
||||||
|
<span style="color:#777">Generation typically takes ${estSec < 30 ? 'a few seconds' : estSec < 90 ? '30–90 seconds on local GPU' : '1–3 minutes on local GPU'}.</span>
|
||||||
|
</div>`,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'prompt',
|
name: 'prompt',
|
||||||
title: 'Describe your image:',
|
title: 'Describe your image:',
|
||||||
@@ -58,7 +92,7 @@ class Generate_text_to_image_class {
|
|||||||
{
|
{
|
||||||
name: 'width',
|
name: 'width',
|
||||||
title: 'Width (px):',
|
title: 'Width (px):',
|
||||||
value: Math.min(canvasW, 1024),
|
value: defaultW,
|
||||||
range: [256, 2048],
|
range: [256, 2048],
|
||||||
step: 64,
|
step: 64,
|
||||||
type: 'range',
|
type: 'range',
|
||||||
@@ -66,7 +100,7 @@ class Generate_text_to_image_class {
|
|||||||
{
|
{
|
||||||
name: 'height',
|
name: 'height',
|
||||||
title: 'Height (px):',
|
title: 'Height (px):',
|
||||||
value: Math.min(canvasH, 1024),
|
value: defaultH,
|
||||||
range: [256, 2048],
|
range: [256, 2048],
|
||||||
step: 64,
|
step: 64,
|
||||||
type: 'range',
|
type: 'range',
|
||||||
@@ -99,15 +133,17 @@ class Generate_text_to_image_class {
|
|||||||
alertify.warning('Please enter a description.');
|
alertify.warning('Please enter a description.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await _this._generate(params);
|
await _this._generate(params, estSec);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async _generate(params) {
|
async _generate(params, estSec) {
|
||||||
if (this.isProcessing) return;
|
if (this.isProcessing) return;
|
||||||
this.isProcessing = true;
|
this.isProcessing = true;
|
||||||
alertify.message('Generating image... please wait', 0);
|
|
||||||
|
connectProgressSSE('txt2img', window.API_BASE_URL || '');
|
||||||
|
showProgress('Generating image…', estSec || 60);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
var result = await apiService.textToImage(params.prompt, {
|
var result = await apiService.textToImage(params.prompt, {
|
||||||
@@ -118,10 +154,11 @@ class Generate_text_to_image_class {
|
|||||||
seed: params.seed || 0,
|
seed: params.seed || 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
updateProgress(95, 'Placing image…');
|
||||||
|
|
||||||
var img = new Image();
|
var img = new Image();
|
||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
if (params.placement === 'replace_canvas') {
|
if (params.placement === 'replace_canvas') {
|
||||||
// Resize canvas and replace bottom layer
|
|
||||||
config.WIDTH = img.naturalWidth;
|
config.WIDTH = img.naturalWidth;
|
||||||
config.HEIGHT = img.naturalHeight;
|
config.HEIGHT = img.naturalHeight;
|
||||||
var resultCanvas = document.createElement('canvas');
|
var resultCanvas = document.createElement('canvas');
|
||||||
@@ -134,16 +171,13 @@ class Generate_text_to_image_class {
|
|||||||
])
|
])
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Add as new layer on top
|
|
||||||
var dataURL = img.src;
|
|
||||||
app.State.do_action(
|
app.State.do_action(
|
||||||
new app.Actions.Bundle_action('txt2img_layer', 'Text → Image Layer', [
|
new app.Actions.Bundle_action('txt2img_layer', 'Text → Image Layer', [
|
||||||
new app.Actions.Insert_layer_action({
|
new app.Actions.Insert_layer_action({
|
||||||
name: params.prompt.slice(0, 30),
|
name: params.prompt.slice(0, 30),
|
||||||
type: 'image',
|
type: 'image',
|
||||||
data: dataURL,
|
data: img.src,
|
||||||
x: 0,
|
x: 0, y: 0,
|
||||||
y: 0,
|
|
||||||
width: img.naturalWidth,
|
width: img.naturalWidth,
|
||||||
height: img.naturalHeight,
|
height: img.naturalHeight,
|
||||||
width_original: img.naturalWidth,
|
width_original: img.naturalWidth,
|
||||||
@@ -152,23 +186,31 @@ class Generate_text_to_image_class {
|
|||||||
])
|
])
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
alertify.dismissAll();
|
disconnectProgressSSE();
|
||||||
|
hideProgress();
|
||||||
alertify.success('Image generated!');
|
alertify.success('Image generated!');
|
||||||
this.isProcessing = false;
|
this.isProcessing = false;
|
||||||
};
|
};
|
||||||
img.onerror = () => {
|
img.onerror = () => {
|
||||||
alertify.dismissAll();
|
disconnectProgressSSE();
|
||||||
|
hideProgress();
|
||||||
alertify.error('Failed to load generated image.');
|
alertify.error('Failed to load generated image.');
|
||||||
this.isProcessing = false;
|
this.isProcessing = false;
|
||||||
};
|
};
|
||||||
img.src = 'data:image/png;base64,' + result.result;
|
img.src = 'data:image/png;base64,' + result.result;
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alertify.dismissAll();
|
disconnectProgressSSE();
|
||||||
|
hideProgress();
|
||||||
alertify.error('Generation failed: ' + (err.message || err));
|
alertify.error('Generation failed: ' + (err.message || err));
|
||||||
this.isProcessing = false;
|
this.isProcessing = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _shortGpu(name) {
|
||||||
|
if (!name) return 'GPU';
|
||||||
|
return name.replace(/^NVIDIA GeForce /i, '').replace(/^NVIDIA /i, '');
|
||||||
|
}
|
||||||
|
|
||||||
export default Generate_text_to_image_class;
|
export default Generate_text_to_image_class;
|
||||||
|
|||||||
@@ -15,11 +15,12 @@ import Base_layers_class from './../../core/base-layers.js';
|
|||||||
import Dialog_class from './../../libs/popup.js';
|
import Dialog_class from './../../libs/popup.js';
|
||||||
import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
|
import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
|
||||||
import { getCapabilities } from './../../api/capabilities.js';
|
import { getCapabilities } from './../../api/capabilities.js';
|
||||||
|
import { showProgress, hideProgress } from './../../libs/progress_overlay.js';
|
||||||
|
|
||||||
var instance = null;
|
var instance = null;
|
||||||
|
|
||||||
const FRAME_SIZES = [
|
const FRAME_SIZES = [
|
||||||
'4x6', '5x7', '8x10', '11x14', '16x20', '20x24', '24x36',
|
'4x6', '5x7', '8x10', '11x14', '16x20', '18x24', '20x24', '24x36',
|
||||||
'4x4', '8x8', '12x12',
|
'4x4', '8x8', '12x12',
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -27,8 +28,8 @@ const FRAME_SIZES = [
|
|||||||
const FRAME_PX = {
|
const FRAME_PX = {
|
||||||
'4x6': [1200, 1800], '5x7': [1500, 2100],
|
'4x6': [1200, 1800], '5x7': [1500, 2100],
|
||||||
'8x10': [2400, 3000], '11x14': [3300, 4200],
|
'8x10': [2400, 3000], '11x14': [3300, 4200],
|
||||||
'16x20': [4800, 6000], '20x24': [6000, 7200],
|
'16x20': [4800, 6000], '18x24': [5400, 7200],
|
||||||
'24x36': [7200, 10800],
|
'20x24': [6000, 7200], '24x36': [7200, 10800],
|
||||||
'4x4': [1200, 1200], '8x8': [2400, 2400], '12x12': [3600, 3600],
|
'4x4': [1200, 1200], '8x8': [2400, 2400], '12x12': [3600, 3600],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -96,7 +97,7 @@ class Image_frame_fit_class {
|
|||||||
name: 'dpi',
|
name: 'dpi',
|
||||||
title: 'Output DPI:',
|
title: 'Output DPI:',
|
||||||
value: '300',
|
value: '300',
|
||||||
values: ['72', '150', '300'],
|
values: ['72', '150', '200', '300'],
|
||||||
type: 'select',
|
type: 'select',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -123,11 +124,11 @@ class Image_frame_fit_class {
|
|||||||
this.isProcessing = true;
|
this.isProcessing = true;
|
||||||
|
|
||||||
var mode = params.mode || 'smart';
|
var mode = params.mode || 'smart';
|
||||||
alertify.message(
|
showProgress(
|
||||||
mode === 'extend'
|
mode === 'extend'
|
||||||
? 'Fitting to frame with AI extension... please wait'
|
? 'Fitting to frame with AI extension…'
|
||||||
: 'Fitting to frame...',
|
: 'Fitting to frame…',
|
||||||
0
|
mode === 'extend' ? 45 : 5
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -203,7 +204,7 @@ class Image_frame_fit_class {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
alertify.dismissAll();
|
hideProgress();
|
||||||
alertify.success(
|
alertify.success(
|
||||||
`Done! ${result.output_pixels.width}×${result.output_pixels.height}px` +
|
`Done! ${result.output_pixels.width}×${result.output_pixels.height}px` +
|
||||||
` (${result.frame} ${result.orientation}, ${result.mode_used})`
|
` (${result.frame} ${result.orientation}, ${result.mode_used})`
|
||||||
@@ -211,14 +212,14 @@ class Image_frame_fit_class {
|
|||||||
this.isProcessing = false;
|
this.isProcessing = false;
|
||||||
};
|
};
|
||||||
img.onerror = () => {
|
img.onerror = () => {
|
||||||
alertify.dismissAll();
|
hideProgress();
|
||||||
alertify.error('Failed to load result.');
|
alertify.error('Failed to load result.');
|
||||||
this.isProcessing = false;
|
this.isProcessing = false;
|
||||||
};
|
};
|
||||||
img.src = 'data:image/png;base64,' + result.result;
|
img.src = 'data:image/png;base64,' + result.result;
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alertify.dismissAll();
|
hideProgress();
|
||||||
alertify.error('Frame fit failed: ' + (err.message || err));
|
alertify.error('Frame fit failed: ' + (err.message || err));
|
||||||
this.isProcessing = false;
|
this.isProcessing = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,287 @@
|
|||||||
|
/**
|
||||||
|
* Prepare for Print — one-click AI upscale + frame fit.
|
||||||
|
*
|
||||||
|
* Shows a quality assessment (current effective DPI, needed upscale factor,
|
||||||
|
* AI vs Lanczos note) then chains AI upscale → frame-fit in a single backend call.
|
||||||
|
*
|
||||||
|
* Menu target: image/print_prepare.print_prepare
|
||||||
|
*/
|
||||||
|
|
||||||
|
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 { getCapabilities } from './../../api/capabilities.js';
|
||||||
|
import { showProgress, updateProgress, hideProgress } from './../../libs/progress_overlay.js';
|
||||||
|
|
||||||
|
const FRAME_SIZES = [
|
||||||
|
'5x7', '8x10', '11x14', '18x24', '16x20', '20x24', '24x36',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Portrait pixels at 300 DPI (label use only)
|
||||||
|
const FRAME_PX = {
|
||||||
|
'5x7': [1500, 2100], '8x10': [2400, 3000],
|
||||||
|
'11x14': [3300, 4200], '18x24': [5400, 7200],
|
||||||
|
'16x20': [4800, 6000], '20x24': [6000, 7200],
|
||||||
|
'24x36': [7200, 10800],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Actual frame inches (portrait w, h)
|
||||||
|
const FRAME_IN = {
|
||||||
|
'5x7': [5, 7], '8x10': [8, 10], '11x14': [11, 14],
|
||||||
|
'18x24': [18, 24], '16x20': [16, 20], '20x24': [20, 24],
|
||||||
|
'24x36': [24, 36],
|
||||||
|
};
|
||||||
|
|
||||||
|
var instance = null;
|
||||||
|
|
||||||
|
class Image_print_prepare_class {
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
if (instance) return instance;
|
||||||
|
instance = this;
|
||||||
|
this.Base_layers = new Base_layers_class();
|
||||||
|
this.Dialog = new Dialog_class();
|
||||||
|
this.isProcessing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async print_prepare() {
|
||||||
|
if (!config.layer || config.layer.type !== 'image') {
|
||||||
|
alertify.error('Select an image layer first.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var caps = await getCapabilities();
|
||||||
|
var hasAI = (caps.remote && caps.remote.healthy) || (caps.local && caps.local.local_gpu_available);
|
||||||
|
|
||||||
|
var W = config.layer.width_original;
|
||||||
|
var H = config.layer.height_original;
|
||||||
|
|
||||||
|
var qualityHtml = _buildQualityHtml(W, H, hasAI);
|
||||||
|
|
||||||
|
var frameLabels = FRAME_SIZES.map(s => {
|
||||||
|
var px = FRAME_PX[s] || [0, 0];
|
||||||
|
return `${s}" (${px[0]}×${px[1]}px @ 300dpi)`;
|
||||||
|
});
|
||||||
|
|
||||||
|
var _this = this;
|
||||||
|
this.Dialog.show({
|
||||||
|
title: 'Prepare for Print',
|
||||||
|
params: [
|
||||||
|
{
|
||||||
|
title: '',
|
||||||
|
html: qualityHtml,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'frame',
|
||||||
|
title: 'Target frame size:',
|
||||||
|
value: frameLabels[0],
|
||||||
|
values: frameLabels,
|
||||||
|
type: 'select',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'orientation',
|
||||||
|
title: 'Orientation:',
|
||||||
|
value: 'auto',
|
||||||
|
values: ['auto', 'portrait', 'landscape'],
|
||||||
|
type: 'select',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'target_dpi',
|
||||||
|
title: 'Target DPI:',
|
||||||
|
value: '300',
|
||||||
|
values: ['200', '300'],
|
||||||
|
type: 'select',
|
||||||
|
comment: '200 dpi is fine for 18×24" and larger (viewed from a distance)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'mode',
|
||||||
|
title: 'Fit mode:',
|
||||||
|
value: 'smart',
|
||||||
|
values: ['smart', 'crop', 'extend'],
|
||||||
|
type: 'select',
|
||||||
|
comment: 'smart = extend if gap <15%, else crop',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'upscale_method',
|
||||||
|
title: 'Upscale engine:',
|
||||||
|
value: 'auto',
|
||||||
|
values: ['auto', 'realesrgan_pytorch', 'realesrgan_ncnn', 'lanczos'],
|
||||||
|
type: 'select',
|
||||||
|
comment: hasAI ? 'auto picks Real-ESRGAN — genuinely adds detail' : 'auto picks Real-ESRGAN if available, else Lanczos',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'prompt',
|
||||||
|
title: 'Extend prompt (optional):',
|
||||||
|
value: '',
|
||||||
|
placeholder: 'e.g. "natural background continuation" — blank works well',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'new_layer',
|
||||||
|
title: 'Result as new layer (keep original):',
|
||||||
|
value: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
on_finish: async function (params) {
|
||||||
|
var frameKey = params.frame.split('"')[0];
|
||||||
|
await _this._run(frameKey, params, W, H);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async _run(frameKey, params, origW, origH) {
|
||||||
|
if (this.isProcessing) return;
|
||||||
|
this.isProcessing = true;
|
||||||
|
|
||||||
|
var dpi = parseInt(params.target_dpi) || 300;
|
||||||
|
var inches = FRAME_IN[frameKey] || [8, 10];
|
||||||
|
var targetW = inches[0] * dpi;
|
||||||
|
var targetH = inches[1] * dpi;
|
||||||
|
|
||||||
|
// Orientation swap for display
|
||||||
|
var orient = params.orientation || 'auto';
|
||||||
|
var imgLandscape = origW >= origH;
|
||||||
|
var frameLandscape = inches[0] >= inches[1];
|
||||||
|
if (orient === 'landscape' || (orient === 'auto' && imgLandscape && !frameLandscape)) {
|
||||||
|
targetW = Math.max(inches[0], inches[1]) * dpi;
|
||||||
|
targetH = Math.min(inches[0], inches[1]) * dpi;
|
||||||
|
} else if (orient === 'portrait' || (orient === 'auto' && !imgLandscape && frameLandscape)) {
|
||||||
|
targetW = Math.min(inches[0], inches[1]) * dpi;
|
||||||
|
targetH = Math.max(inches[0], inches[1]) * dpi;
|
||||||
|
}
|
||||||
|
|
||||||
|
var neededScale = Math.max(targetW / origW, targetH / origH);
|
||||||
|
var willUpscale = neededScale > 1.05;
|
||||||
|
|
||||||
|
showProgress(
|
||||||
|
willUpscale
|
||||||
|
? `Upscaling ${neededScale.toFixed(1)}× with AI, then fitting to frame…\nAI is reconstructing detail — this may take 1–3 minutes.`
|
||||||
|
: 'Fitting to frame…',
|
||||||
|
willUpscale ? 120 : 8
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
var layerCanvas = document.createElement('canvas');
|
||||||
|
layerCanvas.width = origW;
|
||||||
|
layerCanvas.height = origH;
|
||||||
|
layerCanvas.getContext('2d').drawImage(config.layer.link, 0, 0);
|
||||||
|
var imageB64 = layerCanvas.toDataURL('image/png').split(',')[1];
|
||||||
|
|
||||||
|
var base = window.API_BASE_URL || '';
|
||||||
|
var r = await fetch(`${base}/api/print/prepare`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
image: imageB64,
|
||||||
|
frame: frameKey,
|
||||||
|
orientation: orient,
|
||||||
|
target_dpi: dpi,
|
||||||
|
upscale_method: params.upscale_method || 'auto',
|
||||||
|
mode: params.mode || 'smart',
|
||||||
|
prompt: params.prompt || '',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!r.ok) {
|
||||||
|
var err = await r.json().catch(() => ({ detail: 'Server error' }));
|
||||||
|
throw new Error(err.detail || 'Prepare failed');
|
||||||
|
}
|
||||||
|
var result = await r.json();
|
||||||
|
|
||||||
|
updateProgress(90, 'Placing result…');
|
||||||
|
var img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
var resultCanvas = document.createElement('canvas');
|
||||||
|
resultCanvas.width = img.naturalWidth;
|
||||||
|
resultCanvas.height = img.naturalHeight;
|
||||||
|
resultCanvas.getContext('2d').drawImage(img, 0, 0);
|
||||||
|
|
||||||
|
var fitW = img.naturalWidth;
|
||||||
|
var fitH = img.naturalHeight;
|
||||||
|
|
||||||
|
if (params.new_layer) {
|
||||||
|
app.State.do_action(
|
||||||
|
new app.Actions.Bundle_action('print_prepare_layer', 'Prepare for Print', [
|
||||||
|
new app.Actions.Prepare_canvas_action('undo'),
|
||||||
|
new app.Actions.Update_config_action({ WIDTH: fitW, HEIGHT: fitH }),
|
||||||
|
new app.Actions.Insert_layer_action({
|
||||||
|
name: `${frameKey} ${dpi}dpi`,
|
||||||
|
type: 'image',
|
||||||
|
data: img.src,
|
||||||
|
x: 0, y: 0,
|
||||||
|
width: fitW, height: fitH,
|
||||||
|
width_original: fitW, height_original: fitH,
|
||||||
|
}),
|
||||||
|
new app.Actions.Prepare_canvas_action('do'),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
app.State.do_action(
|
||||||
|
new app.Actions.Bundle_action('print_prepare', 'Prepare for Print', [
|
||||||
|
new app.Actions.Prepare_canvas_action('undo'),
|
||||||
|
new app.Actions.Update_config_action({ WIDTH: fitW, HEIGHT: fitH }),
|
||||||
|
new app.Actions.Update_layer_image_action(resultCanvas),
|
||||||
|
new app.Actions.Prepare_canvas_action('do'),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
hideProgress();
|
||||||
|
var upscaleNote = result.upscale_applied
|
||||||
|
? ` · ${result.upscale_factor}× ${result.upscale_method}`
|
||||||
|
: ' · no upscale needed';
|
||||||
|
alertify.success(
|
||||||
|
`Print-ready! ${fitW}×${fitH}px @ ${dpi} DPI (${frameKey}")${upscaleNote}`
|
||||||
|
);
|
||||||
|
this.isProcessing = false;
|
||||||
|
};
|
||||||
|
img.onerror = () => {
|
||||||
|
hideProgress();
|
||||||
|
alertify.error('Failed to load result.');
|
||||||
|
this.isProcessing = false;
|
||||||
|
};
|
||||||
|
img.src = 'data:image/png;base64,' + result.result;
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
hideProgress();
|
||||||
|
alertify.error('Prepare for Print failed: ' + (err.message || err));
|
||||||
|
this.isProcessing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _buildQualityHtml(W, H, hasAI) {
|
||||||
|
var rows = FRAME_SIZES.map(key => {
|
||||||
|
var inches = FRAME_IN[key];
|
||||||
|
// Effective DPI: smaller of the two dimensions (limiting factor)
|
||||||
|
var effDpi = Math.round(Math.min(W / inches[0], H / inches[1]));
|
||||||
|
var quality = effDpi >= 300 ? '✓ excellent'
|
||||||
|
: effDpi >= 200 ? '✓ good for large format'
|
||||||
|
: effDpi >= 150 ? '~ acceptable'
|
||||||
|
: '✗ needs upscaling';
|
||||||
|
var color = effDpi >= 300 ? '#44cc44'
|
||||||
|
: effDpi >= 200 ? '#88cc44'
|
||||||
|
: effDpi >= 150 ? '#ffaa44'
|
||||||
|
: '#ff6644';
|
||||||
|
var neededScale = Math.max(1, Math.ceil((300 / effDpi) * 10) / 10);
|
||||||
|
var scaleNote = effDpi >= 300 ? '' : ` → need ~${neededScale.toFixed(1)}× upscale`;
|
||||||
|
return `<tr>
|
||||||
|
<td style="color:#aaa;padding:2px 10px 2px 0;white-space:nowrap">${key}"</td>
|
||||||
|
<td style="color:#ddd;white-space:nowrap">${effDpi} DPI</td>
|
||||||
|
<td style="color:${color};padding-left:8px">${quality}${scaleNote}</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
var aiNote = hasAI
|
||||||
|
? '<span style="color:#44cc44">Real-ESRGAN available — will add genuine sharpness (AI reconstructs detail)</span>'
|
||||||
|
: '<span style="color:#ffaa44">No AI provider — will use Lanczos (resizes but doesn\'t add detail)</span>';
|
||||||
|
|
||||||
|
return `<div style="font-size:11px;margin:0 0 8px">
|
||||||
|
<div style="color:#aaa;margin-bottom:6px">Current image: <span style="color:#ddd">${W}×${H}px</span> · ${aiNote}</div>
|
||||||
|
<table style="width:100%;border-collapse:collapse;margin-bottom:6px">${rows}</table>
|
||||||
|
<div style="color:#888">200 DPI is fine for 18×24" and larger prints viewed from 2+ feet.</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Image_print_prepare_class;
|
||||||
@@ -16,6 +16,10 @@ const PRINT_SIZES = [
|
|||||||
[3000, 2400, '8x10" Landscape'],
|
[3000, 2400, '8x10" Landscape'],
|
||||||
[3300, 4200, '11x14" Portrait'],
|
[3300, 4200, '11x14" Portrait'],
|
||||||
[4200, 3300, '11x14" Landscape'],
|
[4200, 3300, '11x14" Landscape'],
|
||||||
|
[3600, 4800, '18x24" Portrait 200dpi'],
|
||||||
|
[4800, 3600, '18x24" Landscape 200dpi'],
|
||||||
|
[5400, 7200, '18x24" Portrait 300dpi'],
|
||||||
|
[7200, 5400, '18x24" Landscape 300dpi'],
|
||||||
];
|
];
|
||||||
|
|
||||||
class Image_size_class {
|
class Image_size_class {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import config from './../../config.js';
|
|||||||
import Base_layers_class from './../../core/base-layers.js';
|
import Base_layers_class from './../../core/base-layers.js';
|
||||||
import Dialog_class from './../../libs/popup.js';
|
import Dialog_class from './../../libs/popup.js';
|
||||||
import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
|
import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
|
||||||
|
import { showProgress, updateProgress, hideProgress } from './../../libs/progress_overlay.js';
|
||||||
|
|
||||||
var instance = null;
|
var instance = null;
|
||||||
|
|
||||||
@@ -221,7 +222,12 @@ class Image_upscale_class {
|
|||||||
? `Auto (${caps.recommended_label || 'best available'})`
|
? `Auto (${caps.recommended_label || 'best available'})`
|
||||||
: (METHOD_LABELS[method] || method);
|
: (METHOD_LABELS[method] || method);
|
||||||
|
|
||||||
alertify.message(`Upscaling ${scale}× · ${methodLabel}…`, 0);
|
var isAI = method !== 'lanczos';
|
||||||
|
showProgress(
|
||||||
|
`Upscaling ${scale}× with ${methodLabel}…` +
|
||||||
|
(isAI ? '\nAI is reconstructing detail — this may take 30–120 seconds.' : ''),
|
||||||
|
isAI ? 90 : 10
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
var layerCanvas = document.createElement('canvas');
|
var layerCanvas = document.createElement('canvas');
|
||||||
@@ -276,21 +282,21 @@ class Image_upscale_class {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
alertify.dismissAll();
|
hideProgress();
|
||||||
alertify.success(
|
alertify.success(
|
||||||
`${result.output.width}×${result.output.height}px · ${usedLabel}`
|
`${result.output.width}×${result.output.height}px · ${usedLabel}`
|
||||||
);
|
);
|
||||||
this.isProcessing = false;
|
this.isProcessing = false;
|
||||||
};
|
};
|
||||||
img.onerror = () => {
|
img.onerror = () => {
|
||||||
alertify.dismissAll();
|
hideProgress();
|
||||||
alertify.error('Failed to load upscaled image.');
|
alertify.error('Failed to load upscaled image.');
|
||||||
this.isProcessing = false;
|
this.isProcessing = false;
|
||||||
};
|
};
|
||||||
img.src = 'data:image/png;base64,' + result.result;
|
img.src = 'data:image/png;base64,' + result.result;
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alertify.dismissAll();
|
hideProgress();
|
||||||
alertify.error('Upscale failed: ' + (err.message || err));
|
alertify.error('Upscale failed: ' + (err.message || err));
|
||||||
this.isProcessing = false;
|
this.isProcessing = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
import Dialog_class from './../../libs/popup.js';
|
import Dialog_class from './../../libs/popup.js';
|
||||||
import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
|
import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
|
||||||
import { getCapabilities } from './../../api/capabilities.js';
|
import { getCapabilities, getGpuStatus } from './../../api/capabilities.js';
|
||||||
|
|
||||||
// localStorage key prefix
|
// localStorage key prefix
|
||||||
const LS = 'paintplus_ai_';
|
const LS = 'paintplus_ai_';
|
||||||
@@ -30,26 +30,46 @@ class Tools_ai_provider_settings_class {
|
|||||||
|
|
||||||
async ai_provider_settings() {
|
async ai_provider_settings() {
|
||||||
var _this = this;
|
var _this = this;
|
||||||
|
|
||||||
|
// Fetch caps and GPU status in parallel
|
||||||
var caps = await getCapabilities();
|
var caps = await getCapabilities();
|
||||||
|
var gpuStatus = null;
|
||||||
|
var local = caps.local || {};
|
||||||
|
if (local.local_gpu_available) {
|
||||||
|
gpuStatus = await getGpuStatus().catch(() => null);
|
||||||
|
}
|
||||||
|
|
||||||
var remote = caps.remote || {};
|
var remote = caps.remote || {};
|
||||||
var statusHtml = remote.provider
|
var statusHtml = remote.provider
|
||||||
? (remote.healthy
|
? (remote.healthy
|
||||||
? `<span style="color:#44cc44">● ${remote.provider} — connected</span>`
|
? '<span style="color:#44cc44">● ' + remote.provider + ' — connected</span>'
|
||||||
: `<span style="color:#ffaa00">● ${remote.provider} — unreachable</span>`)
|
: '<span style="color:#ffaa00">● ' + remote.provider + ' — unreachable</span>')
|
||||||
: '<span style="color:#888">No remote provider configured</span>';
|
: '<span style="color:#888">No remote provider configured</span>';
|
||||||
|
|
||||||
this.POP.show({
|
var gpuInfoHtml = gpuStatus ? _renderGpuInfo(gpuStatus) : '';
|
||||||
title: 'AI Provider Settings',
|
|
||||||
params: [
|
var providerValues = ['', 'openai', 'invokeai', 'comfyui', 'replicate', 'local_gpu'];
|
||||||
|
|
||||||
|
var params = [
|
||||||
{
|
{
|
||||||
title: 'Status:',
|
title: 'Status:',
|
||||||
html: `<div style="margin:4px 0 8px;font-size:12px;">${statusHtml}</div>`,
|
html: '<div style="margin:4px 0 8px;font-size:12px;">' + statusHtml + '</div>',
|
||||||
},
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (gpuInfoHtml) {
|
||||||
|
params.push({
|
||||||
|
title: '',
|
||||||
|
html: '<div style="margin:4px 0 8px"><div style="font-size:11px;color:#aaa;margin-bottom:3px">Detected GPU:</div>' + gpuInfoHtml + '</div>',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
params.push(
|
||||||
{
|
{
|
||||||
name: 'provider',
|
name: 'provider',
|
||||||
title: 'Default provider (used unless overridden below):',
|
title: 'Default provider (used unless overridden below):',
|
||||||
value: ls_get('provider', remote.provider || ''),
|
value: ls_get('provider', remote.provider || ''),
|
||||||
values: ['', 'openai', 'invokeai', 'comfyui', 'replicate'],
|
values: providerValues,
|
||||||
type: 'select',
|
type: 'select',
|
||||||
},
|
},
|
||||||
// ── Per-operation overrides ───────────────────────────────
|
// ── Per-operation overrides ───────────────────────────────
|
||||||
@@ -61,28 +81,28 @@ class Tools_ai_provider_settings_class {
|
|||||||
name: 'provider_inpaint',
|
name: 'provider_inpaint',
|
||||||
title: 'Inpaint / Replace Selection:',
|
title: 'Inpaint / Replace Selection:',
|
||||||
value: ls_get('provider_inpaint', remote.overrides?.inpaint || ''),
|
value: ls_get('provider_inpaint', remote.overrides?.inpaint || ''),
|
||||||
values: ['', 'openai', 'invokeai', 'comfyui', 'replicate'],
|
values: providerValues,
|
||||||
type: 'select',
|
type: 'select',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'provider_txt2img',
|
name: 'provider_txt2img',
|
||||||
title: 'Text → Image:',
|
title: 'Text → Image:',
|
||||||
value: ls_get('provider_txt2img', remote.overrides?.txt2img || ''),
|
value: ls_get('provider_txt2img', remote.overrides?.txt2img || ''),
|
||||||
values: ['', 'openai', 'invokeai', 'comfyui', 'replicate'],
|
values: providerValues,
|
||||||
type: 'select',
|
type: 'select',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'provider_img2img',
|
name: 'provider_img2img',
|
||||||
title: 'Image → Image:',
|
title: 'Image → Image:',
|
||||||
value: ls_get('provider_img2img', remote.overrides?.img2img || ''),
|
value: ls_get('provider_img2img', remote.overrides?.img2img || ''),
|
||||||
values: ['', 'openai', 'invokeai', 'comfyui', 'replicate'],
|
values: providerValues,
|
||||||
type: 'select',
|
type: 'select',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'provider_outpaint',
|
name: 'provider_outpaint',
|
||||||
title: 'Expand Canvas (Outpaint):',
|
title: 'Expand Canvas (Outpaint):',
|
||||||
value: ls_get('provider_outpaint', remote.overrides?.outpaint || ''),
|
value: ls_get('provider_outpaint', remote.overrides?.outpaint || ''),
|
||||||
values: ['', 'openai', 'invokeai', 'comfyui', 'replicate'],
|
values: providerValues,
|
||||||
type: 'select',
|
type: 'select',
|
||||||
},
|
},
|
||||||
// ── OpenAI ────────────────────────────────────────────────
|
// ── OpenAI ────────────────────────────────────────────────
|
||||||
@@ -131,8 +151,12 @@ class Tools_ai_provider_settings_class {
|
|||||||
title: 'Replicate API key:',
|
title: 'Replicate API key:',
|
||||||
value: ls_get('replicate_key'),
|
value: ls_get('replicate_key'),
|
||||||
placeholder: 'r8_...',
|
placeholder: 'r8_...',
|
||||||
},
|
}
|
||||||
],
|
);
|
||||||
|
|
||||||
|
this.POP.show({
|
||||||
|
title: 'AI Provider Settings',
|
||||||
|
params: params,
|
||||||
on_finish: async function (params) {
|
on_finish: async function (params) {
|
||||||
await _this._save(params);
|
await _this._save(params);
|
||||||
},
|
},
|
||||||
@@ -182,12 +206,15 @@ class Tools_ai_provider_settings_class {
|
|||||||
var { refreshCapabilities } = await import('./../../api/capabilities.js');
|
var { refreshCapabilities } = await import('./../../api/capabilities.js');
|
||||||
var caps = await refreshCapabilities();
|
var caps = await refreshCapabilities();
|
||||||
if (caps?.remote?.healthy) {
|
if (caps?.remote?.healthy) {
|
||||||
alertify.success(`Connected to ${caps.remote.provider}!`);
|
alertify.success('Connected to ' + caps.remote.provider + '!');
|
||||||
} else if (params.provider) {
|
} else if (params.provider) {
|
||||||
|
if (params.provider === 'local_gpu') {
|
||||||
|
alertify.success('local_gpu set — restart the container with docker-compose.gpu.yml to activate.');
|
||||||
|
} else {
|
||||||
alertify.warning('Settings saved but provider is not reachable. Check URL/key.');
|
alertify.warning('Settings saved but provider is not reachable. Check URL/key.');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Server-side config update not supported — inform user to set .env
|
|
||||||
alertify.warning(
|
alertify.warning(
|
||||||
'Settings saved locally. To make them permanent, ' +
|
'Settings saved locally. To make them permanent, ' +
|
||||||
'set these values in your .env file and restart the server.'
|
'set these values in your .env file and restart the server.'
|
||||||
@@ -201,4 +228,44 @@ class Tools_ai_provider_settings_class {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _renderGpuInfo(g) {
|
||||||
|
var flags = [
|
||||||
|
g.fp16 && 'fp16',
|
||||||
|
g.bf16 && 'bf16',
|
||||||
|
g.fp8 && 'fp8',
|
||||||
|
g.int8 && 'int8',
|
||||||
|
g.tensor_cores && 'tensor-cores',
|
||||||
|
g.xformers && 'xformers',
|
||||||
|
].filter(Boolean).join(' · ');
|
||||||
|
|
||||||
|
var rows = Object.entries(g.recommended || {})
|
||||||
|
.filter(([, s]) => s)
|
||||||
|
.map(function([op, s]) {
|
||||||
|
var modelName = s.model_id.split('/').pop();
|
||||||
|
return '<tr>' +
|
||||||
|
'<td style="color:#aaa;padding:2px 8px 2px 0;white-space:nowrap">' + op + '</td>' +
|
||||||
|
'<td style="color:#ddd">' + modelName + '</td>' +
|
||||||
|
'<td style="color:#888;padding-left:8px;font-size:10px">' + s.memory_opt + '</td>' +
|
||||||
|
'</tr>';
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
var warnHtml = (g.warnings || []).length
|
||||||
|
? '<div style="color:#ffaa44;margin-top:6px;font-size:10px">' +
|
||||||
|
g.warnings.map(function(w) { return '⚠ ' + w; }).join('<br>') + '</div>'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return '<div style="background:#1a2a1a;border:1px solid #2a4a2a;border-radius:6px;padding:10px;font-size:11px;font-family:monospace">' +
|
||||||
|
'<div style="color:#44cc44;font-size:12px;margin-bottom:6px">⬛ ' + (g.device_name || 'GPU') + '</div>' +
|
||||||
|
'<div style="color:#aaa">VRAM: <span style="color:#ddd">' + g.vram_total_gb + ' GB total · ' + g.vram_free_gb + ' GB free</span></div>' +
|
||||||
|
'<div style="color:#aaa">Compute: <span style="color:#ddd">CC ' + g.compute_capability + '</span>' +
|
||||||
|
(flags ? ' <span style="color:#888">' + flags + '</span>' : '') + '</div>' +
|
||||||
|
'<div style="color:#aaa">Effective: <span style="color:#ddd">' + g.effective_vram_gb + ' GB</span>' +
|
||||||
|
' Tier: <span style="color:#44cc44">' + g.tier + '</span></div>' +
|
||||||
|
(rows ? '<div style="color:#aaa;margin-top:8px">Models selected:</div>' +
|
||||||
|
'<table style="width:100%;margin-top:3px">' + rows + '</table>' : '') +
|
||||||
|
warnHtml +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
|
||||||
export default Tools_ai_provider_settings_class;
|
export default Tools_ai_provider_settings_class;
|
||||||
|
|||||||
@@ -213,6 +213,21 @@ class ApiService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch GPU status: hardware, feature flags, and selected models per operation.
|
||||||
|
* Only meaningful when AI_PROVIDER=local_gpu.
|
||||||
|
* @returns {Promise<Object|null>}
|
||||||
|
*/
|
||||||
|
async getGpuStatus() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.baseUrl}/api/gpu/status`);
|
||||||
|
if (!response.ok) return null;
|
||||||
|
return response.json();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Health check for the backend
|
* Health check for the backend
|
||||||
* @returns {Promise<boolean>}
|
* @returns {Promise<boolean>}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import Base_layers_class from './../core/base-layers.js';
|
|||||||
import Helper_class from './../libs/helpers.js';
|
import Helper_class from './../libs/helpers.js';
|
||||||
import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js';
|
import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js';
|
||||||
import apiService from './../services/api.js';
|
import apiService from './../services/api.js';
|
||||||
|
import { SelectionActions, updateLayerWithResult } from './selection_actions.js';
|
||||||
|
|
||||||
class Brush_select_class extends Base_tools_class {
|
class Brush_select_class extends Base_tools_class {
|
||||||
|
|
||||||
@@ -39,6 +40,9 @@ class Brush_select_class extends Base_tools_class {
|
|||||||
|
|
||||||
// Processing state
|
// Processing state
|
||||||
this.isProcessing = false;
|
this.isProcessing = false;
|
||||||
|
|
||||||
|
// Quick-action panel shown after selection
|
||||||
|
this.selectionActions = new SelectionActions(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
load() {
|
load() {
|
||||||
@@ -300,31 +304,23 @@ class Brush_select_class extends Base_tools_class {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Offer to float the selection to a new layer for manipulation (Canva-like workflow)
|
* Show quick-action panel after selection (AI operations, scale, clipboard paste, etc.)
|
||||||
*/
|
*/
|
||||||
offerFloatSelection() {
|
offerFloatSelection() {
|
||||||
var _this = this;
|
var imageData = this.getLayerImageData();
|
||||||
|
var maskData = this.maskCanvas
|
||||||
alertify.confirm(
|
? this.maskCanvas.toDataURL('image/png').split(',')[1]
|
||||||
'Selection Complete',
|
: null;
|
||||||
'Would you like to move/scale this selection? This will copy it to a new layer.',
|
if (maskData) {
|
||||||
function() {
|
this.selectionActions.show(imageData, maskData);
|
||||||
// Yes - copy to layer and switch to Select tool
|
|
||||||
_this.copyToLayer();
|
|
||||||
|
|
||||||
// Switch to Select tool
|
|
||||||
setTimeout(function() {
|
|
||||||
var selectTool = document.querySelector('.sidebar_left .item[data-tool="select"]');
|
|
||||||
if (selectTool) {
|
|
||||||
selectTool.click();
|
|
||||||
}
|
}
|
||||||
}, 100);
|
|
||||||
},
|
|
||||||
function() {
|
|
||||||
// No - just keep the selection
|
|
||||||
alertify.message('Tip: Use Ctrl+C to copy or Ctrl+X to cut the selection.');
|
|
||||||
}
|
}
|
||||||
).set('labels', {ok: 'Yes, Move/Scale', cancel: 'Keep Selection'});
|
|
||||||
|
/**
|
||||||
|
* Update the current layer canvas with a base64 result from a backend operation.
|
||||||
|
*/
|
||||||
|
updateLayerWithResult(base64) {
|
||||||
|
updateLayerWithResult(base64, this);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -781,6 +777,7 @@ class Brush_select_class extends Base_tools_class {
|
|||||||
}
|
}
|
||||||
|
|
||||||
clearSelection() {
|
clearSelection() {
|
||||||
|
this.selectionActions.hide();
|
||||||
this.currentMask = null;
|
this.currentMask = null;
|
||||||
this.maskCanvas = null;
|
this.maskCanvas = null;
|
||||||
this.edgeCanvas = null;
|
this.edgeCanvas = null;
|
||||||
@@ -793,8 +790,9 @@ class Brush_select_class extends Base_tools_class {
|
|||||||
}
|
}
|
||||||
|
|
||||||
on_leave() {
|
on_leave() {
|
||||||
|
this.selectionActions.hide();
|
||||||
this.isDrawing = false;
|
this.isDrawing = false;
|
||||||
this.isProcessing = false; // Reset processing state when leaving tool
|
this.isProcessing = false;
|
||||||
this.brushPath = [];
|
this.brushPath = [];
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,361 @@
|
|||||||
|
/**
|
||||||
|
* SelectionActions — floating quick-action panel that appears after a SAM selection.
|
||||||
|
*
|
||||||
|
* Surfaces high-value real-world workflows directly in the UI:
|
||||||
|
* • Scale by % — make object 3% (or any %) bigger/smaller, gap AI-filled
|
||||||
|
* • Make less symmetrical — AI redraws the region with organic variation
|
||||||
|
* • Replace with clipboard — paste clipboard image into the selection shape
|
||||||
|
* • Copy / Cut to layer — classic Photoshop workflow
|
||||||
|
* • AI Edit (custom prompt) — full inpaint with user text
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* this.selectionActions = new SelectionActions(this);
|
||||||
|
* // after successful selection:
|
||||||
|
* this.selectionActions.show(imageBase64, maskBase64);
|
||||||
|
*/
|
||||||
|
|
||||||
|
import app from './../app.js';
|
||||||
|
import config from './../config.js';
|
||||||
|
import Base_layers_class from './../core/base-layers.js';
|
||||||
|
import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js';
|
||||||
|
import { showProgress, updateProgress, hideProgress, connectProgressSSE, disconnectProgressSSE } from './../libs/progress_overlay.js';
|
||||||
|
|
||||||
|
const BASE = window.API_BASE_URL || '';
|
||||||
|
|
||||||
|
export class SelectionActions {
|
||||||
|
constructor(tool) {
|
||||||
|
this.tool = tool;
|
||||||
|
this.Base_layers = new Base_layers_class();
|
||||||
|
this._panel = null;
|
||||||
|
this._imageData = null;
|
||||||
|
this._maskData = null;
|
||||||
|
this._escHandler = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
show(imageBase64, maskBase64) {
|
||||||
|
this.hide();
|
||||||
|
this._imageData = imageBase64;
|
||||||
|
this._maskData = maskBase64;
|
||||||
|
|
||||||
|
var panel = document.createElement('div');
|
||||||
|
panel.id = 'sel-actions-panel';
|
||||||
|
panel.style.cssText = [
|
||||||
|
'position:fixed',
|
||||||
|
'bottom:80px',
|
||||||
|
'left:50%',
|
||||||
|
'transform:translateX(-50%)',
|
||||||
|
'background:#1a1a2e',
|
||||||
|
'border:1px solid #3a3a6a',
|
||||||
|
'border-radius:12px',
|
||||||
|
'padding:14px 16px',
|
||||||
|
'z-index:10000',
|
||||||
|
'font-family:sans-serif',
|
||||||
|
'font-size:12px',
|
||||||
|
'color:#d0d0e0',
|
||||||
|
'min-width:340px',
|
||||||
|
'box-shadow:0 8px 32px rgba(0,0,0,0.7)',
|
||||||
|
'display:flex',
|
||||||
|
'flex-direction:column',
|
||||||
|
'gap:6px',
|
||||||
|
].join(';');
|
||||||
|
|
||||||
|
// ── Title row ────────────────────────────────────────────────────────
|
||||||
|
var titleRow = document.createElement('div');
|
||||||
|
titleRow.style.cssText = 'display:flex;align-items:center;justify-content:space-between;margin-bottom:4px';
|
||||||
|
var title = document.createElement('span');
|
||||||
|
title.textContent = 'Selection Actions';
|
||||||
|
title.style.cssText = 'font-size:13px;font-weight:bold;color:#aaaaff';
|
||||||
|
var closeX = document.createElement('button');
|
||||||
|
closeX.textContent = '✕';
|
||||||
|
closeX.style.cssText = 'background:none;border:none;color:#666;cursor:pointer;font-size:14px;padding:0;line-height:1';
|
||||||
|
closeX.title = 'Close panel (keep selection)';
|
||||||
|
closeX.onclick = () => this.hide();
|
||||||
|
titleRow.appendChild(title);
|
||||||
|
titleRow.appendChild(closeX);
|
||||||
|
panel.appendChild(titleRow);
|
||||||
|
|
||||||
|
// ── Scale by % ───────────────────────────────────────────────────────
|
||||||
|
var scaleRow = document.createElement('div');
|
||||||
|
scaleRow.style.cssText = 'display:flex;align-items:center;gap:6px;background:#16213e;border-radius:7px;padding:7px 10px';
|
||||||
|
var scaleLabel = document.createElement('span');
|
||||||
|
scaleLabel.textContent = 'Scale by';
|
||||||
|
scaleLabel.style.color = '#aaa';
|
||||||
|
var scaleInput = document.createElement('input');
|
||||||
|
scaleInput.type = 'number';
|
||||||
|
scaleInput.value = '103';
|
||||||
|
scaleInput.min = '1';
|
||||||
|
scaleInput.max = '500';
|
||||||
|
scaleInput.title = '103 = 3% bigger · 95 = 5% smaller';
|
||||||
|
scaleInput.style.cssText = 'width:52px;background:#0f0f1a;color:#fff;border:1px solid #4a4a8a;border-radius:4px;padding:2px 5px;font-size:12px';
|
||||||
|
var scaleUnit = document.createElement('span');
|
||||||
|
scaleUnit.textContent = '%';
|
||||||
|
scaleUnit.style.color = '#888';
|
||||||
|
var scaleBtn = _btn('Apply', '#1a2a4a', '#8aacff');
|
||||||
|
scaleBtn.style.marginLeft = 'auto';
|
||||||
|
scaleBtn.onclick = () => {
|
||||||
|
var pct = parseFloat(scaleInput.value) || 103;
|
||||||
|
this._scaleSelection(pct);
|
||||||
|
};
|
||||||
|
scaleRow.appendChild(scaleLabel);
|
||||||
|
scaleRow.appendChild(scaleInput);
|
||||||
|
scaleRow.appendChild(scaleUnit);
|
||||||
|
scaleRow.appendChild(scaleBtn);
|
||||||
|
panel.appendChild(scaleRow);
|
||||||
|
|
||||||
|
// ── AI actions ───────────────────────────────────────────────────────
|
||||||
|
panel.appendChild(
|
||||||
|
_actionBtn('Make less symmetrical', '#1c1a2e', '#cc99ff',
|
||||||
|
'⟳ AI redraws the region with natural, organic asymmetry',
|
||||||
|
() => this._makeAsymmetric())
|
||||||
|
);
|
||||||
|
panel.appendChild(
|
||||||
|
_actionBtn('Replace with clipboard', '#1a2a1a', '#88dd88',
|
||||||
|
'📋 Scales your clipboard image into the selection shape',
|
||||||
|
() => this._pasteFromClipboard())
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Custom AI edit prompt ─────────────────────────────────────────────
|
||||||
|
var aiRow = document.createElement('div');
|
||||||
|
aiRow.style.cssText = 'display:flex;align-items:center;gap:6px;background:#16213e;border-radius:7px;padding:7px 10px';
|
||||||
|
var aiInput = document.createElement('input');
|
||||||
|
aiInput.type = 'text';
|
||||||
|
aiInput.placeholder = 'AI edit: "add a scar", "make it look aged", …';
|
||||||
|
aiInput.style.cssText = 'flex:1;background:#0f0f1a;color:#fff;border:1px solid #4a4a8a;border-radius:4px;padding:3px 7px;font-size:11px';
|
||||||
|
var aiBtn = _btn('Edit', '#1a2a4a', '#8aacff');
|
||||||
|
aiBtn.onclick = () => {
|
||||||
|
var instruction = aiInput.value.trim();
|
||||||
|
if (!instruction) { alertify.warning('Enter an AI edit instruction first.'); return; }
|
||||||
|
this._aiEditRegion(instruction);
|
||||||
|
};
|
||||||
|
aiRow.appendChild(aiInput);
|
||||||
|
aiRow.appendChild(aiBtn);
|
||||||
|
panel.appendChild(aiRow);
|
||||||
|
|
||||||
|
// ── Divider ──────────────────────────────────────────────────────────
|
||||||
|
var hr = document.createElement('div');
|
||||||
|
hr.style.cssText = 'border-top:1px solid #2a2a4a;margin:2px 0';
|
||||||
|
panel.appendChild(hr);
|
||||||
|
|
||||||
|
// ── Classic selection ops ─────────────────────────────────────────────
|
||||||
|
var classicRow = document.createElement('div');
|
||||||
|
classicRow.style.cssText = 'display:flex;gap:6px';
|
||||||
|
var copyBtn = _btn('Copy to layer', '#1a2a1a', '#88cc88');
|
||||||
|
copyBtn.style.flex = '1';
|
||||||
|
copyBtn.title = 'Ctrl+C';
|
||||||
|
copyBtn.onclick = () => { this.tool.copyToLayer(); this.hide(); };
|
||||||
|
var cutBtn = _btn('Cut to layer', '#2a1a1a', '#cc8888');
|
||||||
|
cutBtn.style.flex = '1';
|
||||||
|
cutBtn.title = 'Ctrl+X';
|
||||||
|
cutBtn.onclick = () => { this.tool.cutToLayer(); this.hide(); };
|
||||||
|
var delBtn = _btn('Erase', '#2a1a1a', '#ff7766');
|
||||||
|
delBtn.style.flex = '0 0 auto';
|
||||||
|
delBtn.title = 'Delete key';
|
||||||
|
delBtn.onclick = () => { this.tool.deleteSelection(); this.hide(); };
|
||||||
|
classicRow.appendChild(copyBtn);
|
||||||
|
classicRow.appendChild(cutBtn);
|
||||||
|
classicRow.appendChild(delBtn);
|
||||||
|
panel.appendChild(classicRow);
|
||||||
|
|
||||||
|
document.body.appendChild(panel);
|
||||||
|
this._panel = panel;
|
||||||
|
|
||||||
|
this._escHandler = (e) => { if (e.key === 'Escape') this.hide(); };
|
||||||
|
document.addEventListener('keydown', this._escHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
hide() {
|
||||||
|
if (this._panel) { this._panel.remove(); this._panel = null; }
|
||||||
|
if (this._escHandler) {
|
||||||
|
document.removeEventListener('keydown', this._escHandler);
|
||||||
|
this._escHandler = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Actions ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async _scaleSelection(scalePct) {
|
||||||
|
if (!this._check()) return;
|
||||||
|
this.hide();
|
||||||
|
showProgress('Scaling object and AI-filling the gap…', 30);
|
||||||
|
try {
|
||||||
|
var res = await _post('/api/image/scale-selection', {
|
||||||
|
image: this._imageData,
|
||||||
|
mask: this._maskData,
|
||||||
|
scale_pct: scalePct,
|
||||||
|
});
|
||||||
|
this.tool.updateLayerWithResult(res.result);
|
||||||
|
this.tool.clearSelection();
|
||||||
|
hideProgress();
|
||||||
|
alertify.success('Scaled by ' + scalePct + '%!');
|
||||||
|
} catch (e) {
|
||||||
|
hideProgress();
|
||||||
|
alertify.error('Scale failed: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _makeAsymmetric() {
|
||||||
|
if (!this._check()) return;
|
||||||
|
this.hide();
|
||||||
|
connectProgressSSE('inpaint', window.API_BASE_URL || '');
|
||||||
|
showProgress('AI is adding natural asymmetry…', 60);
|
||||||
|
try {
|
||||||
|
var res = await _post('/api/image/ai-edit-region', {
|
||||||
|
image: this._imageData,
|
||||||
|
mask: this._maskData,
|
||||||
|
instruction: 'natural asymmetry, slight organic variation, realistic, subtle imperfection',
|
||||||
|
negative_prompt:'perfectly symmetric, mirror image, artificial, identical halves',
|
||||||
|
steps: 30,
|
||||||
|
cfg_scale: 7.5,
|
||||||
|
});
|
||||||
|
this.tool.updateLayerWithResult(res.result);
|
||||||
|
this.tool.clearSelection();
|
||||||
|
disconnectProgressSSE();
|
||||||
|
hideProgress();
|
||||||
|
alertify.success('Made less symmetrical!');
|
||||||
|
} catch (e) {
|
||||||
|
disconnectProgressSSE();
|
||||||
|
hideProgress();
|
||||||
|
alertify.error('AI edit failed: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _aiEditRegion(instruction) {
|
||||||
|
if (!this._check()) return;
|
||||||
|
this.hide();
|
||||||
|
connectProgressSSE('inpaint', window.API_BASE_URL || '');
|
||||||
|
showProgress('AI is editing the region…', 60);
|
||||||
|
try {
|
||||||
|
var res = await _post('/api/image/ai-edit-region', {
|
||||||
|
image: this._imageData,
|
||||||
|
mask: this._maskData,
|
||||||
|
instruction: instruction,
|
||||||
|
steps: 30,
|
||||||
|
cfg_scale: 7.5,
|
||||||
|
});
|
||||||
|
this.tool.updateLayerWithResult(res.result);
|
||||||
|
this.tool.clearSelection();
|
||||||
|
disconnectProgressSSE();
|
||||||
|
hideProgress();
|
||||||
|
alertify.success('Done!');
|
||||||
|
} catch (e) {
|
||||||
|
disconnectProgressSSE();
|
||||||
|
hideProgress();
|
||||||
|
alertify.error('AI edit failed: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _pasteFromClipboard() {
|
||||||
|
if (!this._check()) return;
|
||||||
|
|
||||||
|
if (!navigator.clipboard || !navigator.clipboard.read) {
|
||||||
|
alertify.error('Clipboard API not available. Use HTTPS or enable clipboard permissions.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
var items = await navigator.clipboard.read();
|
||||||
|
var clipBlob = null;
|
||||||
|
for (var item of items) {
|
||||||
|
for (var type of item.types) {
|
||||||
|
if (type.startsWith('image/')) {
|
||||||
|
clipBlob = await item.getType(type);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (clipBlob) break;
|
||||||
|
}
|
||||||
|
if (!clipBlob) {
|
||||||
|
alertify.error('No image in clipboard. Copy an image first (e.g., right-click → Copy image).');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var clipBase64 = await _blobToBase64(clipBlob);
|
||||||
|
this.hide();
|
||||||
|
showProgress('Pasting clipboard into selection…', 10);
|
||||||
|
|
||||||
|
var res = await _post('/api/image/paste-into-selection', {
|
||||||
|
image: this._imageData,
|
||||||
|
mask: this._maskData,
|
||||||
|
paste_image: clipBase64,
|
||||||
|
});
|
||||||
|
this.tool.updateLayerWithResult(res.result);
|
||||||
|
this.tool.clearSelection();
|
||||||
|
hideProgress();
|
||||||
|
alertify.success('Clipboard pasted into selection!');
|
||||||
|
} catch (e) {
|
||||||
|
hideProgress();
|
||||||
|
alertify.error('Paste failed: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_check() {
|
||||||
|
if (!this._imageData || !this._maskData) {
|
||||||
|
alertify.error('No selection data. Make a new selection first.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shared method: patch into both smart_select and brush_select instances ───
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the active layer canvas with a base64 result image from the backend.
|
||||||
|
* Call as `this.updateLayerWithResult(base64)` on any tool that extends Base_tools_class.
|
||||||
|
*/
|
||||||
|
export function updateLayerWithResult(base64, tool) {
|
||||||
|
var img = new Image();
|
||||||
|
img.onload = function () {
|
||||||
|
var canvas = document.createElement('canvas');
|
||||||
|
canvas.width = img.width;
|
||||||
|
canvas.height = img.height;
|
||||||
|
canvas.getContext('2d').drawImage(img, 0, 0);
|
||||||
|
|
||||||
|
app.State.do_action(
|
||||||
|
new app.Actions.Bundle_action('ai_transform', 'AI Transform', [
|
||||||
|
new app.Actions.Update_layer_image_action(canvas, config.layer.id)
|
||||||
|
])
|
||||||
|
);
|
||||||
|
// Trigger re-render
|
||||||
|
config.need_render = true;
|
||||||
|
};
|
||||||
|
img.src = 'data:image/png;base64,' + base64;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Private helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _btn(text, bg, color) {
|
||||||
|
var b = document.createElement('button');
|
||||||
|
b.textContent = text;
|
||||||
|
b.style.cssText = 'background:' + bg + ';color:' + color + ';border:1px solid #3a3a6a;padding:4px 10px;border-radius:5px;cursor:pointer;font-size:11px;white-space:nowrap';
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _actionBtn(text, bg, color, tooltip, handler) {
|
||||||
|
var b = _btn(text, bg, color);
|
||||||
|
b.style.cssText += ';display:block;width:100%;text-align:left;padding:7px 10px;border-radius:7px;font-size:12px';
|
||||||
|
if (tooltip) b.title = tooltip;
|
||||||
|
b.onclick = handler;
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _post(path, body) {
|
||||||
|
var r = await fetch(BASE + path, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!r.ok) {
|
||||||
|
var err = await r.json().catch(() => ({ detail: r.statusText }));
|
||||||
|
throw new Error(err.detail || 'Request failed');
|
||||||
|
}
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _blobToBase64(blob) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
var reader = new FileReader();
|
||||||
|
reader.onload = (e) => resolve(e.target.result.split(',')[1]);
|
||||||
|
reader.onerror = reject;
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import Helper_class from './../libs/helpers.js';
|
|||||||
import Dialog_class from './../libs/popup.js';
|
import Dialog_class from './../libs/popup.js';
|
||||||
import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js';
|
import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js';
|
||||||
import apiService from './../services/api.js';
|
import apiService from './../services/api.js';
|
||||||
|
import { SelectionActions, updateLayerWithResult } from './selection_actions.js';
|
||||||
|
|
||||||
class Smart_select_class extends Base_tools_class {
|
class Smart_select_class extends Base_tools_class {
|
||||||
|
|
||||||
@@ -35,6 +36,9 @@ class Smart_select_class extends Base_tools_class {
|
|||||||
|
|
||||||
// Edge canvas for drawing the mask outline
|
// Edge canvas for drawing the mask outline
|
||||||
this.edgeCanvas = null;
|
this.edgeCanvas = null;
|
||||||
|
|
||||||
|
// Quick-action panel shown after selection
|
||||||
|
this.selectionActions = new SelectionActions(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
load() {
|
load() {
|
||||||
@@ -146,7 +150,7 @@ class Smart_select_class extends Base_tools_class {
|
|||||||
if (isAdditive && this.currentMask) {
|
if (isAdditive && this.currentMask) {
|
||||||
alertify.success('Added to selection! Shift+Click to add more.');
|
alertify.success('Added to selection! Shift+Click to add more.');
|
||||||
} else {
|
} else {
|
||||||
alertify.success('Selection complete! Shift+Click to add more, Ctrl+C to copy, Ctrl+X to cut.');
|
this._showActionPanel();
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -633,10 +637,31 @@ class Smart_select_class extends Base_tools_class {
|
|||||||
alertify.success('Selection deleted!');
|
alertify.success('Selection deleted!');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the quick-action panel for the current selection.
|
||||||
|
*/
|
||||||
|
_showActionPanel() {
|
||||||
|
var imageData = this.getLayerImageData();
|
||||||
|
var maskData = this.maskCanvas
|
||||||
|
? this.maskCanvas.toDataURL('image/png').split(',')[1]
|
||||||
|
: null;
|
||||||
|
if (maskData) {
|
||||||
|
this.selectionActions.show(imageData, maskData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the current layer canvas with a base64 result from a backend operation.
|
||||||
|
*/
|
||||||
|
updateLayerWithResult(base64) {
|
||||||
|
updateLayerWithResult(base64, this);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear the current selection
|
* Clear the current selection
|
||||||
*/
|
*/
|
||||||
clearSelection() {
|
clearSelection() {
|
||||||
|
this.selectionActions.hide();
|
||||||
this.currentMask = null;
|
this.currentMask = null;
|
||||||
this.maskCanvas = null;
|
this.maskCanvas = null;
|
||||||
this.edgeCanvas = null;
|
this.edgeCanvas = null;
|
||||||
@@ -647,7 +672,7 @@ class Smart_select_class extends Base_tools_class {
|
|||||||
}
|
}
|
||||||
|
|
||||||
on_leave() {
|
on_leave() {
|
||||||
// Don't clear mask when switching tools - AI inpaint needs it
|
this.selectionActions.hide();
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
GPU setup script — runs at container startup.
|
||||||
|
Uses the same detection logic as the backend (gpu_detect.py) to show
|
||||||
|
exactly which models will be used before the server starts.
|
||||||
|
Non-fatal: any failure just prints a warning and startup continues.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("Detecting GPU capabilities…")
|
||||||
|
|
||||||
|
try:
|
||||||
|
import torch
|
||||||
|
except ImportError:
|
||||||
|
print("⚠ PyTorch not installed — GPU detection skipped")
|
||||||
|
return
|
||||||
|
|
||||||
|
if torch.cuda.is_available():
|
||||||
|
props = torch.cuda.get_device_properties(0)
|
||||||
|
free_b, total_b = torch.cuda.mem_get_info(0)
|
||||||
|
vram_total = total_b / (1024 ** 3)
|
||||||
|
vram_free = free_b / (1024 ** 3)
|
||||||
|
major, minor = props.major, props.minor
|
||||||
|
cc = f"{major}.{minor}"
|
||||||
|
|
||||||
|
fp16 = major >= 6
|
||||||
|
bf16 = major >= 8
|
||||||
|
fp8 = major > 8 or (major == 8 and minor >= 9)
|
||||||
|
int8 = major >= 7
|
||||||
|
tc = major >= 7
|
||||||
|
|
||||||
|
flags = []
|
||||||
|
if fp16: flags.append("fp16")
|
||||||
|
if bf16: flags.append("bf16")
|
||||||
|
if fp8: flags.append("fp8")
|
||||||
|
if int8: flags.append("int8")
|
||||||
|
if tc: flags.append("tensor-cores")
|
||||||
|
|
||||||
|
print(f"✓ GPU : {props.name}")
|
||||||
|
print(f" VRAM : {vram_total:.1f} GB total | {vram_free:.1f} GB free")
|
||||||
|
print(f" Compute : CC {cc} ({', '.join(flags) or 'fp32 only'})")
|
||||||
|
|
||||||
|
if major < 6:
|
||||||
|
print(f" ⚠ Pre-Pascal (CC {cc}): using fp32 — effective VRAM budget halved")
|
||||||
|
|
||||||
|
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
||||||
|
print("✓ Apple Silicon MPS GPU detected (fp32 mode)")
|
||||||
|
vram_total = vram_free = 0.0
|
||||||
|
else:
|
||||||
|
print("⚠ No GPU detected — AI inference will use CPU (very slow)")
|
||||||
|
vram_total = vram_free = 0.0
|
||||||
|
|
||||||
|
provider = os.environ.get("AI_PROVIDER", "").lower()
|
||||||
|
if provider != "local_gpu":
|
||||||
|
print(f" AI_PROVIDER={provider!r} — local GPU not active, skipping model selection")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Import and run the full detection to show what was selected
|
||||||
|
try:
|
||||||
|
sys.path.insert(0, "/app")
|
||||||
|
from app.services.gpu_detect import detect_gpu
|
||||||
|
info = detect_gpu()
|
||||||
|
|
||||||
|
print(f"\n Effective VRAM : {info.effective_vram_gb:.1f} GB (tier: {info.tier})")
|
||||||
|
print("\n Model selection:")
|
||||||
|
printed: set = set()
|
||||||
|
for op, spec in info.recommended.items():
|
||||||
|
if spec is None:
|
||||||
|
print(f" {op:<12} → (none — will use existing upscaler)")
|
||||||
|
elif spec.model_id not in printed:
|
||||||
|
print(f" {op:<12} → [{spec.family}] {spec.model_id}")
|
||||||
|
print(f" mem_opt={spec.memory_opt} res={spec.native_res}px ~{spec.vram_fp16_gb}GB fp16")
|
||||||
|
printed.add(spec.model_id)
|
||||||
|
else:
|
||||||
|
print(f" {op:<12} → (same as above: {spec.model_id})")
|
||||||
|
|
||||||
|
for w in info.warnings:
|
||||||
|
print(f"\n ⚠ {w}")
|
||||||
|
|
||||||
|
auto_dl = os.environ.get("AUTO_DOWNLOAD_MODELS", "true").lower()
|
||||||
|
print()
|
||||||
|
if auto_dl == "true":
|
||||||
|
print(" AUTO_DOWNLOAD_MODELS=true")
|
||||||
|
print(" → Model files will download in background at startup.")
|
||||||
|
print(" → First request loads from local disk (20-60s, not internet).")
|
||||||
|
print(" → Track progress: GET /api/gpu/prefetch-status")
|
||||||
|
else:
|
||||||
|
print(" AUTO_DOWNLOAD_MODELS=false — models download on first request.")
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
print(f" (Could not run full detection: {exc})")
|
||||||
|
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user