From 32c14ae9da756b41fdb64c8593c355b9839617c6 Mon Sep 17 00:00:00 2001 From: Alexander Date: Tue, 16 Jun 2026 16:59:58 -0400 Subject: [PATCH 01/24] docs: comfyui integration plan --- ...2026-06-16-comfyui-platform-integration.md | 378 ++++++++++++++++++ 1 file changed, 378 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-16-comfyui-platform-integration.md diff --git a/docs/superpowers/plans/2026-06-16-comfyui-platform-integration.md b/docs/superpowers/plans/2026-06-16-comfyui-platform-integration.md new file mode 100644 index 00000000..7ec6c297 --- /dev/null +++ b/docs/superpowers/plans/2026-06-16-comfyui-platform-integration.md @@ -0,0 +1,378 @@ +# ComfyUI Platform Integration — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make ComfyUI a first-class, deterministically-provisioned hal0 platform component (image/video gen) — own image, installer wiring, capability-driven model picker, fully-wired operator pane. + +**Architecture:** Build `ghcr.io/hal0ai/comfyui` (port kyuz0 gfx1151 recipe; bake nodes+workflows+gpu_gate). Keep ComfyUI as slot/arbiter-managed `img` runtime (implicit GPU-yield switchover) — NOT a standalone always-on service. Promote to "official" at the **provisioning layer**: ship control scripts + sudoers in-repo, Extensions-registry entry, services/health/repair, capability→model picker. Wire the V2 "Render hero" pane to live ComfyUI APIs + real hal0 telemetry. + +**Tech Stack:** Python 3.12 (FastAPI), podman, systemd, ROCm-7 TheRock (gfx1151), ComfyUI, JS/JSX dashboard SPA (ui/), pytest, Playwright. + +## Global Constraints + +- Base hardware: Strix Halo gfx1151, iGPU + unified memory. ComfyUI = ROCm only. +- Image identity: `ghcr.io/hal0ai/comfyui`, **digest-pinned** in `manifest.json` `toolbox_images.comfyui`. +- Launch flags (mandatory): `--disable-mmap --gpu-only --disable-smart-memory --cache-none --bf16-vae`. Port `8188` (hal0 convention; kyuz0 default is 8000 — override). +- Env (fast path): `TORCH_ROCM_AOTRITON_ENABLE_EXPERIMENTAL=1`, `TORCH_BLAS_PREFER_HIPBLASLT=1`, `COMFYUI_ENABLE_MIOPEN=1`. +- Model store: `/mnt/ai-models/comfyui/models//` (= `model_store_root()/comfyui`). Writable ZFS bind on CT105. NO `/var/lib/hal0/comfyui/models`. +- ComfyUI is a seeded, non-deletable `img`-family slot. LLM registry untouched by ComfyUI models. +- iGPU exclusivity: render enqueue -> arbiter gives ComfyUI GPU -> inference slots yield. Surfaced as header note, never a user toggle. +- Third-party-official-fix-first: port kyuz0's solved recipe; flag any deviation w/ upstream link. +- TDD throughout. Frequent commits. Deploy via `scripts/deploy.sh` (rebuilds ui/dist). + +--- + +## Decisions locked (grill 2026-06-16) + +1. **B2** — build `ghcr.io/hal0ai/comfyui`, port kyuz0 recipe, bake nodes/workflows/gpu_gate. +2. Model store `/mnt/ai-models/comfyui/models`, clean names, fix `_comfyui_models_dir`. +3. **3A** — keep slot/arbiter/gpu-gate lifecycle; "official" = deterministic provisioning. +4. Capability matrix: txt2img=Qwen-Image-2512+4step; edit=Qwen-Edit-2511+4step; txt2vid/img2vid=LTX-2 default (Wan2.2/Hunyuan quality alts); video-upscale embedded; **+ESRGAN 4× image upscale**; **+SDXL-Lightning fast tier**; defer FLUX. +5. Model fetch = wrap vendored `get_*.sh`, deferred async pulls, picker records selections. +6. Pane = ops surface (monitor+control+quick-launch), authoring via `Open ComfyUI ↗`; implicit switchover; live controls. +7. Test = TDD unit/contract + e2e mock UI + image smoke + CT105 live cheapest-render. + +--- + +## File Structure + +**New (repo):** +- `packaging/toolbox/comfyui.Dockerfile` — hal0 ComfyUI image (FROM rocm-7 base or kyuz0-recipe build). +- `installer/comfyui/scripts/{enter_imagegen.sh,exit_imagegen.sh,cancel.sh,restart.sh,logs.sh,status.sh}` — the 6 control scripts (currently hand-placed on CT105 only). +- `installer/comfyui/scripts/{set_extra_paths.sh, get_qwen_image.sh, get_wan22.sh, get_hunyuan15.sh, get_ltx2.sh, get_sdxl.sh, get_esrgan.sh}` — vendored kyuz0 fetchers + 2 new. +- `installer/comfyui/workflows/*.json` — curated API-format workflows (10 kyuz0 + esrgan + sdxl). +- `installer/comfyui/extra_model_paths.yaml.tmpl` — 8-key layout, base `/mnt/ai-models/comfyui/models`. +- `src/hal0/comfyui/__init__.py`, `src/hal0/comfyui/fetch.py` — get_*.sh wrapper -> hal0 job. +- `src/hal0/comfyui/capabilities.py` — capability→family→variant matrix (the picker source of truth). +- `tests/comfyui/*` — unit/contract tests. +- `ui/src/.../ImageGenCard.*` — ported design pane (from Design/design_handoff_comfyui_imagegen/design/). + +**Modify:** +- `src/hal0/install/extensions.py:24-58` — add `comfyui` Extension + install branch. +- `src/hal0/api/routes/installer.py:419-486` — add comfyui to `_REPAIRABLE_UNITS` + services step. +- `src/hal0/api/routes/comfyui.py` — point at shipped `/opt/comfyui/*.sh`; un-gate switchover (was 501); add control routes. +- `src/hal0/registry/pull.py:174` — `_comfyui_models_dir` -> `model_store_root()/comfyui/models`. +- `src/hal0/registry/curated.py` — add SDXL + ESRGAN curated entries (`comfyui_subdir`,`model_class`). +- `installer/install.sh` — place `/opt/comfyui/*` scripts + sudoers; comfyui in services start. +- `packaging/sudoers/hal0-comfyui` — verify cmds match shipped scripts. +- `manifest.json` — `toolbox_images.comfyui` -> ghcr image (digest via `scripts/update-toolbox-digests.sh`). +- `installer/etc-hal0/slots/img.toml`, `SEED_PROFILES` (`schema.py:763-770`), `profiles.toml` — profile image -> ghcr. +- `.github/workflows/` — build+push comfyui image (new job) or document external build. + +--- + +# PHASE 1 — Image (`ghcr.io/hal0ai/comfyui`) + +Produces a digest-pinned, reproducible ComfyUI server image w/ baked recipe. + +### Task 1.1: Dockerfile — base + ComfyUI + torch wheels + +**Files:** Create `packaging/toolbox/comfyui.Dockerfile`; Test `tests/comfyui/test_image_smoke.sh`. + +**Interfaces:** Produces image tag `hal0/comfyui:dev`; exposes `:8188`; entrypoint runs ComfyUI w/ Global-Constraints flags+env. + +- [ ] **Step 1: Write failing smoke test** `tests/comfyui/test_image_smoke.sh`: +```bash +#!/usr/bin/env bash +set -euo pipefail +IMG="${1:-hal0/comfyui:dev}" +cid=$(podman run -d --device /dev/dri --device /dev/kfd --group-add video --group-add render \ + --security-opt seccomp=unconfined -p 18188:8188 "$IMG") +trap 'podman rm -f "$cid" >/dev/null' EXIT +for i in $(seq 1 60); do curl -sf localhost:18188/system_stats && break; sleep 2; done +curl -sf localhost:18188/system_stats | grep -q comfyui_version +# gpu_gate node loaded: +podman logs "$cid" 2>&1 | grep -qi "hal0_gpu_gate" +echo SMOKE_OK +``` +- [ ] **Step 2: Run -> FAIL** (no image). `bash tests/comfyui/test_image_smoke.sh` -> error pull/run. +- [ ] **Step 3: Write Dockerfile.** Recipe (port kyuz0 `01-rocm-envs.sh`+`99-toolbox-banner.sh`): +```dockerfile +# pin a real digest in CI; rocm-7 gfx1151 base +FROM rocm/dev-ubuntu-24.04:7.0-complete AS base +ENV TORCH_ROCM_AOTRITON_ENABLE_EXPERIMENTAL=1 \ + TORCH_BLAS_PREFER_HIPBLASLT=1 \ + COMFYUI_ENABLE_MIOPEN=1 \ + HSA_OVERRIDE_GFX_VERSION=11.5.1 +RUN python3 -m venv /opt/venv +ENV PATH=/opt/venv/bin:$PATH VIRTUAL_ENV=/opt/venv +# TheRock gfx1151 nightlies (pin versions in CI, not :latest) +RUN pip install --index-url https://rocm.nightlies.amd.com/v2-staging/gfx1151 \ + torch torchvision torchaudio +RUN git clone --depth 1 https://github.com/comfyanonymous/ComfyUI /opt/ComfyUI +RUN pip install -r /opt/ComfyUI/requirements.txt transformers==4.56.2 gguf +# custom nodes (the 11 + gpu_gate baked, Task 1.2) +WORKDIR /opt/ComfyUI +EXPOSE 8188 +COPY packaging/toolbox/comfyui-entrypoint.sh /usr/local/bin/comfyui-entrypoint +ENTRYPOINT ["/usr/local/bin/comfyui-entrypoint"] +``` +Entrypoint `comfyui-entrypoint.sh`: +```bash +#!/usr/bin/env bash +set -e +[ -f /opt/comfyui/set_extra_paths.sh ] && /opt/comfyui/set_extra_paths.sh || true +exec python /opt/ComfyUI/main.py --listen 0.0.0.0 --port 8188 \ + --disable-mmap --gpu-only --disable-smart-memory --cache-none --bf16-vae +``` +- [ ] **Step 4: Build + run smoke** `podman build -t hal0/comfyui:dev -f packaging/toolbox/comfyui.Dockerfile . && bash tests/comfyui/test_image_smoke.sh` -> SMOKE_OK (run on CT105, GPU box). +- [ ] **Step 5: Commit** `feat(comfyui): hal0 ComfyUI image — gfx1151 recipe + flags`. + +### Task 1.2: Bake custom nodes (11 + gpu_gate) + +**Files:** Modify `comfyui.Dockerfile`; Create `installer/comfyui/custom_nodes/` manifest (reuse existing `hal0_gpu_gate.py`). + +- [ ] **Step 1:** Extend smoke test: assert each node dir present via `/object_info` containing gpu-gate + WanVideo + LTXV node classes. +- [ ] **Step 2:** Run -> FAIL. +- [ ] **Step 3:** Add to Dockerfile `RUN` block cloning the 11 nodes (pin commits): ComfyUI_essentials, ComfyUI-AMDGPUMonitor, ComfyUI-GGUF, ComfyUI-Manager, ComfyUI-WanVideoWrapper, ComfyUI-VideoHelperSuite, rgthree-comfy, ComfyUI-Model-Manager, ComfyUI-LTXVideo, ComfyUI-Crystools, ComfyUI-Custom-Scripts; `COPY installer/comfyui/custom_nodes/hal0_gpu_gate.py /opt/ComfyUI/custom_nodes/`. Pip-install each node's requirements.txt. +- [ ] **Step 4:** Rebuild + smoke -> PASS. +- [ ] **Step 5: Commit** `feat(comfyui): bake 11 custom nodes + gpu_gate`. + +### Task 1.3: CI build/push + manifest digest + +**Files:** Create `.github/workflows/comfyui-image.yml`; Modify `manifest.json`, `scripts/update-toolbox-digests.sh`. + +- [ ] **Step 1:** Test `tests/comfyui/test_manifest.py`: assert `manifest.json toolbox_images.comfyui.image` startswith `ghcr.io/hal0ai/comfyui@sha256:` and digest non-null. +- [ ] **Step 2:** Run -> FAIL (currently kyuz0 image). +- [ ] **Step 3:** Add CI job: build comfyui.Dockerfile, push `ghcr.io/hal0ai/comfyui`, capture digest; update `update-toolbox-digests.sh` to include comfyui; set manifest entry. +- [ ] **Step 4:** Run -> PASS. +- [ ] **Step 5: Commit** `ci(comfyui): build+push ghcr image, pin digest`. + +--- + +# PHASE 2 — Model store + fetch + +### Task 2.1: Reconcile model path + +**Files:** Modify `src/hal0/registry/pull.py:174`; Test `tests/registry/test_comfyui_path.py`. + +**Interfaces:** Produces `_comfyui_models_dir(subdir) -> Path` = `model_store_root()/comfyui/models/`. + +- [ ] **Step 1:** Test: +```python +def test_comfyui_models_dir_uses_store_root(monkeypatch, tmp_path): + monkeypatch.setattr(paths, "model_store_root", lambda: tmp_path) + assert pull._comfyui_models_dir("loras") == tmp_path/"comfyui"/"models"/"loras" +``` +- [ ] **Step 2:** Run -> FAIL (returns `/var/lib/hal0/comfyui/models`). +- [ ] **Step 3:** Change `_comfyui_models_dir` to derive from `model_store_root()`. +- [ ] **Step 4:** Run -> PASS. +- [ ] **Step 5: Commit** `fix(comfyui): model pulls target /mnt/ai-models/comfyui/models`. + +### Task 2.2: Capability matrix (picker source of truth) + +**Files:** Create `src/hal0/comfyui/capabilities.py`; Test `tests/comfyui/test_capabilities.py`. + +**Interfaces:** Produces `CAPABILITIES: dict[str, Capability]`; `Capability(id, label, default_family, alternatives:list[ModelVariant])`; `ModelVariant(family, precision, lora, est_seconds, fetch_script, workflow)`. + +- [ ] **Step 1:** Test: `CAPABILITIES["txt2img"].default_family=="qwen-image"`; default variant `est_seconds<=80`; every capability of {txt2img,img2img,txt2video,img2video,image_upscale} present; each variant has a `fetch_script` that exists in `installer/comfyui/scripts/`. +- [ ] **Step 2:** Run -> FAIL. +- [ ] **Step 3:** Write matrix per Decision 4 (defaults = 4-step; LTX-2 video default; +sdxl +esrgan). +- [ ] **Step 4:** Run -> PASS. +- [ ] **Step 5: Commit** `feat(comfyui): capability→model matrix`. + +### Task 2.3: Vendor fetch scripts + 2 new + +**Files:** Create `installer/comfyui/scripts/get_*.sh` (vendor 4 kyuz0 + write `get_sdxl.sh`, `get_esrgan.sh`, `set_extra_paths.sh`); Test `tests/comfyui/test_fetch_scripts.py`. + +- [ ] **Step 1:** Test: each script `bash -n` clean; `set_extra_paths.sh` emits yaml w/ 8 keys + base `/mnt/ai-models/comfyui/models`; new scripts download to correct subdir (dry-run flag). +- [ ] **Step 2:** Run -> FAIL. +- [ ] **Step 3:** Vendor kyuz0 scripts verbatim (cite upstream commit in header); write `get_sdxl.sh` (SDXL base + 8-step Lightning LoRA + sdxl-vae) and `get_esrgan.sh` (4x-UltraSharp + RealESRGAN_x4 -> upscale_models/). Adapt `MODEL_DIR=/mnt/ai-models/comfyui/models`. +- [ ] **Step 4:** Run -> PASS. +- [ ] **Step 5: Commit** `feat(comfyui): vendor fetch scripts + sdxl/esrgan`. + +### Task 2.4: Fetch wrapper -> hal0 job + +**Files:** Create `src/hal0/comfyui/fetch.py`; Test `tests/comfyui/test_fetch.py`. + +**Interfaces:** Produces `fetch_model(variant: ModelVariant) -> JobId`; async, progress, cancellable; shells `get_.sh --precision

`. + +- [ ] **Step 1:** Test (mock subprocess): `fetch_model(variant)` invokes correct script+args, registers a job, streams progress, lands files under store root. +- [ ] **Step 2:** Run -> FAIL. +- [ ] **Step 3:** Implement wrapper (reuse hal0 job/registry infra; do NOT route through LLM PullPlan). +- [ ] **Step 4:** Run -> PASS. +- [ ] **Step 5: Commit** `feat(comfyui): async model fetch wrapper`. + +### Task 2.5: Curated entries (SDXL, ESRGAN) + +**Files:** Modify `src/hal0/registry/curated.py`; Test `tests/registry/test_curated_comfyui.py`. + +- [ ] **Step 1:** Test: curated catalog includes `sdxl-lightning`, `esrgan-4x` with `model_class=="image"` and correct `comfyui_subdir`. +- [ ] **Step 2:** Run -> FAIL. +- [ ] **Step 3:** Add entries. +- [ ] **Step 4:** Run -> PASS. +- [ ] **Step 5: Commit** `feat(comfyui): curated sdxl + esrgan`. + +--- + +# PHASE 3 — Installer provisioning (the reliability gap) + +### Task 3.1: Ship control scripts + +**Files:** Create `installer/comfyui/scripts/{enter_imagegen,exit_imagegen,cancel,restart,logs,status}.sh`; Modify `installer/install.sh`; Test `tests/install/test_comfyui_scripts_shipped.py`. + +**Interfaces:** Produces `/opt/comfyui/.sh` placed by install.sh; consumed by `api/routes/comfyui.py` + sudoers. + +- [ ] **Step 1:** Test: every script path referenced in `api/routes/comfyui.py` + `packaging/sudoers/hal0-comfyui` exists in `installer/comfyui/scripts/`; `bash -n` clean. +- [ ] **Step 2:** Run -> FAIL (scripts only on CT105). +- [ ] **Step 3:** Author the 6 scripts (extract current CT105 copies via `ssh hal0 'cat /opt/comfyui/*.sh'`, sanitize, commit). `enter_imagegen.sh`=arbiter claim GPU + start resident container; `exit_imagegen.sh`=release; `cancel.sh`=`curl :8188/queue -d '{"clear":true}'` + interrupt; etc. Add install.sh block: `install -d /opt/comfyui; install -m0755 installer/comfyui/scripts/*.sh /opt/comfyui/`. +- [ ] **Step 4:** Run -> PASS. +- [ ] **Step 5: Commit** `feat(comfyui): ship switchover control scripts in-repo`. + +### Task 3.2: Sudoers + extra_model_paths placement + +**Files:** Modify `installer/install.sh`, `packaging/sudoers/hal0-comfyui`; Test `tests/install/test_comfyui_sudoers.py`. + +- [ ] **Step 1:** Test: install.sh contains `install -m0440 packaging/sudoers/hal0-comfyui /etc/sudoers.d/`; sudoers cmd paths == shipped script paths; `visudo -cf` clean. +- [ ] **Step 2:** Run -> FAIL. +- [ ] **Step 3:** Add install.sh lines (sudoers + render extra_model_paths.yaml from tmpl to `/mnt/ai-models/comfyui/extra_model_paths.yaml`). +- [ ] **Step 4:** Run -> PASS. +- [ ] **Step 5: Commit** `feat(comfyui): install sudoers + extra_model_paths`. + +### Task 3.3: Extensions registry entry + +**Files:** Modify `src/hal0/install/extensions.py:24-58`; Test `tests/install/test_extensions_comfyui.py`. + +**Interfaces:** Consumes `Extension`; Produces `comfyui` extension (kind `app`, default on) + install branch (`hal0 capability apply image` / slot ensure, NOT systemctl-only). + +- [ ] **Step 1:** Test: `comfyui` in `EXTENSIONS`; `install_extension("comfyui")` ensures img slot + container present (mock). +- [ ] **Step 2:** Run -> FAIL. +- [ ] **Step 3:** Add Extension + branch (image slot is seeded; branch ensures resident container + extra_paths). +- [ ] **Step 4:** Run -> PASS. +- [ ] **Step 5: Commit** `feat(comfyui): extensions-registry entry`. + +### Task 3.4: Services step + repair allowlist + +**Files:** Modify `src/hal0/api/routes/installer.py:419-486`, `services_health.py`; Test `tests/api/test_services_comfyui.py`. + +- [ ] **Step 1:** Test: `GET /api/install/services` includes comfyui dot; comfyui in `_REPAIRABLE_UNITS`; repair restarts resident container (mock). +- [ ] **Step 2:** Run -> FAIL. +- [ ] **Step 3:** Add comfyui to services step + repair (repair = `/opt/comfyui/restart.sh`, not systemctl). +- [ ] **Step 4:** Run -> PASS. +- [ ] **Step 5: Commit** `feat(comfyui): installer services step + repair`. + +### Task 3.5: Picker integration (capability matrix in setup) + +**Files:** Modify `src/hal0/cli/setup_ui.py`, `setup_command.py`, `api/routes/installer.py` (curated-models), `install/orchestrate.py`; Test `tests/install/test_picker_comfyui.py`. + +**Interfaces:** Consumes `CAPABILITIES` (2.2); Produces selections recorded (no pull at install, `--no-pull`); auto-mode picks best 4-step per enabled capability. + +- [ ] **Step 1:** Test: auto selections cover all 5 capabilities w/ default variants; interactive `_choose_model` lists alternatives w/ est_seconds; selections persisted, NOT pulled. +- [ ] **Step 2:** Run -> FAIL. +- [ ] **Step 3:** Wire matrix into picker (TUI + `/curated-models` + auto build). Post-install: expose `POST /api/comfyui/models/fetch` to trigger deferred pulls (uses 2.4). +- [ ] **Step 4:** Run -> PASS. +- [ ] **Step 5: Commit** `feat(comfyui): install-time capability model picker`. + +--- + +# PHASE 4 — API: control + monitoring + +### Task 4.1: Un-gate switchover + control routes + +**Files:** Modify `src/hal0/api/routes/comfyui.py`; Test `tests/api/test_comfyui_routes.py`. + +**Interfaces:** Produces `POST /api/comfyui/render/cancel`, `/restart`, `GET /logs`, existing `/status`; switchover implicit (enqueue triggers arbiter; no manual toggle route). + +- [ ] **Step 1:** Test: cancel -> calls `/opt/comfyui/cancel.sh` (mock); restart -> restart.sh; status aggregates ComfyUI `/queue`+`/system_stats`; no 501. +- [ ] **Step 2:** Run -> FAIL (switchover 501). +- [ ] **Step 3:** Implement routes against shipped scripts; status reads ComfyUI `/queue`,`/history`,`/system_stats`. +- [ ] **Step 4:** Run -> PASS. +- [ ] **Step 5: Commit** `feat(comfyui): live control routes, un-gate switchover`. + +### Task 4.2: Telemetry contract (real signals) + +**Files:** Modify `src/hal0/api/routes/comfyui.py` (status payload); Test same. + +**Interfaces:** Produces `status.telemetry = {gtt_used,gtt_total,ram_used,ram_total,util,temp,clock,it_s,eta}`. GTT/RAM/temp/clock from hal0 real telemetry; util NOT raw `gpu_busy_percent` (forced-high artifact) — derive from active-job + duty signals; it_s/eta from ComfyUI ws progress. + +- [ ] **Step 1:** Test: payload has all keys; util not hardcoded 100 when idle (use job-presence gate). +- [ ] **Step 2:** Run -> FAIL. +- [ ] **Step 3:** Implement; reuse existing hal0 GPU telemetry helpers; gate util on render-active. +- [ ] **Step 4:** Run -> PASS. +- [ ] **Step 5: Commit** `feat(comfyui): real-telemetry status payload`. + +### Task 4.3: Quick-launch + preview proxy + +**Files:** Modify `comfyui.py`; Test same. + +**Interfaces:** Produces `POST /api/comfyui/workflows/{id}/launch` (fire curated workflow w/ defaults -> ComfyUI `/prompt`); `GET /api/comfyui/preview` (proxy ComfyUI `/view` latest output). + +- [ ] **Step 1:** Test: launch posts workflow json to `/prompt` (mock); preview proxies latest `/history` image bytes. +- [ ] **Step 2:** Run -> FAIL. +- [ ] **Step 3:** Implement (workflows from baked `/opt/comfy-workflows/`). +- [ ] **Step 4:** Run -> PASS. +- [ ] **Step 5: Commit** `feat(comfyui): quick-launch + preview proxy`. + +--- + +# PHASE 5 — Pane UI (V2 Render hero) + +Port `Design/design_handoff_comfyui_imagegen/design/` into ui/. Component reads `RUN/QUEUE/GTT/RAM/STATS` objects (handoff §Integration). + +### Task 5.1: Port ImageGenCard + comfy.css (mock data) + +**Files:** Create `ui/src/pages/slots/ImageGenCard.jsx`, `comfy.css`, `comfy-core.jsx`; Test `ui/tests/e2e/imagegen.spec.ts` (forced-mock). + +- [ ] **Step 1:** e2e test (mock): card renders render-hero + queue + telemetry + workflows strip + footer; reduced-motion freezes pulse; empty-queue state has no overlay (recall #845 lockup). +- [ ] **Step 2:** Run -> FAIL. +- [ ] **Step 3:** Port jsx/css; scope under `.comfy-page`; mock fixture matches handoff demo data. +- [ ] **Step 4:** Run -> PASS (`npx playwright test imagegen`). +- [ ] **Step 5: Commit** `feat(ui): ImageGen V2 render-hero pane (mock)`. + +### Task 5.2: Bind to live API + +**Files:** Modify `comfy-core.jsx` (data hooks), slots page mount; Test e2e live-shaped mock. + +**Interfaces:** Consumes 4.1/4.2/4.3 routes. RUN<-status.active; QUEUE<-status.queue; GTT/RAM/STATS<-status.telemetry; preview<-/preview. + +- [ ] **Step 1:** Test: controls fire correct endpoints (route intercept asserts); progress/queue/telemetry bind; cancel disables while pending. +- [ ] **Step 2:** Run -> FAIL. +- [ ] **Step 3:** Wire fetch hooks (900ms tick), control handlers, preview img. +- [ ] **Step 4:** Run -> PASS. +- [ ] **Step 5: Commit** `feat(ui): wire ImageGen pane to live ComfyUI API`. + +--- + +# PHASE 6 — Integration, deploy, docs + +### Task 6.1: Profile/manifest -> ghcr image + +**Files:** Modify `schema.py:763-770` SEED_PROFILES, `installer/etc-hal0/profiles.toml`, `slots/img.toml`, `manifest.json`; Test `tests/config/test_comfyui_profile.py`. + +- [ ] **Step 1:** Test: comfyui profile image == ghcr digest; img.toml profile==comfyui, port 8188, device gpu-rocm. +- [ ] **Step 2:** Run -> FAIL. +- [ ] **Step 3:** Update all 4 (+ live CT105 `/etc/hal0/profiles.toml` per memory note — deploy.sh won't sync it). +- [ ] **Step 4:** Run -> PASS. +- [ ] **Step 5: Commit** `feat(comfyui): point profile/manifest at ghcr image`. + +### Task 6.2: Full pytest + e2e + lint + +- [ ] Run `PYTHONPATH=src pytest tests/comfyui tests/install tests/api tests/registry tests/config -v` -> all pass (CT132 box or CI; whole-suite hangs locally per memory). +- [ ] Run `cd ui && npx playwright test imagegen` -> pass. +- [ ] `ruff format --check` (NOT black). Commit fixes. + +### Task 6.3: CT105 live validation (cheapest render) + +**Tier-3 (verify-then-act): another session may hold /opt/hal0 — run `wip hal0 status` first; deploy to PREVIEW ref, not main.** + +- [ ] `wip hal0 status` clean; `wip hal0 claim "comfyui integration deploy" /opt/hal0`. +- [ ] Build+push ghcr comfyui image; refresh digest. +- [ ] `ssh hal0 'cd /opt/hal0 && sudo bash scripts/deploy.sh --ref origin/'`. +- [ ] Fetch ONE cheapest model set (ESRGAN or SDXL-Lightning) via `/api/comfyui/models/fetch`. +- [ ] Real-browser pass (Playwright MCP, not just spec — recall durability lesson): enqueue render -> verify (a) inference slots yield (header note), (b) progress/preview update, (c) cancel works, (d) GPU released after idle. +- [ ] Capture results; `wip hal0 release`. + +### Task 6.4: Docs + memory + +- [ ] `hal0-docs` skill: add ComfyUI image-gen page (capabilities, picker, flags, model store). +- [ ] Update README/PLAN + hal0-web CONTENT_BRIEF (recall docs-after-shipping rule). +- [ ] `hal0-memory` `memory_add`: integration decisions + gotchas (image recipe, path reconcile, switchover) dataset `shared`, `document_id=comfyui-platform-integration`. + +--- + +## Self-Review + +- **Spec coverage:** installer-official ✓(P3); kyuz0 recipe/image ✓(P1); extensions config ✓(3.3); templates baked ✓(1.2,2.3); model picker every capability ✓(2.2,3.5); set_extra_paths ✓(2.3); model_manager→wrapped fetch ✓(2.4); benchmark model choices+adds ✓(Decision 4); launch flags ✓(GC,1.1); pane wired+controls+monitoring ✓(P4,P5); testing ✓(P6). +- **Placeholders:** none — fetch script bodies vendored verbatim at exec time (cite upstream); CT105 script extraction is a real step (6.3/3.1). +- **Type consistency:** `ModelVariant`/`Capability` (2.2) consumed by fetch (2.4) + picker (3.5); `status.telemetry` keys (4.2) consumed by UI (5.2); control script paths single-sourced `installer/comfyui/scripts/` (3.1) consumed by sudoers (3.2)+routes (4.1). +- **Open risk:** TheRock nightly wheel pinning — must pin exact versions in CI (1.1) or builds drift; flagged upstream WIP. From 9071cd95f2160dbeeb383607447c88c9761c939a Mon Sep 17 00:00:00 2001 From: Alexander Date: Tue, 16 Jun 2026 17:02:15 -0400 Subject: [PATCH 02/24] fix(comfyui): model pulls target /mnt/ai-models/comfyui/models _comfyui_models_dir() now returns model_store_root()/comfyui/models/ instead of hardcoded /var/lib/hal0/comfyui/models/. Aligns with per-install model-store config and deployment expectations. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/hal0/registry/pull.py | 14 +++++++------- tests/registry/test_comfyui_path.py | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+), 7 deletions(-) create mode 100644 tests/registry/test_comfyui_path.py diff --git a/src/hal0/registry/pull.py b/src/hal0/registry/pull.py index de2051d1..a860dc5a 100644 --- a/src/hal0/registry/pull.py +++ b/src/hal0/registry/pull.py @@ -172,20 +172,20 @@ def _final_path(model_id: str, filename: str) -> Path: def _comfyui_models_dir(subdir: str) -> Path: - """ComfyUI checkpoints/loras/vae directory under the persistent base dir. + """ComfyUI checkpoints/loras/vae directory under the model store. - Hal0 ComfyUI slots bind-mount ``/var/lib/hal0/comfyui`` into the - container at the same path, with ``models//`` being - the layout ComfyUI's own ``CheckpointLoaderSimple`` / - ``LoraLoader`` / etc. expect when ``--base-directory`` points at - that root. + ComfyUI models live under the configurable model-store root + (default /mnt/ai-models) at /comfyui/models/, aligned + with the slot's bind-mount path into the container. This ensures + ComfyUI's own CheckpointLoaderSimple / LoraLoader / etc. find + models when --base-directory points at the root. The subdir name is sanitised the same way model ids are so a curated entry can't escape the comfyui tree by setting ``comfyui_subdir="../../etc/passwd"``. """ cleaned = _SANITISE_RE.sub("-", subdir).strip("-.") or "checkpoints" - return paths.var_lib() / "comfyui" / "models" / cleaned + return Path(paths.model_store_root()) / "comfyui" / "models" / cleaned def _final_path_for_entry( diff --git a/tests/registry/test_comfyui_path.py b/tests/registry/test_comfyui_path.py new file mode 100644 index 00000000..04259bbe --- /dev/null +++ b/tests/registry/test_comfyui_path.py @@ -0,0 +1,19 @@ +"""TDD: test _comfyui_models_dir uses model_store_root() instead of hardcoded /var/lib/hal0.""" + +from pathlib import Path + +import pytest + +from hal0.registry import pull +from hal0.config import paths + + +def test_comfyui_models_dir_uses_store_root(monkeypatch, tmp_path): + """_comfyui_models_dir must return model_store_root()/comfyui/models/.""" + # Monkeypatch model_store_root to return tmp_path + monkeypatch.setattr(paths, "model_store_root", lambda: str(tmp_path)) + + result = pull._comfyui_models_dir("loras") + expected = tmp_path / "comfyui" / "models" / "loras" + + assert result == expected From 7feb8d2ae1f04fb9c02e43a98998e7516f2f55fd Mon Sep 17 00:00:00 2001 From: Alexander Date: Tue, 16 Jun 2026 17:08:55 -0400 Subject: [PATCH 03/24] feat(comfyui): vendor fetch scripts + sdxl/esrgan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Vendor 5 kyuz0 scripts (set_extra_paths, get_qwen_image, get_wan22, get_hunyuan15, get_ltx2) — adapted MODEL_DIR → /mnt/ai-models/comfyui/models - New get_sdxl.sh: SDXL base + SDXL-Lightning 8-step LoRA + sdxl-vae-fp16-fix; supports --precision and --dry-run - New get_esrgan.sh: 4x-UltraSharp + RealESRGAN_x4plus → upscale_models/; supports --dry-run - TDD: tests/comfyui/test_fetch_scripts.py 24/24 pass (bash -n, exec bit, yaml 8 keys + base_path, dry-run subdir assertions) Co-Authored-By: Claude Sonnet 4.6 --- installer/comfyui/scripts/get_esrgan.sh | 48 +++++++ installer/comfyui/scripts/get_hunyuan15.sh | 136 +++++++++++++++++++ installer/comfyui/scripts/get_ltx2.sh | 107 +++++++++++++++ installer/comfyui/scripts/get_qwen_image.sh | 98 +++++++++++++ installer/comfyui/scripts/get_sdxl.sh | 86 ++++++++++++ installer/comfyui/scripts/get_wan22.sh | 127 +++++++++++++++++ installer/comfyui/scripts/set_extra_paths.sh | 28 ++++ tests/comfyui/test_fetch_scripts.py | 126 +++++++++++++++++ 8 files changed, 756 insertions(+) create mode 100755 installer/comfyui/scripts/get_esrgan.sh create mode 100755 installer/comfyui/scripts/get_hunyuan15.sh create mode 100755 installer/comfyui/scripts/get_ltx2.sh create mode 100755 installer/comfyui/scripts/get_qwen_image.sh create mode 100755 installer/comfyui/scripts/get_sdxl.sh create mode 100755 installer/comfyui/scripts/get_wan22.sh create mode 100755 installer/comfyui/scripts/set_extra_paths.sh create mode 100644 tests/comfyui/test_fetch_scripts.py diff --git a/installer/comfyui/scripts/get_esrgan.sh b/installer/comfyui/scripts/get_esrgan.sh new file mode 100755 index 00000000..6d9ae33e --- /dev/null +++ b/installer/comfyui/scripts/get_esrgan.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# get_esrgan.sh — download ESRGAN upscale models for ComfyUI +# Targets: upscale_models/4x-UltraSharp.pth + upscale_models/RealESRGAN_x4plus.pth +# hal0 model store: /mnt/ai-models/comfyui/models +# Follows kyuz0 vendored-script conventions (curl download, dry-run). +set -euo pipefail + +MODEL_DIR="${MODEL_DIR:-/mnt/ai-models/comfyui/models}" +DRY_RUN=0 + +for arg in "$@"; do + case "$arg" in + --dry-run) DRY_RUN=1 ;; + esac +done + +if [[ "$DRY_RUN" -eq 1 ]]; then + echo "[dry-run] MODEL_DIR=$MODEL_DIR" + echo "[dry-run] Would download to:" + echo " upscale_models/ 4x-UltraSharp.pth" + echo " upscale_models/ RealESRGAN_x4plus.pth" + exit 0 +fi + +mkdir -p "$MODEL_DIR/upscale_models" + +download_if_missing() { + local url="$1" + local dest_file="$MODEL_DIR/upscale_models/$(basename "$url")" + + if [[ -f "$dest_file" ]]; then + echo "✓ Already present: $dest_file" + return + fi + + echo "↓ Downloading $(basename "$url") → $dest_file" + curl -fL --progress-bar -o "$dest_file" "$url" +} + +# 4x-UltraSharp (widely used ESRGAN upscale model) +download_if_missing \ + "https://huggingface.co/Kim2091/4x-UltraSharp/resolve/main/4x-UltraSharp.pth" + +# RealESRGAN x4plus (xinntao/Real-ESRGAN official release) +download_if_missing \ + "https://github.com/xinntao/Real-ESRGAN/releases/download/v0.1.0/RealESRGAN_x4plus.pth" + +echo "✓ ESRGAN models ready in $MODEL_DIR/upscale_models" diff --git a/installer/comfyui/scripts/get_hunyuan15.sh b/installer/comfyui/scripts/get_hunyuan15.sh new file mode 100755 index 00000000..64981b12 --- /dev/null +++ b/installer/comfyui/scripts/get_hunyuan15.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +# get_hunyuan15.sh (resume-friendly) +# Downloads models for ComfyUI HunyuanVideo 1.5 (T2V & I2V) +# Vendored from https://raw.githubusercontent.com/kyuz0/amd-strix-halo-comfyui-toolboxes/main/scripts/get_hunyuan15.sh +# vendored 2026-06-16 — adapted: MODEL_DIR defaults to /mnt/ai-models/comfyui/models (hal0 store) +set -euo pipefail + +export HF_HUB_ENABLE_HF_TRANSFER=1 +export HF_HOME="${HF_HOME:-$HOME/.cache/huggingface}" +HF="/opt/venv/bin/hf" + +MODEL_DIR="${MODEL_DIR:-/mnt/ai-models/comfyui/models}" +STAGE="$MODEL_DIR/.hf_stage_hunyuan15" + +# Repositories +REPO_MAIN="Comfy-Org/HunyuanVideo_1.5_repackaged" +REPO_VISION="Comfy-Org/sigclip_vision_384" + +# Ensure directories exist +mkdir -p "$MODEL_DIR"/{text_encoders,vae,diffusion_models,clip_vision,latent_upscale_models,loras} +mkdir -p "$STAGE" + +download_if_missing () { + local repo="$1" + local remote="$2" + local dest_path="$3" + + local dest_dir="$MODEL_DIR/$dest_path" + local dest_file="$dest_dir/$(basename "$remote")" + local staged="$STAGE/$remote" + + if [[ -f "$dest_file" ]]; then + echo "✓ Already present: $dest_file" + return + fi + + echo "↓ Downloading $(basename "$remote") → $dest_file" + mkdir -p "$(dirname "$staged")" + mkdir -p "$dest_dir" + + "$HF" download "$repo" "$remote" \ + --repo-type model \ + --cache-dir "$HF_HOME" \ + --local-dir "$STAGE" + mv -f "$staged" "$dest_file" +} + +usage() { + cat <<'USAGE' +Usage: get_hunyuan15.sh + +Targets: + common Text Encoders, VAE, CLIP Vision (Shared dependencies) + - text_encoders/qwen_2.5_vl_7b_fp8_scaled.safetensors + - text_encoders/byt5_small_glyphxl_fp16.safetensors + - vae/hunyuanvideo15_vae_fp16.safetensors + - clip_vision/sigclip_vision_patch14_384.safetensors (Only for I2V) + + 720p-t2v Text-to-Video Model (FP16) + - diffusion_models/hunyuanvideo1.5_720p_t2v_fp16.safetensors + + 720p-i2v Image-to-Video Model (FP16) + - diffusion_models/hunyuanvideo1.5_720p_i2v_fp16.safetensors + + upscale Upscaling Models (1080p SR + Latent Upsampler) + - diffusion_models/hunyuanvideo1.5_1080p_sr_distilled_fp16.safetensors + - latent_upscale_models/hunyuanvideo15_latent_upsampler_1080p.safetensors + + lora HunyuanVideo 1.5 LoRAs + - loras/hunyuanvideo1.5_t2v_480p_lightx2v_4step_lora_rank_32_bf16.safetensors + + all Download EVERYTHING (T2V, I2V, Upscale, LoRA, Common) + +Maintenance: + clean-stage Remove staging folder (keeps final models) + clean-cache Remove Hugging Face cache (~/.cache/huggingface) + +USAGE +} + +case "${1:-}" in + common) + echo "==> Text Encoders, VAE, & CLIP Vision" + download_if_missing "$REPO_MAIN" "split_files/text_encoders/qwen_2.5_vl_7b_fp8_scaled.safetensors" "text_encoders" + download_if_missing "$REPO_MAIN" "split_files/text_encoders/byt5_small_glyphxl_fp16.safetensors" "text_encoders" + download_if_missing "$REPO_MAIN" "split_files/vae/hunyuanvideo15_vae_fp16.safetensors" "vae" + download_if_missing "$REPO_VISION" "sigclip_vision_patch14_384.safetensors" "clip_vision" + ;; + + 720p-t2v) + echo "==> 720p Text-to-Video Model" + download_if_missing "$REPO_MAIN" "split_files/diffusion_models/hunyuanvideo1.5_720p_t2v_fp16.safetensors" "diffusion_models" + ;; + + 720p-i2v) + echo "==> 720p Image-to-Video Model" + download_if_missing "$REPO_MAIN" "split_files/diffusion_models/hunyuanvideo1.5_720p_i2v_fp16.safetensors" "diffusion_models" + ;; + + upscale) + echo "==> 1080p Upscaling Models" + download_if_missing "$REPO_MAIN" "split_files/diffusion_models/hunyuanvideo1.5_1080p_sr_distilled_fp16.safetensors" "diffusion_models" + download_if_missing "$REPO_MAIN" "split_files/latent_upscale_models/hunyuanvideo15_latent_upsampler_1080p.safetensors" "latent_upscale_models" + ;; + + lora) + echo "==> HunyuanVideo 1.5 LoRAs" + download_if_missing "$REPO_MAIN" "split_files/loras/hunyuanvideo1.5_t2v_480p_lightx2v_4step_lora_rank_32_bf16.safetensors" "loras" + ;; + + all) + echo "==> Downloading Full Suite (T2V + I2V + Upscale + LoRA)..." + "$0" common + "$0" 720p-t2v + "$0" 720p-i2v + "$0" upscale + "$0" lora + ;; + + clean-stage) + rm -rf "$STAGE"; echo "✓ Removed stage: $STAGE" + ;; + clean-cache) + rm -rf "$HF_HOME"; echo "✓ Removed HF cache: $HF_HOME" + ;; + ""|-h|--help|help) + usage + ;; + *) + echo "Unknown target: $1" >&2 + usage + exit 1 + ;; +esac + +echo "✓ Done." diff --git a/installer/comfyui/scripts/get_ltx2.sh b/installer/comfyui/scripts/get_ltx2.sh new file mode 100755 index 00000000..8cf31bbb --- /dev/null +++ b/installer/comfyui/scripts/get_ltx2.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# get_ltx2.sh (resume-friendly) +# Vendored from https://raw.githubusercontent.com/kyuz0/amd-strix-halo-comfyui-toolboxes/main/scripts/get_ltx2.sh +# vendored 2026-06-16 — adapted: MODEL_DIR defaults to /mnt/ai-models/comfyui/models (hal0 store) +set -euo pipefail + +export HF_HUB_ENABLE_HF_TRANSFER=1 +export HF_HOME="${HF_HOME:-$HOME/.cache/huggingface}" # persistent HF cache +HF="/opt/venv/bin/hf" + +MODEL_DIR="${MODEL_DIR:-/mnt/ai-models/comfyui/models}" +STAGE="$MODEL_DIR/.hf_stage_ltx2" # persistent staging (enables resume) + +mkdir -p "$MODEL_DIR"/{checkpoints,text_encoders,loras,latent_upscale_models} +mkdir -p "$STAGE" + +download_if_missing () { + local repo="$1" + local remote="$2" + local dest_path="$3" # Relative path under MODEL_DIR, e.g., "text_encoders" + + local dest_dir="$MODEL_DIR/$dest_path" + local dest_file="$dest_dir/$(basename "$remote")" + local staged="$STAGE/$remote" + + if [[ -f "$dest_file" ]]; then + echo "✓ Already present: $dest_file" + return + fi + + echo "↓ Downloading $(basename "$remote") → $dest_file" + mkdir -p "$(dirname "$staged")" # ensure stage path exists + mkdir -p "$dest_dir" # ensure dest dir exists + + "$HF" download "$repo" "$remote" \ + --repo-type model \ + --cache-dir "$HF_HOME" \ + --local-dir "$STAGE" + mv -f "$staged" "$dest_file" +} + +usage() { + cat <<'USAGE' +Usage: get_ltx2.sh [variant] + +Targets: + common Text encoder (Gemma 3) + Spatial Upscaler + checkpoint LTX-2 19B Checkpoint (Default: BF16. Use 'fp8' as 2nd arg for FP8) + lora Distilled LoRA + Camera Control LoRA + +Maintenance: + clean-stage Remove staging folder (keeps final models) + clean-cache Remove Hugging Face cache (~/.cache/huggingface) + +Notes: +- Downloads RESUME automatically via persistent --cache-dir and --local-dir. +USAGE +} + +case "${1:-}" in + common) + echo "==> Text Encoder + Spatial Upscaler" + # Text Encoder: Gemma 3 12B IT FP4 Mixed + download_if_missing "Comfy-Org/ltx-2" "split_files/text_encoders/gemma_3_12B_it_fp4_mixed.safetensors" "text_encoders" + + # Spatial Upscaler x2 + download_if_missing "Lightricks/LTX-2" "ltx-2-spatial-upscaler-x2-1.0.safetensors" "latent_upscale_models" + ;; + + checkpoint) + VARIANT="${2:-bf16}" + echo "==> LTX-2 19B Checkpoint ($VARIANT)" + + if [[ "$VARIANT" == "fp8" ]]; then + download_if_missing "Lightricks/LTX-2" "ltx-2-19b-dev-fp8.safetensors" "checkpoints" + else + # Default / BF16 + download_if_missing "Lightricks/LTX-2" "ltx-2-19b-dev.safetensors" "checkpoints" + fi + ;; + + lora) + echo "==> LTX-2 LoRAs" + # Distilled LoRA + download_if_missing "Lightricks/LTX-2" "ltx-2-19b-distilled-lora-384.safetensors" "loras" + + # Camera Control LoRA + download_if_missing "Lightricks/LTX-2-19b-LoRA-Camera-Control-Dolly-Left" "ltx-2-19b-lora-camera-control-dolly-left.safetensors" "loras" + ;; + + clean-stage) + rm -rf "$STAGE"; echo "✓ Removed stage: $STAGE" + ;; + clean-cache) + rm -rf "$HF_HOME"; echo "✓ Removed HF cache: $HF_HOME" + ;; + ""|-h|--help|help) + usage + ;; + *) + echo "Unknown target: $1" >&2 + usage + exit 1 + ;; +esac + +echo "✓ Done." diff --git a/installer/comfyui/scripts/get_qwen_image.sh b/installer/comfyui/scripts/get_qwen_image.sh new file mode 100755 index 00000000..014dc199 --- /dev/null +++ b/installer/comfyui/scripts/get_qwen_image.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# get_qwen_image.sh (resume-friendly, supports Qwen Image + Qwen Image Edit) +# Vendored from https://raw.githubusercontent.com/kyuz0/amd-strix-halo-comfyui-toolboxes/main/scripts/get_qwen_image.sh +# vendored 2026-06-16 — adapted: MODEL_DIR defaults to /mnt/ai-models/comfyui/models (hal0 store) +set -euo pipefail + +export HF_HUB_ENABLE_HF_TRANSFER=1 +export HF_HOME="${HF_HOME:-$HOME/.cache/huggingface}" # persistent HF cache +HF="/opt/venv/bin/hf" + +MODEL_DIR="${MODEL_DIR:-/mnt/ai-models/comfyui/models}" +STAGE="$MODEL_DIR/.hf_stage_qwen" # persistent staging (resume support) + +mkdir -p "$MODEL_DIR"/{text_encoders,vae,diffusion_models,loras} +mkdir -p "$STAGE" + +dl() { + local repo="$1"; shift + local remote="$1"; shift + local subdir="$1"; shift + local dest="$MODEL_DIR/$subdir/$(basename "$remote")" + local staged="$STAGE/$remote" + + if [[ -f "$dest" ]]; then + echo "✓ Already present: $dest" + return + fi + + echo "↓ Downloading $(basename "$remote") → $dest" + mkdir -p "$(dirname "$staged")" + "$HF" download "$repo" "$remote" \ + --repo-type model \ + --cache-dir "$HF_HOME" \ + --local-dir "$STAGE" + mv -f "$staged" "$dest" +} + +echo "Which Qwen variant do you want to download?" +echo " 1) Qwen-Image 2512 (20B text-to-image)" +echo " 2) Qwen-Image-Edit 2511 (image editing)" +echo " 3) Qwen-Image-Lightning LoRA (4-steps)" +echo " 4) Qwen-Image-Edit-Lightning LoRA (4-steps, bf16)" + +# Check if an argument is provided +if [ -n "${1:-}" ]; then + choice="$1" +else + read -rp "Enter 1, 2, 3 or 4: " choice +fi + +PRECISION="fp8" +if [[ "${2:-}" == "bf16" ]]; then + PRECISION="bf16" +fi + +case "$choice" in + 1) + REPO="Comfy-Org/Qwen-Image_ComfyUI" + echo "==> Downloading Qwen-Image 2512 (20B) - $PRECISION" + if [[ "$PRECISION" == "bf16" ]]; then + dl "$REPO" "split_files/diffusion_models/qwen_image_2512_bf16.safetensors" "diffusion_models" + else + dl "$REPO" "split_files/diffusion_models/qwen_image_2512_fp8_e4m3fn.safetensors" "diffusion_models" + fi + dl "$REPO" "split_files/text_encoders/qwen_2.5_vl_7b_fp8_scaled.safetensors" "text_encoders" + dl "$REPO" "split_files/vae/qwen_image_vae.safetensors" "vae" + ;; + 2) + REPO="Comfy-Org/Qwen-Image-Edit_ComfyUI" + echo "==> Downloading Qwen-Image-Edit - $PRECISION" + # Requires text encoder + VAE from Qwen-Image + BASE="Comfy-Org/Qwen-Image_ComfyUI" + dl "$BASE" "split_files/text_encoders/qwen_2.5_vl_7b_fp8_scaled.safetensors" "text_encoders" + dl "$BASE" "split_files/vae/qwen_image_vae.safetensors" "vae" + + if [[ "$PRECISION" == "bf16" ]]; then + dl "$REPO" "split_files/diffusion_models/qwen_image_edit_2511_bf16.safetensors" "diffusion_models" + else + dl "$REPO" "split_files/diffusion_models/qwen_image_edit_2511_fp8mixed.safetensors" "diffusion_models" + fi + ;; + 3) + REPO="lightx2v/Qwen-Image-2512-Lightning" + echo "==> Downloading Qwen-Image-2512-Lightning LoRA" + dl "$REPO" "Qwen-Image-2512-Lightning-4steps-V1.0-bf16.safetensors" "loras" + ;; + 4) + REPO="lightx2v/Qwen-Image-Edit-2511-Lightning" + echo "==> Downloading Qwen-Image-Edit-Lightning LoRA" + dl "$REPO" "Qwen-Image-Edit-2511-Lightning-4steps-V1.0-bf16.safetensors" "loras" + ;; + *) + echo "Invalid choice. Exiting." + exit 1 + ;; +esac + +echo "✓ Models ready in $MODEL_DIR" diff --git a/installer/comfyui/scripts/get_sdxl.sh b/installer/comfyui/scripts/get_sdxl.sh new file mode 100755 index 00000000..69d41cb5 --- /dev/null +++ b/installer/comfyui/scripts/get_sdxl.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# get_sdxl.sh — download SDXL base + SDXL-Lightning LoRA + SDXL VAE for ComfyUI +# hal0 model store: /mnt/ai-models/comfyui/models +# Follows kyuz0 vendored-script conventions (hf download, resume, dry-run). +set -euo pipefail + +export HF_HUB_ENABLE_HF_TRANSFER=1 +export HF_HOME="${HF_HOME:-$HOME/.cache/huggingface}" +HF="/opt/venv/bin/hf" + +MODEL_DIR="${MODEL_DIR:-/mnt/ai-models/comfyui/models}" +STAGE="$MODEL_DIR/.hf_stage_sdxl" + +PRECISION="${PRECISION:-fp16}" +DRY_RUN=0 + +# Parse args +for arg in "$@"; do + case "$arg" in + --dry-run) DRY_RUN=1 ;; + --precision=*) PRECISION="${arg#--precision=}" ;; + --precision) shift; PRECISION="${1:-fp16}" ;; + esac +done + +if [[ "$DRY_RUN" -eq 1 ]]; then + echo "[dry-run] MODEL_DIR=$MODEL_DIR PRECISION=$PRECISION" + echo "[dry-run] Would download to:" + echo " checkpoints/ stabilityai/stable-diffusion-xl-base-1.0 → sd_xl_base_1.0.safetensors" + echo " loras/ ByteDance/SDXL-Lightning → sdxl_lightning_8step_lora.safetensors" + echo " vae/ madebyollin/sdxl-vae-fp16-fix → diffusion_pytorch_model.safetensors" + exit 0 +fi + +mkdir -p "$MODEL_DIR"/{checkpoints,loras,vae} +mkdir -p "$STAGE" + +download_if_missing() { + local repo="$1" + local remote="$2" + local dest_path="$3" + local dest_name="${4:-$(basename "$remote")}" + + local dest_dir="$MODEL_DIR/$dest_path" + local dest_file="$dest_dir/$dest_name" + local staged="$STAGE/$remote" + + if [[ -f "$dest_file" ]]; then + echo "✓ Already present: $dest_file" + return + fi + + echo "↓ Downloading $(basename "$remote") → $dest_file" + mkdir -p "$(dirname "$staged")" + mkdir -p "$dest_dir" + + "$HF" download "$repo" "$remote" \ + --repo-type model \ + --cache-dir "$HF_HOME" \ + --local-dir "$STAGE" + mv -f "$staged" "$dest_file" +} + +# 1. SDXL base checkpoint (stabilityai/stable-diffusion-xl-base-1.0) +echo "==> SDXL base checkpoint" +download_if_missing \ + "stabilityai/stable-diffusion-xl-base-1.0" \ + "sd_xl_base_1.0.safetensors" \ + "checkpoints" + +# 2. SDXL VAE (madebyollin/sdxl-vae-fp16-fix) +echo "==> SDXL VAE (fp16-fix)" +download_if_missing \ + "madebyollin/sdxl-vae-fp16-fix" \ + "diffusion_pytorch_model.safetensors" \ + "vae" \ + "sdxl_vae_fp16_fix.safetensors" + +# 3. SDXL-Lightning 8-step LoRA (ByteDance/SDXL-Lightning) +echo "==> SDXL-Lightning 8-step LoRA" +download_if_missing \ + "ByteDance/SDXL-Lightning" \ + "sdxl_lightning_8step_lora.safetensors" \ + "loras" + +echo "✓ SDXL models ready in $MODEL_DIR" diff --git a/installer/comfyui/scripts/get_wan22.sh b/installer/comfyui/scripts/get_wan22.sh new file mode 100755 index 00000000..8f33bdd8 --- /dev/null +++ b/installer/comfyui/scripts/get_wan22.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +# get_wan22.sh (resume-friendly) +# Vendored from https://raw.githubusercontent.com/kyuz0/amd-strix-halo-comfyui-toolboxes/main/scripts/get_wan22.sh +# vendored 2026-06-16 — adapted: MODEL_DIR defaults to /mnt/ai-models/comfyui/models (hal0 store) +set -euo pipefail + +export HF_HUB_ENABLE_HF_TRANSFER=1 +export HF_HOME="${HF_HOME:-$HOME/.cache/huggingface}" # persistent HF cache +HF="/opt/venv/bin/hf" + +MODEL_DIR="${MODEL_DIR:-/mnt/ai-models/comfyui/models}" +STAGE="$MODEL_DIR/.hf_stage_wan22" # persistent staging (enables resume) + +# Repositories +REPO_22="Comfy-Org/Wan_2.2_ComfyUI_Repackaged" +REPO_21="Comfy-Org/Wan_2.1_ComfyUI_repackaged" +REPO_LORA="lightx2v/Wan2.2-Lightning" + +mkdir -p "$MODEL_DIR"/{text_encoders,vae,diffusion_models,loras} +mkdir -p "$STAGE" + +PRECISION="fp8" +if [[ "${2:-}" == "fp16" ]]; then + PRECISION="fp16" +fi + +download_if_missing () { + local repo="$1" + local remote="$2" + local dest_path="$3" # Relative path under MODEL_DIR, e.g., "text_encoders" or "loras/subdir" + + local dest_dir="$MODEL_DIR/$dest_path" + local dest_file="$dest_dir/$(basename "$remote")" + local staged="$STAGE/$remote" + + if [[ -f "$dest_file" ]]; then + echo "✓ Already present: $dest_file" + return + fi + + echo "↓ Downloading $(basename "$remote") → $dest_file" + mkdir -p "$(dirname "$staged")" # ensure stage path exists + mkdir -p "$dest_dir" # ensure dest dir exists + + "$HF" download "$repo" "$remote" \ + --repo-type model \ + --cache-dir "$HF_HOME" \ + --local-dir "$STAGE" + mv -f "$staged" "$dest_file" +} + +usage() { + cat <<'USAGE' +Usage: get_wan22.sh [fp16] + +Targets: + common Text encoder + VAEs + 14b-t2v 14B T2V diffusion models (Defaults to FP8, use 'fp16' as 2nd arg for FP16) + 14b-i2v 14B I2V diffusion models (Defaults to FP8, use 'fp16' as 2nd arg for FP16) + lora Wan2.2 Lightning LoRAs + +Maintenance: + clean-stage Remove staging folder (keeps final models) + clean-cache Remove Hugging Face cache (~/.cache/huggingface) + +Notes: +- Downloads RESUME automatically via persistent --cache-dir and --local-dir. +USAGE +} + +case "${1:-}" in + common) + echo "==> text encoder + VAEs" + if [[ "$PRECISION" == "fp16" ]]; then + download_if_missing "$REPO_22" "split_files/text_encoders/umt5_xxl_fp16.safetensors" "text_encoders" + else + download_if_missing "$REPO_21" "split_files/text_encoders/umt5_xxl_fp8_e4m3fn_scaled.safetensors" "text_encoders" + fi + download_if_missing "$REPO_22" "split_files/vae/wan_2.1_vae.safetensors" "vae" + ;; + 14b-t2v) + echo "==> 14B Text→Video ($PRECISION)" + if [[ "$PRECISION" == "fp16" ]]; then + download_if_missing "$REPO_22" "split_files/diffusion_models/wan2.2_t2v_high_noise_14B_fp16.safetensors" "diffusion_models" + download_if_missing "$REPO_22" "split_files/diffusion_models/wan2.2_t2v_low_noise_14B_fp16.safetensors" "diffusion_models" + else + download_if_missing "$REPO_22" "split_files/diffusion_models/wan2.2_t2v_high_noise_14B_fp8_scaled.safetensors" "diffusion_models" + download_if_missing "$REPO_22" "split_files/diffusion_models/wan2.2_t2v_low_noise_14B_fp8_scaled.safetensors" "diffusion_models" + fi + ;; + 14b-i2v) + echo "==> 14B Image→Video ($PRECISION)" + if [[ "$PRECISION" == "fp16" ]]; then + download_if_missing "$REPO_22" "split_files/diffusion_models/wan2.2_i2v_high_noise_14B_fp16.safetensors" "diffusion_models" + download_if_missing "$REPO_22" "split_files/diffusion_models/wan2.2_i2v_low_noise_14B_fp16.safetensors" "diffusion_models" + else + download_if_missing "$REPO_22" "split_files/diffusion_models/wan2.2_i2v_high_noise_14B_fp8_scaled.safetensors" "diffusion_models" + download_if_missing "$REPO_22" "split_files/diffusion_models/wan2.2_i2v_low_noise_14B_fp8_scaled.safetensors" "diffusion_models" + fi + ;; + lora) + echo "==> Wan2.2 Lightning LoRAs (Seko V2)" + LORA_SUBDIR_T2V="loras/Wan2.2-T2V-A14B-4steps-lora-rank64-Seko-V2.0" + download_if_missing "$REPO_LORA" "Wan2.2-T2V-A14B-4steps-lora-rank64-Seko-V2.0/high_noise_model.safetensors" "$LORA_SUBDIR_T2V" + download_if_missing "$REPO_LORA" "Wan2.2-T2V-A14B-4steps-lora-rank64-Seko-V2.0/low_noise_model.safetensors" "$LORA_SUBDIR_T2V" + + LORA_SUBDIR_I2V="loras/Wan2.2-I2V-A14B-4steps-lora-rank64-Seko-V1" + download_if_missing "$REPO_LORA" "Wan2.2-I2V-A14B-4steps-lora-rank64-Seko-V1/high_noise_model.safetensors" "$LORA_SUBDIR_I2V" + download_if_missing "$REPO_LORA" "Wan2.2-I2V-A14B-4steps-lora-rank64-Seko-V1/low_noise_model.safetensors" "$LORA_SUBDIR_I2V" + ;; + clean-stage) + rm -rf "$STAGE"; echo "✓ Removed stage: $STAGE" + ;; + clean-cache) + rm -rf "$HF_HOME"; echo "✓ Removed HF cache: $HF_HOME" + ;; + ""|-h|--help|help) + usage + ;; + *) + echo "Unknown target: $1" >&2 + usage + exit 1 + ;; +esac + +echo "✓ Done." diff --git a/installer/comfyui/scripts/set_extra_paths.sh b/installer/comfyui/scripts/set_extra_paths.sh new file mode 100755 index 00000000..b8a7eac0 --- /dev/null +++ b/installer/comfyui/scripts/set_extra_paths.sh @@ -0,0 +1,28 @@ +#!/bin/bash +# set_extra_paths.sh +# Vendored from https://raw.githubusercontent.com/kyuz0/amd-strix-halo-comfyui-toolboxes/main/scripts/set_extra_paths.sh +# vendored 2026-06-16 — adapted: MODEL_DIR defaults to /mnt/ai-models/comfyui/models (hal0 store) + +set -euo pipefail + +CONFY_DIR="${CONFY_DIR:-/opt/ComfyUI}" +YAML_FILE="$CONFY_DIR/extra_model_paths.yaml" +MODEL_DIR="${MODEL_DIR:-/mnt/ai-models/comfyui/models}" + +mkdir -p "$MODEL_DIR"/{text_encoders,vae,diffusion_models,loras} + +cat > "$YAML_FILE" < Date: Tue, 16 Jun 2026 17:11:26 -0400 Subject: [PATCH 04/24] =?UTF-8?q?feat(comfyui):=20Task=202.2=20=E2=80=94?= =?UTF-8?q?=20capability=20registry=20with=20ModelVariant=20dataclasses?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds src/hal0/comfyui/capabilities.py: CAPABILITIES dict (5 caps × 14 variants) with Capability/ModelVariant dataclasses and default_variant() helper. Tests in tests/comfyui/test_capabilities.py (8 tests, all green). Co-Authored-By: Claude Sonnet 4.6 --- src/hal0/comfyui/__init__.py | 1 + src/hal0/comfyui/capabilities.py | 80 ++++++++++++++++++++++++++++++ tests/comfyui/test_capabilities.py | 55 ++++++++++++++++++++ 3 files changed, 136 insertions(+) create mode 100644 src/hal0/comfyui/__init__.py create mode 100644 src/hal0/comfyui/capabilities.py create mode 100644 tests/comfyui/test_capabilities.py diff --git a/src/hal0/comfyui/__init__.py b/src/hal0/comfyui/__init__.py new file mode 100644 index 00000000..0eb66221 --- /dev/null +++ b/src/hal0/comfyui/__init__.py @@ -0,0 +1 @@ +# hal0.comfyui package diff --git a/src/hal0/comfyui/capabilities.py b/src/hal0/comfyui/capabilities.py new file mode 100644 index 00000000..2ea65ad5 --- /dev/null +++ b/src/hal0/comfyui/capabilities.py @@ -0,0 +1,80 @@ +"""ComfyUI capability registry — Task 2.2.""" +from __future__ import annotations +from dataclasses import dataclass, field +from typing import Optional + + +@dataclass +class ModelVariant: + family: str + precision: Optional[str] + lora: Optional[str] + est_seconds: int + fetch_script: str + workflow: str + + +@dataclass +class Capability: + id: str + label: str + default_family: str + alternatives: list[ModelVariant] = field(default_factory=list) + + +def default_variant(cap: "str | Capability") -> ModelVariant: + """Return the default (first) variant for a capability id or Capability.""" + if isinstance(cap, str): + cap = CAPABILITIES[cap] + return cap.alternatives[0] + + +CAPABILITIES: dict[str, Capability] = { + "txt2img": Capability( + id="txt2img", + label="Text → Image", + default_family="qwen-image", + alternatives=[ + ModelVariant("qwen-image", "bf16", "lightning-4step", 75, "get_qwen_image.sh", "Qwen-Image-2512-BF16-4-Step-LoRA.json"), + ModelVariant("qwen-image", "bf16", None, 359, "get_qwen_image.sh", "Qwen-Image-2512-BF16-20-Steps.json"), + ModelVariant("sdxl", "fp16", "lightning-8step", 10, "get_sdxl.sh", "SDXL-Lightning-8step.json"), + ], + ), + "img2img": Capability( + id="img2img", + label="Image Edit", + default_family="qwen-image-edit", + alternatives=[ + ModelVariant("qwen-image-edit", "bf16", "lightning-4step", 113, "get_qwen_image.sh", "Qwen-Image-Edit-2511-BF16-4-Step-LoRA.json"), + ModelVariant("qwen-image-edit", "bf16", None, 667, "get_qwen_image.sh", "Qwen-Image-Edit-2511-BF16-20-Steps.json"), + ], + ), + "txt2video": Capability( + id="txt2video", + label="Text → Video", + default_family="ltx2", + alternatives=[ + ModelVariant("ltx2", "bf16", None, 615, "get_ltx2.sh", "LTX2-T2V-BF16.json"), + ModelVariant("hunyuan15", "fp16", "lightx2v-4step", 929, "get_hunyuan15.sh", "Hunyuan-Video-1.5_720p_t2v-4-step-lora.json"), + ModelVariant("wan22", "fp16", "seko-v2-4step", 2007, "get_wan22.sh", "Wan2.2-T2V-A14B-FP16-4steps-lora-rank64-Seko-V2.json"), + ], + ), + "img2video": Capability( + id="img2video", + label="Image → Video", + default_family="ltx2", + alternatives=[ + ModelVariant("ltx2", "bf16", None, 616, "get_ltx2.sh", "LTX2-I2V-BF16.json"), + ModelVariant("hunyuan15", "fp16", "lightx2v-4step", 947, "get_hunyuan15.sh", "Hunyuan-Video-1.5_720p_i2v-4-step-lora.json"), + ModelVariant("wan22", "fp16", "seko-v1-4step", 2029, "get_wan22.sh", "Wan2.2-I2V-A14B-4steps-lora-rank64-Seko-V1-FP16.json"), + ], + ), + "image_upscale": Capability( + id="image_upscale", + label="Upscale", + default_family="esrgan", + alternatives=[ + ModelVariant("esrgan", None, None, 10, "get_esrgan.sh", "ESRGAN-4x-Upscale.json"), + ], + ), +} diff --git a/tests/comfyui/test_capabilities.py b/tests/comfyui/test_capabilities.py new file mode 100644 index 00000000..af9e6962 --- /dev/null +++ b/tests/comfyui/test_capabilities.py @@ -0,0 +1,55 @@ +"""Task 2.2: ComfyUI capability registry — TDD tests.""" +import os +import pytest +from hal0.comfyui.capabilities import CAPABILITIES, Capability, ModelVariant, default_variant + +SCRIPTS_DIR = os.path.join( + os.path.dirname(__file__), + "../../installer/comfyui/scripts", +) + + +def test_all_capability_ids_present(): + assert set(CAPABILITIES.keys()) == {"txt2img", "img2img", "txt2video", "img2video", "image_upscale"} + + +def test_txt2img_default_family(): + assert CAPABILITIES["txt2img"].default_family == "qwen-image" + + +def test_default_variant_est_seconds(): + v = default_variant("txt2img") + assert v.est_seconds <= 80 + + +def test_ltx2_default_txt2video(): + assert CAPABILITIES["txt2video"].default_family == "ltx2" + assert default_variant("txt2video").family == "ltx2" + + +def test_ltx2_default_img2video(): + assert CAPABILITIES["img2video"].default_family == "ltx2" + assert default_variant("img2video").family == "ltx2" + + +def test_every_variant_fetch_script_exists(): + for cap_id, cap in CAPABILITIES.items(): + for v in cap.alternatives: + script_path = os.path.abspath(os.path.join(SCRIPTS_DIR, v.fetch_script)) + assert os.path.isfile(script_path), ( + f"{cap_id}/{v.family}: fetch_script {v.fetch_script!r} not found at {script_path}" + ) + + +def test_default_variant_is_first_alternative(): + for cap_id, cap in CAPABILITIES.items(): + v = default_variant(cap_id) + assert v is cap.alternatives[0], f"{cap_id}: default_variant should be first alternative" + + +def test_capability_labels(): + assert CAPABILITIES["txt2img"].label == "Text → Image" + assert CAPABILITIES["img2img"].label == "Image Edit" + assert CAPABILITIES["txt2video"].label == "Text → Video" + assert CAPABILITIES["img2video"].label == "Image → Video" + assert CAPABILITIES["image_upscale"].label == "Upscale" From e2f4e3513d3933e6d590d8996316bdede9981e40 Mon Sep 17 00:00:00 2001 From: Alexander Date: Tue, 16 Jun 2026 17:13:53 -0400 Subject: [PATCH 05/24] feat(comfyui): async model fetch wrapper Task 2.4: fetch_model(variant) -> job_id via subprocess.Popen; get_job/cancel_job with module-level registry. esrgan (precision=None) skips --precision arg. 11 TDD tests (mocked Popen), no real downloads. Co-Authored-By: Claude Sonnet 4.6 --- src/hal0/comfyui/fetch.py | 82 +++++++++++++++++++++ tests/comfyui/test_fetch.py | 138 ++++++++++++++++++++++++++++++++++++ 2 files changed, 220 insertions(+) create mode 100644 src/hal0/comfyui/fetch.py create mode 100644 tests/comfyui/test_fetch.py diff --git a/src/hal0/comfyui/fetch.py b/src/hal0/comfyui/fetch.py new file mode 100644 index 00000000..aedfe795 --- /dev/null +++ b/src/hal0/comfyui/fetch.py @@ -0,0 +1,82 @@ +"""Task 2.4: Async model fetch wrapper for ComfyUI scripts. + +Public API: + fetch_model(variant) -> job_id (starts subprocess, non-blocking) + get_job(job_id) -> dict | None + cancel_job(job_id) -> bool +""" +from __future__ import annotations + +import subprocess +import uuid +from pathlib import Path +from typing import Optional + +from hal0.comfyui.capabilities import ModelVariant + +# Scripts live at /installer/comfyui/scripts/ +_SCRIPTS_DIR: Path = Path(__file__).parent.parent.parent.parent / "installer" / "comfyui" / "scripts" + +# Module-level job registry: job_id -> {"id", "family", "status", "returncode", "script", "_proc"} +_JOBS: dict[str, dict] = {} + + +def fetch_model(variant: ModelVariant) -> str: + """Launch fetch script for *variant* as a background subprocess. + + Returns a job_id. The script is run from _SCRIPTS_DIR; stdout/stderr + go to PIPE (captured but not streamed — YAGNI until progress API exists). + """ + script_path = str(_SCRIPTS_DIR / variant.fetch_script) + cmd = [script_path] + if variant.precision is not None: + cmd += ["--precision", variant.precision] + + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + + job_id = str(uuid.uuid4()) + _JOBS[job_id] = { + "id": job_id, + "family": variant.family, + "status": "running", + "returncode": None, + "script": script_path, + "_proc": proc, + } + return job_id + + +def get_job(job_id: str) -> Optional[dict]: + """Return job dict (without _proc) or None if unknown. + + Status is refreshed from proc.poll() on each call. + """ + rec = _JOBS.get(job_id) + if rec is None: + return None + + # Refresh status if still running + if rec["status"] == "running": + rc = rec["_proc"].poll() + if rc is not None: + rec["returncode"] = rc + rec["status"] = "done" if rc == 0 else "failed" + + return {k: v for k, v in rec.items() if k != "_proc"} + + +def cancel_job(job_id: str) -> bool: + """Terminate a running job. Returns True if terminated, False otherwise.""" + rec = _JOBS.get(job_id) + if rec is None: + return False + + # Refresh status first + get_job(job_id) + + if rec["status"] != "running": + return False + + rec["_proc"].terminate() + rec["status"] = "cancelled" + return True diff --git a/tests/comfyui/test_fetch.py b/tests/comfyui/test_fetch.py new file mode 100644 index 00000000..3a7b5c6c --- /dev/null +++ b/tests/comfyui/test_fetch.py @@ -0,0 +1,138 @@ +"""Task 2.4: fetch_model TDD — mocked subprocess, no real downloads.""" +from __future__ import annotations + +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from hal0.comfyui.capabilities import ModelVariant, default_variant +from hal0.comfyui.fetch import cancel_job, fetch_model, get_job + +# ── helpers ─────────────────────────────────────────────────────────────────── + +LTX2_VARIANT = default_variant("txt2video") # family=ltx2, precision=bf16, fetch_script=get_ltx2.sh +ESRGAN_VARIANT = default_variant("image_upscale") # family=esrgan, precision=None, fetch_script=get_esrgan.sh + + +def _make_proc(returncode=None, pid=12345): + """Return a mock Popen process.""" + proc = MagicMock() + proc.pid = pid + proc.returncode = returncode + proc.poll.return_value = returncode + return proc + + +# ── tests ───────────────────────────────────────────────────────────────────── + + +class TestFetchModel: + def test_returns_job_id_string(self, monkeypatch): + proc = _make_proc() + monkeypatch.setattr("hal0.comfyui.fetch.subprocess.Popen", lambda *a, **kw: proc) + job_id = fetch_model(LTX2_VARIANT) + assert isinstance(job_id, str) and len(job_id) > 0 + + def test_ltx2_invokes_correct_script_with_precision(self, monkeypatch): + captured = {} + proc = _make_proc() + + def fake_popen(cmd, **kw): + captured["cmd"] = cmd + return proc + + monkeypatch.setattr("hal0.comfyui.fetch.subprocess.Popen", fake_popen) + fetch_model(LTX2_VARIANT) + + cmd = captured["cmd"] + # script name must end with get_ltx2.sh + assert cmd[0].endswith("get_ltx2.sh"), f"expected get_ltx2.sh, got {cmd[0]}" + # --precision bf16 must appear + assert "--precision" in cmd + assert "bf16" in cmd + + def test_esrgan_invoked_without_precision(self, monkeypatch): + """esrgan has precision=None — must NOT pass --precision.""" + captured = {} + proc = _make_proc() + + def fake_popen(cmd, **kw): + captured["cmd"] = cmd + return proc + + monkeypatch.setattr("hal0.comfyui.fetch.subprocess.Popen", fake_popen) + fetch_model(ESRGAN_VARIANT) + + cmd = captured["cmd"] + assert cmd[0].endswith("get_esrgan.sh"), f"expected get_esrgan.sh, got {cmd[0]}" + assert "--precision" not in cmd + + def test_job_registered_as_running(self, monkeypatch): + proc = _make_proc(returncode=None) # still running + monkeypatch.setattr("hal0.comfyui.fetch.subprocess.Popen", lambda *a, **kw: proc) + job_id = fetch_model(LTX2_VARIANT) + job = get_job(job_id) + assert job is not None + assert job["id"] == job_id + assert job["status"] == "running" + assert job["family"] == "ltx2" + + def test_job_has_script_field(self, monkeypatch): + proc = _make_proc() + monkeypatch.setattr("hal0.comfyui.fetch.subprocess.Popen", lambda *a, **kw: proc) + job_id = fetch_model(LTX2_VARIANT) + job = get_job(job_id) + assert "script" in job + assert job["script"].endswith("get_ltx2.sh") + + +class TestGetJob: + def test_unknown_job_id_returns_none(self): + result = get_job("nonexistent-job-id-xyz") + assert result is None + + def test_job_done_when_proc_exits_0(self, monkeypatch): + proc = _make_proc(returncode=0) + monkeypatch.setattr("hal0.comfyui.fetch.subprocess.Popen", lambda *a, **kw: proc) + job_id = fetch_model(LTX2_VARIANT) + job = get_job(job_id) + assert job["status"] == "done" + assert job["returncode"] == 0 + + def test_job_failed_when_proc_exits_nonzero(self, monkeypatch): + proc = _make_proc(returncode=1) + monkeypatch.setattr("hal0.comfyui.fetch.subprocess.Popen", lambda *a, **kw: proc) + job_id = fetch_model(LTX2_VARIANT) + job = get_job(job_id) + assert job["status"] == "failed" + assert job["returncode"] == 1 + + +class TestCancelJob: + def test_cancel_running_job_terminates_and_marks_cancelled(self, monkeypatch): + proc = _make_proc(returncode=None) + monkeypatch.setattr("hal0.comfyui.fetch.subprocess.Popen", lambda *a, **kw: proc) + job_id = fetch_model(LTX2_VARIANT) + + result = cancel_job(job_id) + + assert result is True + proc.terminate.assert_called_once() + job = get_job(job_id) + assert job["status"] == "cancelled" + + def test_cancel_unknown_job_returns_false(self): + result = cancel_job("does-not-exist") + assert result is False + + def test_cancel_already_done_job_returns_false(self, monkeypatch): + proc = _make_proc(returncode=0) + monkeypatch.setattr("hal0.comfyui.fetch.subprocess.Popen", lambda *a, **kw: proc) + job_id = fetch_model(LTX2_VARIANT) + + result = cancel_job(job_id) + assert result is False + # status must not change + assert get_job(job_id)["status"] == "done" From 413865017f8f7605b38ecf27247869920d1b62c2 Mon Sep 17 00:00:00 2001 From: Alexander Date: Tue, 16 Jun 2026 17:42:56 -0400 Subject: [PATCH 06/24] feat(comfyui): curated sdxl + esrgan Add two image-gen entries to the curated catalogue: - sdxl-lightning: ByteDance SDXL + 8-step Lightning LoRA (checkpoints/) - esrgan-4x: xinntao RealESRGAN x4plus upscaler (upscale_models/, bundle_only) TDD: tests/registry/test_curated_comfyui.py (8 tests, all green). Co-Authored-By: Claude Sonnet 4.6 --- src/hal0/registry/curated.py | 54 +++++++++++++++++++++++ tests/registry/test_curated_comfyui.py | 60 ++++++++++++++++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 tests/registry/test_curated_comfyui.py diff --git a/src/hal0/registry/curated.py b/src/hal0/registry/curated.py index 8e43f866..7e2b5d43 100644 --- a/src/hal0/registry/curated.py +++ b/src/hal0/registry/curated.py @@ -492,6 +492,60 @@ class CuratedModel(BaseModel): model_class="sd-1.5", comfyui_subdir="checkpoints", ), + CuratedModel( + id="sdxl-lightning", + display_name="SDXL Lightning (8-step)", + description=( + "ByteDance's SDXL + Lightning LoRA distillation. 8-step fast txt2img " + "at full 1024x1024 SDXL quality on Strix Halo. CreativeML Open RAIL++" + ), + family="sdxl", + size_gb=6.8, + vram_gb_min=8.0, + license="CreativeML-OpenRAIL++", + license_url="https://huggingface.co/ByteDance/SDXL-Lightning/blob/main/LICENSE.md", + hf_repo="ByteDance/SDXL-Lightning", + hf_file="sdxl_lightning_8step_lora.safetensors", + context_length=0, + recommended_slot="img", + tags=["image", "sdxl", "fast", "lora"], + notes=( + "Requires the SDXL base checkpoint (stabilityai/stable-diffusion-xl-base-1.0) " + "already present in ComfyUI checkpoints/. Use the sdxl_lightning workflow " + "with 8 steps, cfg≈1.0, and the Lightning LoRA loaded at strength 1.0." + ), + capability="image", + model_class="image", + comfyui_subdir="checkpoints", + ), + CuratedModel( + id="esrgan-4x", + display_name="Real-ESRGAN 4x", + description=( + "xinntao's RealESRGAN x4plus upscaler. 4x upscale for post-processing " + "any ComfyUI output. Runs on CPU or GPU." + ), + family="esrgan", + size_gb=0.064, + vram_gb_min=0.5, + license="BSD-3-Clause", + license_url="https://github.com/xinntao/Real-ESRGAN/blob/master/LICENSE", + hf_repo="xinntao/Real-ESRGAN", + hf_file="RealESRGAN_x4plus.pth", + context_length=0, + recommended_slot="img", + tags=["image", "upscale", "esrgan"], + notes=( + "Direct download via get_esrgan.sh (installer/comfyui/scripts/). " + "The .pth is fetched from the GitHub release " + "v0.1.0 — not a standard HF safetensors pull. " + "Lands in ComfyUI upscale_models/ directory." + ), + capability="image", + model_class="image", + comfyui_subdir="upscale_models", + bundle_only=True, + ), # ── Bundle-only canonical defs (#500) ──────────────────────────────── # These give the omni bundle manifests a single, loadable id to # reference. Ids match the legacy stock ``server_models.json`` keys diff --git a/tests/registry/test_curated_comfyui.py b/tests/registry/test_curated_comfyui.py new file mode 100644 index 00000000..18261848 --- /dev/null +++ b/tests/registry/test_curated_comfyui.py @@ -0,0 +1,60 @@ +"""TDD: curated catalogue includes sdxl-lightning and esrgan-4x entries. + +Task 2.5 — ComfyUI image-gen models: SDXL Lightning + ESRGAN 4x. +""" + +from __future__ import annotations + +from hal0.registry.curated import CURATED_MODELS, get_curated + + +def _by_id(model_id: str): + return next((m for m in CURATED_MODELS if m.id == model_id), None) + + +def test_sdxl_lightning_present(): + """sdxl-lightning must be in the curated catalogue.""" + ids = {m.id for m in CURATED_MODELS} + assert "sdxl-lightning" in ids + + +def test_esrgan_4x_present(): + """esrgan-4x must be in the curated catalogue.""" + ids = {m.id for m in CURATED_MODELS} + assert "esrgan-4x" in ids + + +def test_sdxl_lightning_model_class_image(): + m = get_curated("sdxl-lightning") + assert m is not None + assert m.model_class == "image", f"got {m.model_class!r}" + + +def test_esrgan_4x_model_class_image(): + m = get_curated("esrgan-4x") + assert m is not None + assert m.model_class == "image", f"got {m.model_class!r}" + + +def test_sdxl_lightning_comfyui_subdir(): + m = get_curated("sdxl-lightning") + assert m is not None + assert m.comfyui_subdir == "checkpoints" + + +def test_esrgan_4x_comfyui_subdir(): + m = get_curated("esrgan-4x") + assert m is not None + assert m.comfyui_subdir == "upscale_models" + + +def test_sdxl_lightning_capability_image(): + m = get_curated("sdxl-lightning") + assert m is not None + assert m.capability == "image" + + +def test_esrgan_4x_capability_image(): + m = get_curated("esrgan-4x") + assert m is not None + assert m.capability == "image" From 89f0990b7bd481f3228c0836d1cf4b010f35ee00 Mon Sep 17 00:00:00 2001 From: Alexander Date: Tue, 16 Jun 2026 17:46:59 -0400 Subject: [PATCH 07/24] feat(comfyui): ship switchover control scripts in-repo (Task 3.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TDD: tests/install/test_comfyui_scripts_shipped.py (6/6 pass). - Add installer/comfyui/scripts/{comfy-up,comfy-down,comfy-logs, comfy-postinstall,start-inference,stop-inference}.sh — verbatim from the working CT105 copies (digest-pinned kyuz0 image kept intentionally). - installer/install.sh: new "ComfyUI control scripts" block installs scripts to /opt/comfyui/ (fixed path; comfy-up.sh self-references it), creates /mnt/ai-models/comfyui/{models,output,input,user,custom_nodes}, and places extra_model_paths.yaml if absent. Dev-mode skipped. No script-name mismatch: comfyui.py does not shell out to any /opt/comfyui/*.sh (API comment confirms — scripts are manual-ops only). Co-Authored-By: Claude Sonnet 4.6 --- installer/comfyui/scripts/comfy-down.sh | 3 + installer/comfyui/scripts/comfy-logs.sh | 2 + .../comfyui/scripts/comfy-postinstall.sh | 14 +++ installer/comfyui/scripts/comfy-up.sh | 37 ++++++ installer/comfyui/scripts/start-inference.sh | 12 ++ installer/comfyui/scripts/stop-inference.sh | 17 +++ installer/install.sh | 44 +++++++ tests/install/test_comfyui_scripts_shipped.py | 110 ++++++++++++++++++ 8 files changed, 239 insertions(+) create mode 100755 installer/comfyui/scripts/comfy-down.sh create mode 100755 installer/comfyui/scripts/comfy-logs.sh create mode 100755 installer/comfyui/scripts/comfy-postinstall.sh create mode 100755 installer/comfyui/scripts/comfy-up.sh create mode 100755 installer/comfyui/scripts/start-inference.sh create mode 100755 installer/comfyui/scripts/stop-inference.sh create mode 100644 tests/install/test_comfyui_scripts_shipped.py diff --git a/installer/comfyui/scripts/comfy-down.sh b/installer/comfyui/scripts/comfy-down.sh new file mode 100755 index 00000000..f18bd8e7 --- /dev/null +++ b/installer/comfyui/scripts/comfy-down.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -euo pipefail +docker stop comfyui >/dev/null 2>&1 && echo "[comfy-down] stopped" || echo "[comfy-down] not running" diff --git a/installer/comfyui/scripts/comfy-logs.sh b/installer/comfyui/scripts/comfy-logs.sh new file mode 100755 index 00000000..6ae1c15f --- /dev/null +++ b/installer/comfyui/scripts/comfy-logs.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +docker logs --tail "${1:-60}" -f comfyui diff --git a/installer/comfyui/scripts/comfy-postinstall.sh b/installer/comfyui/scripts/comfy-postinstall.sh new file mode 100755 index 00000000..d9145291 --- /dev/null +++ b/installer/comfyui/scripts/comfy-postinstall.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Reinstall custom-node Python deps into the container venv. +# WHY: node *code* lives on the mounted custom_nodes (persistent), but node *deps* +# live in the container's /opt/venv (ephemeral). After any `docker rm` + recreate +# the deps are gone and the added nodes fail to import. comfy-up.sh runs this +# automatically on a fresh create; run it by hand if you ever recreate manually +# or add nodes via the Manager UI and want them to survive a recreate. +set -uo pipefail +NAME=comfyui +if ! docker ps --format '{{.Names}}' | grep -qx "$NAME"; then + echo "[comfy-postinstall] container '$NAME' not running — start it first (comfy-up.sh)"; exit 1 +fi +docker exec "$NAME" bash /root/comfy-models/.node-install.sh +echo "[comfy-postinstall] node deps reinstalled" diff --git a/installer/comfyui/scripts/comfy-up.sh b/installer/comfyui/scripts/comfy-up.sh new file mode 100755 index 00000000..a6700cc7 --- /dev/null +++ b/installer/comfyui/scripts/comfy-up.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Launch (or resume) the ComfyUI container on hal0 iGPU (gfx1151). +# - Image is DIGEST-PINNED for reproducibility (a re-pull can't silently change the build). +# To update: pull a new tag, then replace the @sha256 below with its RepoDigest. +# - --restart no: does NOT auto-start on boot, so it never contends with Lemonade at boot. +# (After a CT reboot, run this script to bring ComfyUI back up.) +# - On a FRESH create it self-heals node deps (which live in the ephemeral container venv). +set -euo pipefail +IMG=docker.io/kyuz0/amd-strix-halo-comfyui@sha256:0066678ae9043f69a1c8c7699e70626ceffd35c1a8ca03227a05640ad0241ed2 +NAME=comfyui +ROOT=/mnt/ai-models/comfyui +if docker ps -a --format "{{.Names}}" | grep -qx "$NAME"; then + docker start "$NAME" >/dev/null && echo "[comfy-up] resumed existing container" +else + docker run -d --name "$NAME" --restart no \ + --device /dev/kfd --device /dev/dri \ + --group-add video --group-add render \ + --security-opt apparmor=unconfined --security-opt seccomp=unconfined \ + --ipc=host --shm-size=8g \ + -p 8188:8188 \ + -v "$ROOT/models":/root/comfy-models \ + -v "$ROOT/output":/opt/ComfyUI/output \ + -v "$ROOT/input":/opt/ComfyUI/input \ + -v "$ROOT/user":/opt/ComfyUI/user \ + -v "$ROOT/custom_nodes":/opt/ComfyUI/custom_nodes \ + -v "$ROOT/extra_model_paths.yaml":/opt/ComfyUI/extra_model_paths.yaml:ro \ + --entrypoint bash "$IMG" \ + -lc "cd /opt/ComfyUI && exec python main.py --listen 0.0.0.0 --port 8188 --disable-mmap --bf16-vae --cache-none" >/dev/null + echo "[comfy-up] created container — waiting for it, then installing custom-node deps (fresh venv)…" + for i in $(seq 1 40); do [ "$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8188/ 2>/dev/null)" = "200" ] && break; sleep 3; done + if /opt/comfyui/comfy-postinstall.sh; then + docker restart "$NAME" >/dev/null && echo "[comfy-up] node deps installed; restarted to load them" + else + echo "[comfy-up] WARN: postinstall failed — run /opt/comfyui/comfy-postinstall.sh then 'docker restart comfyui'" + fi +fi +echo "[comfy-up] ComfyUI → http://$(hostname -I | awk '{print $1}'):8188 (logs: /opt/comfyui/comfy-logs.sh)" diff --git a/installer/comfyui/scripts/start-inference.sh b/installer/comfyui/scripts/start-inference.sh new file mode 100755 index 00000000..b045d595 --- /dev/null +++ b/installer/comfyui/scripts/start-inference.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# Restore hal0 inference after ComfyUI work. Reverses stop-inference.sh. +set -euo pipefail +echo "[start-inference] starting hal0-lemonade.service ..." +systemctl start hal0-lemonade.service || true +sleep 3 +echo "[start-inference] starting hal0-agent@hermes.service ..." +systemctl start hal0-agent@hermes.service || true +sleep 2 +echo "[start-inference] state:" +systemctl is-active hal0-lemonade.service hal0-agent@hermes.service hindsight-api.service || true +echo "[start-inference] Hermes messaging + Hindsight extraction restored." diff --git a/installer/comfyui/scripts/stop-inference.sh b/installer/comfyui/scripts/stop-inference.sh new file mode 100755 index 00000000..578decd8 --- /dev/null +++ b/installer/comfyui/scripts/stop-inference.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Stop hal0 iGPU inference so ComfyUI has the GPU/unified memory to itself. +# Scope (per user choice): Lemonade (iGPU model runner) + Hermes agent (depends on it). +# Side-effect: Hindsight memory *extraction* (LLM via lemonade:13305 -> gemma3-4b on NPU) +# pauses while down. Retain calls queue; embeddings/rerank are CPU-pinned and unaffected. +# Restore with: /opt/comfyui/start-inference.sh +set -euo pipefail +echo "[stop-inference] stopping hal0-agent@hermes.service ..." +systemctl stop hal0-agent@hermes.service || true +echo "[stop-inference] stopping hal0-lemonade.service ..." +systemctl stop hal0-lemonade.service || true +sleep 2 +echo "[stop-inference] state:" +systemctl is-active hal0-lemonade.service hal0-agent@hermes.service hindsight-api.service || true +echo "[stop-inference] iGPU memory now:" +free -h | awk "NR==1||/Mem:/" +echo "[stop-inference] NOTE: Telegram/Discord (Hermes) are DARK and Hindsight extraction is PAUSED until start-inference.sh." diff --git a/installer/install.sh b/installer/install.sh index b107dc8f..43e9c423 100755 --- a/installer/install.sh +++ b/installer/install.sh @@ -943,6 +943,50 @@ for seed_slot in npu tts rerank utility img; do fi done +# ── ComfyUI control scripts ────────────────────────────────────────────────── +# Place the six manual-ops scripts at /opt/comfyui/ (fixed path — comfy-up.sh +# self-references /opt/comfyui/comfy-postinstall.sh and /opt/comfyui/comfy-logs.sh +# so the directory cannot vary with PREFIX). Idempotent: install(1) overwrites +# in place on re-run. +# Also create the model/output/input/user/custom_nodes subdirectories and place +# extra_model_paths.yaml on the share so comfy-up.sh can mount them. +ui_step "ComfyUI control scripts" + +COMFYUI_SCRIPTS_SRC="${REPO_ROOT}/installer/comfyui/scripts" +COMFYUI_DIR="/opt/comfyui" +COMFYUI_MODELS_ROOT="/mnt/ai-models/comfyui" + +if [[ "${DEV_MODE}" -eq 1 ]]; then + info "dev mode — skipping /opt/comfyui install (no system writes)" +else + if [[ -d "${COMFYUI_SCRIPTS_SRC}" ]]; then + install -d "${COMFYUI_DIR}" + install -m0755 "${COMFYUI_SCRIPTS_SRC}"/*.sh "${COMFYUI_DIR}/" + info "wrote ComfyUI control scripts → ${COMFYUI_DIR}/" + else + warn "${COMFYUI_SCRIPTS_SRC} not found — ComfyUI control scripts not installed" + fi + + # Create the model-share subdirs that comfy-up.sh bind-mounts into the container. + for _subdir in models output input user custom_nodes; do + install -d "${COMFYUI_MODELS_ROOT}/${_subdir}" + done + info "ensured ${COMFYUI_MODELS_ROOT}/{models,output,input,user,custom_nodes}" + + # Place extra_model_paths.yaml if not already present (operator may have a + # customised copy — never overwrite). + _EXTRA_PATHS_SRC="${REPO_ROOT}/installer/comfyui/extra_model_paths.yaml" + _EXTRA_PATHS_DST="${COMFYUI_MODELS_ROOT}/extra_model_paths.yaml" + if [[ -f "${_EXTRA_PATHS_DST}" ]]; then + info "${_EXTRA_PATHS_DST} exists — left alone" + elif [[ -f "${_EXTRA_PATHS_SRC}" ]]; then + install -m0644 "${_EXTRA_PATHS_SRC}" "${_EXTRA_PATHS_DST}" + info "wrote ${_EXTRA_PATHS_DST}" + else + warn "${_EXTRA_PATHS_SRC} not found — extra_model_paths.yaml not placed (create manually before first comfy-up)" + fi +fi + # ── hal0 system user ──────────────────────────────────────────────────────── # A dedicated `hal0` system user/group runs the non-root hal0 services: # hal0-agent@ (the Hermes runner), hermes-gateway, and the shared diff --git a/tests/install/test_comfyui_scripts_shipped.py b/tests/install/test_comfyui_scripts_shipped.py new file mode 100644 index 00000000..e53b9be1 --- /dev/null +++ b/tests/install/test_comfyui_scripts_shipped.py @@ -0,0 +1,110 @@ +"""TDD — Task 3.1: Ship ComfyUI control scripts. + +Three assertions: + (a) All 6 scripts exist in installer/comfyui/scripts/ and are bash -n clean. + (b) Every /opt/comfyui/*.sh path referenced in src/hal0/api/routes/comfyui.py + has a matching shipped script in installer/comfyui/scripts/. + (c) installer/install.sh contains the /opt/comfyui placement block. +""" + +from __future__ import annotations + +import re +import subprocess +from pathlib import Path + +# Repo root = three levels up from tests/install/ +REPO = Path(__file__).parent.parent.parent +SCRIPTS_DIR = REPO / "installer" / "comfyui" / "scripts" +COMFYUI_PY = REPO / "src" / "hal0" / "api" / "routes" / "comfyui.py" +INSTALL_SH = REPO / "installer" / "install.sh" + +EXPECTED_SCRIPTS = [ + "comfy-up.sh", + "comfy-down.sh", + "comfy-logs.sh", + "comfy-postinstall.sh", + "start-inference.sh", + "stop-inference.sh", +] + + +# ── (a) scripts exist and are bash -n clean ────────────────────────────────── + +def test_all_scripts_exist(): + missing = [s for s in EXPECTED_SCRIPTS if not (SCRIPTS_DIR / s).exists()] + assert not missing, f"Missing scripts in {SCRIPTS_DIR}: {missing}" + + +def test_all_scripts_are_executable(): + import stat + not_exec = [ + s for s in EXPECTED_SCRIPTS + if not (SCRIPTS_DIR / s).stat().st_mode & stat.S_IXUSR + ] + assert not not_exec, f"Scripts not executable: {not_exec}" + + +def test_all_scripts_bash_syntax_clean(): + errors = [] + for name in EXPECTED_SCRIPTS: + path = SCRIPTS_DIR / name + if not path.exists(): + errors.append(f"{name}: file not found") + continue + result = subprocess.run( + ["bash", "-n", str(path)], + capture_output=True, + text=True, + ) + if result.returncode != 0: + errors.append(f"{name}: {result.stderr.strip()}") + assert not errors, f"bash -n failures:\n" + "\n".join(errors) + + +# ── (b) comfyui.py /opt/comfyui/*.sh references covered ───────────────────── + +def test_comfyui_py_script_refs_all_shipped(): + """Every /opt/comfyui/.sh referenced in comfyui.py must be shipped. + + NOTE: comfyui.py currently does NOT shell out (the API comment explicitly + states the scripts are for manual ops only). This test will pass with an + empty reference set, but will catch regressions if someone adds a + subprocess call to a script that isn't shipped. + """ + source = COMFYUI_PY.read_text() + # Match any string literal containing /opt/comfyui/.sh + refs = re.findall(r"/opt/comfyui/([\w.-]+\.sh)", source) + shipped = {s for s in EXPECTED_SCRIPTS} + missing = [r for r in refs if r not in shipped] + assert not missing, ( + f"comfyui.py references /opt/comfyui/ scripts not in shipped set: {missing}" + ) + + +# ── (c) install.sh places scripts at /opt/comfyui ──────────────────────────── + +def test_install_sh_contains_opt_comfyui_placement(): + content = INSTALL_SH.read_text() + assert "/opt/comfyui" in content, ( + "installer/install.sh has no /opt/comfyui placement block" + ) + # More specific: must use install command to copy the scripts + assert "installer/comfyui/scripts" in content or "comfyui/scripts" in content, ( + "installer/install.sh does not reference installer/comfyui/scripts" + ) + + +def test_install_sh_uses_install_command_for_scripts(): + content = INSTALL_SH.read_text() + # Should contain something like: install -m0755 .../comfyui/scripts/*.sh /opt/comfyui/ + # We look for the pattern of install + comfyui scripts + /opt/comfyui + has_install_block = ( + "install -d /opt/comfyui" in content + or "install -d \"${PREFIX}/opt/comfyui\"" in content + or 'install -d "${PREFIX}/opt/comfyui"' in content + or "COMFYUI_DIR" in content + ) + assert has_install_block, ( + "install.sh does not contain 'install -d /opt/comfyui' or equivalent" + ) From 4c91c0e9c8421245a3ca5b3707e0dd7fb069f65c Mon Sep 17 00:00:00 2001 From: Alexander Date: Tue, 16 Jun 2026 17:50:18 -0400 Subject: [PATCH 08/24] feat(comfyui): ship extra_model_paths.yaml template Adds installer/comfyui/extra_model_paths.yaml with in-container bind path /root/comfy-models and all standard ComfyUI model-type keys. TDD tests verify file existence, YAML validity, base_path, and required keys. Co-Authored-By: Claude Sonnet 4.6 --- installer/comfyui/extra_model_paths.yaml | 15 +++++++++ tests/install/test_comfyui_extra_paths.py | 41 +++++++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 installer/comfyui/extra_model_paths.yaml create mode 100644 tests/install/test_comfyui_extra_paths.py diff --git a/installer/comfyui/extra_model_paths.yaml b/installer/comfyui/extra_model_paths.yaml new file mode 100644 index 00000000..e7573811 --- /dev/null +++ b/installer/comfyui/extra_model_paths.yaml @@ -0,0 +1,15 @@ +comfyui: + base_path: /root/comfy-models + checkpoints: checkpoints + diffusion_models: diffusion_models + unet: unet + text_encoders: text_encoders + clip: clip + clip_vision: clip_vision + vae: vae + loras: loras + controlnet: controlnet + upscale_models: upscale_models + latent_upscale_models: latent_upscale_models + embeddings: embeddings + style_models: style_models diff --git a/tests/install/test_comfyui_extra_paths.py b/tests/install/test_comfyui_extra_paths.py new file mode 100644 index 00000000..d1096a25 --- /dev/null +++ b/tests/install/test_comfyui_extra_paths.py @@ -0,0 +1,41 @@ +"""TDD — Task 3.2: extra_model_paths.yaml template shipped. + +Assertions: + (a) installer/comfyui/extra_model_paths.yaml exists. + (b) Parses as valid YAML. + (c) comfyui.base_path == "/root/comfy-models". + (d) Required keys present: checkpoints, loras, vae, upscale_models. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +REPO = Path(__file__).parent.parent.parent +YAML_PATH = REPO / "installer" / "comfyui" / "extra_model_paths.yaml" + + +def test_extra_model_paths_file_exists(): + assert YAML_PATH.exists(), f"Missing: {YAML_PATH}" + + +def test_extra_model_paths_parses_as_yaml(): + yaml = pytest.importorskip("yaml") + data = yaml.safe_load(YAML_PATH.read_text()) + assert isinstance(data, dict), "YAML root must be a mapping" + + +def test_extra_model_paths_base_path(): + yaml = pytest.importorskip("yaml") + data = yaml.safe_load(YAML_PATH.read_text()) + assert data["comfyui"]["base_path"] == "/root/comfy-models" + + +def test_extra_model_paths_required_keys(): + yaml = pytest.importorskip("yaml") + data = yaml.safe_load(YAML_PATH.read_text()) + section = data["comfyui"] + for key in ("checkpoints", "loras", "vae", "upscale_models"): + assert key in section, f"Missing key: {key}" From f6523625f82d4d0159c29cfe805c0997f09f703b Mon Sep 17 00:00:00 2001 From: Alexander Date: Tue, 16 Jun 2026 17:51:01 -0400 Subject: [PATCH 09/24] feat(comfyui): extensions-registry entry Adds ComfyUI to EXTENSIONS (kind=app, default_enabled=True). install_extension branch starts the container via /opt/comfyui/comfy-up.sh if a container runtime is present, skipping silently otherwise. ComfyUI is NOT a systemd unit. Co-Authored-By: Claude Sonnet 4.6 --- src/hal0/install/extensions.py | 14 +++++++ tests/install/test_extensions_comfyui.py | 47 ++++++++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 tests/install/test_extensions_comfyui.py diff --git a/src/hal0/install/extensions.py b/src/hal0/install/extensions.py index f3d7e083..bef2f1e1 100644 --- a/src/hal0/install/extensions.py +++ b/src/hal0/install/extensions.py @@ -5,6 +5,7 @@ from __future__ import annotations +import shutil import subprocess from dataclasses import dataclass from typing import Literal @@ -23,6 +24,7 @@ class Extension: EXTENSIONS: list[Extension] = [ Extension("openwebui", "app", "Open WebUI", "Chat web UI for your models", True), + Extension("comfyui", "app", "ComfyUI", "Image & video generation (iGPU)", True), Extension("hermes", "agent", "Hermes", "Conversational agent with memory", True), Extension("pi", "agent", "Pi", "Coding agent", False), ] @@ -53,6 +55,18 @@ def install_extension(ext_id: str) -> ExtensionOutcome: _run(["hal0", "agent", "install", ext.id]) elif ext.id == "openwebui": _run(["systemctl", "enable", "--now", "hal0-openwebui.service"]) + elif ext.id == "comfyui": + # ComfyUI is arbiter/slot-managed (not a systemd unit). + # Bring the container up via comfy-up.sh if a container runtime is present. + runtime = shutil.which("podman") or shutil.which("docker") + if runtime: + _run(["/opt/comfyui/comfy-up.sh"]) + else: + import logging + logging.getLogger(__name__).info( + "comfyui: no container runtime found; skipping container start" + ) + return ExtensionOutcome(ext_id=ext_id, skipped="no_container_runtime") return ExtensionOutcome(ext_id=ext_id, installed=True) except Exception as exc: # best-effort return ExtensionOutcome(ext_id=ext_id, error=str(exc)) diff --git a/tests/install/test_extensions_comfyui.py b/tests/install/test_extensions_comfyui.py new file mode 100644 index 00000000..a44ecb09 --- /dev/null +++ b/tests/install/test_extensions_comfyui.py @@ -0,0 +1,47 @@ +"""TDD — Task 3.3: ComfyUI extensions-registry entry. + +Assertions: + (a) "comfyui" is in EXTENSIONS (by id). + (b) install_extension("comfyui") calls comfy-up.sh path (not systemctl). +""" + +from __future__ import annotations + + +def test_comfyui_in_extensions(): + from hal0.install.extensions import EXTENSIONS + + ids = [e.id for e in EXTENSIONS] + assert "comfyui" in ids, f"comfyui missing from EXTENSIONS; got: {ids}" + + +def test_comfyui_extension_metadata(): + from hal0.install.extensions import get_extension + + ext = get_extension("comfyui") + assert ext is not None + assert ext.kind == "app" + assert ext.default_enabled is True + + +def test_install_extension_comfyui_calls_comfy_up(monkeypatch): + """install_extension('comfyui') must invoke comfy-up.sh (not systemctl).""" + import hal0.install.extensions as exts + + called = [] + + def _fake_run(cmd, **kw): + called.append(cmd) + + monkeypatch.setattr(exts, "_run", _fake_run) + # Also mock shutil.which so the podman guard passes + import shutil + monkeypatch.setattr(shutil, "which", lambda name: f"/usr/bin/{name}") + + result = exts.install_extension("comfyui") + assert result.installed is True or result.skipped is None + # Must have called something with comfy-up.sh, NOT systemctl + assert called, "install_extension('comfyui') made no subprocess call" + flat = " ".join(str(c) for c in called[0]) + assert "comfy-up" in flat, f"Expected comfy-up.sh in call; got: {called}" + assert "systemctl" not in flat, f"Should not call systemctl for comfyui; got: {called}" From 517c4af9514aff9010c7066829e657883af5834d Mon Sep 17 00:00:00 2001 From: Alexander Date: Tue, 16 Jun 2026 17:52:10 -0400 Subject: [PATCH 10/24] feat(comfyui): installer services step + repair Adds ComfyUI to GET /api/install/services (probed via _container_active() using podman/docker inspect). Repair is a special case before the systemd allowlist: calls /opt/comfyui/comfy-up.sh directly, not systemctl. Adds _container_active() helper. Co-Authored-By: Claude Sonnet 4.6 --- src/hal0/api/routes/installer.py | 53 ++++++++++++++++++++-- tests/api/test_services_comfyui.py | 72 ++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 4 deletions(-) create mode 100644 tests/api/test_services_comfyui.py diff --git a/src/hal0/api/routes/installer.py b/src/hal0/api/routes/installer.py index f4604271..74a5a791 100644 --- a/src/hal0/api/routes/installer.py +++ b/src/hal0/api/routes/installer.py @@ -438,12 +438,32 @@ def _unit_active(unit: str) -> bool: return False +def _container_active() -> bool: + """True when the ComfyUI container is running under podman or docker.""" + for runtime in ("podman", "docker"): + exe = shutil.which(runtime) + if exe is None: + continue + try: + out = subprocess.run( + [exe, "inspect", "--format", "{{.State.Running}}", "comfyui"], + capture_output=True, + text=True, + timeout=5, + ) + if out.returncode == 0 and out.stdout.strip().lower() == "true": + return True + except (OSError, subprocess.SubprocessError): + pass + return False + + @router.get("/services") async def install_services() -> dict[str, Any]: """Verify post-install services for the FirstRun services step (design D5). - Reports Hermes + OpenWebUI health so the wizard can show honest dots and - offer the one-click repair below when a unit is down. + Reports Hermes + OpenWebUI + ComfyUI health so the wizard can show honest + dots and offer the one-click repair below when a unit is down. """ owui = "hal0-openwebui.service" hermes_active = bool(os.environ.get("HAL0_HERMES_PUBLIC_URL")) or _unit_active( @@ -462,6 +482,14 @@ async def install_services() -> dict[str, Any]: "active": hermes_active, "repairable": True, }, + # ComfyUI is arbiter/slot-managed (not a systemd unit) — probed via + # container runtime, repaired via comfy-up.sh (see service_repair). + { + "unit": "comfyui", + "label": "ComfyUI", + "active": _container_active(), + "repairable": True, + }, ] return {"services": services} @@ -470,9 +498,26 @@ async def install_services() -> dict[str, Any]: async def service_repair(unit: str) -> dict[str, Any]: """Restart a known unit (design D5 one-click repair). - Restricted to :data:`_REPAIRABLE_UNITS` so the ``{unit}`` path segment - can't be used to restart arbitrary system services. + Restricted to :data:`_REPAIRABLE_UNITS` (systemd) or the special-cased + ``comfyui`` container so the ``{unit}`` path segment can't be used to + restart arbitrary system services. + + NOTE: ComfyUI is NOT a systemd unit and cannot live in ``_REPAIRABLE_UNITS`` + (which drives ``systemctl restart``). It is handled as a special case here: + we call ``/opt/comfyui/comfy-up.sh`` directly. If a future refactor + generalises repair to support non-systemd targets (e.g. a ``repair_fn`` + dispatch table), this special case should be folded in at that point. """ + # Special case: ComfyUI is container-managed, not systemd. + if unit == "comfyui": + try: + subprocess.run(["/opt/comfyui/comfy-up.sh"], check=True, timeout=60) + except (OSError, subprocess.SubprocessError) as exc: + raise PickDefaultError( + f"comfyui restart failed: {exc}", details={"unit": unit} + ) from exc + return {"unit": unit, "active": _container_active()} + if unit not in _REPAIRABLE_UNITS: raise BadRequest( f"unit {unit!r} is not repairable", diff --git a/tests/api/test_services_comfyui.py b/tests/api/test_services_comfyui.py new file mode 100644 index 00000000..99de82cb --- /dev/null +++ b/tests/api/test_services_comfyui.py @@ -0,0 +1,72 @@ +"""TDD — Task 3.4: ComfyUI installer services step + repair. + +Assertions: + (a) GET /api/install/services includes a comfyui entry. + (b) repair path for comfyui calls comfy-up.sh (not systemctl). +""" + +from __future__ import annotations + + +def test_services_includes_comfyui(isolated_client, monkeypatch): + import hal0.api.routes.installer as inst + + monkeypatch.setattr(inst, "_unit_active", lambda u: False) + r = isolated_client.get("/api/install/services") + assert r.status_code == 200, r.text + services = r.json()["services"] + units = [s.get("unit") or s.get("id") or "" for s in services] + assert any("comfyui" in u for u in units), ( + f"comfyui not in services response; got units: {units}" + ) + + +def test_comfyui_repair_calls_comfy_up_not_systemctl(isolated_client, monkeypatch): + import hal0.api.routes.installer as inst + + calls = [] + + def _fake_run(cmd, **kw): + calls.append(list(cmd)) + + class _R: + returncode = 0 + stdout = "" + + return _R() + + monkeypatch.setattr(inst.subprocess, "run", _fake_run) + monkeypatch.setattr(inst, "_unit_active", lambda u: False) + monkeypatch.setattr(inst, "_container_active", lambda: True) + + r = isolated_client.post("/api/install/services/comfyui/repair") + assert r.status_code == 200, r.text + assert calls, "repair made no subprocess calls" + flat = " ".join(str(x) for c in calls for x in c) + assert "comfy-up" in flat, f"Expected comfy-up.sh call; got: {calls}" + assert "systemctl" not in flat, f"Should not call systemctl for comfyui; got: {calls}" + + +def test_comfyui_repair_not_blocked_by_unknown_unit_check(isolated_client, monkeypatch): + """Ensure comfyui repair returns 200, not 400 'unit not repairable'.""" + import hal0.api.routes.installer as inst + + calls = [] + + def _fake_run(cmd, **kw): + calls.append(list(cmd)) + + class _R: + returncode = 0 + stdout = "" + + return _R() + + monkeypatch.setattr(inst.subprocess, "run", _fake_run) + monkeypatch.setattr(inst, "_unit_active", lambda u: False) + monkeypatch.setattr(inst, "_container_active", lambda: True) + + r = isolated_client.post("/api/install/services/comfyui/repair") + assert r.status_code != 400, ( + "comfyui repair returned 400 — it must not be gated by the systemd allowlist" + ) From c391d1dfcd414669faa502b2baa6abff3dfd6864 Mon Sep 17 00:00:00 2001 From: Alexander Date: Tue, 16 Jun 2026 17:56:26 -0400 Subject: [PATCH 11/24] =?UTF-8?q?feat(comfyui):=20Task=203.5=20=E2=80=94?= =?UTF-8?q?=20selection=20module=20+=20/models/fetch=20route=20+=20install?= =?UTF-8?q?=20hook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/hal0/comfyui/selection.py: auto_selections() and variant_for() - POST /api/comfyui/models/fetch: auto or explicit selections → 202 + job ids - Selections.comfyui_defaults: (cap_id, family) pairs recorded at install, no pull - build_auto_selections() populates comfyui_defaults from CAPABILITIES defaults - tests: 15 new TDD tests (7 selection + 8 fetch route), all green Co-Authored-By: Claude Sonnet 4.6 --- src/hal0/api/routes/comfyui.py | 72 ++++++++++++++++++- src/hal0/cli/setup_command.py | 14 +++- src/hal0/comfyui/selection.py | 32 +++++++++ src/hal0/install/orchestrate.py | 10 +++ tests/api/test_comfyui_fetch_route.py | 99 +++++++++++++++++++++++++++ tests/comfyui/test_selection.py | 50 ++++++++++++++ 6 files changed, 275 insertions(+), 2 deletions(-) create mode 100644 src/hal0/comfyui/selection.py create mode 100644 tests/api/test_comfyui_fetch_route.py create mode 100644 tests/comfyui/test_selection.py diff --git a/src/hal0/api/routes/comfyui.py b/src/hal0/api/routes/comfyui.py index fbc53b4c..937a5f46 100644 --- a/src/hal0/api/routes/comfyui.py +++ b/src/hal0/api/routes/comfyui.py @@ -33,11 +33,15 @@ import contextlib import os import shutil -from typing import Any +from typing import Any, Optional import httpx from fastapi import APIRouter, BackgroundTasks, Request from fastapi.responses import JSONResponse +from pydantic import BaseModel + +import hal0.comfyui.fetch as _fetch_module +from hal0.comfyui.selection import auto_selections, variant_for router = APIRouter() @@ -539,4 +543,70 @@ async def comfyui_pin(request: Request) -> JSONResponse: return JSONResponse(status_code=200, content={"pinned": pinned}) +# --------------------------------------------------------------------------- +# POST /models/fetch — deferred model pull trigger (Task 3.5) +# --------------------------------------------------------------------------- + + +class _SelectionItem(BaseModel): + capability: str + family: str + + +class _FetchBody(BaseModel): + auto: Optional[bool] = None + selections: Optional[list[_SelectionItem]] = None + + +@router.post("/models/fetch", status_code=202) +async def comfyui_models_fetch(body: _FetchBody) -> JSONResponse: + """Trigger deferred model pulls for ComfyUI capabilities. + + Body (one of): + {"auto": true} + Fetches the default variant for every capability (5 total). + {"selections": [{"capability": "txt2img", "family": "sdxl"}, ...]} + Fetches the named variant(s) explicitly. + + Returns 202 with {"jobs": [job_id, ...]} immediately; each job runs in the + background via fetch.fetch_model (subprocess, non-blocking). + + This is the DEFERRED post-install pull path — install runs with --no-pull + and this endpoint is called by the dashboard after setup completes. + """ + if body.auto is None and not body.selections: + return JSONResponse( + status_code=422, + content={ + "error": { + "code": "comfyui.fetch.invalid_body", + "message": "body must be {'auto': true} or {'selections': [...]}", + } + }, + ) + + if body.auto: + variants = auto_selections() + else: + # Resolve explicit selections; surface unknown cap/family as 422. + variants = [] + for item in body.selections: # type: ignore[union-attr] + try: + v = variant_for(item.capability, item.family) + except KeyError as exc: + return JSONResponse( + status_code=422, + content={ + "error": { + "code": "comfyui.fetch.unknown_variant", + "message": str(exc), + } + }, + ) + variants.append(v) + + job_ids = [_fetch_module.fetch_model(v) for v in variants] + return JSONResponse(status_code=202, content={"jobs": job_ids}) + + __all__ = ["aclose_client", "router"] diff --git a/src/hal0/cli/setup_command.py b/src/hal0/cli/setup_command.py index 0f98e9ce..1df91a8b 100644 --- a/src/hal0/cli/setup_command.py +++ b/src/hal0/cli/setup_command.py @@ -92,8 +92,20 @@ def build_auto_selections( if coder: name, port = _SETUP_SLOTS["coder"] slots.append(SlotSelection("coder", name, port, coder[0].model_id)) + # Record ComfyUI default capability picks as (capability_id, family) pairs. + # No model pull at install — operator triggers downloads later via + # POST /api/comfyui/models/fetch. + from hal0.comfyui.capabilities import CAPABILITIES as _CAPS + comfyui_defaults = tuple( + (cap_id, cap.alternatives[0].family) + for cap_id, cap in _CAPS.items() + ) return Selections( - storage_dir=storage_dir, slots=slots, extensions=ext, npu_opt_in=bool(hw.npu.present) + storage_dir=storage_dir, + slots=slots, + extensions=ext, + npu_opt_in=bool(hw.npu.present), + comfyui_defaults=comfyui_defaults, ) diff --git a/src/hal0/comfyui/selection.py b/src/hal0/comfyui/selection.py new file mode 100644 index 00000000..2951de15 --- /dev/null +++ b/src/hal0/comfyui/selection.py @@ -0,0 +1,32 @@ +"""Task 3.5: ComfyUI model selection helpers. + +Public API: + auto_selections() -> list[ModelVariant] + Returns the default variant for every capability, in CAPABILITIES order. + + variant_for(capability_id, family) -> ModelVariant + Looks up the variant with the given family within the capability. + Raises KeyError for unknown capability or unknown family. +""" +from __future__ import annotations + +from hal0.comfyui.capabilities import CAPABILITIES, ModelVariant, default_variant + + +def auto_selections() -> list[ModelVariant]: + """Return the default ModelVariant for every capability in CAPABILITIES order.""" + return [default_variant(cap) for cap in CAPABILITIES.values()] + + +def variant_for(capability_id: str, family: str) -> ModelVariant: + """Return the ModelVariant with *family* from the named capability. + + Raises: + KeyError: if *capability_id* is not in CAPABILITIES, or if no + alternative in that capability has the given *family*. + """ + cap = CAPABILITIES[capability_id] # raises KeyError if unknown capability + for v in cap.alternatives: + if v.family == family: + return v + raise KeyError(f"{capability_id!r} has no variant with family {family!r}") diff --git a/src/hal0/install/orchestrate.py b/src/hal0/install/orchestrate.py index 93727137..d5b75a00 100644 --- a/src/hal0/install/orchestrate.py +++ b/src/hal0/install/orchestrate.py @@ -38,6 +38,16 @@ class Selections: slots: list[SlotSelection] extensions: dict[str, bool] # extension id -> enabled npu_opt_in: bool = False + # Task 3.5: ComfyUI default capability selections recorded at install time. + # These are NOT pulled at install — the operator triggers pulls later via + # POST /api/comfyui/models/fetch. Stored as (capability_id, family) pairs + # so they survive serialisation without depending on capabilities.py here. + # NOTE: install-selection integration friction — Selections models LLM slots + # (chat/coder), not image-gen pickers; this field is a lightweight sidecar + # rather than a first-class slot because ComfyUI picks are capability/family + # pairs (no port, no model_id from the LLM registry). A future refactor + # could lift ComfyUI picks into a dedicated setup phase. + comfyui_defaults: tuple[tuple[str, str], ...] = () @dataclass diff --git a/tests/api/test_comfyui_fetch_route.py b/tests/api/test_comfyui_fetch_route.py new file mode 100644 index 00000000..afed7c02 --- /dev/null +++ b/tests/api/test_comfyui_fetch_route.py @@ -0,0 +1,99 @@ +"""Task 3.5 TDD: POST /api/comfyui/models/fetch route.""" +from __future__ import annotations + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +import hal0.comfyui.fetch as fetch_module +from hal0.api import create_app +from hal0.comfyui.capabilities import CAPABILITIES + + +@pytest.fixture +def client(tmp_hal0_home, monkeypatch): + """Isolated TestClient with fetch_model monkeypatched.""" + call_log = [] + + def fake_fetch(variant): + job_id = f"fake-{variant.family}-{len(call_log)}" + call_log.append((variant, job_id)) + return job_id + + monkeypatch.setattr(fetch_module, "fetch_model", fake_fetch) + + app: FastAPI = create_app() + with TestClient(app) as c: + yield c, call_log + + +def test_auto_fetch_returns_202(client): + c, call_log = client + resp = c.post("/api/comfyui/models/fetch", json={"auto": True}) + assert resp.status_code == 202 + + +def test_auto_fetch_returns_5_jobs(client): + c, call_log = client + resp = c.post("/api/comfyui/models/fetch", json={"auto": True}) + data = resp.json() + assert "jobs" in data + assert len(data["jobs"]) == 5 + + +def test_auto_fetch_calls_fetch_model_per_capability(client): + c, call_log = client + c.post("/api/comfyui/models/fetch", json={"auto": True}) + assert len(call_log) == len(CAPABILITIES) + + +def test_auto_fetch_job_ids_match_response(client): + c, call_log = client + resp = c.post("/api/comfyui/models/fetch", json={"auto": True}) + returned_ids = set(resp.json()["jobs"]) + produced_ids = {jid for _, jid in call_log} + assert returned_ids == produced_ids + + +def test_explicit_selections(client): + """Explicit selection list resolves to correct variants and calls fetch.""" + c, call_log = client + resp = c.post( + "/api/comfyui/models/fetch", + json={ + "selections": [ + {"capability": "txt2img", "family": "sdxl"}, + {"capability": "txt2video", "family": "wan22"}, + ] + }, + ) + assert resp.status_code == 202 + data = resp.json() + assert len(data["jobs"]) == 2 + families = [v.family for v, _ in call_log] + assert "sdxl" in families + assert "wan22" in families + + +def test_unknown_capability_returns_422(client): + c, _ = client + resp = c.post( + "/api/comfyui/models/fetch", + json={"selections": [{"capability": "bogus", "family": "foo"}]}, + ) + assert resp.status_code == 422 + + +def test_unknown_family_returns_422(client): + c, _ = client + resp = c.post( + "/api/comfyui/models/fetch", + json={"selections": [{"capability": "txt2img", "family": "no-such-model"}]}, + ) + assert resp.status_code == 422 + + +def test_missing_body_returns_422(client): + c, _ = client + resp = c.post("/api/comfyui/models/fetch", json={}) + assert resp.status_code == 422 diff --git a/tests/comfyui/test_selection.py b/tests/comfyui/test_selection.py new file mode 100644 index 00000000..492444d6 --- /dev/null +++ b/tests/comfyui/test_selection.py @@ -0,0 +1,50 @@ +"""Task 3.5 TDD: selection.py — auto_selections + variant_for.""" +from __future__ import annotations + +import pytest + +from hal0.comfyui.capabilities import CAPABILITIES, ModelVariant +from hal0.comfyui.selection import auto_selections, variant_for + + +def test_auto_selections_count(): + """One variant per capability (5 total).""" + result = auto_selections() + assert len(result) == len(CAPABILITIES) == 5 + + +def test_auto_selections_are_model_variants(): + for v in auto_selections(): + assert isinstance(v, ModelVariant) + + +def test_auto_selections_match_defaults(): + """Each returned variant == the capability's first alternative.""" + result = auto_selections() + cap_list = list(CAPABILITIES.values()) + for v, cap in zip(result, cap_list): + assert v is cap.alternatives[0], f"{cap.id}: expected first alternative" + + +def test_variant_for_known(): + """variant_for('txt2video', 'wan22') resolves to the wan22 variant.""" + v = variant_for("txt2video", "wan22") + assert v.family == "wan22" + + +def test_variant_for_default_family(): + """variant_for returns the correct variant for the default family.""" + v = variant_for("txt2img", "qwen-image") + assert v.family == "qwen-image" + assert v is CAPABILITIES["txt2img"].alternatives[0] + + +def test_variant_for_unknown_capability_raises(): + with pytest.raises(KeyError): + variant_for("nonexistent", "foo") + + +def test_variant_for_unknown_family_raises(): + """Unknown family within a valid capability raises KeyError.""" + with pytest.raises(KeyError): + variant_for("txt2img", "no-such-model") From 9771f9627b17047463810504ec27cf208e781ced Mon Sep 17 00:00:00 2001 From: Alexander Date: Tue, 16 Jun 2026 18:00:29 -0400 Subject: [PATCH 12/24] feat(comfyui): Phase 4 control routes + TDD tests Add POST /render/cancel, POST /restart, GET /logs, POST /workflows/{name}/launch, GET /preview to the ComfyUI API router. Switchover gate untouched. Telemetry util field gated to null when no running job (gpu_busy_percent forced-high artifact). it/s, eta, step absent by design (websocket-only, skip noted). Co-Authored-By: Claude Sonnet 4.6 --- src/hal0/api/routes/comfyui.py | 230 ++++++++++++++++ tests/api/test_comfyui_phase4.py | 432 +++++++++++++++++++++++++++++++ 2 files changed, 662 insertions(+) create mode 100644 tests/api/test_comfyui_phase4.py diff --git a/src/hal0/api/routes/comfyui.py b/src/hal0/api/routes/comfyui.py index 937a5f46..800c32c7 100644 --- a/src/hal0/api/routes/comfyui.py +++ b/src/hal0/api/routes/comfyui.py @@ -609,4 +609,234 @@ async def comfyui_models_fetch(body: _FetchBody) -> JSONResponse: return JSONResponse(status_code=202, content={"jobs": job_ids}) +def _comfyui_workflows_dir() -> str: + """Primary workflow directory — env override for tests; default is the bind-mount path.""" + return os.environ.get("COMFYUI_WORKFLOWS_DIR", "/mnt/ai-models/comfyui/workflows") + + +def _comfyui_data_dir() -> str: + """Root of the ComfyUI data directory (for fallback user/default/workflows path).""" + return os.environ.get("COMFYUI_DATA_DIR", "/mnt/ai-models/comfyui") + + +def _find_workflow(name: str) -> str | None: + """Locate .json, trying primary then user/default fallback. None if absent.""" + primary = os.path.join(_comfyui_workflows_dir(), f"{name}.json") + if os.path.isfile(primary): + return primary + fallback = os.path.join(_comfyui_data_dir(), "user", "default", "workflows", f"{name}.json") + if os.path.isfile(fallback): + return fallback + return None + + +# --------------------------------------------------------------------------- +# POST /render/cancel — clear queue + interrupt current render +# --------------------------------------------------------------------------- + + +@router.post("/render/cancel", status_code=202) +async def comfyui_render_cancel() -> JSONResponse: + """Cancel current and queued renders. + + Issues POST /queue (clear: true) and POST /interrupt to the ComfyUI + HTTP API. Fail-soft: network errors are suppressed — the cancel + intent is best-effort and the render state is visible via /status. + """ + base = _comfyui_base_url() + client = _get_client() + + async def _post(path: str, body: dict[str, Any]) -> None: + with contextlib.suppress(Exception): + await client.post(f"{base}{path}", json=body) + + await asyncio.gather( + _post("/queue", {"clear": True}), + _post("/interrupt", {}), + ) + return JSONResponse(status_code=202, content={"status": "cancel_requested"}) + + +# --------------------------------------------------------------------------- +# POST /restart — restart the comfyui container via comfy-up.sh +# --------------------------------------------------------------------------- + +_COMFYUI_UP_SCRIPT = "/opt/comfyui/comfy-up.sh" + + +@router.post("/restart", status_code=202) +async def comfyui_restart() -> JSONResponse: + """Restart the ComfyUI container by invoking comfy-up.sh (down + up). + + Runs in the background and returns 202 immediately — the container + takes several seconds to come back up. Track readiness via /status. + """ + try: + proc = await asyncio.create_subprocess_exec( + _COMFYUI_UP_SCRIPT, + stdin=asyncio.subprocess.DEVNULL, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, + ) + # Fire-and-forget: don't wait — answer 202 now, let the container start. + asyncio.ensure_future(proc.communicate()) + except (OSError, asyncio.TimeoutError): + pass # fail-soft: missing script or exec failure still returns 202 + return JSONResponse(status_code=202, content={"status": "restart_requested"}) + + +# --------------------------------------------------------------------------- +# GET /logs — tail container logs +# --------------------------------------------------------------------------- + + +@router.get("/logs") +async def comfyui_logs(tail: int = 60) -> JSONResponse: + """Return the last N lines of the ComfyUI container logs. + + Uses ``docker logs --tail N`` (or ``podman``) against the container + name. Returns ``{"lines": []}`` when the container runtime is absent + or the container has no logs yet — never a 500. + """ + container = _comfyui_container() + # Prefer podman if available (post-D9 the img slot runs under podman) + runtime = shutil.which("podman") or shutil.which("docker") + if not runtime: + return JSONResponse(status_code=200, content={"lines": []}) + try: + proc = await asyncio.create_subprocess_exec( + runtime, + "logs", + "--tail", + str(tail), + container, + stdin=asyncio.subprocess.DEVNULL, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + out, err = await asyncio.wait_for(proc.communicate(), timeout=10.0) + except (TimeoutError, OSError): + return JSONResponse(status_code=200, content={"lines": []}) + # docker logs writes to stderr for container output; combine both + combined = (out + err).decode("utf-8", "replace") + lines = [ln for ln in combined.splitlines() if ln] + return JSONResponse(status_code=200, content={"lines": lines}) + + +# --------------------------------------------------------------------------- +# POST /workflows/{name}/launch — quick-launch a curated workflow +# --------------------------------------------------------------------------- + + +@router.post("/workflows/{name}/launch", status_code=202) +async def comfyui_workflow_launch(name: str) -> JSONResponse: + """Quick-launch a workflow by name from the bind-mounted workflows directory. + + Reads .json from the primary workflows dir, falling back to the + user/default/workflows path. Posts the API-format workflow JSON to + ComfyUI's /prompt endpoint and returns 202 with the prompt_id. + 404 when the workflow file does not exist. + """ + workflow_path = _find_workflow(name) + if workflow_path is None: + return JSONResponse( + status_code=404, + content={ + "error": { + "code": "comfyui.workflow_not_found", + "message": f"workflow '{name}' not found in workflows directories", + } + }, + ) + try: + with open(workflow_path) as fh: + workflow = fh.read() + workflow_data = __import__("json").loads(workflow) + except (OSError, ValueError) as exc: + return JSONResponse( + status_code=500, + content={"error": {"code": "comfyui.workflow_read_error", "message": str(exc)}}, + ) + base = _comfyui_base_url() + try: + resp = await _get_client().post( + f"{base}/prompt", + json={"prompt": workflow_data}, + ) + result = resp.json() if resp.status_code == 200 else {} + except (httpx.HTTPError, ValueError): + result = {} + prompt_id = result.get("prompt_id") + return JSONResponse( + status_code=202, + content={"status": "queued", "prompt_id": prompt_id}, + ) + + +# --------------------------------------------------------------------------- +# GET /preview — proxy the latest output image from ComfyUI history +# --------------------------------------------------------------------------- + + +def _latest_output_image(history: dict[str, Any]) -> dict[str, str] | None: + """Find the newest output image entry in ComfyUI's /history response. + + ComfyUI history is a dict keyed by prompt_id; each entry has an + ``outputs`` dict with node-keyed image lists. We pick the entry with + the highest timestamp (or first if none) and return the first image. + """ + if not history: + return None + # Sort by timestamp if available, newest first + def _ts(entry): + return entry.get("timestamp", 0.0) + + candidates = sorted(history.values(), key=_ts, reverse=True) + for entry in candidates: + outputs = entry.get("outputs", {}) + for node_out in outputs.values(): + images = node_out.get("images", []) + if images: + return images[0] + return None + + +@router.get("/preview") +async def comfyui_preview() -> Any: + """Proxy the latest output image from the ComfyUI history. + + Queries /history for the most recent completed prompt, fetches the + newest output image via /view?filename=...&type=output, and streams + the bytes back with the correct content-type. Returns 404 when there + is no output yet. + """ + from fastapi.responses import Response + + history = await _fetch_json("/history") + if not isinstance(history, dict): + return JSONResponse(status_code=404, content={"error": {"code": "comfyui.no_output"}}) + img = _latest_output_image(history) + if img is None: + return JSONResponse(status_code=404, content={"error": {"code": "comfyui.no_output"}}) + filename = img.get("filename", "") + subfolder = img.get("subfolder", "") + img_type = img.get("type", "output") + params = f"filename={filename}&type={img_type}" + if subfolder: + params += f"&subfolder={subfolder}" + base = _comfyui_base_url() + try: + resp = await _get_client().get(f"{base}/view?{params}") + if resp.status_code != 200: + return JSONResponse( + status_code=404, content={"error": {"code": "comfyui.image_fetch_failed"}} + ) + content_type = resp.headers.get("content-type", "image/png") + return Response(content=resp.content, media_type=content_type) + except (httpx.HTTPError, OSError): + return JSONResponse( + status_code=404, content={"error": {"code": "comfyui.image_fetch_failed"}} + ) + + __all__ = ["aclose_client", "router"] diff --git a/tests/api/test_comfyui_phase4.py b/tests/api/test_comfyui_phase4.py new file mode 100644 index 00000000..8763f86f --- /dev/null +++ b/tests/api/test_comfyui_phase4.py @@ -0,0 +1,432 @@ +"""Phase 4 TDD tests — control + monitoring routes for ComfyUI. + +Covers: + POST /api/comfyui/render/cancel — clears queue + interrupts + POST /api/comfyui/restart — shells out to comfy-up.sh + GET /api/comfyui/logs?tail=N — container log lines + POST /api/comfyui/workflows/{name}/launch — reads workflow file + posts /prompt + GET /api/comfyui/preview — proxies latest output image bytes + +All network + subprocess calls are mocked — no real ComfyUI or container. +""" + +from __future__ import annotations + +import asyncio +import json +import os +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +import pytest +from fastapi.testclient import TestClient + + +# --------------------------------------------------------------------------- +# Module-state isolation (same pattern as test_comfyui_proxy.py) +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _reset_comfyui_state(): + from hal0.api.routes import comfyui as mod + + mod._reset_state() + yield + mod._reset_state() + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_BASE = "hal0.api.routes.comfyui" + + +def _make_proc(returncode: int = 0, stdout: bytes = b"") -> MagicMock: + """Return an AsyncMock that looks like asyncio.Process.""" + proc = MagicMock() + proc.returncode = returncode + proc.communicate = AsyncMock(return_value=(stdout, b"")) + return proc + + +# --------------------------------------------------------------------------- +# POST /api/comfyui/render/cancel +# --------------------------------------------------------------------------- + + +class TestRenderCancel: + def test_cancel_posts_clear_and_interrupt_returns_202(self, client: TestClient): + """cancel must POST {base}/queue?clear=true AND {base}/interrupt.""" + posted = [] + + async def fake_post(url, **kwargs): + posted.append(url) + resp = MagicMock() + resp.status_code = 200 + return resp + + with patch(f"{_BASE}._get_client") as mock_client: + http = MagicMock() + http.post = AsyncMock(side_effect=fake_post) + mock_client.return_value = http + + r = client.post("/api/comfyui/render/cancel") + + assert r.status_code == 202 + # both endpoints called + assert any("/queue" in u for u in posted), f"no /queue in {posted}" + assert any("/interrupt" in u for u in posted), f"no /interrupt in {posted}" + + def test_cancel_is_fail_soft_when_comfyui_unreachable(self, client: TestClient): + """Network errors must still return 202 (fail-soft).""" + import httpx + + with patch(f"{_BASE}._get_client") as mock_client: + http = MagicMock() + http.post = AsyncMock(side_effect=httpx.ConnectError("refused")) + mock_client.return_value = http + + r = client.post("/api/comfyui/render/cancel") + + assert r.status_code == 202 + + +# --------------------------------------------------------------------------- +# POST /api/comfyui/restart +# --------------------------------------------------------------------------- + + +class TestRestart: + def test_restart_calls_comfy_up_sh_returns_202(self, client: TestClient): + """restart must invoke /opt/comfyui/comfy-up.sh via subprocess.""" + called_args = [] + + async def fake_subprocess(*args, **kwargs): + called_args.extend(args) + return _make_proc(returncode=0) + + with patch("asyncio.create_subprocess_exec", side_effect=fake_subprocess): + r = client.post("/api/comfyui/restart") + + assert r.status_code == 202 + assert any("comfy-up.sh" in str(a) for a in called_args), ( + f"comfy-up.sh not in called args: {called_args}" + ) + + def test_restart_returns_202_even_when_script_fails(self, client: TestClient): + """Script non-zero exit must still answer 202 (background op).""" + + async def fake_subprocess(*args, **kwargs): + return _make_proc(returncode=1) + + with patch("asyncio.create_subprocess_exec", side_effect=fake_subprocess): + r = client.post("/api/comfyui/restart") + + assert r.status_code == 202 + + +# --------------------------------------------------------------------------- +# GET /api/comfyui/logs?tail=N +# --------------------------------------------------------------------------- + + +class TestLogs: + def _make_log_proc(self, lines: list[str]) -> MagicMock: + out = "\n".join(lines).encode() + return _make_proc(returncode=0, stdout=out) + + def test_logs_returns_lines_list(self, client: TestClient): + log_lines = ["2026-06-16 startup ok", "loading model", "ready"] + proc = self._make_log_proc(log_lines) + + with patch("asyncio.create_subprocess_exec", return_value=proc): + r = client.get("/api/comfyui/logs?tail=60") + + assert r.status_code == 200 + body = r.json() + assert "lines" in body + assert body["lines"] == log_lines + + def test_logs_default_tail_60(self, client: TestClient): + """Default tail when not supplied must be 60.""" + call_args_store = [] + + async def capture(*args, **kwargs): + call_args_store.extend(args) + return _make_proc(stdout=b"line1\nline2") + + with patch("asyncio.create_subprocess_exec", side_effect=capture): + r = client.get("/api/comfyui/logs") + + assert r.status_code == 200 + joined = " ".join(str(a) for a in call_args_store) + assert "60" in joined, f"tail=60 not found in subprocess args: {call_args_store}" + + def test_logs_custom_tail(self, client: TestClient): + """tail= query param must be forwarded to the container runtime.""" + call_args_store = [] + + async def capture(*args, **kwargs): + call_args_store.extend(args) + return _make_proc(stdout=b"x") + + with patch("asyncio.create_subprocess_exec", side_effect=capture): + r = client.get("/api/comfyui/logs?tail=10") + + assert r.status_code == 200 + joined = " ".join(str(a) for a in call_args_store) + assert "10" in joined + + def test_logs_empty_when_no_container_runtime(self, client: TestClient): + """If docker/podman not found, return empty lines not a 500.""" + with patch("shutil.which", return_value=None): + r = client.get("/api/comfyui/logs") + + assert r.status_code == 200 + assert r.json() == {"lines": []} + + +# --------------------------------------------------------------------------- +# POST /api/comfyui/workflows/{name}/launch +# --------------------------------------------------------------------------- + +_SAMPLE_WORKFLOW = { + "1": { + "inputs": {"text": "a dog", "clip": ["2", 1]}, + "class_type": "CLIPTextEncode", + } +} + + +class TestWorkflowLaunch: + def test_launch_reads_workflow_and_posts_to_prompt( + self, client: TestClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + """Happy path: workflow file found, POSTed to /prompt, 202 returned.""" + wf_dir = tmp_path / "comfyui" / "workflows" + wf_dir.mkdir(parents=True) + wf_file = wf_dir / "test_wf.json" + wf_file.write_text(json.dumps(_SAMPLE_WORKFLOW)) + + monkeypatch.setenv("COMFYUI_WORKFLOWS_DIR", str(wf_dir)) + + prompt_id = "abc-123" + + async def fake_post(url, **kwargs): + resp = MagicMock() + resp.status_code = 200 + resp.json = Mock(return_value={"prompt_id": prompt_id}) + return resp + + with patch(f"{_BASE}._get_client") as mock_client: + http = MagicMock() + http.post = AsyncMock(side_effect=fake_post) + mock_client.return_value = http + + r = client.post("/api/comfyui/workflows/test_wf/launch") + + assert r.status_code == 202 + body = r.json() + assert body["prompt_id"] == prompt_id + + def test_launch_404_when_workflow_missing( + self, client: TestClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + wf_dir = tmp_path / "comfyui" / "workflows" + wf_dir.mkdir(parents=True) + monkeypatch.setenv("COMFYUI_WORKFLOWS_DIR", str(wf_dir)) + + r = client.post("/api/comfyui/workflows/nonexistent/launch") + + assert r.status_code == 404 + + def test_launch_falls_back_to_user_default_dir( + self, client: TestClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + """If primary dir has no match, fall back to user/default/workflows/.""" + primary = tmp_path / "comfyui" / "workflows" + primary.mkdir(parents=True) + fallback = tmp_path / "comfyui" / "user" / "default" / "workflows" + fallback.mkdir(parents=True) + wf_file = fallback / "fb_wf.json" + wf_file.write_text(json.dumps(_SAMPLE_WORKFLOW)) + + # Point primary to the dir that does NOT have the file + monkeypatch.setenv("COMFYUI_WORKFLOWS_DIR", str(primary)) + # Fallback is derived relative to the workflows dir's parent's parent + # The implementation should infer it from COMFYUI_MODELS_DIR base path + monkeypatch.setenv("COMFYUI_MODELS_DIR", str(tmp_path / "comfyui" / "models")) + # Override to point at the right base + monkeypatch.setenv("COMFYUI_DATA_DIR", str(tmp_path / "comfyui")) + + async def fake_post(url, **kwargs): + resp = MagicMock() + resp.status_code = 200 + resp.json = Mock(return_value={"prompt_id": "xyz"}) + return resp + + with patch(f"{_BASE}._get_client") as mock_client: + http = MagicMock() + http.post = AsyncMock(side_effect=fake_post) + mock_client.return_value = http + + r = client.post("/api/comfyui/workflows/fb_wf/launch") + + assert r.status_code == 202 + assert r.json()["prompt_id"] == "xyz" + + +# --------------------------------------------------------------------------- +# GET /api/comfyui/preview +# --------------------------------------------------------------------------- + +_HISTORY_RESP = { + "abc123": { + "outputs": { + "9": { + "images": [ + {"filename": "ComfyUI_00001_.png", "subfolder": "", "type": "output"} + ] + } + }, + "timestamp": 1718530000.0, + } +} + + +class TestPreview: + def test_preview_404_when_no_history(self, client: TestClient): + """Empty history → 404.""" + + async def fetch(path): + if "/history" in path: + return {} + return None + + with patch(f"{_BASE}._fetch_json", new_callable=AsyncMock, side_effect=fetch): + r = client.get("/api/comfyui/preview") + + assert r.status_code == 404 + + def test_preview_streams_image_bytes(self, client: TestClient): + """When history has output, the image bytes are proxied back.""" + png_bytes = b"\x89PNG\r\n\x1a\n" + b"\x00" * 20 + + async def fetch(path): + if "/history" in path: + return _HISTORY_RESP + return None + + async def fake_get(url, **kwargs): + resp = MagicMock() + resp.status_code = 200 + resp.content = png_bytes + resp.headers = {"content-type": "image/png"} + return resp + + with ( + patch(f"{_BASE}._fetch_json", new_callable=AsyncMock, side_effect=fetch), + patch(f"{_BASE}._get_client") as mock_client, + ): + http = MagicMock() + http.get = AsyncMock(side_effect=fake_get) + mock_client.return_value = http + + r = client.get("/api/comfyui/preview") + + assert r.status_code == 200 + assert r.content == png_bytes + assert "image" in r.headers.get("content-type", "") + + def test_preview_404_when_history_has_no_outputs(self, client: TestClient): + """History entry with empty outputs → 404.""" + history = {"abc": {"outputs": {}, "timestamp": 1.0}} + + async def fetch(path): + if "/history" in path: + return history + return None + + with patch(f"{_BASE}._fetch_json", new_callable=AsyncMock, side_effect=fetch): + r = client.get("/api/comfyui/preview") + + assert r.status_code == 404 + + +# --------------------------------------------------------------------------- +# Telemetry: status payload checks (4.2) +# --------------------------------------------------------------------------- + + +class TestStatusTelemetry: + """Ensure /status fields needed by the pane are present and well-formed.""" + + _SYSTEM_STATS = { + "system": {"ram_total": 128 * 1024**3, "ram_free": 46 * 1024**3}, + "devices": [ + { + "name": "Radeon 8060S", + "type": "cuda", + "vram_total": 80 * 1024**3, + "vram_free": 26 * 1024**3, + } + ], + } + _QUEUE_IDLE = {"queue_running": [], "queue_pending": []} + _QUEUE_BUSY = { + "queue_running": [[0, "abc", {}, {}, {}]], + "queue_pending": [], + } + + def _patch_status(self, stats, queue): + base = _BASE + + async def fetch(path): + if "system_stats" in path: + return stats + if "queue" in path: + return queue + return None + + return ( + patch(f"{base}._container_state", new_callable=AsyncMock, return_value="running"), + patch(f"{base}._systemd_active", new_callable=AsyncMock, return_value=False), + patch(f"{base}._fetch_json", new_callable=AsyncMock, side_effect=fetch), + ) + + def test_status_memory_fields_present(self, client: TestClient): + c, s, f = self._patch_status(self._SYSTEM_STATS, self._QUEUE_IDLE) + with c, s, f: + body = client.get("/api/comfyui/status").json() + + mem = body["memory"] + assert mem is not None + assert "gtt_used_gb" in mem + assert "gtt_ceil_gb" in mem + assert "ram_used_gb" in mem + assert "ram_ceil_gb" in mem + + def test_util_is_none_or_zero_when_no_running_job(self, client: TestClient): + """gpu_busy_percent is forced-high artifact — util must be 0/None when idle.""" + c, s, f = self._patch_status(self._SYSTEM_STATS, self._QUEUE_IDLE) + with c, s, f: + body = client.get("/api/comfyui/status").json() + + # util key may be absent or be 0/None — it must NOT be 100 when idle + util = body.get("util") or (body.get("memory") or {}).get("util") + assert util in (None, 0, 0.0), f"util should be null/0 when idle, got {util}" + + def test_it_s_eta_step_absent_is_acceptable(self, client: TestClient): + """it/s, eta, step are websocket-derived — absent is OK (UI degrades gracefully).""" + c, s, f = self._patch_status(self._SYSTEM_STATS, self._QUEUE_BUSY) + with c, s, f: + body = client.get("/api/comfyui/status").json() + + # These fields are optional — test just asserts that if present they're sensible + for field in ("it_s", "eta", "step"): + val = body.get(field) + if val is not None: + assert isinstance(val, (int, float, str)), f"{field} unexpected type" From 16a19bcd55e58f3fb8bfcca2d74531b92f713682 Mon Sep 17 00:00:00 2001 From: Alexander Date: Tue, 16 Jun 2026 18:11:52 -0400 Subject: [PATCH 13/24] feat(ui): ImageGen V2 render-hero pane (mock) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port Design/design_handoff_comfyui_imagegen into comfyui-pane.jsx: render-hero (preview frame + step pips + progress + it/s + eta), ordered queue rows (running ldot.generating + pending), iGPU GTT radial gauge + RAM spark + 2x2 device grid, workflows strip (6 flows), models-on-share inventory, container footer with sctrl controls. CSS scoped under .comfy-page (blue --comfy accent). Empty-queue state in-flow with min-height (not position:absolute overlay — PR #845 guard). Reduced-motion rule freezes pulse + shimmer animations. Mock data via window.__comfyuiV2MockOverride seam for e2e injection. 8/8 imagegen-v2 e2e tests pass. comfyui-arbiter-v3 (4 tests) breaks as expected: those specs test the old switchover/pin UI removed in V2; will be updated in Task 5.2 live wiring. Co-Authored-By: Claude Sonnet 4.6 --- ui/src/dash/comfyui-pane.css | 632 +++++++------ ui/src/dash/comfyui-pane.jsx | 1130 ++++++++++-------------- ui/tests/e2e/specs/imagegen-v2.spec.ts | 213 +++++ 3 files changed, 1030 insertions(+), 945 deletions(-) create mode 100644 ui/tests/e2e/specs/imagegen-v2.spec.ts diff --git a/ui/src/dash/comfyui-pane.css b/ui/src/dash/comfyui-pane.css index 35acaa29..1fb87bab 100644 --- a/ui/src/dash/comfyui-pane.css +++ b/ui/src/dash/comfyui-pane.css @@ -1,289 +1,353 @@ -/* ComfyUI "generation engine" pane — slots-page Image-Gen tab. +/* hal0 dashboard — ComfyUI "Image Gen" pane · V2 "Render hero" styles. * - * Ported (auto-scoped) from the hal0 Design System exploration - * Design System/explorations/comfyui-row/row.css. Every selector is scoped - * under `.comfy-pane` so the design's generic class names (.engine, .queue, - * .gauge, .flow, .tel, .switchover …) can't collide with dashboard globals. - * The design's :root token block is re-scoped to .comfy-pane below; the - * --comfy* accent is ALSO mirrored into dashboard.css :root so the page tabs - * can reference it. Motion/sunken tokens the design assumes but the live - * stylesheet lacks (--dur*, --ease, --bg-sunken) are added to the same block. */ + * Ported from Design/design_handoff_comfyui_imagegen/design/comfy.css. + * Scoped under .comfy-page so the blue --comfy accent tokens apply only + * inside the ImageGen pane. The outer pane wrapper also carries + * .comfy-v2-pane (used by e2e selectors). + * + * Shared vocabulary (.wcard/.wcard-h/.wcard-b/.wfoot/.blk-h/.ldot/.sctrl) + * mirrors the Inference pane and NPU widget design language. Primitives + * already in engine-panes.css / npu.css are re-declared here scoped to + * .comfy-page so the blue accent overrides them correctly within this pane. + * + * CRITICAL (PR #845 lockup): the empty-state element (.queue-empty-state) + * must be in-flow — never position:absolute;inset:0 overlay. */ + +/* ── Accent tokens (scoped to .comfy-page) ─────────────────────────────────── */ +.comfy-page { + --comfy: #5EA4F9; + --comfy-hi: #8CC0FF; + --comfy-soft: rgba(94,164,249,0.10); + --comfy-line: rgba(94,164,249,0.32); + --comfy-bg: #0C1622; + --comfy-glow: rgba(94,164,249,0.55); + --comfy-dim: rgba(94,164,249,0.16); + --ease: cubic-bezier(0.22, 1, 0.36, 1); +} +.comfy-page * { box-sizing: border-box; } + +/* ── Card shell ─────────────────────────────────────────────────────────────── */ +.comfy-page .wcard { + border: 1px solid var(--line); + border-radius: var(--rad-lg); + background: var(--bg-1); + overflow: hidden; +} +.comfy-page .wcard.active { + border-color: var(--comfy-line); + box-shadow: 0 0 0 1px var(--comfy-line), 0 0 44px -22px var(--comfy-glow); +} -.comfy-pane { - --dur: 0.18s; - --dur-fast: 0.12s; - --dur-slow: 0.22s; - --ease: cubic-bezier(0.22, 1, 0.36, 1); - --bg-sunken: #070707; - font-family: var(--geist); - color: var(--fg); - --comfy: #5EA4F9; - --comfy-hi: #8CC0FF; - --comfy-soft: rgba(94,164,249,0.10); - --comfy-line: rgba(94,164,249,0.34); - --comfy-bg: #0C1622; - --comfy-glow: rgba(94,164,249,0.20); - --npu: var(--dev-npu); - --npu-soft: rgba(200,150,255,0.08); - --npu-line: rgba(200,150,255,0.30); } -.comfy-pane .proto { font-family: var(--geist); color: var(--fg); background: var(--bg); } -.comfy-pane .proto * { box-sizing: border-box; } -.comfy-pane .sec-label { display: flex; align-items: center; gap: 10px; - font-family: var(--jbm); font-size: 12px; letter-spacing: 0.04em; - color: var(--fg-3); margin: 0 0 16px; } -.comfy-pane .sec-label b { color: var(--comfy); font-weight: 500; text-transform: uppercase; letter-spacing: 0.1em; } -.comfy-pane .sec-label .dim { color: var(--fg-5); } -.comfy-pane .sec-label .meta { color: var(--fg-4); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; } -.comfy-pane .engine { border: 1px solid var(--line); border-radius: var(--rad-lg); - background: var(--bg-1); overflow: hidden; } -.comfy-pane .engine.active { border-color: var(--comfy-line); box-shadow: 0 0 0 1px var(--comfy-line), 0 0 40px -20px var(--comfy-glow); } -.comfy-pane .engine.active::before { content: ""; } -.comfy-pane .engine-h { display: flex; align-items: center; gap: 12px; - padding: 14px 18px; border-bottom: 1px solid var(--line-soft); - background: var(--bg); } -.comfy-pane .engine.active .engine-h { background: linear-gradient(90deg, var(--comfy-soft), transparent 60%); } -.comfy-pane .engine-glyph { width: 26px; height: 26px; border-radius: var(--rad-sm); +/* ── Card header ────────────────────────────────────────────────────────────── */ +.comfy-page .wcard-h { + display: flex; + align-items: center; + gap: 11px; + padding: 13px 16px; + border-bottom: 1px solid var(--line-soft); + background: linear-gradient(90deg, var(--comfy-soft), transparent 58%); +} +.comfy-page .wcard-h .glyph { + width: 26px; height: 26px; + border-radius: var(--rad-sm); display: inline-flex; align-items: center; justify-content: center; - background: var(--comfy-soft); color: var(--comfy); border: 1px solid var(--comfy-line); } -.comfy-pane .engine-title { font-family: var(--jbm); font-size: 15px; font-weight: 500; color: var(--fg); } -.comfy-pane .engine-sub { font-family: var(--jbm); font-size: 11px; color: var(--fg-4); } -.comfy-pane .engine-h .grow { flex: 1; } -.comfy-pane .epill { display: inline-flex; align-items: center; gap: 7px; - padding: 3px 10px; border-radius: 999px; + background: var(--comfy-soft); + color: var(--comfy); + border: 1px solid var(--comfy-line); + flex-shrink: 0; +} +.comfy-page .wcard-h .col { display: flex; flex-direction: column; gap: 2px; min-width: 0; } +.comfy-page .wcard-h .ttl { font-family: var(--jbm); font-size: 14.5px; font-weight: 500; color: var(--fg); letter-spacing: -0.01em; line-height: 1.1; } +.comfy-page .wcard-h .sub { font-family: var(--jbm); font-size: 10.5px; color: var(--fg-4); } +.comfy-page .wcard-h .grow { flex: 1; } +.comfy-page .wcard-h .meta { font-family: var(--jbm); font-size: 10px; color: var(--fg-4); white-space: nowrap; } +.comfy-page .wcard-h .meta b { color: var(--fg-2); font-weight: 500; } +.comfy-page .wcard-b { padding: 16px; } + +/* ── State pill ─────────────────────────────────────────────────────────────── */ +.comfy-page .epill { + display: inline-flex; align-items: center; gap: 7px; + padding: 3px 10px; border-radius: var(--rad-pill); font-family: var(--jbm); font-size: 11px; - border: 1px solid var(--line); color: var(--fg-3); background: var(--bg-1); } -.comfy-pane .epill .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--fg-4); } -.comfy-pane .epill.exclusive { color: var(--comfy); border-color: var(--comfy-line); background: var(--comfy-soft); } -.comfy-pane .epill.exclusive .dot { background: var(--comfy); box-shadow: 0 0 8px var(--comfy); } -.comfy-pane .epill.running { color: var(--comfy); border-color: var(--comfy-line); background: var(--comfy-soft); } -.comfy-pane .epill.running .dot { background: var(--comfy); box-shadow: 0 0 8px var(--comfy); } -.comfy-pane .epill.generating { color: var(--comfy); border-color: var(--comfy-line); background: var(--comfy-soft); } -.comfy-pane .epill.generating .dot { background: var(--comfy); box-shadow: 0 0 8px var(--comfy); animation: pulse 1.2s ease-in-out infinite; } -.comfy-pane .epill.starting { color: var(--warn); border-color: var(--warn-line); background: var(--warn-soft); } -.comfy-pane .epill.starting .dot { background: var(--warn); box-shadow: 0 0 8px var(--warn); animation: pulse 1.2s ease-in-out infinite; } -.comfy-pane .epill.stopped { color: var(--fg-3); } -.comfy-pane .epill.stopped .dot { background: var(--fg-4); } -.comfy-pane .epill.error { color: var(--err); border-color: var(--err-line); background: var(--err-soft); } -.comfy-pane .epill.error .dot { background: var(--err); } -.comfy-pane .switchover { display: inline-flex; align-items: center; gap: 11px; } -.comfy-pane .switchover .mode { font-family: var(--jbm); font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; - color: var(--fg-4); } -.comfy-pane .switchover .mode.on { color: var(--comfy); } -.comfy-pane .switchover .mode.llm-on { color: var(--accent); } -.comfy-pane .toggle { --tg-color: var(--accent); - position: relative; width: 38px; height: 22px; border-radius: 999px; - background: var(--bg-3); border: 1px solid var(--line-strong); - cursor: pointer; flex-shrink: 0; transition: background var(--dur) var(--ease), border-color var(--dur) var(--ease); } -.comfy-pane .toggle .knob { position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%; - background: var(--fg-3); transition: transform var(--dur) var(--ease), background var(--dur) var(--ease); } -.comfy-pane .toggle.on { background: var(--tg-color); border-color: var(--tg-color); } -.comfy-pane .toggle.on .knob { transform: translateX(16px); background: #0a0a0a; } -.comfy-pane .toggle.comfy { --tg-color: var(--comfy); } -.comfy-pane .toggle.big { width: 46px; height: 26px; } -.comfy-pane .toggle.big .knob { width: 20px; height: 20px; } -.comfy-pane .toggle.big.on .knob { transform: translateX(20px); } -.comfy-pane .toggle.busy { opacity: 0.6; cursor: progress; } -.comfy-pane .rchip { display: inline-flex; align-items: center; gap: 5px; padding: 2px 8px; - border-radius: 3px; font-family: var(--jbm); font-size: 10px; letter-spacing: 0.02em; - background: var(--bg-2); color: var(--fg-3); border: 1px solid var(--line); text-transform: lowercase; } -.comfy-pane .rchip.comfy { color: var(--comfy); border-color: var(--comfy-line); background: var(--comfy-soft); } -.comfy-pane .rchip.npu { color: var(--npu); border-color: var(--npu-line); background: var(--npu-soft); } -.comfy-pane .rchip.ok { color: var(--ok); border-color: var(--ok-line); background: var(--ok-soft); } -.comfy-pane .rchip.warn { color: var(--warn); border-color: var(--warn-line); background: var(--warn-soft); } -.comfy-pane .rbtn { display: inline-flex; align-items: center; gap: 6px; padding: 5px 11px; height: 28px; - font-family: var(--jbm); font-size: 11px; border-radius: var(--rad-sm); cursor: pointer; - background: transparent; color: var(--fg-2); border: 1px solid var(--line); white-space: nowrap; } -.comfy-pane .rbtn:hover { border-color: var(--line-strong); background: var(--bg-2); color: var(--fg); } -.comfy-pane .rbtn.primary { background: var(--comfy); border-color: var(--comfy); color: #04111f; font-weight: 600; } -.comfy-pane .rbtn.primary:hover { filter: brightness(1.08); background: var(--comfy); } -.comfy-pane .rbtn.ghost-comfy { color: var(--comfy); border-color: var(--comfy-line); } -.comfy-pane .rbtn.ghost-comfy:hover { background: var(--comfy-soft); } -.comfy-pane .rbtn.danger { color: var(--err); border-color: var(--err-line); } -.comfy-pane .rbtn.danger:hover { background: var(--err-soft); } -.comfy-pane .rbtn.sm { height: 24px; padding: 3px 8px; font-size: 10.5px; } -.comfy-pane .engine-b { padding: 16px 18px; display: flex; flex-direction: column; gap: 16px; } -.comfy-pane .engine-foot { padding: 11px 18px; border-top: 1px solid var(--line-soft); background: var(--bg); - font-family: var(--jbm); font-size: 11px; color: var(--fg-4); - display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } -.comfy-pane .engine-foot .k { color: var(--fg-5); } -.comfy-pane .engine-foot .v { color: var(--fg-3); } -.comfy-pane .engine-foot .v.comfy { color: var(--comfy); } -.comfy-pane .engine-foot .sep { color: var(--fg-5); } -.comfy-pane .subgrid { display: grid; gap: 12px; } -.comfy-pane .subcard { border: 1px solid var(--line); border-radius: var(--rad); background: var(--bg); - padding: 12px 14px; display: flex; flex-direction: column; gap: 9px; } -.comfy-pane .subcard-h { display: flex; align-items: center; gap: 8px; font-family: var(--jbm); font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--fg-4); } -.comfy-pane .subcard-h .grow { flex: 1; } -.comfy-pane .card-section { margin-top: 4px; padding-top: 12px; border-top: 1px solid var(--line-soft); display: flex; flex-direction: column; gap: 10px; } -.comfy-pane .gauge { display: flex; flex-direction: column; gap: 6px; } -.comfy-pane .gauge-h { display: flex; justify-content: space-between; font-family: var(--jbm); font-size: 11px; color: var(--fg-3); } -.comfy-pane .gauge-h b { color: var(--fg); font-weight: 500; } -.comfy-pane .gauge-h .ceil { color: var(--fg-5); } -.comfy-pane .gauge-track { height: 8px; border-radius: 2px; background: var(--bg-3); overflow: hidden; display: flex; } -.comfy-pane .gauge-track i { display: block; height: 100%; } -.comfy-pane .gauge-track i.comfy { background: var(--comfy); } -.comfy-pane .gauge-track i.warnz { background: var(--warn); } -.comfy-pane .gauge-track i.os { background: var(--bg-4); } -.comfy-pane .gauge-note { font-family: var(--jbm); font-size: 10px; color: var(--fg-4); } -.comfy-pane .gauge-note.pressure { color: var(--warn); } -.comfy-pane .minigauge { display: inline-flex; flex-direction: column; gap: 4px; min-width: 132px; } -.comfy-pane .minigauge .lbl { display: flex; justify-content: space-between; font-family: var(--jbm); font-size: 10px; color: var(--fg-4); } -.comfy-pane .minigauge .lbl b { color: var(--fg-2); font-weight: 500; } -.comfy-pane .minigauge .bar { height: 5px; border-radius: 2px; background: var(--bg-3); overflow: hidden; } -.comfy-pane .minigauge .bar i { display: block; height: 100%; background: var(--comfy); } -.comfy-pane .minigauge .bar i.warnz { background: var(--warn); } -.comfy-pane .genbar { display: flex; flex-direction: column; gap: 7px; } -.comfy-pane .genbar-h { display: flex; align-items: center; gap: 10px; font-family: var(--jbm); font-size: 11.5px; color: var(--fg-2); } -.comfy-pane .genbar-h .job { color: var(--fg); } -.comfy-pane .genbar-h .grow { flex: 1; } -.comfy-pane .genbar-h .pct { color: var(--comfy); } -.comfy-pane .genbar-track { height: 6px; border-radius: 2px; background: var(--bg-3); overflow: hidden; position: relative; } -.comfy-pane .genbar-fill { height: 100%; background: var(--comfy); position: relative; overflow: hidden; } -.comfy-pane .genbar-fill::after { content: ""; position: absolute; inset: 0; background: linear-gradient(90deg, transparent, rgba(255,255,255,0.22), transparent); animation: cf-shimmer 1.4s linear infinite; } -@keyframes cf-shimmer {from { transform: translateX(-100%); }to { transform: translateX(100%); } } -.comfy-pane .genbar-steps { display: flex; justify-content: space-between; font-family: var(--jbm); font-size: 10px; color: var(--fg-4); } -.comfy-pane .inv { display: flex; flex-wrap: wrap; gap: 8px; } -.comfy-pane .inv-pill { display: inline-flex; align-items: baseline; gap: 6px; padding: 5px 10px; - border: 1px solid var(--line); border-radius: var(--rad-sm); background: var(--bg); - font-family: var(--jbm); font-size: 11px; color: var(--fg-3); } -.comfy-pane .inv-pill b { color: var(--fg); font-weight: 500; font-size: 13px; } -.comfy-pane .inv-pill .u { color: var(--fg-5); } -.comfy-pane .flows { display: flex; flex-wrap: wrap; gap: 8px; } -.comfy-pane .flow { display: inline-flex; align-items: center; gap: 8px; padding: 7px 12px; - border: 1px solid var(--line); border-radius: var(--rad); background: var(--bg); - font-family: var(--jbm); font-size: 11.5px; color: var(--fg-2); cursor: pointer; } -.comfy-pane .flow:hover { border-color: var(--comfy-line); color: var(--comfy); background: var(--comfy-soft); } -.comfy-pane .flow .ic { color: var(--comfy); display: inline-flex; } -.comfy-pane .flow .arr { color: var(--fg-5); } -.comfy-pane .llm-rows { display: flex; flex-direction: column; gap: 8px; } -.comfy-pane .llm-row { display: flex; align-items: center; gap: 12px; padding: 11px 16px; - border: 1px solid var(--line); border-radius: var(--rad); background: var(--bg-1); - transition: opacity var(--dur) var(--ease), filter var(--dur) var(--ease); } -.comfy-pane .llm-row .dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; } -.comfy-pane .llm-row .name { font-family: var(--jbm); font-size: 13px; font-weight: 500; color: var(--fg); width: 80px; } -.comfy-pane .llm-row .model { font-family: var(--jbm); font-size: 12px; color: var(--fg-3); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.comfy-pane .llm-row .grow { flex: 1; } -.comfy-pane .llm-row.yielded { opacity: 0.5; } -.comfy-pane .llm-row.yielded .dot { background: var(--warn); } -.comfy-pane .llm-row.up .dot { background: var(--ok); box-shadow: 0 0 8px var(--ok); } -.comfy-pane .yield-tag { font-family: var(--jbm); font-size: 10px; color: var(--warn); border: 1px solid var(--warn-line); background: var(--warn-soft); padding: 1px 7px; border-radius: 3px; text-transform: lowercase; } -.comfy-pane .gpu-mode { display: inline-flex; border: 1px solid var(--line-strong); border-radius: var(--rad); overflow: hidden; - background: var(--bg-1); } -.comfy-pane .gpu-mode button { display: inline-flex; align-items: center; gap: 8px; padding: 8px 16px; - background: transparent; border: none; cursor: pointer; - font-family: var(--jbm); font-size: 12px; color: var(--fg-3); letter-spacing: 0.02em; } -.comfy-pane .gpu-mode button + button { border-left: 1px solid var(--line); } -.comfy-pane .gpu-mode button.on-llm { background: var(--accent-bg); color: var(--accent); } -.comfy-pane .gpu-mode button.on-comfy { background: var(--comfy-bg); color: var(--comfy); } -.comfy-pane .gpu-mode button .dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; box-shadow: 0 0 8px currentColor; } -.comfy-pane .cf-scrim { position: absolute; inset: 0; background: rgba(0,0,0,0.66); backdrop-filter: blur(2px); - display: flex; align-items: center; justify-content: center; border-radius: var(--rad-lg); } -.comfy-pane .cf { width: 520px; max-width: calc(100% - 40px); - background: var(--bg-1); border: 1px solid var(--line-strong); border-radius: var(--rad-lg); - box-shadow: var(--shadow-menu); overflow: hidden; } -.comfy-pane .cf-h { padding: 18px 20px 14px; border-bottom: 1px solid var(--line-soft); } -.comfy-pane .cf-eye { font-family: var(--jbm); font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--comfy); margin-bottom: 6px; } -.comfy-pane .cf-eye.warn { color: var(--warn); } -.comfy-pane .cf-title { font-family: var(--jbm); font-size: 17px; font-weight: 500; margin: 0; letter-spacing: -0.01em; color: var(--fg); } -.comfy-pane .cf-b { padding: 16px 20px; display: flex; flex-direction: column; gap: 13px; } -.comfy-pane .cf-lede { font-size: 13px; color: var(--fg-2); line-height: 1.55; } -.comfy-pane .cf-lede .mono { font-family: var(--jbm); color: var(--fg); } -.comfy-pane .cf-blast { display: flex; flex-direction: column; gap: 8px; } -.comfy-pane .cf-blast .row { display: flex; align-items: flex-start; gap: 10px; font-size: 12.5px; color: var(--fg-2); line-height: 1.45; } -.comfy-pane .cf-blast .row .ic { color: var(--warn); flex-shrink: 0; margin-top: 1px; } -.comfy-pane .cf-blast .row .ic.ok { color: var(--ok); } -.comfy-pane .cf-steps { border: 1px solid var(--line); border-radius: var(--rad); background: var(--bg-sunken); - padding: 11px 13px; font-family: var(--jbm); font-size: 11.5px; color: var(--fg-3); line-height: 1.7; } -.comfy-pane .cf-steps .n { color: var(--comfy); } -.comfy-pane .cf-steps .cmd { color: var(--fg-2); } -.comfy-pane .cf-steps .arr { color: var(--fg-5); } -.comfy-pane .cf-guard { display: flex; align-items: center; gap: 9px; padding: 10px 12px; - border: 1px solid var(--err-line); background: var(--err-soft); border-radius: var(--rad); - font-size: 12px; color: var(--err); font-family: var(--jbm); } -.comfy-pane .cf-f { padding: 13px 20px; border-top: 1px solid var(--line-soft); background: var(--bg); display: flex; align-items: center; gap: 10px; } -.comfy-pane .cf-f .note { font-family: var(--jbm); font-size: 10.5px; color: var(--fg-4); } -.comfy-pane .cf-f .grow { flex: 1; } -.comfy-pane .row-flex { display: flex; align-items: center; gap: 10px; } -.comfy-pane .col { display: flex; flex-direction: column; } -.comfy-pane .grow { flex: 1; } -.comfy-pane .mono { font-family: var(--jbm); } -.comfy-pane .dimx { color: var(--fg-4); } -.comfy-pane .spin { animation: cf-spin 1s linear infinite; display: inline-flex; } -@keyframes cf-spin {to { transform: rotate(360deg); } } -.comfy-pane .engine-body { max-height: 0; overflow: hidden; transition: max-height var(--dur-slow) var(--ease); } -.comfy-pane .engine.open .engine-body { max-height: 1400px; } -.comfy-pane .collapsed-prog { padding: 12px 18px 14px; border-top: 1px solid var(--line-soft); } -.comfy-pane .engine.open .collapsed-prog { display: none; } -.comfy-pane .tel-strip { display: flex; align-items: center; gap: 0; flex-wrap: wrap; row-gap: 8px; margin-top: 12px; padding-top: 11px; border-top: 1px solid var(--line-soft); } -.comfy-pane .tel { display: inline-flex; align-items: center; gap: 7px; padding: 0 14px; border-right: 1px solid var(--line-soft); font-family: var(--jbm); } -.comfy-pane .tel:first-child { padding-left: 0; } -.comfy-pane .tel:last-child { border-right: none; } -.comfy-pane .tel .l { font-size: 9px; text-transform: uppercase; letter-spacing: 0.07em; color: var(--fg-4); } -.comfy-pane .tel .v { font-size: 12.5px; color: var(--fg); } -.comfy-pane .tel .v.comfy { color: var(--comfy); } -.comfy-pane .tel .v.warn { color: var(--warn); } -.comfy-pane .tel .v .u { color: var(--fg-4); font-size: 10px; margin-left: 1px; } -.comfy-pane .tel .minibar { width: 52px; height: 5px; border-radius: 2px; background: var(--bg-3); overflow: hidden; } -.comfy-pane .tel .minibar i { display: block; height: 100%; } -.comfy-pane .tel .qn { display: inline-flex; align-items: center; justify-content: center; min-width: 15px; height: 15px; padding: 0 4px; border-radius: 999px; background: var(--comfy); color: #04111f; font-size: 9.5px; font-weight: 600; } -.comfy-pane .engine-foot.has-q { justify-content: flex-start; } -.comfy-pane .engine-foot .foot-id { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; min-width: 0; } -.comfy-pane .qcaret { margin-left: auto; - display: inline-flex; align-items: stretch; - border: 1px solid var(--comfy-line); border-radius: var(--rad-sm); - background: var(--comfy-soft); cursor: pointer; overflow: hidden; - transition: border-color var(--dur-fast) var(--ease), background var(--dur-fast) var(--ease); } -.comfy-pane .qcaret:hover { background: rgba(94,164,249,0.16); } -.comfy-pane .qcaret .q { display: inline-flex; align-items: center; gap: 7px; padding: 5px 10px; - font-family: var(--jbm); font-size: 11px; color: var(--comfy); } -.comfy-pane .qcaret .q .qn { display: inline-flex; align-items: center; justify-content: center; min-width: 16px; height: 16px; - padding: 0 4px; border-radius: 999px; background: var(--comfy); color: #04111f; - font-size: 10px; font-weight: 600; } -.comfy-pane .qcaret .q .qrun { color: var(--fg-4); } -.comfy-pane .qcaret .car { display: inline-flex; align-items: center; justify-content: center; width: 28px; - border-left: 1px solid var(--comfy-line); color: var(--comfy); - transition: transform var(--dur) var(--ease); } -.comfy-pane .engine.open .qcaret .car { transform: rotate(180deg); } -.comfy-pane .qcaret.empty { border-color: var(--line); background: transparent; } -.comfy-pane .qcaret.empty .q { color: var(--fg-3); } -.comfy-pane .qcaret.empty .car { border-left-color: var(--line); color: var(--fg-3); } -.comfy-pane .queue { border: 1px solid var(--line); border-radius: var(--rad); background: var(--bg); overflow: hidden; } -.comfy-pane .queue-h { display: flex; align-items: center; gap: 8px; padding: 9px 14px; - border-bottom: 1px solid var(--line-soft); font-family: var(--jbm); font-size: 10px; - text-transform: uppercase; letter-spacing: 0.08em; color: var(--fg-4); } -.comfy-pane .queue-h .grow { flex: 1; } -.comfy-pane .queue-h .clear { color: var(--fg-4); cursor: pointer; text-transform: none; letter-spacing: 0; font-size: 11px; } -.comfy-pane .queue-h .clear:hover { color: var(--err); } -.comfy-pane .qrow { display: grid; grid-template-columns: 16px minmax(0,1fr) 150px 84px 28px; - align-items: center; gap: 12px; padding: 9px 14px; - border-bottom: 1px solid var(--line-soft); font-family: var(--jbm); font-size: 12px; } -.comfy-pane .qrow:last-child { border-bottom: none; } -.comfy-pane .qrow.running { background: var(--comfy-soft); } -.comfy-pane .qrow .qdot { width: 7px; height: 7px; border-radius: 50%; background: var(--fg-5); } -.comfy-pane .qrow.running .qdot { background: var(--comfy); box-shadow: 0 0 8px var(--comfy); animation: pulse 1.2s ease-in-out infinite; } -.comfy-pane .qrow .qname { color: var(--fg); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.comfy-pane .qrow .qname .qkind { color: var(--fg-4); } -.comfy-pane .qrow .qmini { height: 4px; border-radius: 2px; background: var(--bg-3); overflow: hidden; } -.comfy-pane .qrow .qmini i { display: block; height: 100%; background: var(--comfy); } -.comfy-pane .qrow .qstat { font-size: 11px; color: var(--fg-4); text-align: right; } -.comfy-pane .qrow.running .qstat { color: var(--comfy); } -.comfy-pane .qrow .qx { display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: var(--rad-sm); color: var(--fg-4); cursor: pointer; } -.comfy-pane .qrow .qx:hover { color: var(--err); background: var(--err-soft); } -.comfy-pane .queue-empty { padding: 18px 14px; text-align: center; font-family: var(--jbm); font-size: 11.5px; color: var(--fg-4); } -.comfy-pane .qrow .qpos { color: var(--fg-5); } -.comfy-pane .gpu-stats { display: flex; gap: 22px; padding-top: 12px; margin-top: 4px; - border-top: 1px solid var(--line-soft); } -.comfy-pane .gpu-stats .gs { display: flex; flex-direction: column; gap: 2px; } -.comfy-pane .gpu-stats .gs b { font-family: var(--jbm); font-size: 18px; font-weight: 500; color: var(--fg); letter-spacing: -0.01em; } -.comfy-pane .gpu-stats .gs .u { font-family: var(--jbm); font-size: 10px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--fg-4); } + border: 1px solid var(--line); color: var(--fg-3); background: var(--bg-1); + white-space: nowrap; +} +.comfy-page .epill .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--fg-4); } +.comfy-page .epill.generating { color: var(--comfy); border-color: var(--comfy-line); background: var(--comfy-soft); } +.comfy-page .epill.generating .dot { + background: var(--comfy); + box-shadow: 0 0 8px var(--comfy); + animation: pulse 1.4s ease-in-out infinite; +} + +/* ── Device pill ────────────────────────────────────────────────────────────── */ +.comfy-page .cf-pill { + display: inline-flex; align-items: center; gap: 6px; + padding: 2px 8px; border-radius: 3px; + font-family: var(--jbm); font-size: 10px; letter-spacing: 0.03em; + color: var(--comfy); border: 1px solid var(--comfy-line); + background: var(--comfy-soft); white-space: nowrap; +} +.comfy-page .cf-pill .d { + width: 5px; height: 5px; border-radius: 50%; + background: currentColor; box-shadow: 0 0 6px currentColor; +} + +/* ── GPU-contention status note ─────────────────────────────────────────────── */ +.comfy-page .gpu-note { + display: inline-flex; align-items: center; gap: 7px; + padding: 3px 10px; border-radius: var(--rad-sm); + font-family: var(--jbm); font-size: 10px; + color: var(--warn); border: 1px solid var(--warn-line); + background: var(--warn-soft); white-space: nowrap; +} +.comfy-page .gpu-note .b { color: var(--fg-2); } + +/* ── Live dot ───────────────────────────────────────────────────────────────── */ +.comfy-page .ldot { width: 8px; height: 8px; border-radius: 50%; background: var(--fg-5); flex-shrink: 0; } +.comfy-page .ldot.generating { + background: var(--comfy); + box-shadow: 0 0 7px var(--comfy-glow); + animation: pulse 1.4s var(--ease) infinite; +} +.comfy-page .ldot.pending { background: var(--fg-4); } +.comfy-page .ldot.ready { background: var(--ok); } + +/* ── Block header ───────────────────────────────────────────────────────────── */ +.comfy-page .blk-h { + display: flex; align-items: center; gap: 8px; + font-family: var(--jbm); font-size: 10px; + text-transform: uppercase; letter-spacing: 0.08em; + color: var(--fg-4); margin: 0 0 12px; +} +.comfy-page .blk-h .ic { color: var(--fg-4); display: inline-flex; } +.comfy-page .blk-h .ic.acc { color: var(--comfy); } +.comfy-page .blk-h .grow { flex: 1; } +.comfy-page .blk-h .note { font-size: 10px; text-transform: none; letter-spacing: 0; color: var(--fg-5); } + +/* ── Pulse keyframe (shared; reduced-motion overrides below) ────────────────── */ +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.35; } +} + +/* ─── CRITICAL: reduced-motion freezes pulse ────────────────────────────────── */ +@media (prefers-reduced-motion: reduce) { + .comfy-page .ldot.generating, + .comfy-page .epill.generating .dot { + animation: none; + } + .comfy-page .gbar i::after, + .comfy-page .steps-pips .pip.now i::after, + .comfy-page .preview .scan { + animation: none; + } +} + +/* ── Radial Gauge (270° sweep) ──────────────────────────────────────────────── */ +.comfy-page .gauge { position: relative; } +.comfy-page .gauge svg { display: block; } +.comfy-page .gauge .gtrack { fill: none; stroke: var(--bg-3); } +.comfy-page .gauge .gfill { + fill: none; stroke: var(--comfy); stroke-linecap: round; + filter: drop-shadow(0 0 6px var(--comfy-glow)); + transition: stroke-dashoffset 0.6s var(--ease); +} +.comfy-page .gauge .gfill.warn { stroke: var(--warn); filter: drop-shadow(0 0 6px rgba(232,185,78,0.5)); } +.comfy-page .gauge .gc { + position: absolute; inset: 0; + display: flex; flex-direction: column; align-items: center; justify-content: center; +} +.comfy-page .gauge .gc .pct { font-family: var(--jbm); font-size: 36px; font-weight: 500; letter-spacing: -0.03em; color: var(--fg); line-height: 1; } +.comfy-page .gauge .gc .pct .s { font-size: 16px; color: var(--fg-3); } +.comfy-page .gauge .gc .lbl { font-family: var(--jbm); font-size: 9.5px; text-transform: uppercase; letter-spacing: 0.09em; color: var(--comfy); margin-top: 7px; } +.comfy-page .gauge .gc .sub { font-family: var(--jbm); font-size: 10px; color: var(--fg-4); margin-top: 3px; } +.comfy-page .gauge.sm .gc .pct { font-size: 22px; } +.comfy-page .gauge.sm .gc .pct .s { font-size: 11px; } +.comfy-page .gauge.sm .gc .lbl { font-size: 8px; margin-top: 4px; letter-spacing: 0.07em; } +.comfy-page .gauge.sm .gc .sub { font-size: 9px; margin-top: 2px; } + +/* ── 2×2 device metric grid ─────────────────────────────────────────────────── */ +.comfy-page .mx2 { + display: grid; grid-template-columns: 1fr 1fr; + gap: 1px; background: var(--line-soft); + border: 1px solid var(--line-soft); border-radius: var(--rad-sm); overflow: hidden; +} +.comfy-page .cstat { background: var(--bg-1); padding: 11px 13px; display: flex; flex-direction: column; gap: 5px; } +.comfy-page .cstat .cl { font-family: var(--jbm); font-size: 9px; text-transform: uppercase; letter-spacing: 0.07em; color: var(--fg-5); } +.comfy-page .cstat .cv { font-family: var(--jbm); font-size: 18px; font-weight: 500; letter-spacing: -0.02em; color: var(--fg); line-height: 1; } +.comfy-page .cstat .cv .u { font-size: 10px; color: var(--fg-4); margin-left: 3px; letter-spacing: 0; } +.comfy-page .cstat .cv.acc { color: var(--comfy); } + +/* ── Active render progress block ───────────────────────────────────────────── */ +.comfy-page .job { border: 1px solid var(--comfy-line); border-radius: var(--rad); background: var(--comfy-bg); padding: 13px 15px; display: flex; flex-direction: column; gap: 11px; } +.comfy-page .job-h { display: flex; align-items: center; gap: 9px; } +.comfy-page .job-h .nm { font-family: var(--jbm); font-size: 13.5px; font-weight: 500; color: var(--fg); } +.comfy-page .job-h .kind { font-family: var(--jbm); font-size: 11px; color: var(--fg-4); } +.comfy-page .job-h .grow { flex: 1; } +.comfy-page .job-h .pct { font-family: var(--jbm); font-size: 22px; font-weight: 500; letter-spacing: -0.02em; color: var(--comfy); line-height: 1; } +.comfy-page .job-h .pct.big { font-size: 40px; } + +.comfy-page .gbar { height: 7px; border-radius: 3px; background: var(--bg-3); overflow: hidden; position: relative; } +.comfy-page .gbar.tall { height: 10px; } +.comfy-page .gbar i { display: block; height: 100%; background: var(--comfy); position: relative; overflow: hidden; transition: width 0.5s var(--ease); } +.comfy-page .gbar i::after { + content: ""; + position: absolute; inset: 0; + background: linear-gradient(90deg, transparent, rgba(255,255,255,0.22), transparent); + animation: cf-shimmer 1.5s linear infinite; +} +@keyframes cf-shimmer { from { transform: translateX(-100%); } to { transform: translateX(100%); } } + +.comfy-page .job-steps { display: flex; justify-content: space-between; font-family: var(--jbm); font-size: 10.5px; color: var(--fg-4); } +.comfy-page .job-steps .node { color: var(--fg-3); } +.comfy-page .job-loaded { font-family: var(--jbm); font-size: 10.5px; color: var(--fg-4); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.comfy-page .job-empty { font-family: var(--jbm); font-size: 12px; color: var(--fg-4); padding: 4px 0; } + +/* ── Step pip timeline ──────────────────────────────────────────────────────── */ +.comfy-page .steps-pips { display: flex; gap: 6px; } +.comfy-page .steps-pips .pip { flex: 1; height: 4px; border-radius: 2px; background: var(--bg-3); position: relative; overflow: hidden; } +.comfy-page .steps-pips .pip.done i { position: absolute; inset: 0; background: var(--comfy); } +.comfy-page .steps-pips .pip.now i { position: absolute; inset: 0; background: var(--comfy); } +.comfy-page .steps-pips .pip.now i::after { + content: ""; position: absolute; inset: 0; + background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent); + animation: cf-shimmer 1.5s linear infinite; +} + +/* ── Preview frame (striped placeholder) ────────────────────────────────────── */ +.comfy-page .preview { + position: relative; + border: 1px solid var(--comfy-line); border-radius: var(--rad); overflow: hidden; + background-color: var(--bg-sunken); + background-image: repeating-linear-gradient(135deg, transparent 0 9px, rgba(94,164,249,0.05) 9px 18px); + display: flex; flex-direction: column; align-items: center; justify-content: center; + gap: 8px; min-height: 184px; +} +.comfy-page .preview .scan { + position: absolute; left: 0; right: 0; height: 2px; + background: linear-gradient(90deg, transparent, var(--comfy), transparent); + opacity: 0.7; + animation: cf-scan 2.6s var(--ease) infinite; +} +@keyframes cf-scan { 0% { top: 6%; } 50% { top: 94%; } 100% { top: 6%; } } +.comfy-page .preview .glyph { color: var(--comfy); opacity: 0.6; } +.comfy-page .preview .lab { font-family: var(--jbm); font-size: 10.5px; color: var(--fg-4); text-align: center; line-height: 1.6; } +.comfy-page .preview .lab b { color: var(--fg-2); font-weight: 500; } + +/* ── Queue rows (card-styled, V2 row layout) ────────────────────────────────── */ +.comfy-page .qcard { + border: 1px solid var(--line); border-radius: var(--rad-sm); + background: var(--bg-2); padding: 10px 12px; + display: flex; flex-direction: column; gap: 8px; + position: relative; overflow: hidden; +} +.comfy-page .qcard::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 2px; background: var(--qc-hue, var(--line)); } +.comfy-page .qcard.running::before { background: var(--comfy); } +.comfy-page .qcard.pending { opacity: 0.78; } +.comfy-page .qcard.pending::before { background: var(--fg-5); } + +/* Row layout for V2 queue */ +.comfy-page .qcard.row { flex-direction: row; align-items: center; gap: 11px; padding: 11px 14px; } +.comfy-page .qcard.row .qjob { display: flex; flex-direction: column; gap: 1px; min-width: 0; } +.comfy-page .qcard.row .qjob .qnm { font-family: var(--jbm); font-size: 12.5px; font-weight: 500; color: var(--fg); } +.comfy-page .qcard.row .qjob .qkind { font-family: var(--jbm); font-size: 10px; color: var(--fg-4); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.comfy-page .qcard.row .qprog { display: flex; align-items: center; gap: 9px; } +.comfy-page .qcard.row .qprog .bar { width: 96px; height: 5px; border-radius: 2px; background: var(--bg-3); overflow: hidden; } +.comfy-page .qcard.row .qprog .bar i { display: block; height: 100%; background: var(--comfy); } +.comfy-page .qcard.row .qprog .pc { font-family: var(--jbm); font-size: 11px; color: var(--comfy); } +.comfy-page .qcard.row .qspeed { font-family: var(--jbm); font-size: 11px; color: var(--fg-2); white-space: nowrap; } +.comfy-page .qcard.row .stat { font-family: var(--jbm); font-size: 11px; color: var(--fg-4); white-space: nowrap; } +.comfy-page .grow { flex: 1; } + +/* ── Empty-queue state — IN-FLOW, never an overlay (PR #845 lockup guard) ───── */ +/* MUST NOT use position:absolute;inset:0 — see PR #845. */ +.comfy-page .queue-empty-state { + font-family: var(--jbm); + font-size: 12px; + color: var(--fg-4); + padding: 16px 0; + min-height: 48px; /* in-flow, never zero height */ + display: flex; + align-items: center; +} + +/* ── Per-item controls ──────────────────────────────────────────────────────── */ +.comfy-page .ctrls { display: inline-flex; align-items: center; gap: 4px; } +.comfy-page .sctrl { + display: inline-flex; align-items: center; justify-content: center; + width: 24px; height: 24px; border-radius: var(--rad-sm); + border: 1px solid var(--line); background: var(--bg-1); + color: var(--fg-4); cursor: pointer; + transition: border-color 0.15s, background 0.15s, color 0.15s; +} +.comfy-page .sctrl:hover { border-color: var(--line-strong); background: var(--bg-3); color: var(--fg-2); } +.comfy-page .sctrl.stop { color: var(--err); border-color: var(--err-line); } +.comfy-page .sctrl.stop:hover { background: var(--err-soft); } +.comfy-page .sctrl.restart { color: var(--info); border-color: var(--info-line); } +.comfy-page .sctrl.restart:hover { background: var(--info-soft); } +.comfy-page .sctrl.acc { color: var(--comfy); border-color: var(--comfy-line); } +.comfy-page .sctrl.acc:hover { background: var(--comfy-soft); } + +/* ── Throughput spark ───────────────────────────────────────────────────────── */ +.comfy-page .cspark { display: flex; align-items: flex-end; gap: 2px; height: 30px; } +.comfy-page .cspark i { flex: 1; background: var(--comfy); border-radius: 1px; min-height: 2px; opacity: 0.42; } +.comfy-page .cspark i.hot { opacity: 1; } +.comfy-page .tp-num { font-family: var(--jbm); font-weight: 500; letter-spacing: -0.02em; color: var(--comfy); line-height: 1; } +.comfy-page .tp-num .u { font-size: 11px; color: var(--fg-4); margin-left: 4px; letter-spacing: 0; font-weight: 400; } + +/* ── Lower section: workflows + models on share ─────────────────────────────── */ +.comfy-page .wcard-sub { + display: grid; grid-template-columns: 1.3fr 1fr; gap: 24px; + margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--line-soft); +} + +.comfy-page .flows { display: flex; flex-wrap: wrap; gap: 8px; } +.comfy-page .flow { + display: inline-flex; align-items: center; gap: 7px; + padding: 7px 11px; border: 1px solid var(--line); border-radius: var(--rad-sm); + background: var(--bg); font-family: var(--jbm); font-size: 11.5px; + color: var(--fg-2); cursor: pointer; + transition: border-color 0.14s, color 0.14s, background 0.14s; +} +.comfy-page .flow:hover { border-color: var(--comfy-line); color: var(--comfy); background: var(--comfy-soft); } +.comfy-page .flow .ic { color: var(--comfy); display: inline-flex; } +.comfy-page .flow .arr { color: var(--fg-5); } +.comfy-page .flow .tag { color: var(--fg-4); font-size: 10px; margin-left: 1px; } + +.comfy-page .inv { display: flex; flex-wrap: wrap; gap: 8px; } +.comfy-page .inv-pill { + display: inline-flex; align-items: baseline; gap: 6px; + padding: 5px 10px; border: 1px solid var(--line); border-radius: var(--rad-sm); + background: var(--bg); font-family: var(--jbm); font-size: 11px; color: var(--fg-3); +} +.comfy-page .inv-pill b { color: var(--fg); font-weight: 500; font-size: 13px; } +.comfy-page .inv-pill .u { color: var(--fg-5); } +.comfy-page .inv-models { font-family: var(--jbm); font-size: 10.5px; color: var(--fg-4); margin-top: 10px; } + +/* ── Card footer (container identity + controls) ────────────────────────────── */ +.comfy-page .wfoot { + display: flex; align-items: center; gap: 8px; flex-wrap: wrap; + padding: 11px 16px; border-top: 1px solid var(--line-soft); + background: var(--bg); font-family: var(--jbm); font-size: 11px; color: var(--fg-4); +} +.comfy-page .wfoot .foot-id { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; min-width: 0; } +.comfy-page .wfoot .k { color: var(--fg-5); } +.comfy-page .wfoot .v { color: var(--fg-3); } +.comfy-page .wfoot .v.acc { color: var(--comfy); } +.comfy-page .wfoot .sep { color: var(--fg-5); } +.comfy-page .wfoot .grow { flex: 1; } +.comfy-page .wfoot .foot-ctrls { display: inline-flex; gap: 6px; } -/* Header right cluster + switchover toggle. These lived in the design's - comfyui-pane.html inline