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
|
||||
# =============================================================================
|
||||
# Options: mock, openai, stability, replicate
|
||||
# Options: local_gpu, mock, openai, stability, replicate, invokeai, comfyui
|
||||
#
|
||||
# mock = Free, but returns original image unchanged (for testing UI)
|
||||
# openai = DALL-E 2 inpainting (~$0.02/image) - lower quality
|
||||
# stability = Stability AI SDXL (~$0.01/image) - good quality
|
||||
# replicate = Multiple models (~$0.002-0.03/image) - RECOMMENDED
|
||||
# local_gpu = FREE, runs on YOUR GPU — best option if you have an NVIDIA card
|
||||
# (use docker-compose.gpu.yml — models auto-download on first use)
|
||||
# mock = Free, returns original image unchanged (UI testing only)
|
||||
# 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
|
||||
|
||||
# ── 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)
|
||||
# Example: use OpenAI for text-to-image (best quality) but InvokeAI for everything else
|
||||
#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_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
|
||||
data_dir: str = "./data"
|
||||
max_upload_size_mb: int = 50
|
||||
|
||||
@@ -10,6 +10,7 @@ import os
|
||||
from app.config import settings
|
||||
from app.database import init_db
|
||||
from app.routers import projects, edits, images, patches, generate, tools, ai_tools, print_tools
|
||||
from app.routers import gpu_status
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -24,6 +25,33 @@ async def lifespan(app: FastAPI):
|
||||
# Pre-download SAM model in background so first click is fast
|
||||
from app.services.sam_service import 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
|
||||
|
||||
|
||||
@@ -52,6 +80,7 @@ app.include_router(generate.router)
|
||||
app.include_router(tools.router)
|
||||
app.include_router(ai_tools.router)
|
||||
app.include_router(print_tools.router)
|
||||
app.include_router(gpu_status.router)
|
||||
|
||||
|
||||
@app.get("/api")
|
||||
|
||||
@@ -4,10 +4,12 @@ All endpoints are under /api prefix.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
import base64
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from app.services.local_inpaint import (
|
||||
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))
|
||||
|
||||
|
||||
@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")
|
||||
async def txt2img(req: Txt2ImgRequest):
|
||||
"""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_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 {
|
||||
"local": {
|
||||
"lama": lama_available(),
|
||||
"rembg": rembg_available(),
|
||||
"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": {
|
||||
"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) ──────────────────────────────────────────────────
|
||||
|
||||
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),
|
||||
"11x14": (11, 14),
|
||||
"16x20": (16, 20),
|
||||
"18x24": (18, 24),
|
||||
"20x24": (20, 24),
|
||||
"24x36": (24, 36),
|
||||
# Square
|
||||
@@ -65,6 +66,16 @@ class UpscaleRequest(BaseModel):
|
||||
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 ───────────────────────────────────────────────────
|
||||
|
||||
@router.get("/frame-sizes")
|
||||
@@ -335,6 +346,105 @@ def upscale_install_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")
|
||||
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 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
|
||||
|
||||
|
||||
|
||||
@@ -61,6 +61,11 @@ else
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Checking GPU capabilities..."
|
||||
echo "------------------------------------------"
|
||||
python /scripts/gpu_setup.py || echo "Warning: GPU detection failed (non-fatal)"
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
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 _fetchPromise = null;
|
||||
let _gpuStatus = null;
|
||||
let _gpuFetchPromise = null;
|
||||
|
||||
/**
|
||||
* Return capabilities (fetched lazily, cached thereafter).
|
||||
@@ -48,12 +50,29 @@ export function hasRemote() {
|
||||
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).
|
||||
*/
|
||||
export async function refreshCapabilities() {
|
||||
_caps = null;
|
||||
_fetchPromise = null;
|
||||
_gpuStatus = null;
|
||||
_gpuFetchPromise = null;
|
||||
return getCapabilities();
|
||||
}
|
||||
|
||||
@@ -62,4 +81,4 @@ export async function refreshCapabilities() {
|
||||
*/
|
||||
getCapabilities();
|
||||
|
||||
export default { getCapabilities, getCachedCapabilities, hasRemote, refreshCapabilities };
|
||||
export default { getCapabilities, getCachedCapabilities, hasRemote, refreshCapabilities, getGpuStatus };
|
||||
|
||||
@@ -344,6 +344,11 @@ const menuDefinition = [
|
||||
ellipsis: true,
|
||||
target: 'image/remove_background.remove_background'
|
||||
},
|
||||
{
|
||||
name: 'Prepare for Print...',
|
||||
ellipsis: true,
|
||||
target: 'image/print_prepare.print_prepare'
|
||||
},
|
||||
{
|
||||
name: 'Fit to Frame...',
|
||||
ellipsis: true,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* ProviderBadge — small DOM element showing the active AI provider.
|
||||
* 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
|
||||
* Grey = local only (LaMa + OpenCV)
|
||||
*/
|
||||
@@ -30,21 +30,55 @@ export async function mountProviderBadge(container) {
|
||||
var remote = caps.remote || {};
|
||||
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';
|
||||
badge.style.background = '#1a2a1a';
|
||||
badge.style.color = '#aaffaa';
|
||||
|
||||
// Show override summary if any operations use different providers
|
||||
var overrides = remote.overrides || {};
|
||||
var overrideEntries = Object.entries(overrides).filter(([, v]) => v);
|
||||
var overrideStr = overrideEntries.length
|
||||
? ' · ' + overrideEntries.map(([k, v]) => `${k}→${v}`).join(', ')
|
||||
? ' · ' + overrideEntries.map(([k, v]) => k + '→' + v).join(', ')
|
||||
: '';
|
||||
label.textContent = remote.provider + overrideStr + (local.gpu_detected ? ' · GPU' : '');
|
||||
|
||||
var opLines = Object.entries(remote.operations || {})
|
||||
.map(([op, s]) => `${op}: ${s.provider || remote.provider} ${s.healthy ? '✓' : '✗'}`)
|
||||
.map(([op, s]) => op + ': ' + (s.provider || remote.provider) + ' ' + (s.healthy ? '✓' : '✗'))
|
||||
.join('\n');
|
||||
badge.title = opLines || ('Provider: ' + remote.provider);
|
||||
} else if (remote.provider && !remote.healthy) {
|
||||
@@ -70,3 +104,10 @@ export async function mountProviderBadge(container) {
|
||||
|
||||
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.
|
||||
*
|
||||
* 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 apiService from './../../services/api.js';
|
||||
import { getCapabilities } from './../../api/capabilities.js';
|
||||
import { showProgress, updateProgress, hideProgress, connectProgressSSE, disconnectProgressSSE } from './../../libs/progress_overlay.js';
|
||||
|
||||
var instance = null;
|
||||
|
||||
@@ -27,21 +28,54 @@ class Generate_text_to_image_class {
|
||||
|
||||
async text_to_image() {
|
||||
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(
|
||||
'Text → Image requires a remote AI provider. ' +
|
||||
'Set AI_PROVIDER (openai / invokeai / comfyui) in .env and restart.'
|
||||
'Text → Image requires an AI provider. ' +
|
||||
'Set AI_PROVIDER=openai / invokeai / comfyui / local_gpu in .env and restart, ' +
|
||||
'or configure one in Image → AI Provider Settings.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
var _this = this;
|
||||
var _this = this;
|
||||
var canvasW = config.WIDTH || 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({
|
||||
title: 'Text → Image',
|
||||
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',
|
||||
title: 'Describe your image:',
|
||||
@@ -58,7 +92,7 @@ class Generate_text_to_image_class {
|
||||
{
|
||||
name: 'width',
|
||||
title: 'Width (px):',
|
||||
value: Math.min(canvasW, 1024),
|
||||
value: defaultW,
|
||||
range: [256, 2048],
|
||||
step: 64,
|
||||
type: 'range',
|
||||
@@ -66,7 +100,7 @@ class Generate_text_to_image_class {
|
||||
{
|
||||
name: 'height',
|
||||
title: 'Height (px):',
|
||||
value: Math.min(canvasH, 1024),
|
||||
value: defaultH,
|
||||
range: [256, 2048],
|
||||
step: 64,
|
||||
type: 'range',
|
||||
@@ -99,29 +133,32 @@ class Generate_text_to_image_class {
|
||||
alertify.warning('Please enter a description.');
|
||||
return;
|
||||
}
|
||||
await _this._generate(params);
|
||||
await _this._generate(params, estSec);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async _generate(params) {
|
||||
async _generate(params, estSec) {
|
||||
if (this.isProcessing) return;
|
||||
this.isProcessing = true;
|
||||
alertify.message('Generating image... please wait', 0);
|
||||
|
||||
connectProgressSSE('txt2img', window.API_BASE_URL || '');
|
||||
showProgress('Generating image…', estSec || 60);
|
||||
|
||||
try {
|
||||
var result = await apiService.textToImage(params.prompt, {
|
||||
width: params.width || 1024,
|
||||
height: params.height || 1024,
|
||||
width: params.width || 1024,
|
||||
height: params.height || 1024,
|
||||
negativePrompt: params.negative_prompt || '',
|
||||
steps: params.steps || 30,
|
||||
seed: params.seed || 0,
|
||||
steps: params.steps || 30,
|
||||
seed: params.seed || 0,
|
||||
});
|
||||
|
||||
updateProgress(95, 'Placing image…');
|
||||
|
||||
var img = new Image();
|
||||
img.onload = () => {
|
||||
if (params.placement === 'replace_canvas') {
|
||||
// Resize canvas and replace bottom layer
|
||||
config.WIDTH = img.naturalWidth;
|
||||
config.HEIGHT = img.naturalHeight;
|
||||
var resultCanvas = document.createElement('canvas');
|
||||
@@ -134,41 +171,46 @@ class Generate_text_to_image_class {
|
||||
])
|
||||
);
|
||||
} else {
|
||||
// Add as new layer on top
|
||||
var dataURL = img.src;
|
||||
app.State.do_action(
|
||||
new app.Actions.Bundle_action('txt2img_layer', 'Text → Image Layer', [
|
||||
new app.Actions.Insert_layer_action({
|
||||
name: params.prompt.slice(0, 30),
|
||||
type: 'image',
|
||||
data: dataURL,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: img.naturalWidth,
|
||||
height: img.naturalHeight,
|
||||
data: img.src,
|
||||
x: 0, y: 0,
|
||||
width: img.naturalWidth,
|
||||
height: img.naturalHeight,
|
||||
width_original: img.naturalWidth,
|
||||
height_original: img.naturalHeight,
|
||||
})
|
||||
])
|
||||
);
|
||||
}
|
||||
alertify.dismissAll();
|
||||
disconnectProgressSSE();
|
||||
hideProgress();
|
||||
alertify.success('Image generated!');
|
||||
this.isProcessing = false;
|
||||
};
|
||||
img.onerror = () => {
|
||||
alertify.dismissAll();
|
||||
disconnectProgressSSE();
|
||||
hideProgress();
|
||||
alertify.error('Failed to load generated image.');
|
||||
this.isProcessing = false;
|
||||
};
|
||||
img.src = 'data:image/png;base64,' + result.result;
|
||||
|
||||
} catch (err) {
|
||||
alertify.dismissAll();
|
||||
disconnectProgressSSE();
|
||||
hideProgress();
|
||||
alertify.error('Generation failed: ' + (err.message || err));
|
||||
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;
|
||||
|
||||
@@ -15,11 +15,12 @@ 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, hideProgress } from './../../libs/progress_overlay.js';
|
||||
|
||||
var instance = null;
|
||||
|
||||
const FRAME_SIZES = [
|
||||
'4x6', '5x7', '8x10', '11x14', '16x20', '20x24', '24x36',
|
||||
'4x6', '5x7', '8x10', '11x14', '16x20', '18x24', '20x24', '24x36',
|
||||
'4x4', '8x8', '12x12',
|
||||
];
|
||||
|
||||
@@ -27,8 +28,8 @@ const FRAME_SIZES = [
|
||||
const FRAME_PX = {
|
||||
'4x6': [1200, 1800], '5x7': [1500, 2100],
|
||||
'8x10': [2400, 3000], '11x14': [3300, 4200],
|
||||
'16x20': [4800, 6000], '20x24': [6000, 7200],
|
||||
'24x36': [7200, 10800],
|
||||
'16x20': [4800, 6000], '18x24': [5400, 7200],
|
||||
'20x24': [6000, 7200], '24x36': [7200, 10800],
|
||||
'4x4': [1200, 1200], '8x8': [2400, 2400], '12x12': [3600, 3600],
|
||||
};
|
||||
|
||||
@@ -96,7 +97,7 @@ class Image_frame_fit_class {
|
||||
name: 'dpi',
|
||||
title: 'Output DPI:',
|
||||
value: '300',
|
||||
values: ['72', '150', '300'],
|
||||
values: ['72', '150', '200', '300'],
|
||||
type: 'select',
|
||||
},
|
||||
{
|
||||
@@ -123,11 +124,11 @@ class Image_frame_fit_class {
|
||||
this.isProcessing = true;
|
||||
|
||||
var mode = params.mode || 'smart';
|
||||
alertify.message(
|
||||
showProgress(
|
||||
mode === 'extend'
|
||||
? 'Fitting to frame with AI extension... please wait'
|
||||
: 'Fitting to frame...',
|
||||
0
|
||||
? 'Fitting to frame with AI extension…'
|
||||
: 'Fitting to frame…',
|
||||
mode === 'extend' ? 45 : 5
|
||||
);
|
||||
|
||||
try {
|
||||
@@ -203,7 +204,7 @@ class Image_frame_fit_class {
|
||||
);
|
||||
}
|
||||
|
||||
alertify.dismissAll();
|
||||
hideProgress();
|
||||
alertify.success(
|
||||
`Done! ${result.output_pixels.width}×${result.output_pixels.height}px` +
|
||||
` (${result.frame} ${result.orientation}, ${result.mode_used})`
|
||||
@@ -211,14 +212,14 @@ class Image_frame_fit_class {
|
||||
this.isProcessing = false;
|
||||
};
|
||||
img.onerror = () => {
|
||||
alertify.dismissAll();
|
||||
hideProgress();
|
||||
alertify.error('Failed to load result.');
|
||||
this.isProcessing = false;
|
||||
};
|
||||
img.src = 'data:image/png;base64,' + result.result;
|
||||
|
||||
} catch (err) {
|
||||
alertify.dismissAll();
|
||||
hideProgress();
|
||||
alertify.error('Frame fit failed: ' + (err.message || err));
|
||||
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'],
|
||||
[3300, 4200, '11x14" Portrait'],
|
||||
[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 {
|
||||
|
||||
@@ -13,6 +13,7 @@ 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 { showProgress, updateProgress, hideProgress } from './../../libs/progress_overlay.js';
|
||||
|
||||
var instance = null;
|
||||
|
||||
@@ -221,7 +222,12 @@ class Image_upscale_class {
|
||||
? `Auto (${caps.recommended_label || 'best available'})`
|
||||
: (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 {
|
||||
var layerCanvas = document.createElement('canvas');
|
||||
@@ -276,21 +282,21 @@ class Image_upscale_class {
|
||||
);
|
||||
}
|
||||
|
||||
alertify.dismissAll();
|
||||
hideProgress();
|
||||
alertify.success(
|
||||
`${result.output.width}×${result.output.height}px · ${usedLabel}`
|
||||
);
|
||||
this.isProcessing = false;
|
||||
};
|
||||
img.onerror = () => {
|
||||
alertify.dismissAll();
|
||||
hideProgress();
|
||||
alertify.error('Failed to load upscaled image.');
|
||||
this.isProcessing = false;
|
||||
};
|
||||
img.src = 'data:image/png;base64,' + result.result;
|
||||
|
||||
} catch (err) {
|
||||
alertify.dismissAll();
|
||||
hideProgress();
|
||||
alertify.error('Upscale failed: ' + (err.message || err));
|
||||
this.isProcessing = false;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import Dialog_class from './../../libs/popup.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
|
||||
const LS = 'paintplus_ai_';
|
||||
@@ -30,109 +30,133 @@ class Tools_ai_provider_settings_class {
|
||||
|
||||
async ai_provider_settings() {
|
||||
var _this = this;
|
||||
|
||||
// Fetch caps and GPU status in parallel
|
||||
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 statusHtml = remote.provider
|
||||
? (remote.healthy
|
||||
? `<span style="color:#44cc44">● ${remote.provider} — connected</span>`
|
||||
: `<span style="color:#ffaa00">● ${remote.provider} — unreachable</span>`)
|
||||
? '<span style="color:#44cc44">● ' + remote.provider + ' — connected</span>'
|
||||
: '<span style="color:#ffaa00">● ' + remote.provider + ' — unreachable</span>')
|
||||
: '<span style="color:#888">No remote provider configured</span>';
|
||||
|
||||
var gpuInfoHtml = gpuStatus ? _renderGpuInfo(gpuStatus) : '';
|
||||
|
||||
var providerValues = ['', 'openai', 'invokeai', 'comfyui', 'replicate', 'local_gpu'];
|
||||
|
||||
var params = [
|
||||
{
|
||||
title: 'Status:',
|
||||
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',
|
||||
title: 'Default provider (used unless overridden below):',
|
||||
value: ls_get('provider', remote.provider || ''),
|
||||
values: providerValues,
|
||||
type: 'select',
|
||||
},
|
||||
// ── Per-operation overrides ───────────────────────────────
|
||||
{
|
||||
title: '',
|
||||
html: '<div style="font-size:11px;color:#888;margin:2px 0 6px;">Per-operation overrides — blank = use default above</div>',
|
||||
},
|
||||
{
|
||||
name: 'provider_inpaint',
|
||||
title: 'Inpaint / Replace Selection:',
|
||||
value: ls_get('provider_inpaint', remote.overrides?.inpaint || ''),
|
||||
values: providerValues,
|
||||
type: 'select',
|
||||
},
|
||||
{
|
||||
name: 'provider_txt2img',
|
||||
title: 'Text → Image:',
|
||||
value: ls_get('provider_txt2img', remote.overrides?.txt2img || ''),
|
||||
values: providerValues,
|
||||
type: 'select',
|
||||
},
|
||||
{
|
||||
name: 'provider_img2img',
|
||||
title: 'Image → Image:',
|
||||
value: ls_get('provider_img2img', remote.overrides?.img2img || ''),
|
||||
values: providerValues,
|
||||
type: 'select',
|
||||
},
|
||||
{
|
||||
name: 'provider_outpaint',
|
||||
title: 'Expand Canvas (Outpaint):',
|
||||
value: ls_get('provider_outpaint', remote.overrides?.outpaint || ''),
|
||||
values: providerValues,
|
||||
type: 'select',
|
||||
},
|
||||
// ── OpenAI ────────────────────────────────────────────────
|
||||
{
|
||||
name: 'openai_key',
|
||||
title: 'OpenAI API key:',
|
||||
value: ls_get('openai_key'),
|
||||
placeholder: 'sk-...',
|
||||
},
|
||||
{
|
||||
name: 'openai_model',
|
||||
title: 'OpenAI model:',
|
||||
value: ls_get('openai_model', 'dall-e-3'),
|
||||
values: ['dall-e-3', 'dall-e-2'],
|
||||
type: 'select',
|
||||
},
|
||||
// ── InvokeAI ──────────────────────────────────────────────
|
||||
{
|
||||
name: 'invokeai_url',
|
||||
title: 'InvokeAI URL:',
|
||||
value: ls_get('invokeai_url'),
|
||||
placeholder: 'http://192.168.1.x:9090',
|
||||
},
|
||||
{
|
||||
name: 'invokeai_model',
|
||||
title: 'InvokeAI default model:',
|
||||
value: ls_get('invokeai_model', 'flux-dev'),
|
||||
placeholder: 'flux-dev',
|
||||
},
|
||||
// ── ComfyUI ───────────────────────────────────────────────
|
||||
{
|
||||
name: 'comfyui_url',
|
||||
title: 'ComfyUI URL:',
|
||||
value: ls_get('comfyui_url'),
|
||||
placeholder: 'http://192.168.1.x:8188',
|
||||
},
|
||||
{
|
||||
name: 'comfyui_model',
|
||||
title: 'ComfyUI default checkpoint:',
|
||||
value: ls_get('comfyui_model', 'v1-5-pruned-emaonly.ckpt'),
|
||||
placeholder: 'v1-5-pruned-emaonly.ckpt',
|
||||
},
|
||||
// ── Replicate ─────────────────────────────────────────────
|
||||
{
|
||||
name: 'replicate_key',
|
||||
title: 'Replicate API key:',
|
||||
value: ls_get('replicate_key'),
|
||||
placeholder: 'r8_...',
|
||||
}
|
||||
);
|
||||
|
||||
this.POP.show({
|
||||
title: 'AI Provider Settings',
|
||||
params: [
|
||||
{
|
||||
title: 'Status:',
|
||||
html: `<div style="margin:4px 0 8px;font-size:12px;">${statusHtml}</div>`,
|
||||
},
|
||||
{
|
||||
name: 'provider',
|
||||
title: 'Default provider (used unless overridden below):',
|
||||
value: ls_get('provider', remote.provider || ''),
|
||||
values: ['', 'openai', 'invokeai', 'comfyui', 'replicate'],
|
||||
type: 'select',
|
||||
},
|
||||
// ── Per-operation overrides ───────────────────────────────
|
||||
{
|
||||
title: '',
|
||||
html: '<div style="font-size:11px;color:#888;margin:2px 0 6px;">Per-operation overrides — blank = use default above</div>',
|
||||
},
|
||||
{
|
||||
name: 'provider_inpaint',
|
||||
title: 'Inpaint / Replace Selection:',
|
||||
value: ls_get('provider_inpaint', remote.overrides?.inpaint || ''),
|
||||
values: ['', 'openai', 'invokeai', 'comfyui', 'replicate'],
|
||||
type: 'select',
|
||||
},
|
||||
{
|
||||
name: 'provider_txt2img',
|
||||
title: 'Text → Image:',
|
||||
value: ls_get('provider_txt2img', remote.overrides?.txt2img || ''),
|
||||
values: ['', 'openai', 'invokeai', 'comfyui', 'replicate'],
|
||||
type: 'select',
|
||||
},
|
||||
{
|
||||
name: 'provider_img2img',
|
||||
title: 'Image → Image:',
|
||||
value: ls_get('provider_img2img', remote.overrides?.img2img || ''),
|
||||
values: ['', 'openai', 'invokeai', 'comfyui', 'replicate'],
|
||||
type: 'select',
|
||||
},
|
||||
{
|
||||
name: 'provider_outpaint',
|
||||
title: 'Expand Canvas (Outpaint):',
|
||||
value: ls_get('provider_outpaint', remote.overrides?.outpaint || ''),
|
||||
values: ['', 'openai', 'invokeai', 'comfyui', 'replicate'],
|
||||
type: 'select',
|
||||
},
|
||||
// ── OpenAI ────────────────────────────────────────────────
|
||||
{
|
||||
name: 'openai_key',
|
||||
title: 'OpenAI API key:',
|
||||
value: ls_get('openai_key'),
|
||||
placeholder: 'sk-...',
|
||||
},
|
||||
{
|
||||
name: 'openai_model',
|
||||
title: 'OpenAI model:',
|
||||
value: ls_get('openai_model', 'dall-e-3'),
|
||||
values: ['dall-e-3', 'dall-e-2'],
|
||||
type: 'select',
|
||||
},
|
||||
// ── InvokeAI ──────────────────────────────────────────────
|
||||
{
|
||||
name: 'invokeai_url',
|
||||
title: 'InvokeAI URL:',
|
||||
value: ls_get('invokeai_url'),
|
||||
placeholder: 'http://192.168.1.x:9090',
|
||||
},
|
||||
{
|
||||
name: 'invokeai_model',
|
||||
title: 'InvokeAI default model:',
|
||||
value: ls_get('invokeai_model', 'flux-dev'),
|
||||
placeholder: 'flux-dev',
|
||||
},
|
||||
// ── ComfyUI ───────────────────────────────────────────────
|
||||
{
|
||||
name: 'comfyui_url',
|
||||
title: 'ComfyUI URL:',
|
||||
value: ls_get('comfyui_url'),
|
||||
placeholder: 'http://192.168.1.x:8188',
|
||||
},
|
||||
{
|
||||
name: 'comfyui_model',
|
||||
title: 'ComfyUI default checkpoint:',
|
||||
value: ls_get('comfyui_model', 'v1-5-pruned-emaonly.ckpt'),
|
||||
placeholder: 'v1-5-pruned-emaonly.ckpt',
|
||||
},
|
||||
// ── Replicate ─────────────────────────────────────────────
|
||||
{
|
||||
name: 'replicate_key',
|
||||
title: 'Replicate API key:',
|
||||
value: ls_get('replicate_key'),
|
||||
placeholder: 'r8_...',
|
||||
},
|
||||
],
|
||||
params: params,
|
||||
on_finish: async function (params) {
|
||||
await _this._save(params);
|
||||
},
|
||||
@@ -182,12 +206,15 @@ class Tools_ai_provider_settings_class {
|
||||
var { refreshCapabilities } = await import('./../../api/capabilities.js');
|
||||
var caps = await refreshCapabilities();
|
||||
if (caps?.remote?.healthy) {
|
||||
alertify.success(`Connected to ${caps.remote.provider}!`);
|
||||
alertify.success('Connected to ' + caps.remote.provider + '!');
|
||||
} else if (params.provider) {
|
||||
alertify.warning('Settings saved but provider is not reachable. Check URL/key.');
|
||||
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.');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Server-side config update not supported — inform user to set .env
|
||||
alertify.warning(
|
||||
'Settings saved locally. To make them permanent, ' +
|
||||
'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;
|
||||
|
||||
@@ -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
|
||||
* @returns {Promise<boolean>}
|
||||
|
||||
@@ -11,6 +11,7 @@ import Base_layers_class from './../core/base-layers.js';
|
||||
import Helper_class from './../libs/helpers.js';
|
||||
import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js';
|
||||
import apiService from './../services/api.js';
|
||||
import { SelectionActions, updateLayerWithResult } from './selection_actions.js';
|
||||
|
||||
class Brush_select_class extends Base_tools_class {
|
||||
|
||||
@@ -39,6 +40,9 @@ class Brush_select_class extends Base_tools_class {
|
||||
|
||||
// Processing state
|
||||
this.isProcessing = false;
|
||||
|
||||
// Quick-action panel shown after selection
|
||||
this.selectionActions = new SelectionActions(this);
|
||||
}
|
||||
|
||||
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() {
|
||||
var _this = this;
|
||||
var imageData = this.getLayerImageData();
|
||||
var maskData = this.maskCanvas
|
||||
? this.maskCanvas.toDataURL('image/png').split(',')[1]
|
||||
: null;
|
||||
if (maskData) {
|
||||
this.selectionActions.show(imageData, maskData);
|
||||
}
|
||||
}
|
||||
|
||||
alertify.confirm(
|
||||
'Selection Complete',
|
||||
'Would you like to move/scale this selection? This will copy it to a new layer.',
|
||||
function() {
|
||||
// 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() {
|
||||
this.selectionActions.hide();
|
||||
this.currentMask = null;
|
||||
this.maskCanvas = null;
|
||||
this.edgeCanvas = null;
|
||||
@@ -793,8 +790,9 @@ class Brush_select_class extends Base_tools_class {
|
||||
}
|
||||
|
||||
on_leave() {
|
||||
this.selectionActions.hide();
|
||||
this.isDrawing = false;
|
||||
this.isProcessing = false; // Reset processing state when leaving tool
|
||||
this.isProcessing = false;
|
||||
this.brushPath = [];
|
||||
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 alertify from './../../../node_modules/alertifyjs/build/alertify.min.js';
|
||||
import apiService from './../services/api.js';
|
||||
import { SelectionActions, updateLayerWithResult } from './selection_actions.js';
|
||||
|
||||
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
|
||||
this.edgeCanvas = null;
|
||||
|
||||
// Quick-action panel shown after selection
|
||||
this.selectionActions = new SelectionActions(this);
|
||||
}
|
||||
|
||||
load() {
|
||||
@@ -146,7 +150,7 @@ class Smart_select_class extends Base_tools_class {
|
||||
if (isAdditive && this.currentMask) {
|
||||
alertify.success('Added to selection! Shift+Click to add more.');
|
||||
} else {
|
||||
alertify.success('Selection complete! Shift+Click to add more, Ctrl+C to copy, Ctrl+X to cut.');
|
||||
this._showActionPanel();
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
@@ -633,10 +637,31 @@ class Smart_select_class extends Base_tools_class {
|
||||
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
|
||||
*/
|
||||
clearSelection() {
|
||||
this.selectionActions.hide();
|
||||
this.currentMask = null;
|
||||
this.maskCanvas = null;
|
||||
this.edgeCanvas = null;
|
||||
@@ -647,7 +672,7 @@ class Smart_select_class extends Base_tools_class {
|
||||
}
|
||||
|
||||
on_leave() {
|
||||
// Don't clear mask when switching tools - AI inpaint needs it
|
||||
this.selectionActions.hide();
|
||||
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