Metadata-Version: 2.5
Name: argus-darkroom
Version: 0.1.2
Summary: Guarded image generation for the Argus suite: prompts and outputs are checked before anything is released.
Project-URL: Repository, https://github.com/smk762/argus-darkroom
Author: smk762
License-Expression: MIT
License-File: LICENSE
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Typing :: Typed
Requires-Python: >=3.11
Requires-Dist: argus-cortex[images,server]<0.5,>=0.4.6
Requires-Dist: imagehash>=4.3
Requires-Dist: pydantic>=2.7
Requires-Dist: structlog>=24.0
Requires-Dist: websockets>=15
Provides-Extra: age
Requires-Dist: argus-curator[faces]<0.15,>=0.14; extra == 'age'
Provides-Extra: cli
Requires-Dist: rich>=13.0; extra == 'cli'
Requires-Dist: typer>=0.12; extra == 'cli'
Provides-Extra: dev
Requires-Dist: argus-curator<0.15,>=0.14; extra == 'dev'
Requires-Dist: fastapi>=0.110; extra == 'dev'
Requires-Dist: httpx2>=2.7; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: rich>=13.0; extra == 'dev'
Requires-Dist: ruff==0.15.16; extra == 'dev'
Requires-Dist: typer>=0.12; extra == 'dev'
Requires-Dist: uvicorn>=0.27; extra == 'dev'
Provides-Extra: server
Requires-Dist: fastapi>=0.110; extra == 'server'
Requires-Dist: uvicorn>=0.27; extra == 'server'
Description-Content-Type: text/markdown

# argus-darkroom

Guarded image generation for the Argus suite. A prompt is checked before anything is generated. Each output is checked
twice, once on its own and once alongside the prompt that made it, and only then released or destroyed. A record is kept
for every attempt.

ComfyUI generates, Ollama optionally expands the prompt, SingGuard and NudeNet judge. Darkroom orchestrates them, decides
and keeps the record. Its demo UI is `argus-safelight`. Part of the
[Argus suite](https://github.com/smk762?tab=repositories&q=argus). The design is in [`spec.md`](spec.md).

```
safelight :3000 ──▶ argus-darkroom :8106 ──▶ ComfyUI :8188
                     record store            Ollama :11434 (optional)
                     output root             SingGuard :9150
                                             NudeNet
```

## How a generation runs

1. **Assemble** the final prompt: your text (or its Ollama expansion), then the LoRA trigger words.
2. **Pre-flight**: SingGuard reads the final prompt and the negative prompt, framed together, under every selected
   policy. An illegal rule refuses. A failed check refuses. Nothing has been rendered yet.
3. **Render** through ComfyUI. The image comes back over the websocket and is held in memory only.
4. **Scan**: per policy, SingGuard judges the image alone and with the prompt. NudeNet scores exposure. If the age check
   is installed, MiVOLO estimates an age per face.
5. **Decide and release or destroy.** `passed` and `review` images are written to the output root. `deleted` and
   `omitted` images are never written.

| Outcome | Means |
|---|---|
| `refused` | pre-flight stopped it; nothing was rendered |
| `passed` | every cell safe |
| `review` | some cell flagged; the image is kept and served behind its shield |
| `deleted` | an illegal rule in any policy, `minor-exposed`, or an enforcing check that could not run |
| `omitted` | SFW only: NudeNet saw exposure, or did not answer |
| `failed` | expansion or generation failed, or the render came back blank; no image |

Nothing is written before the verdict: no temp file, no `SaveImage` node. A workflow containing a node that saves,
previews, exports or writes is refused when it is loaded.

**The age estimate is a tool's guess**, not a fact. It is stored as `age_estimate_years` from `mivolo`, and an
under-18 estimate with exposure deletes the image. The age check is an extra signal on top of SingGuard's minors rule.

## Run it

You need the services darkroom reaches by URL:

- **ComfyUI**, with the `SaveImageWebsocket` node (below).
- **SingGuard** (agent-composer's `/api/guard/check`). **Required**: darkroom never runs unguarded, and an empty
  `ARGUS_DARKROOM_SINGGUARD_URL` makes it refuse to start.
- **NudeNet**. Without it, SFW generation is unavailable (HTTP 400) because every SFW output would be omitted.
- **Ollama**, optional, for `--expand`.

### Compose

```bash
cp .env.example .env     # set SINGGUARD_URL and MODELS_ROOT at least
docker compose up --build
```

This runs darkroom (`:8106`) and safelight (`:3000`), both published on `127.0.0.1` only (`DARKROOM_PUBLISH` /
`SAFELIGHT_PUBLISH` in `.env`). Safelight is built from
`SAFELIGHT_CONTEXT` (default `../argus-safelight`); its darkroom URL is baked in at build time from
`DARKROOM_PUBLIC_URL`. Records and images live in the `darkroom-data` volume. `MODELS_ROOT` is mounted read-only.

`DEVMODE=1` in `.env` becomes `ARGUS_DARKROOM_DEVMODE=1`. It adds `POST /purge` (and safelight's purge button), which
deletes every record and image. Leave it off anywhere you care about the output.

### Without Docker

```bash
uv pip install "argus-darkroom[server,cli]"
export ARGUS_DARKROOM_SINGGUARD_URL=http://localhost:9150
export ARGUS_DARKROOM_MODELS_ROOT=/path/to/ComfyUI/models
export ARGUS_DARKROOM_WORKFLOW=examples/workflows/sdxl.json
argus-darkroom serve            # 127.0.0.1:8106
```

The server binds to localhost by default. It is not meant for public hosting.

### ComfyUI: the `SaveImageWebsocket` node

Darkroom takes the image from the websocket instead of from disk, so ComfyUI needs a `SaveImageWebsocket` node. It
ships with ComfyUI as an example node, `websocket_image_save.py` under `custom_nodes/`. Confirm that ComfyUI knows it:

```bash
curl -s localhost:8188/object_info/SaveImageWebsocket | head -c 200
```

An empty `{}` means the node is not installed. The node does not write to disk, which is why darkroom can use it.

### The workflow template

A ComfyUI API-format workflow with `$placeholders`: `$base_checkpoint`, `$vae`, `$positive`, `$negative`, `$seed`,
`$steps`, `$cfg`, `$sampler`, `$scheduler`, `$width`, `$height`, `$clip_skip`, and `$lora` / `$lora_weight`,
`$lora_2` / `$lora_2_weight` and so on. It must contain exactly one `SaveImageWebsocket` node and no other node that
saves. See [`examples/workflows/`](examples/workflows).

`$clip_skip` is read as A1111 reads it for SDXL: `clip_skip` 1 and 2 both send `stop_at_clip_layer: -2`, the layer SDXL
conditions on, and `n` above 2 sends `-n`. A request may ask for 1 to 12.

## Configuration

All settings are `ARGUS_DARKROOM_*` environment variables.

| Variable | Default | |
|---|---|---|
| `SINGGUARD_URL` | | **Required.** |
| `COMFYUI_URL` | `http://localhost:8188` | |
| `NUDENET_URL` | | Empty: SFW generation unavailable. |
| `AGE_URL` | | Curator's `POST /faces?body=true`. Empty: the in-process `[age]` extra, or no age check. |
| `OLLAMA_URL`, `OLLAMA_MODEL` | | Both set: `expand` is available. |
| `POLICY` | `default`, or `adult-granular` with `ALLOW_NSFW` | The enforcing policy when a request names none. |
| `THINKING` | `cascade` | `fast`, `fast-slow`, `slow`, `cascade`. |
| `ALLOW_NSFW` | off | Lets an NSFW policy enforce. |
| `OUTPUT_ROOT` | `darkroom-output` | `<id>.json` records and `<id>.png` images. |
| `MODELS_ROOT` | | ComfyUI's models folder: checkpoints and LoRAs are listed and hashed from it. |
| `DEFAULT_CHECKPOINT` | `sdxl` | The checkpoint a form starts on: an exact name, or a folder under `checkpoints/` for the first model in it. When nothing matches, `/models` reports null. |
| `WORKFLOW` | | The template path. |
| `CORS_ORIGINS` | `localhost` and `127.0.0.1` on ports 3000 and 3097 | May read and write. `*` allows reads only. Compose defaults to port 3000 only. |
| `DEVMODE` | off | Adds `POST /purge` and `POST /purge/{id}`. |
| `READ_ONLY` | off | Answers 403 to every write from every origin: `POST /preflight`, `POST /generate/stream`, `DELETE /record/{id}` and both purges. Records and images stay readable. |

`ALLOW_NSFW`, `DEVMODE` and `READ_ONLY` accept `1`/`true`/`yes`/`on`. A mistyped value is warned about, not silently ignored.

Policies are chosen by name (`default`, `adult`, `adult-granular`, `adult-cautious`, `adult-cinematic`); a request cannot carry policy
text. `default` and `sfw-*` are SFW, the rest NSFW. SFW refuses on any pre-flight flag and omits exposed output. NSFW
warns on non-illegal flags and blurs exposed output, and illegal content is refused and deleted in both.

## CLI

```bash
argus-darkroom preflight "a cat on a sofa" -n "blurry"
argus-darkroom generate "a cat on a sofa" --checkpoint base.safetensors \
    --sampler euler --scheduler normal --steps 20 --cfg 5 --width 1024 --height 1024 \
    --lora style.safetensors:0.8 --trigger sks --expand
argus-darkroom serve
argus-darkroom schema --check
```

`generate` exits `0` for a released image (`passed` or `review`), `2` for anything else, and `1` for a usage or
configuration error. Sampling options are required: darkroom does not invent them.

`--expand` has Ollama rewrite the prompt. The expansion replaces your text (trigger words are appended) and is checked
like anything else, so it cannot route around the gate. If Ollama fails, the attempt is a `failed` record, never a
silent fall back to the unexpanded prompt.

## HTTP API

| | |
|---|---|
| `GET /health` | version, whether each backend answers (booleans), `nsfw_allowed`, `sfw_available`, `devmode`, `read_only`, age-check status |
| `GET /policies` | each policy's name, modes, `enforceable` flag and rule names (`A`, `B`, ...) |
| `GET /models` | checkpoints and LoRAs as `{name}` objects, `default_checkpoint`, plus the samplers and schedulers ComfyUI offers |
| `POST /preflight` | the text check alone. It does not expand: an expansion differs per run, so `expansion_applied` is `false` |
| `POST /generate/stream` | SSE: `preflight`, `queued`, `progress {step,total}`, `scan {policy,context,cell}`, `complete {record}`, `error {detail}`. One at a time; a second gets 409 |
| `GET /records`, `GET /record/{id}` | `/records` takes `page`, `page_size`, `outcome` and `mode` |
| `GET /image/{id}` | 404 for a deleted, omitted or refused record |
| `DELETE /record/{id}` | removes the user's own image; the record stays |
| `GET /archive` | streamed zip of every record and stored image |
| `POST /purge` | `{"confirm": "purge"}`; only with `DEVMODE` |
| `POST /purge/{id}` | the same body: deletes one record and its image; only with `DEVMODE` |

Writes are refused from origins that are not in `CORS_ORIGINS`.

Every record under an NSFW policy carries a `guard.nudenet` block (`exposed_score`, `labels`, `threshold`, `blur`);
`blur` is the only place the blur decision lives. `exposed_score` is `null` when NudeNet could not be reached, and the
image is then blurred. Each LoRA in `params.loras` keeps the `trigger_words` the request supplied.

## Demo (no GPU)

`demo/run.py` runs the real server and engine against the same fake SingGuard, NudeNet, ComfyUI and Ollama servers the
tests use, so a reviewer can click through every outcome. A marker in the prompt chooses what the fakes say:

```bash
make demo          # http://127.0.0.1:8106, history in ./demo-data
make smoke         # in another shell: 16 prompts, each checked against the outcome it should reach
```

| Marker | Fakes say | Outcome |
|---|---|---|
| none | everything safe | `passed` |
| `[flag]` / `[flag-both]` | the image (or image and prompt) trips rule B | `review` |
| `[unchecked]` | the scan cannot be read | `deleted` (`unchecked`) |
| `[exposed]` | NudeNet reports exposure | SFW: `omitted`; NSFW: `passed`, blurred |
| `[nonude]` | NudeNet is down | SFW: `omitted`; NSFW: `passed`, blurred |
| `[noage]` | MiVOLO has no estimate | `passed` |
| `[minor]` | exposure and an age under 18 | `deleted` (`minor-exposed`) |
| `[illegal]` | the illegal rule fires | `deleted` |
| `[refuse]`, `[refuse-illegal]` | pre-flight flags | `refused` |
| `[preflight-unchecked]` | pre-flight cannot be read | `refused` (`unchecked`) |
| `[fail]` | ComfyUI errors in `KSampler` | `failed` |

`python demo/run.py --help` lists `--host`, `--port`, `--cors-origin`, `--workflow` (use `sdxl_lora.json` to try LoRAs;
the default `sdxl.json` has no LoRA slot, so a request with one is a 400), `--latency`, `--step-delay` and `--fresh`.
The demo turns `DEVMODE` and `ALLOW_NSFW` on and uses fake models. Bind it to a non-loopback host only on a network you
trust. No real content is involved; the verdicts are canned.

[argus-safelight](https://github.com/smk762/argus-safelight) is the UI for it: point `NEXT_PUBLIC_DARKROOM_URL` at the
demo.

## Age check (optional)

The age check is an extra signal on top of SingGuard's minors rule, never a replacement for it. SingGuard judges every
image for itself, with or without an estimate: rule A of each adult policy covers anyone who appears to be a minor. So
when the age check is missing, that rule still applies.

The estimate is a tool's reading, and it is never sent to SingGuard. It has one effect of its own: an estimate under 18
on an image where NudeNet scores an exposed class at 0.2 or more deletes the image (`minor-exposed`). Without exposure
it is only recorded and shown.

Set `AGE_URL` to a curator 0.10+ that serves `POST /faces?body=true` (InsightFace + MiVOLO, a body crop per face, and
unmatched people). The Docker image is meant to be used this way. A failed `people_check` is treated as age unavailable,
not as "no one found", and so is an unmatched person with no estimate. An unavailable age check never deletes on its
own: the record says `age: null` and the image is judged by SingGuard and NudeNet.

```bash
uv pip install "argus-darkroom[age]"   # in-process fallback when AGE_URL is empty
```

The extra runs curator's face pipeline in this process and needs the MiVOLO ONNX file (`argus-curator mivolo-convert`).
Without a URL or the extra, records carry `age: null` and `/health` says why.

## Wire schema

Records are versioned (`record_version`) and the schema is committed at
[`schema/darkroom-wire.schema.json`](schema/darkroom-wire.schema.json) for consumers to generate code from.
`argus-darkroom schema` rewrites it; `schema --check` fails if it is stale. Change a model, regenerate, commit both.

## Develop

```bash
make install   # venv + editable install with the "dev" extras
make test
make lint
argus-darkroom schema --check
```

Tests run against fake SingGuard, NudeNet, ComfyUI and Ollama servers. No real sexual, violent or illegal content goes in
the repo or its tests: the illegal-to-deleted path is driven by a fake verdict.
[`scripts/preflight_eval.py`](scripts/preflight_eval.py) scores the pre-flight framing against a real SingGuard server on
the labelled prompts in `eval/`; run it by hand, not in CI.

## CI / Release

- **CI** runs via the shared [`argus-ci`](https://github.com/smk762/argus-ci) reusable workflow, plus a `schema --check`
  job.
- **Release** publishes to PyPI (OIDC trusted publishing) and GHCR on `v*` tags, then creates the GitHub Release.
- Versioning is derived from git tags via `hatch-vcs`: tag `vX.Y.Z` to cut a release.
- Release notes are the version's section of `CHANGELOG.md`. Add a `## [X.Y.Z]` section before tagging: without one,
  the build fails and nothing is published.

This repo was scaffolded from [`argus-pkg-template`](https://github.com/smk762/argus-pkg-template).
Run `copier update` to pull template changes (CI, release, tooling).
