Files
comfy-adapter/README.md
T
2026-10-04 23:36:43 +03:00

508 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# comfy-adapter
**OpenAI-совместимый HTTP-адаптер для [ComfyUI](https://github.com/Comfy-Org/ComfyUI).**
Адаптер принимает стандартные запросы OpenAI Images API (`/v1/images/generations`,
`/v1/images/edits`), подставляет параметры в заранее подготовленные workflow-шаблоны,
выполняет их на сервере ComfyUI и возвращает готовое изображение в поле `b64_json`.
Клиенту не нужно ничего знать о нодах, графах и формате workflow — ComfyUI становится
обычным image-бэкендом с OpenAI-контрактом.
```
+----------------+ OpenAI Images API +----------------+ ComfyUI HTTP API +----------------+
| | --------------------> | | ------------------> | |
| Любой клиент | /v1/images/* | comfy-adapter | /prompt | ComfyUI |
| (OpenAI SDK, | Bearer <ключ> | (FastAPI) | /upload/image | (GPU-сервер) |
| LangChain, | JSON / multipart | | /history/{id} | |
| curl, ...) | <-------------------- | | <------------------ | |
+----------------+ b64_json (PNG) +----------------+ /view +----------------+
```
**Модели**
| id модели в API | Назначение | Диффузионная модель | Доп. |
|---|---|---|---|
| `z-image-turbo` | генерация по текстовому промпту | `z_image_turbo_bf16.safetensors` | CLIP `qwen_3_4b` (lumina2), VAE `ae` |
| `qwen-image-edit` | редактирование входного изображения | `qwen_image_edit_2509_fp8_e4m3fn.safetensors` | LoRA `Qwen-Image-Edit-2509-Lightning-4steps-V1.0-bf16` (4 шага), CLIP `qwen_2.5_vl_7b_fp8_scaled` (qwen_image), VAE `qwen_image_vae` |
Проверено на ComfyUI **0.37.0** (Tesla V100 16GB, CUDA). Используются только штатные
ноды и официальные HTTP-эндпоинты ComfyUI — кастомные ноды не требуются
(`TextEncodeQwenImageEditPlus` входит в ядро, `comfy_extras.nodes_qwen`).
---
## Возможности
- **Два эндпоинта** в контракте OpenAI: генерация и редактирование изображений.
- **`GET /v1/models`** — список доступных «моделей» (для автодополнения в клиентах).
- **Воспроизводимость**: опциональный `seed`; один и тот же `seed` + промпт дают идентичный результат.
- **Толерантный парсинг OpenAI**: неприменимые поля (`quality`, `style`, `user`) не ломают запрос.
- **Workflow как данные**: шаблоны JSON и карта маппинга параметров на узлы вынесены из кода.
- **Защита от деградации графа**: битые ссылки в шаблоне обнаруживаются локально и на сервере, адаптер не стартует с нерабочим workflow.
- **Статический API-ключ** на всех эндпоинтах, кроме `GET /health`.
- **Ошибки в формате OpenAI** (`error.message/type/param/code`) — клиенты корректно их разбирают.
- **Ответ всегда base64**: адаптеру и клиенту не нужен сетевой доступ к ComfyUI.
---
## Быстрый старт
### Локально
```bash
cd comfy-adapter
python -m venv .venv
# Linux/macOS: .venv/bin/pip install -e . Windows: .venv\Scripts\pip install -e .
cp .env.example .env # заполните COMFY_BASE_URL и ADAPTER_API_KEY
uvicorn app.main:app --host 0.0.0.0 --port 8000 --log-level info
```
### Docker
```bash
docker build -t comfy-adapter .
docker run -p 8000:8000 --env-file .env comfy-adapter
```
### Проверка
```bash
curl -s http://localhost:8000/health
# {"status":"ok"}
```
Конфигурация по умолчанию указывает на `http://192.168.0.3:8188` — поменяйте
`COMFY_BASE_URL`, если ваш сервер другой. ComfyUI должен быть запущен с `--listen`,
иначе адаптер до него не достучится.
---
## Конфигурация
Все настройки — переменные окружения (или `.env` в рабочей директории).
| Переменная | По умолчанию | Назначение |
|---|---|---|
| `COMFY_BASE_URL` | `http://192.168.0.3:8188` | Базовый адрес сервера ComfyUI |
| `ADAPTER_API_KEY` | пусто (**обязательно**) | Ожидаемый Bearer-ключ клиентов. Пустое значение = все запросы получают 401 (fail-closed) |
| `EXECUTION_TIMEOUT` | `300` | Секунд ожидания результата от ComfyUI; превышение -> 502 `comfyui_timeout` |
| `POLL_INTERVAL` | `1.5` | Интервал опроса `/history`, сек |
| `CREATE_TEMPLATE_PATH` | `<репо>/workflow_create.json` | Путь к шаблону генерации |
| `EDIT_TEMPLATE_PATH` | `<репо>/workflow_edit.json` | Путь к шаблону редактирования |
| `MAPPING_FILE` | не задан | JSON с переопределением карты маппинга (см. ниже) |
| `VALIDATE_TEMPLATES_ON_SERVER` | `true` | Прогонять графы через `POST /prompt?front=false` при старте |
Логирование стартовых проверок видно с `uvicorn --log-level info`:
```
INFO:comfy-adapter:workflow templates validated on ComfyUI server
INFO:comfy-adapter:comfy-adapter ready (server=http://192.168.0.3:8188)
```
---
## API
### Аутентификация
Все эндпоинты, кроме `GET /health`, требуют заголовок:
```
Authorization: Bearer <ADAPTER_API_KEY>
```
Отсутствие или несовпадение ключа -> `401` (`code: "invalid_api_key"`). Сравнение
постоянного времени (`secrets.compare_digest`).
### `POST /v1/images/generations`
Только `application/json`.
| Параметр | Тип | По умолчанию | Обязателен | Описание |
|---|---|---|---|---|
| `prompt` | string | — | **да** | Текстовое описание. Пустая строка -> 400 |
| `model` | string \| null | `null` | нет | Если задан — только `z-image-turbo`, иначе 400 |
| `n` | int \| null | `1` | нет | Поддерживается только `1`, иначе 400 |
| `size` | string | `1024x1024` | нет | Строго `WxH`, оба значения > 0. Попала в latent-ноду, то есть реально задаёт разрешение |
| `seed` | int \| null | `null` | нет | `0..2^63-1`. Без него — криптографически случайный (`secrets.randbelow`) |
| `response_format` | string \| null | `null` | нет | Принимается; всегда возвращается `b64_json` |
**Ответ 200**
```json
{
"created": 1791145663,
"data": [{ "b64_json": "<base64 PNG>" }]
}
```
### `POST /v1/images/edits`
Принимаются оба транспорта; набор полей одинаковый.
**multipart/form-data** (как в OpenAI):
| Поле | Тип | Обязателен | Описание |
|---|---|---|---|
| `image` | файл | **да** | PNG или JPEG (проверка по сигнатуре байтов) |
| `prompt` | string | **да** | Инструкция по редактированию |
| `model` | string | нет | Только `qwen-image-edit` |
| `seed` | int | нет | В форме передаётся строкой, парсится в int |
**application/json:**
| Поле | Тип | Обязателен | Описание |
|---|---|---|---|
| `image` | string (base64) | **да** | Допускается data-URL-префикс `data:image/png;base64,...` |
| `prompt` | string | **да** | |
| `model` | string | нет | Только `qwen-image-edit` |
| `seed` | int | нет | |
| `size` | string | нет | **Принимается, но не влияет на результат** — см. «Ограничения» |
**Ответ** — тот же, что у генерации.
### `GET /v1/models`
Требует ключ. Ответ:
```json
{
"object": "list",
"data": [
{ "id": "z-image-turbo", "object": "model", "owned_by": "comfy-adapter" },
{ "id": "qwen-image-edit", "object": "model", "owned_by": "comfy-adapter" }
]
}
```
### `GET /health`
Без ключа. `{"status":"ok"}` — для health-чеков оркестраторов и reverse proxy.
### Ошибки
Канонический формат OpenAI:
```json
{ "error": { "message": "...", "type": "...", "param": null, "code": "..." } }
```
| HTTP | `type` | `code` | Причина |
|---|---|---|---|
| 400 | `invalid_request_error` | `null` | Пустой `prompt`, `n>1`, неверный формат `size`, неизвестная `model`, битый JSON/base64, отсутствующий или недекодируемый `image` |
| 401 | `invalid_request_error` | `invalid_api_key` | Ключ отсутствует или не совпадает (в т.ч. при незаполненном `ADAPTER_API_KEY`) |
| 502 | `server_error` | `comfyui_validation_failed` | ComfyUI отверг граф; в `message` — разбор `node_errors` с id узлов |
| 502 | `server_error` | `comfyui_execution_failed` | Задание упало во время исполнения (`status_str: error`) |
| 502 | `server_error` | `comfyui_timeout` | Результат не появился за `EXECUTION_TIMEOUT` |
| 502 | `server_error` | `comfyui_no_output` | В истории нет изображений типа `output` |
| 502 | `server_error` | `comfyui_error` | ComfyUI недоступен или вернул неожиданный статус |
---
## Примеры
### cURL
```bash
# Генерация
curl -s http://localhost:8000/v1/images/generations \
-H "Authorization: Bearer $ADAPTER_API_KEY" \
-H "Content-Type: application/json" \
-d '{"prompt":"a red apple on a wooden table next to steaming coffee","size":"1024x1024","seed":421337}' \
| jq -r .data[0].b64_json | base64 -d > out.png
# Редактирование (multipart)
curl -s http://localhost:8000/v1/images/edits \
-H "Authorization: Bearer $ADAPTER_API_KEY" \
-F image=@out.png \
-F prompt="change the cup to deep blue ceramic, background a dark kitchen at night, keep the apple the same" \
| jq -r .data[0].b64_json | base64 -d > edited.png
# Список моделей
curl -s http://localhost:8000/v1/models -H "Authorization: Bearer $ADAPTER_API_KEY" | jq
```
### Официальный Python SDK
```python
import base64
from openai import OpenAI
client = OpenAI(base_url="http://localhost:8000/v1", api_key="sk-1234")
# Генерация. seed нет в подписи SDK -> передаём через extra_body
resp = client.images.generate(
model="z-image-turbo",
prompt="a red apple on a wooden table next to steaming coffee",
size="1024x1024",
n=1,
extra_body={"seed": 421337},
)
open("out.png", "wb").write(base64.b64decode(resp.data[0].b64_json))
# Редактирование того же изображения
with open("out.png", "rb") as f:
edited = client.images.edit(
model="qwen-image-edit",
image=("out.png", f.read(), "image/png"),
prompt="change the cup to deep blue ceramic, keep the apple the same",
)
open("edited.png", "wb").write(base64.b64decode(edited.data[0].b64_json))
```
Проверено с `openai` 3.24.0 — оба вызова проходят без кастомного транспорта.
### JavaScript
```js
import OpenAI from "openai";
import fs from "fs";
const client = new OpenAI({ baseURL: "http://localhost:8000/v1", apiKey: "sk-1234" });
const r = await client.images.generate({
model: "z-image-turbo",
prompt: "a red apple on a wooden table",
size: "1024x1024",
n: 1,
seed: 421337, // поддерживается адаптером как обычное поле JSON-тела
});
fs.writeFileSync("out.png", Buffer.from(r.data[0].b64_json, "base64"));
```
---
## Как это работает
Один и тот же цикл для обоих эндпоинтов:
```
request
|
v
[1] auth + валидация тела --> 400 / 401
|
v
[2] deep-copy шаблона workflow, подстановка prompt/size/seed/image по карте маппинга
|
v
[3] (только edits) POST /upload/image -> имя файла в input-каталоге ComfyUI
|
v
[4] POST /prompt (client_id = uuid4 на запрос) --> prompt_id (400 => comfyui_validation_failed)
|
v
[5] poll GET /history/{prompt_id} каждые POLL_INTERVAL, до EXECUTION_TIMEOUT
| (status_str=error => comfyui_execution_failed)
v
[6] GET /view?filename=...&type=output -> байты PNG
|
v
[7] base64 -> data[0].b64_json
```
WebSocket `/ws` намеренно не используется: ComfyUI гарантированно пишет запись в
историю после завершения задания, поэтому опрос проще, не держит долгоживущее
соединение и устойчив к разрывам. Транспорт изолирован в `app/comfy_client.py` —
переход на websocket не затронул бы остальной код.
### Карта маппинга
Соответствие параметра запроса узлу workflow. Правится **без изменения кода** через
`MAPPING_FILE` (JSON с секциями `create` и `edit`; заданные ключи перезаписывают
дефолт, остальные сохраняются).
| Параметр | `workflow_create.json` | `workflow_edit.json` |
|---|---|---|
| `prompt` | `83:27` -> `inputs.text` (CLIPTextEncode) | `115:111` -> `inputs.prompt` (TextEncodeQwenImageEditPlus) |
| negative | — (в графе `ConditioningZeroOut`, константа) | `115:110` -> `inputs.prompt` (всегда `""`) |
| `size` | `83:13` -> `inputs.width` / `inputs.height` (EmptySD3LatentImage) | — (см. «Ограничения») |
| `seed` | `83:3` -> `inputs.seed` (KSampler) | `115:3` -> `inputs.seed` (KSampler) |
| `image` | — | `78` -> `inputs.image` (LoadImage) |
Пример переопределения:
```json
{ "create": { "prompt": { "node": "99:1", "field": "text" } } }
```
При старте каждый id из карты проверяется на существование в шаблоне и на то, что
поле скалярное, — рассинхрон карты и шаблона падает на старт, а не на первый запрос.
### Проверка шаблонов
Двухуровневая, оба уровня на старте:
1. **Локально** (`app/templates.py`): обход всех `inputs` и поиск ссылок
`[node_id, slot]` на несуществующие узлы. Найденное -> исключение со списком
`node X: input 'Y' links to missing node 'Z'`, адаптер не стартует.
2. **На сервере** (`VALIDATE_TEMPLATES_ON_SERVER=true`): `POST /prompt?front=false` —
ComfyUI валидирует граф, **не ставя задание в очередь** и не тратя GPU.
---
## Шаблоны workflow и их починка
Оба шаблона были выгружены из OpenWebUI (там прямая интеграция с ComfyUI). В
**исходном виде они не проходили валидацию ComfyUI** — в цепочках моделей
отсутствовали узлы, на которые шли ссылки:
```
workflow_create.json workflow_edit.json
83:28 UNETLoader 115:37 UNETLoader
| на него никто не ссылается | на него никто не ссылается
v v
83:3 KSampler ---> 83:43 (НЕТ В ГРАФЕ) 115:66 ModelSamplingAuraFlow ---> 115:117 (НЕТ В ГРАФЕ)
83:27 CLIPTextEncode -> 83:43 (НЕТ) 115:89 LoraLoaderModelOnly (Lightning 4-step) - висит без потребителей
```
Ошибка сервера: `prompt_outputs_failed_validation`, `KeyError: '83:43'` / `'115:117'`.
Исправления, применённые в репозиторных файлах (минимальные, по одной ссылке на шаблон):
| Шаблон | Было | Стало |
|---|---|---|
| `workflow_create.json` | `83:3.inputs.model = ["83:43", 0]`, `83:27.inputs.clip = ["83:43", 1]` | `["83:28", 0]` (UNETLoader), `["83:30", 0]` (CLIPLoader) |
| `workflow_edit.json` | `115:66.inputs.model = ["115:117", 0]` | `["115:89", 0]` — LoRA-нода `115:89` подключена в цепочку (`115:37 -> 115:89 -> 115:66 -> 115:75 -> KSampler`) |
Подключение LoRA во втором случае содержательно верно: в настройках OpenWebUI в поле
«Модель» редактирования указан именно Lightning-LoRA на 4 шага, а `steps: 4` в
KSampler без него не имеет смысла.
**Эффект:** оба графа проходят `front=false` и реально выполняют полный цикл —
проверено end-to-end на ComfyUI 0.37.0 (V100).
При смене workflow в OpenWebUI следует перезалить JSON сюда и сверить эти две
цепочки; локальная проверка на старте поймает проблему автоматически.
### Свойства шаблонов, которые стоит знать
- **Размер выхода в edit ≠ `size`.** В графе за разрешение отвечает
`ImageScaleToTotalPixels` (`megapixels: 1`), а latent из `EmptySD3LatentImage`
перебивается `VAEEncode` входного изображения. Итог — ~1 Мп с аспектом входной
картинки. Совместимо с OpenAI, который в edits размер тоже фактически игнорирует.
- **Negative prompt в create не используется.** В графе стоит `ConditioningZeroOut`,
поэтому Z-Image Turbo генерирует без негатива — это свойство модели (turbo/cfg=1),
а не ограничение адаптера.
- **Параметры KSampler (`steps`, `cfg`, `sampler_name`, `scheduler`) и пути к моделям**
закреплены в шаблонах и из запроса не меняются. Правятся редактированием JSON.
---
## Структура проекта
```
comfy-adapter/
├── app/
│ ├── main.py FastAPI: эндпоинты, цикл execute, разбор multipart/JSON
│ ├── config.py настройки из env + дефолтная карта маппинга
│ ├── comfy_client.py async-клиент ComfyUI: /prompt, /upload/image, /history, /view
│ ├── injector.py deep-copy шаблона + подстановка значений, парсинг WxH, seed
│ ├── templates.py загрузка шаблонов, проверка битых ссылок и соответствия карте
│ ├── schemas.py pydantic-модели OpenAI-контракта
│ ├── errors.py иерархия исключений + обработчики в формат OpenAI
│ └── auth.py проверка статического Bearer-ключа
├── tests/
│ ├── conftest.py фикстура: приложение с lifespan и ComfyUI, указывающим на мок
│ ├── test_generations.py генерация: успех, инъекция значений, seed, все классы 400/502
│ ├── test_edits.py edits: multipart и base64, игнорирование size, ошибки входа
│ ├── test_comfy_client.py каждый метод клиента на respx-моках (успех/HTTP-ошибка/таймаут)
│ ├── test_injector.py маппинг, deep-copy без мутации шаблона, seed, парсинг size
│ ├── test_templates.py битые ссылки, сверка с картой, валидность репозиторных шаблонов
│ ├── test_config.py дефолты, переопределение из env, слияние MAPPING_FILE
│ ├── test_auth.py ключ: отсутствует / неверный / верный; health без ключа
│ ├── test_errors.py канонический вид error-body, соответствие статусов
│ ├── test_models.py формат /v1/models
│ └── test_live.py живые прогоны против реального ComfyUI (маркер live)
├── workflow_create.json исправленный шаблон генерации
├── workflow_edit.json исправленный шаблон редактирования
├── Dockerfile python:3.12-slim + uvicorn
├── pyproject.toml
└── .env.example
```
**Технологии:** Python 3.12+, FastAPI, uvicorn, httpx (async), pydantic v2 +
pydantic-settings. Тесты: pytest, pytest-asyncio, respx.
---
## Тесты
```bash
pip install -e ".[dev]"
pytest # юнит- и интеграционные тесты на моках ComfyUI (respx) — сеть не нужна
```
Живые прогоны против реального ComfyUI (тратят GPU-время, реальные генерации):
```bash
uvicorn app.main:app --port 8000 &
export ADAPTER_LIVE=1 ADAPTER_URL=http://127.0.0.1:8000 ADAPTER_KEY=<ваш ключ>
pytest -m live
```
Проверяют: генерацию 1024x1024 с декодированием и верным размером, детерминизм по
seed (повтор даёт идентичный base64), редактирование с выходом ~1 Мп и сохранением
аспекта входа.
---
## Ограничения
- **`n > 1` не поддерживается** -> 400. Нужен — правьте `batch_size` в шаблоне или
запускайте несколько запросов.
- **`size` в `/v1/images/edits` не влияет на выход** (см. «Свойства шаблонов»).
- **`negative_prompt` из запроса не принимается.** Узел в шаблоне есть, но поля в
схеме запроса нет — адаптер всегда пишет пустую строку.
- **Размер в генерации не квантуется.** Любое корректное `WxH` уходит в latent
напрямую; нестандартные значения могут дать артефакты или ошибку уже на стороне
ComfyUI (прилетит как 502, а не 400).
- **`response_format: "url"` не реализован** — всегда `b64_json`.
- **Синхронная модель.** HTTP-соединение держится до конца генерации; отдельной
очереди заданий нет, конкурентные запросы сериализуются очередью ComfyUI.
- **Один сервер ComfyUI** из `COMFY_BASE_URL`, без пула и балансировки.
- **Загружаемые изображения** (edits) остаются в input-каталоге ComfyUI. Имена
уникальны (`adapter_<uuid>_<rand>.png`), коллизий нет, автоматической очистки нет.
- **Неизвестные поля запроса игнорируются** (стандартное поведение pydantic), а не
отклоняются.
## Дорожная карта (осознанно отложено)
`negative_prompt` в запросе -> `quantize` размера под кратные значения ->
несколько ComfyUI-серверов и очереди -> `response_format: "url"` для публикации за
пределами LAN -> websocket вместо опроса, если понадобится меньшая задержка.
---
## Эксплуатация
- **Reverse proxy.** Ответ синхронный и может занимать десятки секунд — поднимите
`proxy_read_timeout` (nginx) или эквивалент выше, чем `EXECUTION_TIMEOUT`, иначе
прокси оборвёт соединение раньше адаптера.
- **VRAM.** На 16GB V100 модели догружаются и выгружаются по очереди; при
чередовании генерации и редактирования первые кадры медленнее из-за загрузки
весов. Это поведение ComfyUI, адаптер на него не влияет.
- **Безопасность.** Ключ задаётся только через окружение, в репозиторий не попадает
(`.env` в `.gitignore`). При пустом `ADAPTER_API_KEY` сервис закрыт по умолчанию.
- **Развёртывание рядом с OpenWebUI.** Адаптер ничего не мигрирует: нативная
интеграция OpenWebUI с ComfyUI продолжает работать напрямую, адаптер —
независимый параллельный путь к тем же workflow.
- **Мониторинг.** `GET /health` без ключа; стартовые строки
`workflow templates validated...` / `comfy-adapter ready` подтверждают
работоспособность графов.
---
## Проверенное поведение
Не только тесты — контракт прогонялся живьём через официальный OpenAI SDK:
| Шаг | Вызов | Результат |
|---|---|---|
| Генерация | `client.images.generate(model="z-image-turbo", seed=421337, size="1024x1024")` | 200, PNG 1024x1024 (~1.0 МБ), кадр точно по промпту (яблоко, дымящий кофе, деревянный стол, мягкий свет) |
| Редактирование | `client.images.edit(model="qwen-image-edit", image=out.png, prompt=...)` | 200, PNG 1024x1024 (~0.9 МБ): чашка -> тёмно-синяя, фон -> ночная кухня, яблоко/композиция/пар/тени сохранены |
Инструкция в редактировании выполнена выборочно — изменено запрошенное, остальное
осталось идентичным входному кадру.