aboutsummaryrefslogtreecommitdiffstats
path: root/CLAUDE.md
blob: 5936155a6e14abbac4252e61e212d8aeacba9975 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## What this is

Local SDXL image generation on an Intel Arc B580 (12GB) under Slackware. Five source files:
`imggen` (host bash wrapper: subcommands + daemon client + one-shot fallback) → `docker compose`
→ either `server.py` (persistent daemon, in-container, XPU) or `generate.py` (one-shot CLI, model
registry + resolve_model + build_pipe + generate), plus `compose.yaml` (compose service) and
`.env.example` (documents the gitignored `.env`). There is also `test_generate.py` (pure-logic
unit tests). README.md carries the full rationale and VRAM measurements; read it before changing
behavior.

## Build and run

```
docker build -t imggen:local .        # image tag is hardcoded as imggen:local in compose.yaml
cp .env.example .env                  # set real IMGGEN_OUT / IMGGEN_CACHE / IMGGEN_UID / IMGGEN_GID
./imggen start                        # start daemon, load default model, hold VRAM
./imggen "a prompt" --steps 30 --seed 42 -o out.png -n "blurry"
./imggen stop                         # free VRAM
```

The wrapper lives in the repo and `cd`s to `$IMGGEN_DIR` (default `$HOME/Programming/GIT/imggen`)
so it can find `compose.yaml` and `.env` no matter where it is invoked from.

**Deploy after finishing work.** All wrapper edits happen on the repo copy `./imggen`. Once a
change is done, tested, and committed, deploy it to PATH so the user gets it globally:

```
cp ./imggen ~/bin/imggen && chmod +x ~/bin/imggen   # ~/bin is on the user's PATH
```

`~/bin/imggen` is a plain copy, not a symlink, so it must be re-copied after every wrapper change.
It still operates on the repo via `IMGGEN_DIR`. Only the wrapper is deployed this way; the other
source files run inside the container. Do this as the last step, not mid-work.

`test_generate.py` covers pure logic (model registry, resolve_model) with no GPU needed. Beyond
that, no lint config; verification is running `imggen` end to end and checking the PNG.

## Architecture constraints (the whole point of the project)

- **12GB VRAM is the hard limit.** Stock SDXL fp16 OOMs at VAE decode because diffusers upcasts
  the VAE to fp32. The fix is the fp16-fix VAE (`madebyollin/sdxl-vae-fp16-fix`, hardcoded in
  `generate.py`). This one swap is why the pipeline fits. Do not remove it. Peak VRAM in the
  working config is 9.07 GB.
- **Scope is deliberately bounded:** SDXL-architecture models, single 1024x1024 image, 25 steps,
  no refiner, no Flux, no batching. The persistent daemon (`server.py`) is IN SCOPE: it holds one
  model resident in VRAM for fast local burst-iteration, and that does not change the limits.
  Refiner/Flux/batching/heavy work stays explicitly out of scope and belongs on a RunPod endpoint,
  not here. Don't add them "to be helpful."
- **XPU only.** `build_pipe()` fails loud (`sys.exit(2)`) if `torch.xpu` is unavailable rather
  than falling back to CPU (which would be minutes per image). Keep that behavior.
- **The model registry (`MODELS` in `generate.py`) is the source of truth.** Three keys: `sdxl`
  (stabilityai/stable-diffusion-xl-base-1.0, default), `realvis` (SG161222/RealVisXL_V5.0), `pony`
  (kitty7779/ponyDiffusionV6XL, an ungated diffusers-format mirror; the original AstraliteHeart
  repo is gated). All three share the fp16-fix VAE and fit 12GB. The daemon holds only ONE model
  at a time and swaps on demand; it never holds two co-resident (would exceed 12GB).

## Version pinning is load-bearing

The Dockerfile pins EXACT versions (torch 2.12.1+xpu, diffusers 0.39.0, transformers 5.13.0,
etc.) captured from the known-good container. XPU-for-diffusion is young and fast-moving. Never
`pip install -U` or loosen a pin casually. torch comes from the Intel XPU wheel index
(`download.pytorch.org/whl/xpu`); everything else from PyPI. If you rebuild with newer versions,
keep the old Dockerfile in git to revert.

## Container boundaries

- `docker compose` (via `compose.yaml`) replaces raw `docker run`. The image tag is still
  `imggen:local`. The service runs as the HOST user (`user: "${IMGGEN_UID:-1000}:${IMGGEN_GID:-100}"`
  plus `group_add: video` for `/dev/dri` access), so generated PNGs are host-owned, not root.
- Real host paths live in the gitignored `.env` (copy `.env.example` to create it), NOT in
  hardcoded wrapper defaults. `.env` sets `IMGGEN_OUT``/out` and `IMGGEN_CACHE``/models`
  (bind mounts), plus optionally `IMGGEN_PORT` (daemon port, default 8765, localhost-only),
  `IMGGEN_MODEL` (default sdxl), `IMGGEN_WIDTH`/`IMGGEN_HEIGHT` (default image size the wrapper
  seeds into `-W`/`-H` when absent; must be multiples of 8; example `.env` is 1280x960 landscape,
  unset falls back to 1024x1024), `IMGGEN_UID`/`IMGGEN_GID`. `HF_HOME=/models` so the ~7.5GB
  weights download once. Output paths in `generate.py`/`server.py` are all under `/out`.
- Daemon args (`generate` endpoint) and one-shot CLI args (`generate.py` argparse) are kept in
  sync by hand in the wrapper; adding a flag means updating `generate.py`, `server.py`, and the
  wrapper's arg parsing together.
- GPU passthrough is `--device /dev/dri` (via compose `devices:`).