Merge pull request #53 from outis1one/claude/fervent-dirac-ldwaki

Claude/fervent dirac ldwaki
This commit is contained in:
Outis
2026-06-13 13:11:42 -04:00
committed by GitHub
28 changed files with 3030 additions and 177 deletions
+25 -6
View File
@@ -14,18 +14,37 @@
# ============================================================================= # =============================================================================
# STEP 1: Choose AI Provider # STEP 1: Choose AI Provider
# ============================================================================= # =============================================================================
# Options: mock, openai, stability, replicate # Options: local_gpu, mock, openai, stability, replicate, invokeai, comfyui
# #
# mock = Free, but returns original image unchanged (for testing UI) # local_gpu = FREE, runs on YOUR GPU — best option if you have an NVIDIA card
# openai = DALL-E 2 inpainting (~$0.02/image) - lower quality # (use docker-compose.gpu.yml — models auto-download on first use)
# stability = Stability AI SDXL (~$0.01/image) - good quality # mock = Free, returns original image unchanged (UI testing only)
# replicate = Multiple models (~$0.002-0.03/image) - RECOMMENDED # openai = DALL-E 3 / gpt-image-1 (~$0.02-0.04/image)
# stability = Stability AI SDXL (~$0.01/image)
# replicate = Multiple models (~$0.002-0.03/image)
# invokeai = Self-hosted InvokeAI running on another machine
# comfyui = Self-hosted ComfyUI running on another machine
# #
# RECOMMENDED: Use "replicate" for best quality and model variety # GPU QUICK-START:
# docker compose -f docker-compose.gpu.yml up --build
# (AI_PROVIDER defaults to local_gpu in that compose file)
# ============================================================================= # =============================================================================
AI_PROVIDER=replicate AI_PROVIDER=replicate
# ── Local GPU settings (only relevant when AI_PROVIDER=local_gpu) ────────────
# Auto-download HuggingFace models on first request (true/false)
AUTO_DOWNLOAD_MODELS=true
# Max diffusion pipelines to keep loaded in GPU memory (each is 27 GB)
LOCAL_GPU_MAX_PIPELINES=2
# HuggingFace token — only needed for gated/private models
#HF_TOKEN=hf_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# Override auto-selected model for any operation (leave blank = auto by VRAM tier)
#HF_MODEL_INPAINT=your-org/your-inpaint-model
#HF_MODEL_TXT2IMG=your-org/your-txt2img-model
#HF_MODEL_IMG2IMG=your-org/your-img2img-model
# ─────────────────────────────────────────────────────────────────────────────
# Per-operation provider overrides (optional — blank means use AI_PROVIDER above) # Per-operation provider overrides (optional — blank means use AI_PROVIDER above)
# Example: use OpenAI for text-to-image (best quality) but InvokeAI for everything else # Example: use OpenAI for text-to-image (best quality) but InvokeAI for everything else
#AI_PROVIDER_TXT2IMG=openai #AI_PROVIDER_TXT2IMG=openai
+75
View File
@@ -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"]
+9
View File
@@ -46,6 +46,15 @@ class Settings(BaseSettings):
# Allow per-edit model override # Allow per-edit model override
allow_model_override: bool = True allow_model_override: bool = True
# Local GPU diffusion (AI_PROVIDER=local_gpu)
auto_download_models: bool = True # download HF models on first use
local_gpu_max_pipelines: int = 2 # max diffusion pipelines kept in GPU memory
hf_token: str = "" # HuggingFace token (only needed for gated models)
# Override auto-selected models per operation (leave blank = auto-pick by VRAM tier)
hf_model_inpaint: str = ""
hf_model_txt2img: str = ""
hf_model_img2img: str = ""
# File Storage # File Storage
data_dir: str = "./data" data_dir: str = "./data"
max_upload_size_mb: int = 50 max_upload_size_mb: int = 50
+29
View File
@@ -10,6 +10,7 @@ import os
from app.config import settings from app.config import settings
from app.database import init_db from app.database import init_db
from app.routers import projects, edits, images, patches, generate, tools, ai_tools, print_tools from app.routers import projects, edits, images, patches, generate, tools, ai_tools, print_tools
from app.routers import gpu_status
@asynccontextmanager @asynccontextmanager
@@ -24,6 +25,33 @@ async def lifespan(app: FastAPI):
# Pre-download SAM model in background so first click is fast # Pre-download SAM model in background so first click is fast
from app.services.sam_service import ensure_sam_installed from app.services.sam_service import ensure_sam_installed
asyncio.create_task(ensure_sam_installed()) asyncio.create_task(ensure_sam_installed())
# If local GPU provider is active, log GPU info at startup
if settings.ai_provider.lower() == "local_gpu" or any(
v.lower() == "local_gpu"
for v in [
settings.ai_provider_inpaint,
settings.ai_provider_txt2img,
settings.ai_provider_img2img,
settings.ai_provider_outpaint,
]
if v
):
from app.services.gpu_detect import get_cached_gpu_info
info = get_cached_gpu_info()
cc_str = f" | CC={info.compute_capability}" if info.compute_capability else ""
print(
f"[gpu] {info.device_name} | {info.vram_gb:.1f} GB{cc_str} | "
f"tier={info.tier} | fp16={info.fp16}"
)
for w in info.warnings:
print(f"[gpu] ⚠ {w}")
if settings.auto_download_models:
# Download model weight files to disk cache in background so first
# user request loads from local disk instead of the internet.
from app.services.local_diffusion import prefetch_model_files
asyncio.create_task(prefetch_model_files())
yield yield
@@ -52,6 +80,7 @@ app.include_router(generate.router)
app.include_router(tools.router) app.include_router(tools.router)
app.include_router(ai_tools.router) app.include_router(ai_tools.router)
app.include_router(print_tools.router) app.include_router(print_tools.router)
app.include_router(gpu_status.router)
@app.get("/api") @app.get("/api")
+209 -1
View File
@@ -4,10 +4,12 @@ All endpoints are under /api prefix.
""" """
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional from typing import Optional
import base64 import base64
import asyncio import asyncio
import json
from app.services.local_inpaint import ( from app.services.local_inpaint import (
lama_inpaint, opencv_inpaint, lama_available, gpu_available, rembg_available, lama_inpaint, opencv_inpaint, lama_available, gpu_available, rembg_available,
@@ -191,6 +193,37 @@ async def inpaint_remote(req: InpaintRemoteRequest):
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.get("/generate/progress")
async def generation_progress_stream():
"""
SSE stream of local GPU pipeline inference progress.
Events are JSON arrays of pipeline state objects, emitted every 200 ms.
Each object: {pipeline, state, step, total_steps, progress, message, model_id, …}
Clients open this with EventSource before firing a generation POST,
then close it when the POST resolves.
"""
from app.services.local_diffusion import get_all_model_states
async def event_gen():
try:
while True:
states = get_all_model_states()
yield f"data: {json.dumps(states)}\n\n"
await asyncio.sleep(0.2)
except asyncio.CancelledError:
pass
return StreamingResponse(
event_gen(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
"Connection": "keep-alive",
},
)
@router.post("/generate/txt2img") @router.post("/generate/txt2img")
async def txt2img(req: Txt2ImgRequest): async def txt2img(req: Txt2ImgRequest):
"""Text-to-image via configured remote provider.""" """Text-to-image via configured remote provider."""
@@ -327,12 +360,29 @@ async def get_config():
# Default provider for display (used when no per-op override) # Default provider for display (used when no per-op override)
default_name = (settings.ai_provider or "").lower() or None default_name = (settings.ai_provider or "").lower() or None
from app.services.gpu_detect import get_cached_gpu_info
gpu_info = get_cached_gpu_info()
return { return {
"local": { "local": {
"lama": lama_available(), "lama": lama_available(),
"rembg": rembg_available(), "rembg": rembg_available(),
"opencv": True, "opencv": True,
"gpu_detected": gpu_available(), "gpu_detected": gpu_available(),
"gpu_backend": gpu_info.backend,
"gpu_device": gpu_info.device_name,
"gpu_vram_total": gpu_info.vram_total_gb,
"gpu_vram_free": gpu_info.vram_free_gb,
"gpu_cc": gpu_info.compute_capability,
"gpu_fp16": gpu_info.fp16,
"gpu_bf16": gpu_info.bf16,
"gpu_fp8": gpu_info.fp8,
"gpu_tensor_cores": gpu_info.tensor_cores,
"gpu_tier": gpu_info.tier,
"gpu_eff_vram": gpu_info.effective_vram_gb,
"local_gpu_available": gpu_info.backend in ("cuda", "mps"),
"local_gpu_capabilities": gpu_info.capabilities,
"local_gpu_warnings": gpu_info.warnings,
}, },
"remote": { "remote": {
"default_provider": default_name, "default_provider": default_name,
@@ -350,6 +400,164 @@ async def get_config():
} }
# ─── Selection image operations ─────────────────────────────────────────────
class ScaleSelectionRequest(BaseModel):
image: str # base64 full canvas
mask: str # base64 selection mask (white = object)
scale_pct: float = 103.0 # 103 = 3% bigger, 95 = 5% smaller
class AiEditRegionRequest(BaseModel):
image: str
mask: str
instruction: str
negative_prompt: str = ""
steps: int = 30
cfg_scale: float = 7.5
class PasteIntoSelectionRequest(BaseModel):
image: str # base64 target canvas
mask: str # base64 selection mask
paste_image: str # base64 image to paste
@router.post("/image/scale-selection")
async def scale_selection(req: ScaleSelectionRequest):
"""
Scale the object selected by mask by scale_pct%, AI-fill the exposed gap.
Works purely with local tools (LaMa/OpenCV) — no remote provider needed.
"""
try:
import numpy as np
from PIL import Image, ImageFilter
except ImportError:
raise HTTPException(status_code=500, detail="PIL/numpy not available")
img = Image.open(BytesIO(_decode(req.image))).convert("RGB")
mask = Image.open(BytesIO(_decode(req.mask))).convert("L")
if img.size != mask.size:
mask = mask.resize(img.size, Image.LANCZOS)
mask_arr = np.array(mask)
ys, xs = np.where(mask_arr > 128)
if len(xs) == 0:
raise HTTPException(status_code=400, detail="Empty mask — nothing to scale")
minx, maxx = int(xs.min()), int(xs.max())
miny, maxy = int(ys.min()), int(ys.max())
cx, cy = (minx + maxx) / 2.0, (miny + maxy) / 2.0
obj_w, obj_h = maxx - minx + 1, maxy - miny + 1
scale = req.scale_pct / 100.0
new_w = max(1, round(obj_w * scale))
new_h = max(1, round(obj_h * scale))
# Extract masked object crop (RGBA with mask as alpha)
img_rgba = img.convert("RGBA")
obj_crop = img_rgba.crop((minx, miny, maxx + 1, maxy + 1))
mask_crop = mask.crop((minx, miny, maxx + 1, maxy + 1))
r, g, b, _ = obj_crop.split()
obj_masked = Image.merge("RGBA", (r, g, b, mask_crop))
scaled_obj = obj_masked.resize((new_w, new_h), Image.LANCZOS)
# AI-fill the original mask area (gap) with LaMa/OpenCV
gap_mask = mask.filter(ImageFilter.MaxFilter(9)) # expand ~4px for clean seam
gap_bytes = BytesIO()
img.save(gap_bytes, format="PNG")
gap_mask_bytes = BytesIO()
gap_mask.save(gap_mask_bytes, format="PNG")
try:
if lama_available():
filled_bytes = await asyncio.get_event_loop().run_in_executor(
None, lama_inpaint, gap_bytes.getvalue(), gap_mask_bytes.getvalue()
)
else:
filled_bytes = await asyncio.get_event_loop().run_in_executor(
None, opencv_inpaint, gap_bytes.getvalue(), gap_mask_bytes.getvalue()
)
filled = Image.open(BytesIO(filled_bytes)).convert("RGBA")
except Exception as exc:
print(f"[scale-selection] fill fallback: {exc}")
filled = img.convert("RGBA")
# Paste scaled object centered on original centroid
px = round(cx - new_w / 2)
py = round(cy - new_h / 2)
result = filled.copy()
result.paste(scaled_obj, (px, py), scaled_obj.split()[3])
out = BytesIO()
result.convert("RGB").save(out, format="PNG")
return {"result": _encode(out.getvalue())}
@router.post("/image/ai-edit-region")
async def ai_edit_region(req: AiEditRegionRequest):
"""
AI-edit the selected region using the configured inpaint provider.
Works with local_gpu, InvokeAI, ComfyUI, or OpenAI.
"""
provider = _require_remote("inpaint")
result_bytes = await provider.inpaint(
_decode(req.image),
_decode(req.mask),
req.instruction,
{"negative_prompt": req.negative_prompt, "steps": req.steps, "cfg_scale": req.cfg_scale},
)
return {"result": _encode(result_bytes)}
@router.post("/image/paste-into-selection")
async def paste_into_selection(req: PasteIntoSelectionRequest):
"""
Scale a clipboard image to the selection bounding box, mask it to the
selection shape, and composite it over the original canvas.
"""
try:
import numpy as np
from PIL import Image
except ImportError:
raise HTTPException(status_code=500, detail="PIL/numpy not available")
img = Image.open(BytesIO(_decode(req.image))).convert("RGBA")
mask = Image.open(BytesIO(_decode(req.mask))).convert("L")
paste_img = Image.open(BytesIO(_decode(req.paste_image))).convert("RGBA")
if img.size != mask.size:
mask = mask.resize(img.size, Image.LANCZOS)
mask_arr = np.array(mask)
ys, xs = np.where(mask_arr > 128)
if len(xs) == 0:
raise HTTPException(status_code=400, detail="Empty mask")
minx, maxx = int(xs.min()), int(xs.max())
miny, maxy = int(ys.min()), int(ys.max())
target_w = maxx - minx + 1
target_h = maxy - miny + 1
# Scale clipboard image to fit the selection bounding box
paste_scaled = paste_img.resize((target_w, target_h), Image.LANCZOS)
# Clip paste to selection shape using mask
mask_crop = mask.crop((minx, miny, maxx + 1, maxy + 1))
r, g, b, a = paste_scaled.split()
mask_np = np.array(mask_crop)
alpha_np = np.array(a)
combined = (alpha_np.astype(np.uint16) * mask_np.astype(np.uint16) // 255).astype(np.uint8)
paste_final = Image.merge("RGBA", (r, g, b, Image.fromarray(combined)))
result = img.copy()
result.paste(paste_final, (minx, miny), paste_final.split()[3])
out = BytesIO()
result.convert("RGB").save(out, format="PNG")
return {"result": _encode(out.getvalue())}
# ─── SAM (Segment Anything) ────────────────────────────────────────────────── # ─── SAM (Segment Anything) ──────────────────────────────────────────────────
class SegmentPointRequest(BaseModel): class SegmentPointRequest(BaseModel):
+96
View File
@@ -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()}
+110
View File
@@ -21,6 +21,7 @@ FRAME_SIZES = {
"8x10": (8, 10), "8x10": (8, 10),
"11x14": (11, 14), "11x14": (11, 14),
"16x20": (16, 20), "16x20": (16, 20),
"18x24": (18, 24),
"20x24": (20, 24), "20x24": (20, 24),
"24x36": (24, 36), "24x36": (24, 36),
# Square # Square
@@ -65,6 +66,16 @@ class UpscaleRequest(BaseModel):
method: str = "auto" method: str = "auto"
class PrepareRequest(BaseModel):
image: str # base64
frame: str # e.g. "8x10"
orientation: Literal["auto", "portrait", "landscape"] = "auto"
target_dpi: int = 300
upscale_method: str = "auto" # auto / realesrgan_pytorch / realesrgan_ncnn / lanczos
mode: Literal["crop", "extend", "smart"] = "smart"
prompt: Optional[str] = ""
# ── Frame sizes endpoint ─────────────────────────────────────────────────── # ── Frame sizes endpoint ───────────────────────────────────────────────────
@router.get("/frame-sizes") @router.get("/frame-sizes")
@@ -335,6 +346,105 @@ def upscale_install_status():
return status return status
@router.post("/prepare")
async def prepare_for_print(req: PrepareRequest):
"""
One-shot Prepare for Print: AI upscale to reach target DPI, then fit to frame.
Steps:
1. Resolve target pixel dimensions (frame × target_dpi, orientation-adjusted)
2. Calculate needed upscale factor so the image meets the target resolution
3. Run Real-ESRGAN if scale > 1.05 (else skip — already large enough)
4. Run frame-fit (crop / extend / smart) to exact target dimensions
5. Return the print-ready image and a quality report
"""
if req.frame not in FRAME_SIZES:
raise HTTPException(status_code=400,
detail=f"Unknown frame '{req.frame}'. Valid: {list(FRAME_SIZES.keys())}")
if not (72 <= req.target_dpi <= 600):
raise HTTPException(status_code=400, detail="target_dpi must be 72600")
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 24×)
remaining = upscale_factor
while remaining > 1.05:
pass_scale = min(remaining, 4.0)
# Round to one decimal to keep scale in 1.18.0 range accepted by upscale service
pass_scale = round(pass_scale, 1)
if pass_scale < 1.1:
break
from app.services.upscale import upscale_image
result_bytes, method_used = await upscale_image(upscaled, pass_scale, req.upscale_method)
upscaled = Image.open(BytesIO(result_bytes)).convert("RGB")
remaining /= pass_scale
upscale_applied = True
# Encode upscaled image and run frame-fit
upscaled_b64 = _encode(_to_png(upscaled))
fit_req = FrameFitRequest(
image=upscaled_b64,
frame=req.frame,
orientation=req.orientation,
mode=req.mode,
dpi=req.target_dpi,
prompt=req.prompt or "",
)
# Re-use the existing frame_fit logic inline
fit_response = await frame_fit(fit_req)
return {
"result": fit_response["result"],
"frame": req.frame,
"orientation": fit_response["orientation"],
"output_pixels": fit_response["output_pixels"],
"output_inches": fit_response["output_inches"],
"dpi": req.target_dpi,
"mode_used": fit_response["mode_used"],
"upscale_applied": upscale_applied,
"upscale_factor": round(upscale_factor, 2),
"upscale_method": method_used,
"summary": fit_response["summary"],
}
@router.post("/upscale") @router.post("/upscale")
async def upscale(req: UpscaleRequest): async def upscale(req: UpscaleRequest):
""" """
+391
View File
@@ -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, 23× 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 530 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 1030 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()
}
+584
View File
@@ -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}")
+7
View File
@@ -428,6 +428,13 @@ def _build_provider(name: str) -> Optional[RemoteAIProvider]:
return None return None
return ComfyUIProvider(settings.comfyui_url, settings.comfyui_default_model) return ComfyUIProvider(settings.comfyui_url, settings.comfyui_default_model)
if name == "local_gpu":
try:
from app.services.local_diffusion import get_local_diffusion_provider
return get_local_diffusion_provider(max_pipelines=settings.local_gpu_max_pipelines)
except ImportError:
return None
return None return None
+5
View File
@@ -61,6 +61,11 @@ else
fi fi
fi fi
echo ""
echo "Checking GPU capabilities..."
echo "------------------------------------------"
python /scripts/gpu_setup.py || echo "Warning: GPU detection failed (non-fatal)"
echo "" echo ""
echo "==========================================" echo "=========================================="
echo "Starting FastAPI server..." echo "Starting FastAPI server..."
+30
View File
@@ -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
+139
View File
@@ -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)
# 816 GB → SDXL
# 48 GB → Stable Diffusion 2.x
# 24 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
+20 -1
View File
@@ -19,6 +19,8 @@ const DEFAULT_CAPS = {
let _caps = null; let _caps = null;
let _fetchPromise = null; let _fetchPromise = null;
let _gpuStatus = null;
let _gpuFetchPromise = null;
/** /**
* Return capabilities (fetched lazily, cached thereafter). * Return capabilities (fetched lazily, cached thereafter).
@@ -48,12 +50,29 @@ export function hasRemote() {
return !!(_caps?.remote?.healthy); return !!(_caps?.remote?.healthy);
} }
/**
* Fetch and cache detailed GPU status (hardware, feature flags, model selection per op).
* Calls /api/gpu/status — only meaningful when AI_PROVIDER=local_gpu.
* Returns null on error.
*/
export async function getGpuStatus() {
if (_gpuStatus !== null) return _gpuStatus;
if (!_gpuFetchPromise) {
_gpuFetchPromise = apiService.getGpuStatus()
.then(data => { _gpuStatus = data; return _gpuStatus; })
.catch(() => { _gpuStatus = null; return null; });
}
return _gpuFetchPromise;
}
/** /**
* Invalidate cache and re-fetch (call after saving provider settings). * Invalidate cache and re-fetch (call after saving provider settings).
*/ */
export async function refreshCapabilities() { export async function refreshCapabilities() {
_caps = null; _caps = null;
_fetchPromise = null; _fetchPromise = null;
_gpuStatus = null;
_gpuFetchPromise = null;
return getCapabilities(); return getCapabilities();
} }
@@ -62,4 +81,4 @@ export async function refreshCapabilities() {
*/ */
getCapabilities(); getCapabilities();
export default { getCapabilities, getCachedCapabilities, hasRemote, refreshCapabilities }; export default { getCapabilities, getCachedCapabilities, hasRemote, refreshCapabilities, getGpuStatus };
+5
View File
@@ -344,6 +344,11 @@ const menuDefinition = [
ellipsis: true, ellipsis: true,
target: 'image/remove_background.remove_background' target: 'image/remove_background.remove_background'
}, },
{
name: 'Prepare for Print...',
ellipsis: true,
target: 'image/print_prepare.print_prepare'
},
{ {
name: 'Fit to Frame...', name: 'Fit to Frame...',
ellipsis: true, ellipsis: true,
@@ -2,7 +2,7 @@
* ProviderBadge — small DOM element showing the active AI provider. * ProviderBadge — small DOM element showing the active AI provider.
* Inserted into the toolbar footer on app load. * Inserted into the toolbar footer on app load.
* *
* Green = remote provider healthy * Green = remote provider healthy (or local_gpu active)
* Yellow = provider configured but unhealthy/unreachable * Yellow = provider configured but unhealthy/unreachable
* Grey = local only (LaMa + OpenCV) * Grey = local only (LaMa + OpenCV)
*/ */
@@ -30,21 +30,55 @@ export async function mountProviderBadge(container) {
var remote = caps.remote || {}; var remote = caps.remote || {};
var local = caps.local || {}; var local = caps.local || {};
if (remote.provider && remote.healthy) { if (remote.provider === 'local_gpu') {
// Local GPU provider — show GPU name and tier from /api/config local fields
var gpuName = _shortGpuName(local.gpu_device);
var tier = local.gpu_tier || '';
if (remote.healthy) {
dot.style.background = '#44cc44';
badge.style.background = '#1a2a1a';
badge.style.color = '#aaffaa';
label.textContent = 'GPU · ' + tier + ' · ' + gpuName;
var flagList = [
local.gpu_fp16 && 'fp16',
local.gpu_bf16 && 'bf16',
local.gpu_fp8 && 'fp8',
local.gpu_tensor_cores && 'tensor-cores',
].filter(Boolean).join(' ');
badge.title = [
local.gpu_device || gpuName,
'VRAM: ' + local.gpu_vram_total + ' GB total ' + local.gpu_vram_free + ' GB free',
'Compute: CC ' + local.gpu_cc + ' Eff: ' + local.gpu_eff_vram + ' GB',
flagList ? 'Features: ' + flagList : '',
'Capabilities: ' + (local.local_gpu_capabilities || []).join(', '),
(local.local_gpu_warnings || []).length
? '\nWarnings:\n' + local.local_gpu_warnings.join('\n')
: '',
].filter(Boolean).join('\n');
} else {
dot.style.background = '#ffaa00';
badge.style.background = '#2a2000';
badge.style.color = '#ffdd88';
label.textContent = 'Local GPU (not ready)';
badge.title = 'local_gpu is configured but the diffusers library may not be installed.\nCheck container logs for details.';
}
} else if (remote.provider && remote.healthy) {
dot.style.background = '#44cc44'; dot.style.background = '#44cc44';
badge.style.background = '#1a2a1a'; badge.style.background = '#1a2a1a';
badge.style.color = '#aaffaa'; badge.style.color = '#aaffaa';
// Show override summary if any operations use different providers
var overrides = remote.overrides || {}; var overrides = remote.overrides || {};
var overrideEntries = Object.entries(overrides).filter(([, v]) => v); var overrideEntries = Object.entries(overrides).filter(([, v]) => v);
var overrideStr = overrideEntries.length var overrideStr = overrideEntries.length
? ' · ' + overrideEntries.map(([k, v]) => `${k}${v}`).join(', ') ? ' · ' + overrideEntries.map(([k, v]) => k + '→' + v).join(', ')
: ''; : '';
label.textContent = remote.provider + overrideStr + (local.gpu_detected ? ' · GPU' : ''); label.textContent = remote.provider + overrideStr + (local.gpu_detected ? ' · GPU' : '');
var opLines = Object.entries(remote.operations || {}) var opLines = Object.entries(remote.operations || {})
.map(([op, s]) => `${op}: ${s.provider || remote.provider} ${s.healthy ? '✓' : '✗'}`) .map(([op, s]) => op + ': ' + (s.provider || remote.provider) + ' ' + (s.healthy ? '✓' : '✗'))
.join('\n'); .join('\n');
badge.title = opLines || ('Provider: ' + remote.provider); badge.title = opLines || ('Provider: ' + remote.provider);
} else if (remote.provider && !remote.healthy) { } else if (remote.provider && !remote.healthy) {
@@ -70,3 +104,10 @@ export async function mountProviderBadge(container) {
return badge; return badge;
} }
function _shortGpuName(name) {
return (name || 'GPU')
.replace(/^NVIDIA GeForce\s+/i, '')
.replace(/^NVIDIA\s+/i, '')
.replace(/^AMD Radeon\s+/i, '');
}
+180
View File
@@ -0,0 +1,180 @@
/**
* ProgressOverlay — shared animated progress indicator for long AI operations.
*
* Usage:
* import { showProgress, updateProgress, hideProgress } from './progress_overlay.js';
*
* showProgress('Generating image…');
* updateProgress(50, 'Denoising step 15/30…'); // optional step updates
* hideProgress();
*
* When you don't have real step counts, call showProgress() and hideProgress() only —
* the bar animates automatically with a shimmer to signal activity.
*/
var _overlay = null;
var _bar = null;
var _label = null;
var _shimmerAnim = null;
var _fakeTimer = null;
var _currentPct = 0;
// ── SSE progress connection ───────────────────────────────────────────────────
var _sse = null;
/**
* Open an EventSource to /api/generate/progress and drive the bar with real
* denoising step counts from the local GPU pipeline.
*
* @param {string} pipeType - 'txt2img' | 'inpaint' | 'img2img'
* @param {string} baseUrl - window.API_BASE_URL or ''
*/
export function connectProgressSSE(pipeType, baseUrl) {
disconnectProgressSSE();
try {
var url = (baseUrl || '') + '/api/generate/progress';
_sse = new EventSource(url);
_sse.onmessage = (e) => {
try {
var states = JSON.parse(e.data);
var s = Array.isArray(states)
? states.find(st => st.pipeline === pipeType)
: null;
if (s && s.state === 'running' && s.total_steps) {
var pct = Math.round(s.step / s.total_steps * 85);
updateProgress(pct, s.message || `Step ${s.step} / ${s.total_steps}`);
}
} catch { /* malformed event — ignore */ }
};
_sse.onerror = () => disconnectProgressSSE();
} catch { /* SSE not supported */ }
}
export function disconnectProgressSSE() {
if (_sse) { _sse.close(); _sse = null; }
}
// ── Progress overlay ──────────────────────────────────────────────────────────
export function showProgress(message, estimatedSeconds) {
hideProgress();
_currentPct = 0;
// ── Backdrop ──────────────────────────────────────────────────────────────
_overlay = document.createElement('div');
_overlay.id = 'ai-progress-overlay';
_overlay.style.cssText = [
'position:fixed', 'inset:0', 'z-index:99999',
'display:flex', 'flex-direction:column',
'align-items:center', 'justify-content:center',
'background:rgba(0,0,0,0.55)',
'backdrop-filter:blur(2px)',
'-webkit-backdrop-filter:blur(2px)',
].join(';');
// ── Card ──────────────────────────────────────────────────────────────────
var card = document.createElement('div');
card.style.cssText = [
'background:#1a1a2e',
'border:1px solid #3a3a6a',
'border-radius:14px',
'padding:28px 36px',
'min-width:320px', 'max-width:480px',
'box-shadow:0 12px 48px rgba(0,0,0,0.8)',
'display:flex', 'flex-direction:column', 'gap:14px',
'text-align:center',
].join(';');
// ── Label ─────────────────────────────────────────────────────────────────
_label = document.createElement('div');
_label.textContent = message || 'Processing…';
_label.style.cssText = 'font-family:sans-serif;font-size:13px;color:#c0c0e0;line-height:1.4;min-height:2.8em';
// ── Track ─────────────────────────────────────────────────────────────────
var track = document.createElement('div');
track.style.cssText = [
'width:100%', 'height:6px',
'background:#0f0f2a',
'border-radius:3px',
'overflow:hidden',
'position:relative',
].join(';');
// ── Shimmer (indeterminate stripe) ────────────────────────────────────────
var shimmer = document.createElement('div');
shimmer.style.cssText = [
'position:absolute', 'inset:0',
'background:linear-gradient(90deg,transparent 0%,rgba(120,120,255,0.25) 50%,transparent 100%)',
'transform:translateX(-100%)',
'will-change:transform',
].join(';');
// ── Filled bar ────────────────────────────────────────────────────────────
_bar = document.createElement('div');
_bar.style.cssText = [
'position:absolute', 'inset-block:0', 'left:0',
'width:0%',
'background:linear-gradient(90deg,#5577ff,#88aaff)',
'border-radius:3px',
'transition:width 0.35s ease',
].join(';');
// ── Cancel hint ───────────────────────────────────────────────────────────
var hint = document.createElement('div');
hint.textContent = 'Press Esc to cancel';
hint.style.cssText = 'font-family:sans-serif;font-size:10px;color:#444;margin-top:2px';
track.appendChild(shimmer);
track.appendChild(_bar);
card.appendChild(_label);
card.appendChild(track);
card.appendChild(hint);
_overlay.appendChild(card);
document.body.appendChild(_overlay);
// Animate shimmer
var pos = -100;
_shimmerAnim = setInterval(() => {
pos += 2.5;
if (pos > 200) pos = -100;
shimmer.style.transform = `translateX(${pos}%)`;
}, 16);
// Fake progress that creeps toward 90% if no real steps given
if (estimatedSeconds) {
var totalMs = estimatedSeconds * 1000;
var step = 90 / (totalMs / 200);
_fakeTimer = setInterval(() => {
if (_currentPct < 90) {
_currentPct = Math.min(90, _currentPct + step);
_bar.style.width = _currentPct + '%';
}
}, 200);
}
// Esc to cancel
_overlay._escHandler = (e) => { if (e.key === 'Escape') hideProgress(); };
document.addEventListener('keydown', _overlay._escHandler);
}
export function updateProgress(pct, message) {
if (!_overlay) return;
_currentPct = Math.max(_currentPct, Math.min(100, pct));
if (_bar) _bar.style.width = _currentPct + '%';
if (_label && message) _label.textContent = message;
}
export function hideProgress() {
if (_shimmerAnim) { clearInterval(_shimmerAnim); _shimmerAnim = null; }
if (_fakeTimer) { clearInterval(_fakeTimer); _fakeTimer = null; }
if (_overlay) {
document.removeEventListener('keydown', _overlay._escHandler);
_overlay.remove();
_overlay = null;
}
_bar = null;
_label = null;
_currentPct = 0;
}
@@ -1,5 +1,5 @@
/** /**
* Text → Image — opens a sidebar-style dialog, generates via remote provider, * Text → Image — generates via remote or local-GPU provider,
* pastes result as a new layer on the current canvas. * pastes result as a new layer on the current canvas.
* *
* Menu target: generate/text_to_image.text_to_image * Menu target: generate/text_to_image.text_to_image
@@ -12,6 +12,7 @@ import Dialog_class from './../../libs/popup.js';
import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
import apiService from './../../services/api.js'; import apiService from './../../services/api.js';
import { getCapabilities } from './../../api/capabilities.js'; import { getCapabilities } from './../../api/capabilities.js';
import { showProgress, updateProgress, hideProgress, connectProgressSSE, disconnectProgressSSE } from './../../libs/progress_overlay.js';
var instance = null; var instance = null;
@@ -27,21 +28,54 @@ class Generate_text_to_image_class {
async text_to_image() { async text_to_image() {
var caps = await getCapabilities(); var caps = await getCapabilities();
if (!caps.remote || !caps.remote.healthy) { var hasRemote = caps.remote && caps.remote.healthy;
var hasLocal = caps.local && caps.local.local_gpu_available;
if (!hasRemote && !hasLocal) {
alertify.error( alertify.error(
'Text → Image requires a remote AI provider. ' + 'Text → Image requires an AI provider. ' +
'Set AI_PROVIDER (openai / invokeai / comfyui) in .env and restart.' 'Set AI_PROVIDER=openai / invokeai / comfyui / local_gpu in .env and restart, ' +
'or configure one in Image → AI Provider Settings.'
); );
return; return;
} }
var _this = this; var _this = this;
var canvasW = config.WIDTH || 1024; var canvasW = config.WIDTH || 1024;
var canvasH = config.HEIGHT || 1024; var canvasH = config.HEIGHT || 1024;
// Build provider info line
var providerHtml = hasRemote
? `<span style="color:#44cc44">● ${caps.remote.provider}</span>`
: `<span style="color:#44cc44">● local GPU · ${caps.local.gpu_tier || ''} · ${_shortGpu(caps.local.gpu_device)}</span>`;
// Model note for local GPU
var modelNote = '';
if (hasLocal && !hasRemote) {
var rec = caps.local.local_gpu_capabilities && caps.local.local_gpu_capabilities.recommended;
var m = rec && rec.txt2img;
if (m) {
modelNote = `Model: <span style="color:#ddd">${m.model_id.split('/').pop()}</span>`;
if (m.memory_opt && m.memory_opt !== 'none') modelNote += ` · <span style="color:#aaa">${m.memory_opt}</span>`;
}
}
// Estimate generation time (rough guide for the progress bar)
var estSec = hasLocal ? 60 : 15; // local GPU ~1 min; OpenAI ~15s
var defaultW = Math.min(canvasW, hasLocal ? (caps.local.local_gpu_capabilities?.recommended?.txt2img?.native_res || 1024) : 1024);
var defaultH = Math.min(canvasH, defaultW);
this.Dialog.show({ this.Dialog.show({
title: 'Text → Image', title: 'Text → Image',
params: [ params: [
{
title: '',
html: `<div style="font-size:11px;margin:0 0 8px">
Provider: ${providerHtml}${modelNote ? ' · ' + modelNote : ''}<br>
<span style="color:#777">Generation typically takes ${estSec < 30 ? 'a few seconds' : estSec < 90 ? '3090 seconds on local GPU' : '13 minutes on local GPU'}.</span>
</div>`,
},
{ {
name: 'prompt', name: 'prompt',
title: 'Describe your image:', title: 'Describe your image:',
@@ -58,7 +92,7 @@ class Generate_text_to_image_class {
{ {
name: 'width', name: 'width',
title: 'Width (px):', title: 'Width (px):',
value: Math.min(canvasW, 1024), value: defaultW,
range: [256, 2048], range: [256, 2048],
step: 64, step: 64,
type: 'range', type: 'range',
@@ -66,7 +100,7 @@ class Generate_text_to_image_class {
{ {
name: 'height', name: 'height',
title: 'Height (px):', title: 'Height (px):',
value: Math.min(canvasH, 1024), value: defaultH,
range: [256, 2048], range: [256, 2048],
step: 64, step: 64,
type: 'range', type: 'range',
@@ -99,29 +133,32 @@ class Generate_text_to_image_class {
alertify.warning('Please enter a description.'); alertify.warning('Please enter a description.');
return; return;
} }
await _this._generate(params); await _this._generate(params, estSec);
}, },
}); });
} }
async _generate(params) { async _generate(params, estSec) {
if (this.isProcessing) return; if (this.isProcessing) return;
this.isProcessing = true; this.isProcessing = true;
alertify.message('Generating image... please wait', 0);
connectProgressSSE('txt2img', window.API_BASE_URL || '');
showProgress('Generating image…', estSec || 60);
try { try {
var result = await apiService.textToImage(params.prompt, { var result = await apiService.textToImage(params.prompt, {
width: params.width || 1024, width: params.width || 1024,
height: params.height || 1024, height: params.height || 1024,
negativePrompt: params.negative_prompt || '', negativePrompt: params.negative_prompt || '',
steps: params.steps || 30, steps: params.steps || 30,
seed: params.seed || 0, seed: params.seed || 0,
}); });
updateProgress(95, 'Placing image…');
var img = new Image(); var img = new Image();
img.onload = () => { img.onload = () => {
if (params.placement === 'replace_canvas') { if (params.placement === 'replace_canvas') {
// Resize canvas and replace bottom layer
config.WIDTH = img.naturalWidth; config.WIDTH = img.naturalWidth;
config.HEIGHT = img.naturalHeight; config.HEIGHT = img.naturalHeight;
var resultCanvas = document.createElement('canvas'); var resultCanvas = document.createElement('canvas');
@@ -134,41 +171,46 @@ class Generate_text_to_image_class {
]) ])
); );
} else { } else {
// Add as new layer on top
var dataURL = img.src;
app.State.do_action( app.State.do_action(
new app.Actions.Bundle_action('txt2img_layer', 'Text → Image Layer', [ new app.Actions.Bundle_action('txt2img_layer', 'Text → Image Layer', [
new app.Actions.Insert_layer_action({ new app.Actions.Insert_layer_action({
name: params.prompt.slice(0, 30), name: params.prompt.slice(0, 30),
type: 'image', type: 'image',
data: dataURL, data: img.src,
x: 0, x: 0, y: 0,
y: 0, width: img.naturalWidth,
width: img.naturalWidth, height: img.naturalHeight,
height: img.naturalHeight,
width_original: img.naturalWidth, width_original: img.naturalWidth,
height_original: img.naturalHeight, height_original: img.naturalHeight,
}) })
]) ])
); );
} }
alertify.dismissAll(); disconnectProgressSSE();
hideProgress();
alertify.success('Image generated!'); alertify.success('Image generated!');
this.isProcessing = false; this.isProcessing = false;
}; };
img.onerror = () => { img.onerror = () => {
alertify.dismissAll(); disconnectProgressSSE();
hideProgress();
alertify.error('Failed to load generated image.'); alertify.error('Failed to load generated image.');
this.isProcessing = false; this.isProcessing = false;
}; };
img.src = 'data:image/png;base64,' + result.result; img.src = 'data:image/png;base64,' + result.result;
} catch (err) { } catch (err) {
alertify.dismissAll(); disconnectProgressSSE();
hideProgress();
alertify.error('Generation failed: ' + (err.message || err)); alertify.error('Generation failed: ' + (err.message || err));
this.isProcessing = false; this.isProcessing = false;
} }
} }
} }
function _shortGpu(name) {
if (!name) return 'GPU';
return name.replace(/^NVIDIA GeForce /i, '').replace(/^NVIDIA /i, '');
}
export default Generate_text_to_image_class; export default Generate_text_to_image_class;
+12 -11
View File
@@ -15,11 +15,12 @@ import Base_layers_class from './../../core/base-layers.js';
import Dialog_class from './../../libs/popup.js'; import Dialog_class from './../../libs/popup.js';
import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
import { getCapabilities } from './../../api/capabilities.js'; import { getCapabilities } from './../../api/capabilities.js';
import { showProgress, hideProgress } from './../../libs/progress_overlay.js';
var instance = null; var instance = null;
const FRAME_SIZES = [ const FRAME_SIZES = [
'4x6', '5x7', '8x10', '11x14', '16x20', '20x24', '24x36', '4x6', '5x7', '8x10', '11x14', '16x20', '18x24', '20x24', '24x36',
'4x4', '8x8', '12x12', '4x4', '8x8', '12x12',
]; ];
@@ -27,8 +28,8 @@ const FRAME_SIZES = [
const FRAME_PX = { const FRAME_PX = {
'4x6': [1200, 1800], '5x7': [1500, 2100], '4x6': [1200, 1800], '5x7': [1500, 2100],
'8x10': [2400, 3000], '11x14': [3300, 4200], '8x10': [2400, 3000], '11x14': [3300, 4200],
'16x20': [4800, 6000], '20x24': [6000, 7200], '16x20': [4800, 6000], '18x24': [5400, 7200],
'24x36': [7200, 10800], '20x24': [6000, 7200], '24x36': [7200, 10800],
'4x4': [1200, 1200], '8x8': [2400, 2400], '12x12': [3600, 3600], '4x4': [1200, 1200], '8x8': [2400, 2400], '12x12': [3600, 3600],
}; };
@@ -96,7 +97,7 @@ class Image_frame_fit_class {
name: 'dpi', name: 'dpi',
title: 'Output DPI:', title: 'Output DPI:',
value: '300', value: '300',
values: ['72', '150', '300'], values: ['72', '150', '200', '300'],
type: 'select', type: 'select',
}, },
{ {
@@ -123,11 +124,11 @@ class Image_frame_fit_class {
this.isProcessing = true; this.isProcessing = true;
var mode = params.mode || 'smart'; var mode = params.mode || 'smart';
alertify.message( showProgress(
mode === 'extend' mode === 'extend'
? 'Fitting to frame with AI extension... please wait' ? 'Fitting to frame with AI extension'
: 'Fitting to frame...', : 'Fitting to frame',
0 mode === 'extend' ? 45 : 5
); );
try { try {
@@ -203,7 +204,7 @@ class Image_frame_fit_class {
); );
} }
alertify.dismissAll(); hideProgress();
alertify.success( alertify.success(
`Done! ${result.output_pixels.width}×${result.output_pixels.height}px` + `Done! ${result.output_pixels.width}×${result.output_pixels.height}px` +
` (${result.frame} ${result.orientation}, ${result.mode_used})` ` (${result.frame} ${result.orientation}, ${result.mode_used})`
@@ -211,14 +212,14 @@ class Image_frame_fit_class {
this.isProcessing = false; this.isProcessing = false;
}; };
img.onerror = () => { img.onerror = () => {
alertify.dismissAll(); hideProgress();
alertify.error('Failed to load result.'); alertify.error('Failed to load result.');
this.isProcessing = false; this.isProcessing = false;
}; };
img.src = 'data:image/png;base64,' + result.result; img.src = 'data:image/png;base64,' + result.result;
} catch (err) { } catch (err) {
alertify.dismissAll(); hideProgress();
alertify.error('Frame fit failed: ' + (err.message || err)); alertify.error('Frame fit failed: ' + (err.message || err));
this.isProcessing = false; this.isProcessing = false;
} }
@@ -0,0 +1,287 @@
/**
* Prepare for Print — one-click AI upscale + frame fit.
*
* Shows a quality assessment (current effective DPI, needed upscale factor,
* AI vs Lanczos note) then chains AI upscale → frame-fit in a single backend call.
*
* Menu target: image/print_prepare.print_prepare
*/
import app from './../../app.js';
import config from './../../config.js';
import Base_layers_class from './../../core/base-layers.js';
import Dialog_class from './../../libs/popup.js';
import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
import { getCapabilities } from './../../api/capabilities.js';
import { showProgress, updateProgress, hideProgress } from './../../libs/progress_overlay.js';
const FRAME_SIZES = [
'5x7', '8x10', '11x14', '18x24', '16x20', '20x24', '24x36',
];
// Portrait pixels at 300 DPI (label use only)
const FRAME_PX = {
'5x7': [1500, 2100], '8x10': [2400, 3000],
'11x14': [3300, 4200], '18x24': [5400, 7200],
'16x20': [4800, 6000], '20x24': [6000, 7200],
'24x36': [7200, 10800],
};
// Actual frame inches (portrait w, h)
const FRAME_IN = {
'5x7': [5, 7], '8x10': [8, 10], '11x14': [11, 14],
'18x24': [18, 24], '16x20': [16, 20], '20x24': [20, 24],
'24x36': [24, 36],
};
var instance = null;
class Image_print_prepare_class {
constructor() {
if (instance) return instance;
instance = this;
this.Base_layers = new Base_layers_class();
this.Dialog = new Dialog_class();
this.isProcessing = false;
}
async print_prepare() {
if (!config.layer || config.layer.type !== 'image') {
alertify.error('Select an image layer first.');
return;
}
var caps = await getCapabilities();
var hasAI = (caps.remote && caps.remote.healthy) || (caps.local && caps.local.local_gpu_available);
var W = config.layer.width_original;
var H = config.layer.height_original;
var qualityHtml = _buildQualityHtml(W, H, hasAI);
var frameLabels = FRAME_SIZES.map(s => {
var px = FRAME_PX[s] || [0, 0];
return `${s}" (${px[0]}×${px[1]}px @ 300dpi)`;
});
var _this = this;
this.Dialog.show({
title: 'Prepare for Print',
params: [
{
title: '',
html: qualityHtml,
},
{
name: 'frame',
title: 'Target frame size:',
value: frameLabels[0],
values: frameLabels,
type: 'select',
},
{
name: 'orientation',
title: 'Orientation:',
value: 'auto',
values: ['auto', 'portrait', 'landscape'],
type: 'select',
},
{
name: 'target_dpi',
title: 'Target DPI:',
value: '300',
values: ['200', '300'],
type: 'select',
comment: '200 dpi is fine for 18×24" and larger (viewed from a distance)',
},
{
name: 'mode',
title: 'Fit mode:',
value: 'smart',
values: ['smart', 'crop', 'extend'],
type: 'select',
comment: 'smart = extend if gap <15%, else crop',
},
{
name: 'upscale_method',
title: 'Upscale engine:',
value: 'auto',
values: ['auto', 'realesrgan_pytorch', 'realesrgan_ncnn', 'lanczos'],
type: 'select',
comment: hasAI ? 'auto picks Real-ESRGAN — genuinely adds detail' : 'auto picks Real-ESRGAN if available, else Lanczos',
},
{
name: 'prompt',
title: 'Extend prompt (optional):',
value: '',
placeholder: 'e.g. "natural background continuation" — blank works well',
},
{
name: 'new_layer',
title: 'Result as new layer (keep original):',
value: true,
},
],
on_finish: async function (params) {
var frameKey = params.frame.split('"')[0];
await _this._run(frameKey, params, W, H);
},
});
}
async _run(frameKey, params, origW, origH) {
if (this.isProcessing) return;
this.isProcessing = true;
var dpi = parseInt(params.target_dpi) || 300;
var inches = FRAME_IN[frameKey] || [8, 10];
var targetW = inches[0] * dpi;
var targetH = inches[1] * dpi;
// Orientation swap for display
var orient = params.orientation || 'auto';
var imgLandscape = origW >= origH;
var frameLandscape = inches[0] >= inches[1];
if (orient === 'landscape' || (orient === 'auto' && imgLandscape && !frameLandscape)) {
targetW = Math.max(inches[0], inches[1]) * dpi;
targetH = Math.min(inches[0], inches[1]) * dpi;
} else if (orient === 'portrait' || (orient === 'auto' && !imgLandscape && frameLandscape)) {
targetW = Math.min(inches[0], inches[1]) * dpi;
targetH = Math.max(inches[0], inches[1]) * dpi;
}
var neededScale = Math.max(targetW / origW, targetH / origH);
var willUpscale = neededScale > 1.05;
showProgress(
willUpscale
? `Upscaling ${neededScale.toFixed(1)}× with AI, then fitting to frame…\nAI is reconstructing detail — this may take 13 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;
+4
View File
@@ -16,6 +16,10 @@ const PRINT_SIZES = [
[3000, 2400, '8x10" Landscape'], [3000, 2400, '8x10" Landscape'],
[3300, 4200, '11x14" Portrait'], [3300, 4200, '11x14" Portrait'],
[4200, 3300, '11x14" Landscape'], [4200, 3300, '11x14" Landscape'],
[3600, 4800, '18x24" Portrait 200dpi'],
[4800, 3600, '18x24" Landscape 200dpi'],
[5400, 7200, '18x24" Portrait 300dpi'],
[7200, 5400, '18x24" Landscape 300dpi'],
]; ];
class Image_size_class { class Image_size_class {
+10 -4
View File
@@ -13,6 +13,7 @@ import config from './../../config.js';
import Base_layers_class from './../../core/base-layers.js'; import Base_layers_class from './../../core/base-layers.js';
import Dialog_class from './../../libs/popup.js'; import Dialog_class from './../../libs/popup.js';
import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
import { showProgress, updateProgress, hideProgress } from './../../libs/progress_overlay.js';
var instance = null; var instance = null;
@@ -221,7 +222,12 @@ class Image_upscale_class {
? `Auto (${caps.recommended_label || 'best available'})` ? `Auto (${caps.recommended_label || 'best available'})`
: (METHOD_LABELS[method] || method); : (METHOD_LABELS[method] || method);
alertify.message(`Upscaling ${scale}× · ${methodLabel}`, 0); var isAI = method !== 'lanczos';
showProgress(
`Upscaling ${scale}× with ${methodLabel}` +
(isAI ? '\nAI is reconstructing detail — this may take 30120 seconds.' : ''),
isAI ? 90 : 10
);
try { try {
var layerCanvas = document.createElement('canvas'); var layerCanvas = document.createElement('canvas');
@@ -276,21 +282,21 @@ class Image_upscale_class {
); );
} }
alertify.dismissAll(); hideProgress();
alertify.success( alertify.success(
`${result.output.width}×${result.output.height}px · ${usedLabel}` `${result.output.width}×${result.output.height}px · ${usedLabel}`
); );
this.isProcessing = false; this.isProcessing = false;
}; };
img.onerror = () => { img.onerror = () => {
alertify.dismissAll(); hideProgress();
alertify.error('Failed to load upscaled image.'); alertify.error('Failed to load upscaled image.');
this.isProcessing = false; this.isProcessing = false;
}; };
img.src = 'data:image/png;base64,' + result.result; img.src = 'data:image/png;base64,' + result.result;
} catch (err) { } catch (err) {
alertify.dismissAll(); hideProgress();
alertify.error('Upscale failed: ' + (err.message || err)); alertify.error('Upscale failed: ' + (err.message || err));
this.isProcessing = false; this.isProcessing = false;
} }
@@ -6,7 +6,7 @@
import Dialog_class from './../../libs/popup.js'; import Dialog_class from './../../libs/popup.js';
import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
import { getCapabilities } from './../../api/capabilities.js'; import { getCapabilities, getGpuStatus } from './../../api/capabilities.js';
// localStorage key prefix // localStorage key prefix
const LS = 'paintplus_ai_'; const LS = 'paintplus_ai_';
@@ -30,109 +30,133 @@ class Tools_ai_provider_settings_class {
async ai_provider_settings() { async ai_provider_settings() {
var _this = this; var _this = this;
// Fetch caps and GPU status in parallel
var caps = await getCapabilities(); var caps = await getCapabilities();
var gpuStatus = null;
var local = caps.local || {};
if (local.local_gpu_available) {
gpuStatus = await getGpuStatus().catch(() => null);
}
var remote = caps.remote || {}; var remote = caps.remote || {};
var statusHtml = remote.provider var statusHtml = remote.provider
? (remote.healthy ? (remote.healthy
? `<span style="color:#44cc44">● ${remote.provider} — connected</span>` ? '<span style="color:#44cc44">● ' + remote.provider + ' — connected</span>'
: `<span style="color:#ffaa00">● ${remote.provider} — unreachable</span>`) : '<span style="color:#ffaa00">● ' + remote.provider + ' — unreachable</span>')
: '<span style="color:#888">No remote provider configured</span>'; : '<span style="color:#888">No remote provider configured</span>';
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({ this.POP.show({
title: 'AI Provider Settings', title: 'AI Provider Settings',
params: [ params: 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_...',
},
],
on_finish: async function (params) { on_finish: async function (params) {
await _this._save(params); await _this._save(params);
}, },
@@ -182,12 +206,15 @@ class Tools_ai_provider_settings_class {
var { refreshCapabilities } = await import('./../../api/capabilities.js'); var { refreshCapabilities } = await import('./../../api/capabilities.js');
var caps = await refreshCapabilities(); var caps = await refreshCapabilities();
if (caps?.remote?.healthy) { if (caps?.remote?.healthy) {
alertify.success(`Connected to ${caps.remote.provider}!`); alertify.success('Connected to ' + caps.remote.provider + '!');
} else if (params.provider) { } else if (params.provider) {
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 { } else {
// Server-side config update not supported — inform user to set .env
alertify.warning( alertify.warning(
'Settings saved locally. To make them permanent, ' + 'Settings saved locally. To make them permanent, ' +
'set these values in your .env file and restart the server.' 'set these values in your .env file and restart the server.'
@@ -201,4 +228,44 @@ class Tools_ai_provider_settings_class {
} }
} }
function _renderGpuInfo(g) {
var flags = [
g.fp16 && 'fp16',
g.bf16 && 'bf16',
g.fp8 && 'fp8',
g.int8 && 'int8',
g.tensor_cores && 'tensor-cores',
g.xformers && 'xformers',
].filter(Boolean).join(' · ');
var rows = Object.entries(g.recommended || {})
.filter(([, s]) => s)
.map(function([op, s]) {
var modelName = s.model_id.split('/').pop();
return '<tr>' +
'<td style="color:#aaa;padding:2px 8px 2px 0;white-space:nowrap">' + op + '</td>' +
'<td style="color:#ddd">' + modelName + '</td>' +
'<td style="color:#888;padding-left:8px;font-size:10px">' + s.memory_opt + '</td>' +
'</tr>';
})
.join('');
var warnHtml = (g.warnings || []).length
? '<div style="color:#ffaa44;margin-top:6px;font-size:10px">' +
g.warnings.map(function(w) { return '⚠ ' + w; }).join('<br>') + '</div>'
: '';
return '<div style="background:#1a2a1a;border:1px solid #2a4a2a;border-radius:6px;padding:10px;font-size:11px;font-family:monospace">' +
'<div style="color:#44cc44;font-size:12px;margin-bottom:6px">⬛ ' + (g.device_name || 'GPU') + '</div>' +
'<div style="color:#aaa">VRAM: <span style="color:#ddd">' + g.vram_total_gb + ' GB total · ' + g.vram_free_gb + ' GB free</span></div>' +
'<div style="color:#aaa">Compute: <span style="color:#ddd">CC ' + g.compute_capability + '</span>' +
(flags ? ' <span style="color:#888">' + flags + '</span>' : '') + '</div>' +
'<div style="color:#aaa">Effective: <span style="color:#ddd">' + g.effective_vram_gb + ' GB</span>' +
' Tier: <span style="color:#44cc44">' + g.tier + '</span></div>' +
(rows ? '<div style="color:#aaa;margin-top:8px">Models selected:</div>' +
'<table style="width:100%;margin-top:3px">' + rows + '</table>' : '') +
warnHtml +
'</div>';
}
export default Tools_ai_provider_settings_class; export default Tools_ai_provider_settings_class;
+15
View File
@@ -213,6 +213,21 @@ class ApiService {
} }
} }
/**
* Fetch GPU status: hardware, feature flags, and selected models per operation.
* Only meaningful when AI_PROVIDER=local_gpu.
* @returns {Promise<Object|null>}
*/
async getGpuStatus() {
try {
const response = await fetch(`${this.baseUrl}/api/gpu/status`);
if (!response.ok) return null;
return response.json();
} catch {
return null;
}
}
/** /**
* Health check for the backend * Health check for the backend
* @returns {Promise<boolean>} * @returns {Promise<boolean>}
+21 -23
View File
@@ -11,6 +11,7 @@ import Base_layers_class from './../core/base-layers.js';
import Helper_class from './../libs/helpers.js'; import Helper_class from './../libs/helpers.js';
import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js'; import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js';
import apiService from './../services/api.js'; import apiService from './../services/api.js';
import { SelectionActions, updateLayerWithResult } from './selection_actions.js';
class Brush_select_class extends Base_tools_class { class Brush_select_class extends Base_tools_class {
@@ -39,6 +40,9 @@ class Brush_select_class extends Base_tools_class {
// Processing state // Processing state
this.isProcessing = false; this.isProcessing = false;
// Quick-action panel shown after selection
this.selectionActions = new SelectionActions(this);
} }
load() { load() {
@@ -300,31 +304,23 @@ class Brush_select_class extends Base_tools_class {
} }
/** /**
* Offer to float the selection to a new layer for manipulation (Canva-like workflow) * Show quick-action panel after selection (AI operations, scale, clipboard paste, etc.)
*/ */
offerFloatSelection() { offerFloatSelection() {
var _this = this; var imageData = this.getLayerImageData();
var maskData = this.maskCanvas
? this.maskCanvas.toDataURL('image/png').split(',')[1]
: null;
if (maskData) {
this.selectionActions.show(imageData, maskData);
}
}
alertify.confirm( /**
'Selection Complete', * Update the current layer canvas with a base64 result from a backend operation.
'Would you like to move/scale this selection? This will copy it to a new layer.', */
function() { updateLayerWithResult(base64) {
// Yes - copy to layer and switch to Select tool updateLayerWithResult(base64, this);
_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'});
} }
/** /**
@@ -781,6 +777,7 @@ class Brush_select_class extends Base_tools_class {
} }
clearSelection() { clearSelection() {
this.selectionActions.hide();
this.currentMask = null; this.currentMask = null;
this.maskCanvas = null; this.maskCanvas = null;
this.edgeCanvas = null; this.edgeCanvas = null;
@@ -793,8 +790,9 @@ class Brush_select_class extends Base_tools_class {
} }
on_leave() { on_leave() {
this.selectionActions.hide();
this.isDrawing = false; this.isDrawing = false;
this.isProcessing = false; // Reset processing state when leaving tool this.isProcessing = false;
this.brushPath = []; this.brushPath = [];
return []; return [];
} }
+361
View File
@@ -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);
});
}
+27 -2
View File
@@ -13,6 +13,7 @@ import Helper_class from './../libs/helpers.js';
import Dialog_class from './../libs/popup.js'; import Dialog_class from './../libs/popup.js';
import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js'; import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js';
import apiService from './../services/api.js'; import apiService from './../services/api.js';
import { SelectionActions, updateLayerWithResult } from './selection_actions.js';
class Smart_select_class extends Base_tools_class { class Smart_select_class extends Base_tools_class {
@@ -35,6 +36,9 @@ class Smart_select_class extends Base_tools_class {
// Edge canvas for drawing the mask outline // Edge canvas for drawing the mask outline
this.edgeCanvas = null; this.edgeCanvas = null;
// Quick-action panel shown after selection
this.selectionActions = new SelectionActions(this);
} }
load() { load() {
@@ -146,7 +150,7 @@ class Smart_select_class extends Base_tools_class {
if (isAdditive && this.currentMask) { if (isAdditive && this.currentMask) {
alertify.success('Added to selection! Shift+Click to add more.'); alertify.success('Added to selection! Shift+Click to add more.');
} else { } else {
alertify.success('Selection complete! Shift+Click to add more, Ctrl+C to copy, Ctrl+X to cut.'); this._showActionPanel();
} }
} catch (error) { } catch (error) {
@@ -633,10 +637,31 @@ class Smart_select_class extends Base_tools_class {
alertify.success('Selection deleted!'); alertify.success('Selection deleted!');
} }
/**
* Show the quick-action panel for the current selection.
*/
_showActionPanel() {
var imageData = this.getLayerImageData();
var maskData = this.maskCanvas
? this.maskCanvas.toDataURL('image/png').split(',')[1]
: null;
if (maskData) {
this.selectionActions.show(imageData, maskData);
}
}
/**
* Update the current layer canvas with a base64 result from a backend operation.
*/
updateLayerWithResult(base64) {
updateLayerWithResult(base64, this);
}
/** /**
* Clear the current selection * Clear the current selection
*/ */
clearSelection() { clearSelection() {
this.selectionActions.hide();
this.currentMask = null; this.currentMask = null;
this.maskCanvas = null; this.maskCanvas = null;
this.edgeCanvas = null; this.edgeCanvas = null;
@@ -647,7 +672,7 @@ class Smart_select_class extends Base_tools_class {
} }
on_leave() { on_leave() {
// Don't clear mask when switching tools - AI inpaint needs it this.selectionActions.hide();
return []; return [];
} }
} }
+100
View File
@@ -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()