aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.gitignore6
-rw-r--r--docs/superpowers/specs/2026-07-08-model-menu-daemon-design.md232
2 files changed, 238 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..d261ac5
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+# Local config with real host paths (see .env.example)
+.env
+
+# Local generated output / model cache, if ever placed in-repo
+/out/
+/models/
diff --git a/docs/superpowers/specs/2026-07-08-model-menu-daemon-design.md b/docs/superpowers/specs/2026-07-08-model-menu-daemon-design.md
new file mode 100644
index 0000000..1a2ed89
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-08-model-menu-daemon-design.md
@@ -0,0 +1,232 @@
+# Design: model menu + persistent daemon + docker compose
+
+Date: 2026-07-08
+Status: approved (brainstorm), pending implementation-plan
+
+## Goal
+
+Extend the local SDXL image generator with:
+
+1. **Model menu** — choose between several SDXL-arch models (permissive set).
+2. **Persistent daemon** — hold one model resident in XPU VRAM so prompt
+ iteration on the same image skips the model-reload tax.
+3. **Explicit lifecycle** — start the daemon while revising, stop it to free
+ VRAM when done.
+4. **docker compose** — replace the raw `docker run` with compose for the
+ long-lived service.
+
+The existing bounded scope (SDXL arch, single 1024x1024 image, no refiner, no
+Flux, no batching) is unchanged. The daemon is in scope specifically as a
+local burst-iteration convenience, not general server hosting.
+
+## Non-goals
+
+- No multiple models resident at once (3 * ~9GB > 12GB VRAM; won't fit).
+- No refiner, Flux, batching, video (still RunPod territory).
+- No arbitrary HF model ids from the CLI — only the registry keys.
+- No auth / remote exposure — daemon binds localhost only.
+
+## Architecture
+
+Three roles (was two):
+
+```
+imggen (host bash wrapper)
+ ├─ subcommands: start / stop / status / list → docker compose + curl
+ └─ default (prompt given) → curl daemon, or one-shot fallback
+ │
+ ▼ HTTP 127.0.0.1:PORT
+server.py (in container, long-lived) ← NEW
+ ├─ holds ONE model resident in XPU VRAM
+ ├─ POST /generate → PNG bytes
+ ├─ POST /model → swap resident model (unload + load)
+ ├─ GET /status → {model, ready}
+ └─ imports generate.py core (build_pipe / generate / resolve_model)
+ │
+ ▼
+generate.py ← refactored: model registry + reusable funcs, keeps one-shot CLI
+```
+
+- `generate.py` stays runnable standalone (one-shot) AND importable by
+ `server.py`. No logic duplicated.
+- Daemon holds one model. Swap on `-m` mismatch (reload tax only on switch).
+- compose defines the service. `imggen start` = `docker compose up -d`,
+ `stop` = `down`.
+
+File count grows from 3 to 5: `imggen`, `generate.py`, `server.py`,
+`compose.yaml`, `.env.example` (+ `.env` gitignored, not committed).
+
+## Model registry
+
+Single dict in `generate.py`, source of truth for both CLI and daemon:
+
+```python
+MODELS = {
+ "sdxl": "stabilityai/stable-diffusion-xl-base-1.0", # default, general
+ "realvis": "SG161222/RealVisXL_V5.0", # photoreal, permissive
+ "pony": "AstraliteHeart/pony-diffusion-v6-xl", # unrestricted, versatile
+}
+DEFAULT_MODEL = "sdxl"
+VAE = "madebyollin/sdxl-vae-fp16-fix" # shared, all three are SDXL arch
+```
+
+- All three are SDXL arch → same `StableDiffusionXLPipeline`, same fp16-fix
+ VAE, same 12GB fit. One `build_pipe(key)` handles all.
+- `-m/--model` accepts a **registry key only** (`realvis`), not raw HF ids.
+ Unknown key → error listing valid keys, exit 2.
+- First use of a model downloads ~7GB to the `/models` cache (HF_HOME), once.
+
+### Load robustness (MUST handle — real risk)
+
+`build_pipe` must be robust to fine-tunes that differ from SDXL base:
+
+```
+try: from_pretrained(..., variant="fp16") # SDXL base publishes fp16 variant
+except: from_pretrained(..., torch_dtype=float16) # fine-tunes may lack an fp16 variant
+```
+
+- **Repo IDs must be verified to load before locking.** RealVis and Pony repo
+ ids + diffusers-format layout + fp16-variant availability are unconfirmed
+ (the verification WebFetch was interrupted during brainstorm). If a repo
+ ships only a single-file `.safetensors` (no diffusers folder), `from_pretrained`
+ fails and it needs `from_single_file` or a diffusers-format mirror instead.
+ This is the one unknown that a real download test resolves.
+
+## HTTP daemon API
+
+`server.py`, stdlib `http.server` only (no new dependency). Binds
+`127.0.0.1:8765` inside the container; port published to host localhost only.
+
+| method | path | body | returns |
+|--------|------------|---------------------------------------------------|---------|
+| GET | `/status` | — | `{"model":"realvis","ready":true}` |
+| POST | `/generate`| `{prompt,negative,steps,guidance,seed,width,height}` | PNG bytes (`image/png`) |
+| POST | `/model` | `{"name":"pony"}` | `{"model":"pony","ready":true}` after swap |
+
+- Holds one pipe resident. `/generate` = pure inference, no reload.
+- `/model` with a different key → unload current (`del pipe;
+ torch.xpu.empty_cache()`), load new. Same key → no-op.
+- Loads `IMGGEN_MODEL` (default `sdxl`) at startup.
+- **Single-threaded / serialized** — one GPU, concurrent generate = OOM risk.
+ `http.server` default is serial, which is what we want. Requests queue.
+- Image returned as **bytes**; the **host wrapper writes the PNG** to
+ `$OUT_DIR`. Daemon stays stateless about filenames; output-path logic stays
+ host-side as it is now.
+
+## compose.yaml
+
+```yaml
+services:
+ imggen:
+ build: .
+ image: imggen:local
+ user: "${IMGGEN_UID:-1000}:${IMGGEN_GID:-100}"
+ group_add:
+ - video # gid for /dev/dri/card* access as non-root
+ devices:
+ - /dev/dri
+ volumes:
+ - ${IMGGEN_OUT:-./out}:/out
+ - ${IMGGEN_CACHE:-./models}:/models
+ environment:
+ - HF_HOME=/models
+ - IMGGEN_MODEL=${IMGGEN_MODEL:-sdxl}
+ ports:
+ - "127.0.0.1:${IMGGEN_PORT:-8765}:8765"
+ command: ["python3", "server.py"]
+```
+
+- `devices: /dev/dri` = compose equivalent of `--device /dev/dri`. Verify Arc
+ XPU is visible under compose (same flag, expected fine).
+- Port default 8765, overridable via `IMGGEN_PORT`.
+
+## Configuration: .env (real values out of git)
+
+- Real host paths live in `.env` (gitignored). Compose auto-reads it.
+- `.env.example` committed with generic placeholders, documents the shape.
+- Real defaults (from the user's actual `~/bin/imggen`):
+
+```
+# .env (gitignored)
+IMGGEN_OUT=/home/danix/Pictures/AI-gen
+IMGGEN_CACHE=/data/LLM-models/imggen-models
+# IMGGEN_PORT=8765
+# IMGGEN_MODEL=sdxl
+```
+
+Note these differ from the paths documented in CLAUDE.md (`~/imggen-out`,
+`~/.cache/imggen-models`) — CLAUDE.md is stale and must be updated (see Docs).
+
+## Host wrapper (imggen)
+
+| command | action |
+|--------------------------------|--------|
+| `imggen start [-m KEY]` | `IMGGEN_MODEL=KEY docker compose up -d` (idempotent) |
+| `imggen stop` | `docker compose down` (frees VRAM) |
+| `imggen status` | `curl -s localhost:PORT/status` else "daemon down" |
+| `imggen list` | print registry keys + descriptions |
+| `imggen [-m KEY] "prompt" ...` | daemon up → POST /model if key differs, then POST /generate, write PNG. daemon down → one-shot fallback |
+
+### Interactive menu
+
+`imggen "prompt"` with **no `-m`**:
+
+- daemon up + TTY present → prompt `which model? 1)sdxl 2)realvis 3)pony`,
+ then swap if chosen ≠ resident, then generate.
+- non-TTY (piped/scripted) → fall back to `sdxl`.
+- daemon down → one-shot fallback (`docker compose run --rm`), non-interactive
+ → `sdxl` or the `-m` key.
+
+## Error handling
+
+| case | behavior |
+|-------------------------------|----------|
+| unknown `-m KEY` | error + list valid keys, exit 2 |
+| daemon down, prompt sent | fall back to one-shot (no error) |
+| model download fails (network)| HF raises → daemon 500 w/ msg; CLI prints + exits |
+| generate OOM | catch, `empty_cache()`, 500 "OOM, try fewer steps"; daemon survives |
+| bad JSON to daemon | 400 |
+| swap during generate | serialized (single-thread), no race |
+| XPU unavailable in daemon | startup crash → container exits → `status` shows down (fail-loud preserved) |
+
+## Testing
+
+Hardware (Intel Arc B580) is on the same machine, so integration runs locally,
+not hand-waved. No test framework added (ponytail): plain `assert`
+self-checks / one `test_*.py` for pure logic; GPU path exercised manually.
+
+**Pure-logic, no GPU (automatable):**
+- `resolve_model` — valid keys resolve, invalid key raises listing valid keys.
+- registry integrity (keys map to non-empty ids, DEFAULT_MODEL in MODELS).
+- wrapper arg dispatch routes start/stop/status/list vs prompt correctly.
+- daemon JSON request parsing (valid body, bad JSON → 400).
+
+**Integration on real Arc (run by the agent on this machine):**
+- one-shot SDXL end-to-end → valid PNG (weights cached, fast).
+- daemon lifecycle: `start` → `status` ready → `generate` → PNG valid → stop
+ frees VRAM.
+- model swap: `-m realvis` while sdxl resident → `/model` swaps → generate.
+- **Model download tests (realvis, pony) — approved to pull** to the real
+ cache `/data/LLM-models/imggen-models`. These confirm diffusers-format load
+ + variant fallback for each fine-tune (resolves the load-robustness unknown).
+
+## Known wrinkles (flagged, not blockers)
+
+- **Container runs as the host user** (`user: 1000:100` + `group_add: video`)
+ so output PNGs + cache land `danix:users`, not `root:root`. GPU access holds
+ because uid 1000 is in `video` and `group_add: video` gives the container
+ that gid; `renderD*` is world-rw as a fallback. Prereqs: the HF cache under
+ `/data/LLM-models/imggen-models` must be owned by `danix:users` so downloads
+ as uid 1000 can write it (the user is handling this chown; older runs left
+ `root:root` files there).
+- VRAM ~9GB is **locked while the daemon lives**. Nothing else can use the GPU
+ meanwhile. `imggen stop` frees it. This is the intended tradeoff, documented
+ for the user in the README warning.
+
+## Docs to update (deliverables)
+
+- **README.md** — new subcommands, model menu, daemon lifecycle, VRAM-locked
+ warning, `.env` setup. Keep the "Development Approach" section.
+- **CLAUDE.md** — new architecture (3 → 5 files), daemon in scope as local
+ burst-iteration, corrected real `.env` defaults, compose replaces raw
+ `docker run`.