Merge pull request #58 from outis1one/claude/fervent-dirac-ldwaki
Claude/fervent dirac ldwaki
This commit is contained in:
+5
-2
@@ -42,10 +42,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Install Python dependencies — base + GPU extras
|
# Install Python dependencies — base + GPU extras
|
||||||
|
# BUILDID forces pip layers to re-run when you need fresh packages without a full --no-cache:
|
||||||
|
# BUILDID=$(date +%s) docker compose -f docker-compose.gpu.yml up --build
|
||||||
|
ARG BUILDID=1
|
||||||
COPY backend/requirements.txt .
|
COPY backend/requirements.txt .
|
||||||
COPY backend/requirements.gpu.txt .
|
COPY backend/requirements.gpu.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN echo "BUILDID=$BUILDID" && pip install --no-cache-dir -r requirements.txt
|
||||||
RUN pip install --no-cache-dir -r requirements.gpu.txt
|
RUN echo "BUILDID=$BUILDID" && pip install --no-cache-dir -r requirements.gpu.txt
|
||||||
|
|
||||||
# Smoke-test rembg (model downloads on first use)
|
# Smoke-test rembg (model downloads on first use)
|
||||||
RUN python -c "from rembg import remove; print('rembg OK')" \
|
RUN python -c "from rembg import remove; print('rembg OK')" \
|
||||||
|
|||||||
@@ -57,6 +57,12 @@ docker compose -f docker-compose.gpu.yml up -d --build
|
|||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If pip packages seem stale after a pull (e.g., wrong diffusers version), force a pip layer rebuild without re-downloading the entire PyTorch base image:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BUILDID=$(date +%s) docker compose -f docker-compose.gpu.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## AI Providers
|
## AI Providers
|
||||||
@@ -183,6 +189,52 @@ docker compose -f docker-compose.gpu.yml logs -f | grep -E "local_gpu|Error|Fail
|
|||||||
# If a private/gated model: add HF_TOKEN=hf_... to .env
|
# If a private/gated model: add HF_TOKEN=hf_... to .env
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**SAM model fails to download (DNS error / firewall blocking port 53)**
|
||||||
|
|
||||||
|
If the container can't reach `dl.fbaipublicfiles.com` (you'll see `Errno -3 Name or service not known` in the logs), download SAM directly on the host and let the bind mount make it visible to the container — no rebuild needed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ./data/models
|
||||||
|
# sudo needed if ./data/ was created by Docker (root-owned):
|
||||||
|
sudo curl -L -o ./data/models/sam_vit_b_01ec64.pth \
|
||||||
|
https://dl.fbaipublicfiles.com/segment_anything/sam_vit_b_01ec64.pth
|
||||||
|
```
|
||||||
|
|
||||||
|
The file is ~375 MB. Once it exists at `./data/models/sam_vit_b_01ec64.pth`, the container picks it up on the next startup (no rebuild required). Verify with:
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.gpu.yml logs | grep -i sam
|
||||||
|
# Should show: "SAM model loaded on cuda" (or cpu)
|
||||||
|
```
|
||||||
|
|
||||||
|
If Docker created `./data/` as root and you can't write there without `sudo`, you can also use root's curl as above — the container reads the file regardless of owner.
|
||||||
|
|
||||||
|
**AI Edit returns "model files not yet downloaded" or "Errno -3 / DNS" error**
|
||||||
|
|
||||||
|
The container's DNS is blocked (common on corporate networks or custom iptables rules), so it can't download SDXL models from HuggingFace. Two options:
|
||||||
|
|
||||||
|
*Option A — fix Docker DNS (recommended, one command):*
|
||||||
|
```bash
|
||||||
|
sudo iptables -I DOCKER-USER -p udp --dport 53 -j ACCEPT
|
||||||
|
docker compose -f docker-compose.gpu.yml restart
|
||||||
|
```
|
||||||
|
|
||||||
|
*Option B — pre-download models on the host (if iptables fix isn't possible):*
|
||||||
|
```bash
|
||||||
|
pip install huggingface-hub
|
||||||
|
|
||||||
|
# Download the inpainting model (~6.5 GB, needed for AI Edit):
|
||||||
|
huggingface-cli download diffusers/stable-diffusion-xl-1.0-inpainting-0.1 \
|
||||||
|
--cache-dir ./data/hf_cache \
|
||||||
|
--exclude "*.msgpack" "flax_*" "tf_*"
|
||||||
|
|
||||||
|
# Download the text-to-image model (~6.5 GB, needed for Text → Image):
|
||||||
|
huggingface-cli download stabilityai/stable-diffusion-xl-base-1.0 \
|
||||||
|
--cache-dir ./data/hf_cache \
|
||||||
|
--exclude "*.msgpack" "flax_*" "tf_*"
|
||||||
|
```
|
||||||
|
|
||||||
|
The models land in `./data/hf_cache/` which is bind-mounted into the container — no rebuild needed. Restart the container and the first AI Edit request loads from local disk.
|
||||||
|
|
||||||
**Out of VRAM during generation**
|
**Out of VRAM during generation**
|
||||||
- Reduce `LOCAL_GPU_MAX_PIPELINES=1` in `.env` (default 2)
|
- Reduce `LOCAL_GPU_MAX_PIPELINES=1` in `.env` (default 2)
|
||||||
- Or override to a smaller model: `HF_MODEL_TXT2IMG=runwayml/stable-diffusion-v1-5`
|
- Or override to a smaller model: `HF_MODEL_TXT2IMG=runwayml/stable-diffusion-v1-5`
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from typing import Optional
|
|||||||
import base64
|
import base64
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
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,
|
||||||
@@ -79,8 +80,19 @@ def _encode(data: bytes) -> str:
|
|||||||
|
|
||||||
def _require_remote(operation: str = None):
|
def _require_remote(operation: str = None):
|
||||||
from app.services.remote_provider import get_remote_provider
|
from app.services.remote_provider import get_remote_provider
|
||||||
|
from app.config import settings
|
||||||
provider = get_remote_provider(operation)
|
provider = get_remote_provider(operation)
|
||||||
if provider is None:
|
if provider is None:
|
||||||
|
if (settings.ai_provider or "").lower() == "local_gpu":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail=(
|
||||||
|
"local_gpu provider failed to load — diffusers may be incompatible with "
|
||||||
|
"the installed PyTorch version. Check container logs for details. "
|
||||||
|
"If you see 'torch has no attribute xpu', rebuild the container from the "
|
||||||
|
"correct branch so the pinned diffusers<0.29.0 is installed."
|
||||||
|
)
|
||||||
|
)
|
||||||
op_hint = f"AI_PROVIDER_{operation.upper()} or " if operation else ""
|
op_hint = f"AI_PROVIDER_{operation.upper()} or " if operation else ""
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=503,
|
status_code=503,
|
||||||
@@ -501,12 +513,28 @@ async def ai_edit_region(req: AiEditRegionRequest):
|
|||||||
Works with local_gpu, InvokeAI, ComfyUI, or OpenAI.
|
Works with local_gpu, InvokeAI, ComfyUI, or OpenAI.
|
||||||
"""
|
"""
|
||||||
provider = _require_remote("inpaint")
|
provider = _require_remote("inpaint")
|
||||||
|
try:
|
||||||
result_bytes = await provider.inpaint(
|
result_bytes = await provider.inpaint(
|
||||||
_decode(req.image),
|
_decode(req.image),
|
||||||
_decode(req.mask),
|
_decode(req.mask),
|
||||||
req.instruction,
|
req.instruction,
|
||||||
{"negative_prompt": req.negative_prompt, "steps": req.steps, "cfg_scale": req.cfg_scale},
|
{"negative_prompt": req.negative_prompt, "steps": req.steps, "cfg_scale": req.cfg_scale},
|
||||||
)
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
import traceback; traceback.print_exc()
|
||||||
|
msg = str(exc)
|
||||||
|
if "Errno -3" in msg or "Name or service not known" in msg or "ConnectError" in msg:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail=(
|
||||||
|
"AI model files not yet downloaded — container DNS appears to be blocked. "
|
||||||
|
"Fix: sudo iptables -I DOCKER-USER -p udp --dport 53 -j ACCEPT on the host, "
|
||||||
|
"or pre-download the model: pip install huggingface-hub && "
|
||||||
|
"huggingface-cli download diffusers/stable-diffusion-xl-1.0-inpainting-0.1 "
|
||||||
|
"--cache-dir ./data/hf_cache"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
raise HTTPException(status_code=500, detail=msg)
|
||||||
return {"result": _encode(result_bytes)}
|
return {"result": _encode(result_bytes)}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ Supported model families:
|
|||||||
sd2x → StableDiffusion2*Pipeline (SD 2.x)
|
sd2x → StableDiffusion2*Pipeline (SD 2.x)
|
||||||
sd15 → StableDiffusionPipeline (SD 1.5)
|
sd15 → StableDiffusionPipeline (SD 1.5)
|
||||||
|
|
||||||
Requires: diffusers>=0.29.0, transformers, accelerate, safetensors
|
Requires: diffusers>=0.28.0,<0.29.0, transformers, accelerate, safetensors
|
||||||
(all in requirements.gpu.txt)
|
(all in requirements.gpu.txt — pinned <0.29.0 for PyTorch 2.1.x compatibility)
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|||||||
@@ -432,7 +432,8 @@ def _build_provider(name: str) -> Optional[RemoteAIProvider]:
|
|||||||
try:
|
try:
|
||||||
from app.services.local_diffusion import get_local_diffusion_provider
|
from app.services.local_diffusion import get_local_diffusion_provider
|
||||||
return get_local_diffusion_provider(max_pipelines=settings.local_gpu_max_pipelines)
|
return get_local_diffusion_provider(max_pipelines=settings.local_gpu_max_pipelines)
|
||||||
except ImportError:
|
except (ImportError, AttributeError) as exc:
|
||||||
|
print(f"[local_gpu] Cannot load diffusion provider: {exc}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -9,9 +9,12 @@
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
# HuggingFace Diffusers ecosystem
|
# HuggingFace Diffusers ecosystem
|
||||||
# 0.29.0+ required for FLUX pipeline support
|
# Pinned <0.29.0: diffusers 0.29.0 added torch.xpu (Intel GPU) which fails on
|
||||||
diffusers>=0.29.0
|
# PyTorch 2.1.x with "AttributeError: module 'torch' has no attribute 'xpu'".
|
||||||
transformers>=4.40.0
|
# Upgrade the base image in Dockerfile.gpu to pytorch 2.4+ before lifting this pin.
|
||||||
|
# (FLUX support requires diffusers>=0.29 + PyTorch>=2.4; SDXL/SD works fine here.)
|
||||||
|
diffusers>=0.28.0,<0.29.0
|
||||||
|
transformers>=4.36.0,<4.40.0
|
||||||
accelerate>=0.27.0
|
accelerate>=0.27.0
|
||||||
huggingface-hub>=0.23.0
|
huggingface-hub>=0.23.0
|
||||||
safetensors>=0.4.0
|
safetensors>=0.4.0
|
||||||
|
|||||||
+16
-8
@@ -62,14 +62,23 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile.gpu
|
dockerfile: Dockerfile.gpu
|
||||||
|
args:
|
||||||
|
# Increment BUILDID to force pip layers to re-run without full --no-cache:
|
||||||
|
# BUILDID=$(date +%s) docker compose -f docker-compose.gpu.yml up --build
|
||||||
|
BUILDID: ${BUILDID:-1}
|
||||||
container_name: editmaskwithai-gpu
|
container_name: editmaskwithai-gpu
|
||||||
ports:
|
ports:
|
||||||
- "${PORT:-3080}:8000"
|
- "${PORT:-3080}:8000"
|
||||||
volumes:
|
volumes:
|
||||||
# Persistent project data
|
# Persistent project data
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
# HuggingFace model cache — keeps downloaded models across rebuilds (~5-20 GB)
|
# HuggingFace model cache — bind mount so models can be pre-downloaded on the host.
|
||||||
- hf_model_cache:/root/.cache/huggingface
|
# If container DNS is blocked, download on the host and the container picks them up:
|
||||||
|
# pip install huggingface-hub
|
||||||
|
# huggingface-cli download diffusers/stable-diffusion-xl-1.0-inpainting-0.1 \
|
||||||
|
# --cache-dir ./data/hf_cache
|
||||||
|
# To free disk space: rm -rf ./data/hf_cache
|
||||||
|
- ./data/hf_cache:/root/.cache/huggingface
|
||||||
# Scripts (for exec access)
|
# Scripts (for exec access)
|
||||||
- ./scripts:/scripts
|
- ./scripts:/scripts
|
||||||
environment:
|
environment:
|
||||||
@@ -126,14 +135,13 @@ services:
|
|||||||
count: 1
|
count: 1
|
||||||
capabilities: [gpu]
|
capabilities: [gpu]
|
||||||
|
|
||||||
# Reliable DNS for HuggingFace Hub downloads and external API calls
|
# DNS: try host resolver first (works on most networks including corporate/VPN),
|
||||||
|
# fall back to Cloudflare then Google public resolvers.
|
||||||
|
# If all three fail (Errno -3), your firewall is blocking port 53 UDP from Docker.
|
||||||
|
# Fix on the host: sudo iptables -I DOCKER-USER -p udp --dport 53 -j ACCEPT
|
||||||
dns:
|
dns:
|
||||||
|
- 1.1.1.1
|
||||||
- 8.8.8.8
|
- 8.8.8.8
|
||||||
- 8.8.4.4
|
- 8.8.4.4
|
||||||
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
|
||||||
hf_model_cache:
|
|
||||||
# Survives docker compose down; delete manually to free disk space:
|
|
||||||
# docker volume rm editmaskwithai_hf_model_cache
|
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* ProviderBadge — small DOM element showing the active AI provider.
|
* ProviderBadge — compact status indicator in the left toolbar footer.
|
||||||
* Inserted into the toolbar footer on app load.
|
* Shows a dot + 3-5 char label; all details in the tooltip.
|
||||||
*
|
|
||||||
* Green = remote provider healthy (or local_gpu active)
|
|
||||||
* Yellow = provider configured but unhealthy/unreachable
|
|
||||||
* Grey = local only (LaMa + OpenCV)
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { getCapabilities } from '../../api/capabilities.js';
|
import { getCapabilities } from '../../api/capabilities.js';
|
||||||
@@ -15,84 +11,74 @@ export async function mountProviderBadge(container) {
|
|||||||
var badge = document.createElement('div');
|
var badge = document.createElement('div');
|
||||||
badge.id = 'provider-badge';
|
badge.id = 'provider-badge';
|
||||||
badge.style.cssText = [
|
badge.style.cssText = [
|
||||||
'display:inline-flex', 'align-items:center', 'gap:5px',
|
'display:flex', 'flex-direction:column', 'align-items:center', 'gap:2px',
|
||||||
'padding:3px 8px', 'border-radius:10px',
|
'padding:4px 2px 4px',
|
||||||
'font-size:11px', 'font-family:sans-serif',
|
'font-size:9px', 'font-family:sans-serif', 'line-height:1.2',
|
||||||
'cursor:default', 'user-select:none',
|
'cursor:default', 'user-select:none',
|
||||||
'margin:4px', 'opacity:0.85',
|
'width:100%', 'box-sizing:border-box',
|
||||||
|
'text-align:center', 'word-break:break-word',
|
||||||
].join(';');
|
].join(';');
|
||||||
|
|
||||||
var dot = document.createElement('span');
|
var dot = document.createElement('span');
|
||||||
dot.style.cssText = 'width:7px;height:7px;border-radius:50%;display:inline-block;';
|
dot.style.cssText = 'width:8px;height:8px;border-radius:50%;display:block;flex-shrink:0;';
|
||||||
|
|
||||||
var label = document.createElement('span');
|
var label = document.createElement('span');
|
||||||
|
label.style.cssText = 'color:inherit;max-width:36px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:block;';
|
||||||
|
|
||||||
var remote = caps.remote || {};
|
var remote = caps.remote || {};
|
||||||
var local = caps.local || {};
|
var local = caps.local || {};
|
||||||
|
|
||||||
if (remote.provider === 'local_gpu') {
|
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 gpuName = _shortGpuName(local.gpu_device);
|
||||||
var tier = local.gpu_tier || '';
|
var tier = local.gpu_tier || '';
|
||||||
|
|
||||||
if (remote.healthy) {
|
if (remote.healthy) {
|
||||||
dot.style.background = '#44cc44';
|
dot.style.background = '#44cc44';
|
||||||
badge.style.background = '#1a2a1a';
|
|
||||||
badge.style.color = '#aaffaa';
|
badge.style.color = '#aaffaa';
|
||||||
label.textContent = 'GPU · ' + tier + ' · ' + gpuName;
|
label.textContent = _shortTier(tier);
|
||||||
|
|
||||||
var flagList = [
|
var flagList = [
|
||||||
local.gpu_fp16 && 'fp16',
|
local.gpu_fp16 && 'fp16',
|
||||||
local.gpu_bf16 && 'bf16',
|
local.gpu_bf16 && 'bf16',
|
||||||
local.gpu_fp8 && 'fp8',
|
local.gpu_fp8 && 'fp8',
|
||||||
local.gpu_tensor_cores && 'tensor-cores',
|
local.gpu_tensor_cores && 'TC',
|
||||||
].filter(Boolean).join(' ');
|
].filter(Boolean).join(' ');
|
||||||
|
|
||||||
badge.title = [
|
badge.title = [
|
||||||
local.gpu_device || gpuName,
|
gpuName,
|
||||||
'VRAM: ' + local.gpu_vram_total + ' GB total ' + local.gpu_vram_free + ' GB free',
|
'VRAM: ' + local.gpu_vram_total + ' GB total / ' + local.gpu_vram_free + ' GB free',
|
||||||
'Compute: CC ' + local.gpu_cc + ' Eff: ' + local.gpu_eff_vram + ' GB',
|
'CC: ' + local.gpu_cc + ' Eff VRAM: ' + local.gpu_eff_vram + ' GB',
|
||||||
flagList ? 'Features: ' + flagList : '',
|
flagList ? 'Flags: ' + flagList : '',
|
||||||
'Capabilities: ' + (local.local_gpu_capabilities || []).join(', '),
|
tier ? 'Tier: ' + tier : '',
|
||||||
(local.local_gpu_warnings || []).length
|
(local.local_gpu_warnings || []).length
|
||||||
? '\nWarnings:\n' + local.local_gpu_warnings.join('\n')
|
? 'Warnings:\n' + local.local_gpu_warnings.join('\n')
|
||||||
: '',
|
: '',
|
||||||
].filter(Boolean).join('\n');
|
].filter(Boolean).join('\n');
|
||||||
} else {
|
} else {
|
||||||
dot.style.background = '#ffaa00';
|
dot.style.background = '#ffaa00';
|
||||||
badge.style.background = '#2a2000';
|
|
||||||
badge.style.color = '#ffdd88';
|
badge.style.color = '#ffdd88';
|
||||||
label.textContent = 'Local GPU (not ready)';
|
label.textContent = 'GPU?';
|
||||||
badge.title = 'local_gpu is configured but the diffusers library may not be installed.\nCheck container logs for details.';
|
badge.title = 'local_gpu configured but diffusers may not be installed.\nCheck container logs.';
|
||||||
}
|
}
|
||||||
} else if (remote.provider && remote.healthy) {
|
} else if (remote.provider && remote.healthy) {
|
||||||
dot.style.background = '#44cc44';
|
dot.style.background = '#44cc44';
|
||||||
badge.style.background = '#1a2a1a';
|
|
||||||
badge.style.color = '#aaffaa';
|
badge.style.color = '#aaffaa';
|
||||||
|
label.textContent = _shortProvider(remote.provider);
|
||||||
var overrides = remote.overrides || {};
|
|
||||||
var overrideEntries = Object.entries(overrides).filter(([, v]) => v);
|
|
||||||
var overrideStr = overrideEntries.length
|
|
||||||
? ' · ' + overrideEntries.map(([k, v]) => k + '→' + v).join(', ')
|
|
||||||
: '';
|
|
||||||
label.textContent = remote.provider + overrideStr + (local.gpu_detected ? ' · GPU' : '');
|
|
||||||
|
|
||||||
var opLines = Object.entries(remote.operations || {})
|
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 = ('Provider: ' + remote.provider) + (opLines ? '\n' + opLines : '');
|
||||||
} else if (remote.provider && !remote.healthy) {
|
} else if (remote.provider && !remote.healthy) {
|
||||||
dot.style.background = '#ffaa00';
|
dot.style.background = '#ffaa00';
|
||||||
badge.style.background = '#2a2000';
|
|
||||||
badge.style.color = '#ffdd88';
|
badge.style.color = '#ffdd88';
|
||||||
label.textContent = remote.provider + ' (offline)';
|
label.textContent = _shortProvider(remote.provider) + '?';
|
||||||
badge.title = remote.provider + ' is configured but not reachable. Check your .env URL.';
|
badge.title = remote.provider + ' configured but not reachable.\nCheck your .env URL.';
|
||||||
} else {
|
} else {
|
||||||
dot.style.background = '#888888';
|
dot.style.background = '#888888';
|
||||||
badge.style.background = '#1a1a1a';
|
|
||||||
badge.style.color = '#aaaaaa';
|
badge.style.color = '#aaaaaa';
|
||||||
label.textContent = 'Local' + (local.lama ? ' · LaMa' : '') + (local.gpu_detected ? ' · GPU' : '');
|
label.textContent = local.lama ? 'LaMa' : 'Local';
|
||||||
badge.title = 'Local only. Set AI_PROVIDER in .env to enable generative tools.';
|
badge.title = 'Local only (no generative AI).\nSet AI_PROVIDER in .env to enable.';
|
||||||
}
|
}
|
||||||
|
|
||||||
badge.appendChild(dot);
|
badge.appendChild(dot);
|
||||||
@@ -108,6 +94,25 @@ export async function mountProviderBadge(container) {
|
|||||||
function _shortGpuName(name) {
|
function _shortGpuName(name) {
|
||||||
return (name || 'GPU')
|
return (name || 'GPU')
|
||||||
.replace(/^NVIDIA GeForce\s+/i, '')
|
.replace(/^NVIDIA GeForce\s+/i, '')
|
||||||
|
.replace(/^NVIDIA Quadro\s+/i, '')
|
||||||
.replace(/^NVIDIA\s+/i, '')
|
.replace(/^NVIDIA\s+/i, '')
|
||||||
.replace(/^AMD Radeon\s+/i, '');
|
.replace(/^AMD Radeon\s+/i, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _shortTier(tier) {
|
||||||
|
if (!tier) return 'GPU';
|
||||||
|
// sdxl_offload → SDXL, flux → FLUX, sd15 → SD15
|
||||||
|
return tier
|
||||||
|
.replace(/_offload$/, '')
|
||||||
|
.replace(/_cpu$/, '')
|
||||||
|
.toUpperCase()
|
||||||
|
.slice(0, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _shortProvider(p) {
|
||||||
|
var map = {
|
||||||
|
openai: 'OAI', replicate: 'Rep', stability: 'Stab',
|
||||||
|
invokeai: 'Inv', comfyui: 'CUI', local_gpu: 'GPU',
|
||||||
|
};
|
||||||
|
return map[p] || (p || 'AI').slice(0, 4);
|
||||||
|
}
|
||||||
|
|||||||
@@ -52,7 +52,8 @@ export class SelectionActions {
|
|||||||
'font-family:sans-serif',
|
'font-family:sans-serif',
|
||||||
'font-size:12px',
|
'font-size:12px',
|
||||||
'color:#d0d0e0',
|
'color:#d0d0e0',
|
||||||
'min-width:340px',
|
'min-width:360px',
|
||||||
|
'max-width:420px',
|
||||||
'box-shadow:0 8px 32px rgba(0,0,0,0.7)',
|
'box-shadow:0 8px 32px rgba(0,0,0,0.7)',
|
||||||
'display:flex',
|
'display:flex',
|
||||||
'flex-direction:column',
|
'flex-direction:column',
|
||||||
@@ -61,37 +62,56 @@ export class SelectionActions {
|
|||||||
|
|
||||||
// ── Title row ────────────────────────────────────────────────────────
|
// ── Title row ────────────────────────────────────────────────────────
|
||||||
var titleRow = document.createElement('div');
|
var titleRow = document.createElement('div');
|
||||||
titleRow.style.cssText = 'display:flex;align-items:center;justify-content:space-between;margin-bottom:4px';
|
titleRow.style.cssText = 'display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:2px';
|
||||||
var title = document.createElement('span');
|
var titleBlock = document.createElement('div');
|
||||||
title.textContent = 'Selection Actions';
|
var title = document.createElement('div');
|
||||||
title.style.cssText = 'font-size:13px;font-weight:bold;color:#aaaaff';
|
title.textContent = 'Selection ready';
|
||||||
|
title.style.cssText = 'font-size:13px;font-weight:bold;color:#aaaaff;line-height:1.3';
|
||||||
|
var subtitle = document.createElement('div');
|
||||||
|
subtitle.textContent = 'Nothing has changed yet — choose an action below';
|
||||||
|
subtitle.style.cssText = 'font-size:10px;color:#7777aa;margin-top:1px';
|
||||||
|
titleBlock.appendChild(title);
|
||||||
|
titleBlock.appendChild(subtitle);
|
||||||
var closeX = document.createElement('button');
|
var closeX = document.createElement('button');
|
||||||
closeX.textContent = '✕';
|
closeX.textContent = '✕';
|
||||||
closeX.style.cssText = 'background:none;border:none;color:#666;cursor:pointer;font-size:14px;padding:0;line-height:1';
|
closeX.style.cssText = 'background:none;border:none;color:#666;cursor:pointer;font-size:14px;padding:0 0 0 8px;line-height:1;flex-shrink:0';
|
||||||
closeX.title = 'Close panel (keep selection)';
|
closeX.title = 'Dismiss (keeps your selection active)';
|
||||||
closeX.onclick = () => this.hide();
|
closeX.onclick = () => this.hide();
|
||||||
titleRow.appendChild(title);
|
titleRow.appendChild(titleBlock);
|
||||||
titleRow.appendChild(closeX);
|
titleRow.appendChild(closeX);
|
||||||
panel.appendChild(titleRow);
|
panel.appendChild(titleRow);
|
||||||
|
|
||||||
// ── Scale by % ───────────────────────────────────────────────────────
|
// ── Section: AI Actions ──────────────────────────────────────────────
|
||||||
|
panel.appendChild(_sectionLabel('AI Actions'));
|
||||||
|
|
||||||
|
// Scale by %
|
||||||
|
var scaleWrap = document.createElement('div');
|
||||||
|
scaleWrap.style.cssText = 'background:#16213e;border-radius:7px;padding:7px 10px';
|
||||||
var scaleRow = document.createElement('div');
|
var scaleRow = document.createElement('div');
|
||||||
scaleRow.style.cssText = 'display:flex;align-items:center;gap:6px;background:#16213e;border-radius:7px;padding:7px 10px';
|
scaleRow.style.cssText = 'display:flex;align-items:center;gap:6px';
|
||||||
var scaleLabel = document.createElement('span');
|
var scaleLabel = document.createElement('span');
|
||||||
scaleLabel.textContent = 'Scale by';
|
scaleLabel.textContent = 'Scale object by';
|
||||||
scaleLabel.style.color = '#aaa';
|
scaleLabel.style.color = '#aaa';
|
||||||
var scaleInput = document.createElement('input');
|
var scaleInput = document.createElement('input');
|
||||||
scaleInput.type = 'number';
|
scaleInput.type = 'number';
|
||||||
scaleInput.value = '103';
|
scaleInput.value = '103';
|
||||||
scaleInput.min = '1';
|
scaleInput.min = '1';
|
||||||
scaleInput.max = '500';
|
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';
|
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');
|
var scaleUnit = document.createElement('span');
|
||||||
scaleUnit.textContent = '%';
|
scaleUnit.textContent = '%';
|
||||||
scaleUnit.style.color = '#888';
|
scaleUnit.style.color = '#888';
|
||||||
|
var scaleHint = document.createElement('span');
|
||||||
|
scaleHint.style.cssText = 'color:#6688aa;font-size:10px;margin-left:2px';
|
||||||
|
scaleHint.textContent = '= 3% bigger';
|
||||||
var scaleBtn = _btn('Apply', '#1a2a4a', '#8aacff');
|
var scaleBtn = _btn('Apply', '#1a2a4a', '#8aacff');
|
||||||
scaleBtn.style.marginLeft = 'auto';
|
scaleBtn.style.marginLeft = 'auto';
|
||||||
|
scaleInput.addEventListener('input', () => {
|
||||||
|
var v = parseFloat(scaleInput.value);
|
||||||
|
if (isNaN(v) || v === 100) scaleHint.textContent = '= no change';
|
||||||
|
else if (v > 100) scaleHint.textContent = '= ' + (v - 100).toFixed(0) + '% bigger';
|
||||||
|
else scaleHint.textContent = '= ' + (100 - v).toFixed(0) + '% smaller';
|
||||||
|
});
|
||||||
scaleBtn.onclick = () => {
|
scaleBtn.onclick = () => {
|
||||||
var pct = parseFloat(scaleInput.value) || 103;
|
var pct = parseFloat(scaleInput.value) || 103;
|
||||||
this._scaleSelection(pct);
|
this._scaleSelection(pct);
|
||||||
@@ -99,57 +119,73 @@ export class SelectionActions {
|
|||||||
scaleRow.appendChild(scaleLabel);
|
scaleRow.appendChild(scaleLabel);
|
||||||
scaleRow.appendChild(scaleInput);
|
scaleRow.appendChild(scaleInput);
|
||||||
scaleRow.appendChild(scaleUnit);
|
scaleRow.appendChild(scaleUnit);
|
||||||
|
scaleRow.appendChild(scaleHint);
|
||||||
scaleRow.appendChild(scaleBtn);
|
scaleRow.appendChild(scaleBtn);
|
||||||
panel.appendChild(scaleRow);
|
var scaleDesc = document.createElement('div');
|
||||||
|
scaleDesc.textContent = 'Moves the selected object, then AI fills the vacated area';
|
||||||
|
scaleDesc.style.cssText = 'color:#5566aa;font-size:10px;margin-top:4px';
|
||||||
|
scaleWrap.appendChild(scaleRow);
|
||||||
|
scaleWrap.appendChild(scaleDesc);
|
||||||
|
panel.appendChild(scaleWrap);
|
||||||
|
|
||||||
// ── AI actions ───────────────────────────────────────────────────────
|
// Make less symmetrical
|
||||||
panel.appendChild(
|
panel.appendChild(_actionCard(
|
||||||
_actionBtn('Make less symmetrical', '#1c1a2e', '#cc99ff',
|
'Make less symmetrical',
|
||||||
'⟳ AI redraws the region with natural, organic asymmetry',
|
'#1c1a2e', '#cc99ff',
|
||||||
() => this._makeAsymmetric())
|
'AI redraws the selection with subtle, natural imperfections',
|
||||||
);
|
() => this._makeAsymmetric()
|
||||||
panel.appendChild(
|
));
|
||||||
_actionBtn('Replace with clipboard', '#1a2a1a', '#88dd88',
|
|
||||||
'📋 Scales your clipboard image into the selection shape',
|
|
||||||
() => this._pasteFromClipboard())
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── Custom AI edit prompt ─────────────────────────────────────────────
|
// Replace with clipboard
|
||||||
|
panel.appendChild(_actionCard(
|
||||||
|
'Replace with clipboard',
|
||||||
|
'#1a2a1a', '#88dd88',
|
||||||
|
'Scales your clipboard image to fit inside the selection shape',
|
||||||
|
() => this._pasteFromClipboard()
|
||||||
|
));
|
||||||
|
|
||||||
|
// Custom AI edit prompt
|
||||||
|
var aiWrap = document.createElement('div');
|
||||||
|
aiWrap.style.cssText = 'background:#16213e;border-radius:7px;padding:7px 10px';
|
||||||
var aiRow = document.createElement('div');
|
var aiRow = document.createElement('div');
|
||||||
aiRow.style.cssText = 'display:flex;align-items:center;gap:6px;background:#16213e;border-radius:7px;padding:7px 10px';
|
aiRow.style.cssText = 'display:flex;align-items:center;gap:6px';
|
||||||
var aiInput = document.createElement('input');
|
var aiInput = document.createElement('input');
|
||||||
aiInput.type = 'text';
|
aiInput.type = 'text';
|
||||||
aiInput.placeholder = 'AI edit: "add a scar", "make it look aged", …';
|
aiInput.placeholder = '"add a scar", "make it look aged", "blue eyes" …';
|
||||||
aiInput.style.cssText = 'flex:1;background:#0f0f1a;color:#fff;border:1px solid #4a4a8a;border-radius:4px;padding:3px 7px;font-size:11px';
|
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');
|
var aiBtn = _btn('AI Edit', '#1a2a4a', '#8aacff');
|
||||||
aiBtn.onclick = () => {
|
aiBtn.onclick = () => {
|
||||||
var instruction = aiInput.value.trim();
|
var instruction = aiInput.value.trim();
|
||||||
if (!instruction) { alertify.warning('Enter an AI edit instruction first.'); return; }
|
if (!instruction) { alertify.warning('Enter an instruction first — describe what to change.'); return; }
|
||||||
this._aiEditRegion(instruction);
|
this._aiEditRegion(instruction);
|
||||||
};
|
};
|
||||||
|
aiInput.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') aiBtn.click();
|
||||||
|
});
|
||||||
|
var aiDesc = document.createElement('div');
|
||||||
|
aiDesc.textContent = 'Inpaints the selected region according to your description';
|
||||||
|
aiDesc.style.cssText = 'color:#5566aa;font-size:10px;margin-top:4px';
|
||||||
aiRow.appendChild(aiInput);
|
aiRow.appendChild(aiInput);
|
||||||
aiRow.appendChild(aiBtn);
|
aiRow.appendChild(aiBtn);
|
||||||
panel.appendChild(aiRow);
|
aiWrap.appendChild(aiRow);
|
||||||
|
aiWrap.appendChild(aiDesc);
|
||||||
|
panel.appendChild(aiWrap);
|
||||||
|
|
||||||
// ── Divider ──────────────────────────────────────────────────────────
|
// ── Section: Classic Tools ────────────────────────────────────────────
|
||||||
var hr = document.createElement('div');
|
panel.appendChild(_sectionLabel('Classic Tools'));
|
||||||
hr.style.cssText = 'border-top:1px solid #2a2a4a;margin:2px 0';
|
|
||||||
panel.appendChild(hr);
|
|
||||||
|
|
||||||
// ── Classic selection ops ─────────────────────────────────────────────
|
|
||||||
var classicRow = document.createElement('div');
|
var classicRow = document.createElement('div');
|
||||||
classicRow.style.cssText = 'display:flex;gap:6px';
|
classicRow.style.cssText = 'display:flex;gap:6px';
|
||||||
var copyBtn = _btn('Copy to layer', '#1a2a1a', '#88cc88');
|
var copyBtn = _btn('Copy to layer', '#1a2a1a', '#88cc88');
|
||||||
copyBtn.style.flex = '1';
|
copyBtn.style.flex = '1';
|
||||||
copyBtn.title = 'Ctrl+C';
|
copyBtn.title = 'Lift a copy of the selection onto a new layer (non-destructive)';
|
||||||
copyBtn.onclick = () => { this.tool.copyToLayer(); this.hide(); };
|
copyBtn.onclick = () => { this.tool.copyToLayer(); this.hide(); };
|
||||||
var cutBtn = _btn('Cut to layer', '#2a1a1a', '#cc8888');
|
var cutBtn = _btn('Cut to layer', '#2a1a1a', '#cc8888');
|
||||||
cutBtn.style.flex = '1';
|
cutBtn.style.flex = '1';
|
||||||
cutBtn.title = 'Ctrl+X';
|
cutBtn.title = 'Cut the selection to a new layer (erases from original)';
|
||||||
cutBtn.onclick = () => { this.tool.cutToLayer(); this.hide(); };
|
cutBtn.onclick = () => { this.tool.cutToLayer(); this.hide(); };
|
||||||
var delBtn = _btn('Erase', '#2a1a1a', '#ff7766');
|
var delBtn = _btn('Erase', '#2a1a1a', '#ff7766');
|
||||||
delBtn.style.flex = '0 0 auto';
|
delBtn.style.flex = '0 0 auto';
|
||||||
delBtn.title = 'Delete key';
|
delBtn.title = 'Delete the selected pixels (transparent / background color)';
|
||||||
delBtn.onclick = () => { this.tool.deleteSelection(); this.hide(); };
|
delBtn.onclick = () => { this.tool.deleteSelection(); this.hide(); };
|
||||||
classicRow.appendChild(copyBtn);
|
classicRow.appendChild(copyBtn);
|
||||||
classicRow.appendChild(cutBtn);
|
classicRow.appendChild(cutBtn);
|
||||||
@@ -330,12 +366,28 @@ function _btn(text, bg, color) {
|
|||||||
return b;
|
return b;
|
||||||
}
|
}
|
||||||
|
|
||||||
function _actionBtn(text, bg, color, tooltip, handler) {
|
function _actionCard(text, bg, color, description, handler) {
|
||||||
var b = _btn(text, bg, color);
|
var wrap = document.createElement('div');
|
||||||
b.style.cssText += ';display:block;width:100%;text-align:left;padding:7px 10px;border-radius:7px;font-size:12px';
|
wrap.style.cssText = 'background:' + bg + ';border-radius:7px;padding:7px 10px;cursor:pointer;border:1px solid transparent';
|
||||||
if (tooltip) b.title = tooltip;
|
wrap.addEventListener('mouseenter', () => { wrap.style.borderColor = color; });
|
||||||
b.onclick = handler;
|
wrap.addEventListener('mouseleave', () => { wrap.style.borderColor = 'transparent'; });
|
||||||
return b;
|
wrap.onclick = handler;
|
||||||
|
var label = document.createElement('div');
|
||||||
|
label.textContent = text;
|
||||||
|
label.style.cssText = 'color:' + color + ';font-size:12px;font-weight:500;pointer-events:none';
|
||||||
|
var desc = document.createElement('div');
|
||||||
|
desc.textContent = description;
|
||||||
|
desc.style.cssText = 'color:#5566aa;font-size:10px;margin-top:3px;pointer-events:none';
|
||||||
|
wrap.appendChild(label);
|
||||||
|
wrap.appendChild(desc);
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _sectionLabel(text) {
|
||||||
|
var el = document.createElement('div');
|
||||||
|
el.style.cssText = 'font-size:9px;font-weight:bold;letter-spacing:0.08em;color:#555577;text-transform:uppercase;margin-top:2px';
|
||||||
|
el.textContent = text;
|
||||||
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function _post(path, body) {
|
async function _post(path, body) {
|
||||||
|
|||||||
Reference in New Issue
Block a user