diff --git a/README.md b/README.md index d28a619..d21bbd1 100644 --- a/README.md +++ b/README.md @@ -235,6 +235,8 @@ SparkyUI includes a **StabilityMatrix-style Model Manager** - a lightweight Fast **Access:** `http://:8189` **Features:** +- **Gallery** - browse generated photos from ComfyUI's `output/` in a large desktop grid, + click for a full-size lightbox view, and **permanently delete** photos (with confirm). - **Browse CivitAI** - search the CivitAI catalog in a thumbnail grid (filter by type, sort, period, NSFW toggle) and **click a model to download it** - no URL pasting needed. Multi-version models get a version picker on the card. @@ -253,6 +255,12 @@ SparkyUI includes a **StabilityMatrix-style Model Manager** - a lightweight Fast by type (`checkpoints/`, `loras/`, `vae/`, `controlnet/`, `upscale_models/`, …) - these are created automatically on first start - ComfyUI mounts the same `models/` folder read-only, so new downloads appear in its loaders +- Mounts the shared `output/` folder read-write for the Gallery's delete feature + +**Device-aware entry point:** open `http://:8189/start` and it detects your device - +**phones** are sent to the mobile UI (ComfyUIMini), **desktops** land on the Model Manager's +Gallery. Append `?force=mobile` or `?force=desktop` to override. Bookmark `/start` as your +single SparkyUI link. **Build only the Model Manager** (if the rest is already built): ```bash diff --git a/docker-compose.yml b/docker-compose.yml index c59668c..e52ad3b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -148,11 +148,17 @@ services: environment: MODELS_DIR: /models DATA_DIR: /data + OUTPUT_DIR: /output + # Ports used by the device-routing landing page (/start) + COMFYUI_PORT: "${COMFYUI_PORT:-8188}" + COMFYUIMINI_PORT: "${COMFYUIMINI_PORT:-3000}" volumes: # Shared models dir - read-WRITE here so downloads land on the host. # ComfyUI mounts the same host folder read-only and picks up new files. - ${COMFYUI_HOST_PATH:-.}/models:/models + # Generated images - read-WRITE so the gallery can delete photos. + - ${SPARKYUI_DATA_PATH:-.}/output:/output # Persistent SQLite DB (sources, API keys, download history) - ${SPARKYUI_DATA_PATH:-.}/sparkyui-data:/data diff --git a/model-manager/app/config.py b/model-manager/app/config.py index c231682..5458e12 100644 --- a/model-manager/app/config.py +++ b/model-manager/app/config.py @@ -10,8 +10,18 @@ MODELS_DIR = Path(os.environ.get("MODELS_DIR", "/models")).resolve() # Persistent data dir for the SQLite database. DATA_DIR = Path(os.environ.get("DATA_DIR", "/data")).resolve() +# Generated images directory (ComfyUI output), mounted read-write for the gallery. +OUTPUT_DIR = Path(os.environ.get("OUTPUT_DIR", "/output")).resolve() + DB_PATH = DATA_DIR / "manager.db" +# Ports of the sibling services, used by the device-routing landing page. +COMFYUI_PORT = os.environ.get("COMFYUI_PORT", "8188") +COMFYUIMINI_PORT = os.environ.get("COMFYUIMINI_PORT", "3000") + +# Image file types shown in the gallery. +IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"} + # StabilityMatrix-style model types mapped to ComfyUI's standard `models/` subfolders. # `key` is the stable id used by the API/UI; `folder` is the on-disk subdirectory. MODEL_TYPES: list[dict[str, str]] = [ @@ -51,3 +61,16 @@ def ensure_dirs() -> None: MODELS_DIR.mkdir(parents=True, exist_ok=True) for t in MODEL_TYPES: (MODELS_DIR / t["folder"]).mkdir(parents=True, exist_ok=True) + + +def safe_output_path(rel_path: str) -> Path: + """Resolve a gallery-relative path to an absolute one under OUTPUT_DIR. + + Raises ValueError on any attempt to escape the output directory. + """ + # Normalize and strip leading separators so it's treated as relative. + rel = rel_path.replace("\\", "/").lstrip("/") + target = (OUTPUT_DIR / rel).resolve() + if target != OUTPUT_DIR and not str(target).startswith(str(OUTPUT_DIR) + os.sep): + raise ValueError("Path is outside the output directory") + return target diff --git a/model-manager/app/main.py b/model-manager/app/main.py index 0953dd7..e80de5d 100644 --- a/model-manager/app/main.py +++ b/model-manager/app/main.py @@ -13,11 +13,16 @@ from pydantic import BaseModel from . import db, downloader, registries from .config import ( + COMFYUI_PORT, + COMFYUIMINI_PORT, + IMAGE_EXTS, KEY_BY_FOLDER, MODEL_TYPES, MODELS_DIR, + OUTPUT_DIR, TYPE_BY_KEY, ensure_dirs, + safe_output_path, ) STATIC_DIR = Path(__file__).parent / "static" @@ -214,6 +219,78 @@ def delete_download(download_id: int) -> dict: return {"removed": download_id} +# ---- gallery (generated photos) ------------------------------------------- + +@app.get("/api/gallery") +def list_gallery(limit: int = 60, offset: int = 0) -> dict: + """List generated images under OUTPUT_DIR, newest first, paginated.""" + files: list[dict] = [] + if OUTPUT_DIR.is_dir(): + for path in OUTPUT_DIR.rglob("*"): + if not path.is_file() or path.suffix.lower() not in IMAGE_EXTS: + continue + rel = path.relative_to(OUTPUT_DIR).as_posix() + stat = path.stat() + files.append({ + "path": rel, + "name": path.name, + "subfolder": path.parent.relative_to(OUTPUT_DIR).as_posix() + if path.parent != OUTPUT_DIR else "", + "size": stat.st_size, + "mtime": stat.st_mtime, + }) + files.sort(key=lambda f: f["mtime"], reverse=True) + total = len(files) + page = files[offset:offset + max(1, min(limit, 500))] + return {"items": page, "total": total, + "offset": offset, "returned": len(page)} + + +@app.get("/gallery/file") +def gallery_file(path: str): + try: + target = safe_output_path(path) + except ValueError: + raise HTTPException(400, "Invalid path") + if not target.is_file() or target.suffix.lower() not in IMAGE_EXTS: + raise HTTPException(404, "Image not found") + return FileResponse(target) + + +class DeletePhotoIn(BaseModel): + path: str + + +@app.delete("/api/gallery") +def delete_photo(body: DeletePhotoIn) -> dict: + try: + target = safe_output_path(body.path) + except ValueError: + raise HTTPException(400, "Invalid path") + if not target.is_file(): + raise HTTPException(404, "Image not found") + try: + target.unlink() + except PermissionError: + raise HTTPException( + 403, + "Permission denied (file is in a folder owned by another user). " + "Run ComfyUI as the same UID, or remove it from a host shell.") + return {"deleted": body.path} + + +# ---- device routing / ui config ------------------------------------------- + +@app.get("/api/ui-config") +def ui_config() -> dict: + return {"comfyui_port": COMFYUI_PORT, "comfyuimini_port": COMFYUIMINI_PORT} + + +@app.get("/start") +def start() -> FileResponse: + return FileResponse(STATIC_DIR / "start.html") + + # ---- static UI ------------------------------------------------------------ @app.get("/") diff --git a/model-manager/app/static/app.js b/model-manager/app/static/app.js index 309c43e..5f11e71 100644 --- a/model-manager/app/static/app.js +++ b/model-manager/app/static/app.js @@ -38,6 +38,7 @@ function showView(name) { $$(".nav-item").forEach((b) => b.classList.toggle("active", b.dataset.view === name)); $$(".view").forEach((v) => v.classList.toggle("active", v.id === `view-${name}`)); if (name === "installed") loadModels(); + if (name === "gallery") loadGallery(true); if (name === "browse" && !browseState.loaded) runSearch(true); if (name === "download") { loadDownloads(); startDownloadsPolling(); } else stopDownloadsPolling(); @@ -244,6 +245,113 @@ function stopDownloadsPolling() { if (downloadsTimer) { clearInterval(downloadsTimer); downloadsTimer = null; } } +// ---- gallery (generated photos) ------------------------------------------- + +const galleryState = { offset: 0, limit: 60, total: 0 }; + +async function loadGallery(reset) { + const grid = $("#galleryGrid"); + const moreBtn = $("#galleryMore"); + if (reset) { + galleryState.offset = 0; + grid.innerHTML = `

Loading…

`; + } else { + moreBtn.textContent = "Loading…"; + } + try { + const data = await api( + `/api/gallery?limit=${galleryState.limit}&offset=${galleryState.offset}`); + if (reset) grid.innerHTML = ""; + if (!data.items.length && reset) { + grid.innerHTML = `

No generated photos yet.

`; + } else { + for (const p of data.items) grid.appendChild(renderPhotoCard(p)); + } + galleryState.total = data.total; + galleryState.offset += data.returned; + const hasMore = galleryState.offset < galleryState.total; + moreBtn.style.display = hasMore ? "" : "none"; + moreBtn.textContent = "Load more"; + } catch (err) { + if (reset) grid.innerHTML = `

Error: ${err.message}

`; + moreBtn.textContent = "Load more"; + } +} + +function photoUrl(p) { + return `/gallery/file?path=${encodeURIComponent(p.path)}`; +} + +function renderPhotoCard(p) { + const card = document.createElement("div"); + card.className = "photo-card"; + + const img = document.createElement("img"); + img.loading = "lazy"; + img.src = photoUrl(p); + img.alt = p.name; + + const del = document.createElement("button"); + del.className = "p-del"; + del.title = "Delete"; + del.textContent = "🗑"; + del.addEventListener("click", (e) => { e.stopPropagation(); deletePhoto(p, card); }); + + const meta = document.createElement("div"); + meta.className = "p-meta"; + meta.textContent = `${p.name} · ${fmtBytes(p.size)}`; + + card.append(img, del, meta); + card.addEventListener("click", () => openLightbox(p, card)); + return card; +} + +async function deletePhoto(p, card) { + if (!confirm(`Permanently delete ${p.name}?`)) return; + try { + await api("/api/gallery", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: p.path }), + }); + if (card) card.remove(); + galleryState.total = Math.max(0, galleryState.total - 1); + galleryState.offset = Math.max(0, galleryState.offset - 1); + closeLightbox(); + toast($("#galleryToast"), "Deleted", false); + if (!$("#galleryGrid").children.length) loadGallery(true); + } catch (err) { + toast($("#galleryToast"), err.message, true); + } +} + +// lightbox +let lbCurrent = null; +function openLightbox(p, card) { + lbCurrent = { p, card }; + $("#lbImage").src = photoUrl(p); + $("#lbName").textContent = p.path; + $("#lbOpen").href = photoUrl(p); + $("#lightbox").hidden = false; +} +function closeLightbox() { + $("#lightbox").hidden = true; + $("#lbImage").src = ""; + lbCurrent = null; +} +$("#lbClose").addEventListener("click", closeLightbox); +$("#lightbox").addEventListener("click", (e) => { + if (e.target.id === "lightbox") closeLightbox(); +}); +document.addEventListener("keydown", (e) => { + if (e.key === "Escape" && !$("#lightbox").hidden) closeLightbox(); +}); +$("#lbDelete").addEventListener("click", () => { + if (lbCurrent) deletePhoto(lbCurrent.p, lbCurrent.card); +}); +$("#refreshGallery").addEventListener("click", () => loadGallery(true)); +$("#galleryMore").addEventListener("click", () => loadGallery(false)); + // ---- browse civitai ------------------------------------------------------- const browseState = { loaded: false, page: null, cursor: null, busy: false }; @@ -445,5 +553,7 @@ $("#saveSettings").addEventListener("click", async () => { (async function init() { await loadModelTypes(); - showView("installed"); + const wanted = new URLSearchParams(location.search).get("view"); + const valid = ["installed", "gallery", "browse", "download", "settings"]; + showView(valid.includes(wanted) ? wanted : "installed"); })(); diff --git a/model-manager/app/static/index.html b/model-manager/app/static/index.html index ee84d21..e933521 100644 --- a/model-manager/app/static/index.html +++ b/model-manager/app/static/index.html @@ -20,6 +20,9 @@ + @@ -48,6 +51,23 @@ + + +
@@ -160,6 +180,20 @@
+ + + + diff --git a/model-manager/app/static/start.html b/model-manager/app/static/start.html new file mode 100644 index 0000000..d0f57ad --- /dev/null +++ b/model-manager/app/static/start.html @@ -0,0 +1,69 @@ + + + + + + SparkyUI + + + + +
+
+

SparkyUI

+

Detecting your device…

+ +
+ + + + diff --git a/model-manager/app/static/style.css b/model-manager/app/static/style.css index d2e46ec..f3fef0f 100644 --- a/model-manager/app/static/style.css +++ b/model-manager/app/static/style.css @@ -132,6 +132,66 @@ code { background: var(--bg-3); padding: 1px 5px; border-radius: 4px; font-size: .model-card .card-foot { display: flex; justify-content: space-between; align-items: center; margin-top: 4px; } .empty { color: var(--text-dim); } +/* ---- gallery (generated photos) ---- */ +.gallery-grid { + display: grid; gap: 12px; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); +} +.photo-card { + position: relative; background: var(--bg-2); + border: 1px solid var(--border); border-radius: var(--radius); + overflow: hidden; cursor: pointer; aspect-ratio: 1 / 1; +} +.photo-card img { + width: 100%; height: 100%; object-fit: cover; display: block; + transition: transform .2s; +} +.photo-card:hover img { transform: scale(1.04); } +.photo-card .p-del { + position: absolute; top: 6px; right: 6px; + background: rgba(12,16,32,.7); color: #fff; border: none; + width: 28px; height: 28px; border-radius: 8px; cursor: pointer; + font-size: 14px; opacity: 0; transition: opacity .15s; +} +.photo-card:hover .p-del { opacity: 1; } +.photo-card .p-del:hover { background: var(--red); } +.photo-card .p-meta { + position: absolute; bottom: 0; left: 0; right: 0; + background: linear-gradient(transparent, rgba(12,16,32,.85)); + color: var(--text); font-size: 11px; padding: 16px 8px 6px; + opacity: 0; transition: opacity .15s; word-break: break-all; +} +.photo-card:hover .p-meta { opacity: 1; } + +/* Single column on phones (the device-routing landing normally sends phones to + ComfyUIMini, but keep the gallery usable if opened directly on mobile). */ +@media (max-width: 720px) { + .gallery-grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); } + .sidebar { width: 64px; } + .brand-title, .brand-sub, .nav-item { font-size: 0; } + .nav-ico { font-size: 18px; } +} + +/* ---- lightbox ---- */ +.lightbox { + position: fixed; inset: 0; z-index: 100; + background: rgba(8,10,16,.92); + display: flex; flex-direction: column; align-items: center; justify-content: center; + padding: 28px; +} +.lightbox[hidden] { display: none; } +.lightbox img { max-width: 92vw; max-height: 82vh; border-radius: 8px; object-fit: contain; } +.lb-close { + position: absolute; top: 18px; right: 22px; + background: transparent; color: #fff; border: none; font-size: 26px; cursor: pointer; +} +.lb-bar { + display: flex; align-items: center; gap: 16px; margin-top: 16px; + max-width: 92vw; +} +.lb-name { color: var(--text-dim); font-size: 13px; word-break: break-all; } +.lb-actions { display: flex; gap: 10px; margin-left: auto; } + /* ---- browse civitai ---- */ .browse-controls { display: flex; flex-wrap: wrap; gap: 10px; align-items: center;