Browse Source

fix workflow

master
e-maks 4 days ago
parent
commit
6fc34aa11b
  1. 500
      API-GUIDE.md
  2. 77
      README.md
  3. 123
      client.py
  4. 2
      nginx.conf
  5. 387
      server.py
  6. 1051
      workflow_api.json

500
API-GUIDE.md

@ -0,0 +1,500 @@
# DaSiWa API Guide
Полное руководство по использованию DaSiWa I2V/FLF2V API для генерации видео через ComfyUI.
---
## 📋 Содержание
1. [Обзор](#обзор)
2. [Аутентификация](#аутентификация)
3. [Endpoints](#endpoints)
4. [Параметры генерации](#параметры-генерации)
5. [Примеры использования](#примеры-использования)
6. [Коды ошибок](#коды-ошибок)
7. [Best Practices](#best-practices)
---
## Обзор
DaSiWa API — асинхронный REST API для генерации видео из изображений с использованием DaSiWa WAN 2.2 Lightspeed моделей через ComfyUI.
**Архитектура:** Submit → Poll → Retrieve (как RunPod)
**Base URL:** `http://<server_ip>:8080`
**Аутентификация:** HMAC-SHA256 с timestamp и nonce
---
## Аутентификация
Все endpoints (кроме `/health`) требуют HMAC подписи.
### Заголовки запроса
```
X-Client-Id: <ваш_client_id>
X-Timestamp: <unix_timestamp>
X-Nonce: <случайная_строка_32_символа>
X-Signature: <hmac_sha256_подпись>
```
### Алгоритм подписи
```python
import hmac
import hashlib
import time
import secrets
timestamp = str(int(time.time()))
nonce = secrets.token_hex(16)
body = json.dumps(payload).encode('utf-8')
message = f"{timestamp}.{nonce}.".encode() + body
signature = hmac.new(
secret_key.encode(),
message,
hashlib.sha256
).hexdigest()
```
### Защита от replay-атак
- **Timestamp:** запросы старше 5 минут отклоняются
- **Nonce:** каждый nonce можно использовать только один раз
- **Signature:** уникальна для каждого запроса
---
## Endpoints
### `GET /health`
Health check сервера. **Не требует аутентификации.**
**Response:**
```json
{
"status": "ok",
"comfyui": "ok",
"queue": 0,
"timestamp": 1234567890
}
```
**Поля:**
- `status` — статус API сервера (`ok` / `error`)
- `comfyui` — статус ComfyUI (`ok` / `unavailable`)
- `queue` — количество задач в очереди
- `timestamp` — текущее время сервера (unix)
---
### `POST /run`
Поставить задачу на генерацию видео в очередь.
**Request Body:**
```json
{
"image_base64": "base64_encoded_image_data",
"prompt": "woman dancing gracefully",
"negative_prompt": "blurry, low quality",
"last_image_base64": "base64_encoded_last_frame",
"width": 528,
"height": 768,
"length": 81,
"steps": 4,
"cfg": 1.0,
"seed": -1,
"fps": 16,
"sampler_name": "euler",
"scheduler": "linear_quadratic"
}
```
**Обязательные поля:**
- `image_base64` — первый кадр (base64)
- `prompt` — текстовое описание
**Опциональные поля:**
- `last_image_base64` — последний кадр для FLF2V режима
- `negative_prompt` — негативный промпт (default: встроенный)
- `width` — ширина (default: 528, кратно 16)
- `height` — высота (default: 768, кратно 16)
- `length` — количество кадров (default: 81)
- `steps` — шаги сэмплинга (default: 4)
- `cfg` — CFG scale (default: 1.0)
- `seed` — сид (-1 = random, default: -1)
- `fps` — кадров в секунду (default: 16)
- `sampler_name` — сэмплер (default: "euler")
- `scheduler` — планировщик (default: "linear_quadratic")
**Response:**
```json
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"status": "IN_QUEUE"
}
```
**Коды ответа:**
- `200` — задача принята
- `400` — ошибка валидации (нет изображения)
- `401` — ошибка аутентификации
---
### `GET /status/<job_id>`
Получить статус задачи.
**Response (IN_QUEUE):**
```json
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"status": "IN_QUEUE"
}
```
**Response (IN_PROGRESS):**
```json
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"status": "IN_PROGRESS"
}
```
**Response (COMPLETED):**
```json
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"status": "COMPLETED",
"output": {
"video": "base64_encoded_video_data",
"seed": 42,
"mode": "I2V",
"elapsed": 45.2
}
}
```
**Response (FAILED):**
```json
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"status": "FAILED",
"error": "Video generation failed — no output from ComfyUI"
}
```
**Коды ответа:**
- `200` — статус получен
- `404` — задача не найдена
- `401` — ошибка аутентификации
---
### `POST /purge/<job_id>`
Удалить завершённую задачу из памяти сервера (освободить RAM от base64 видео).
**Response:**
```json
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"purged": true
}
```
**Коды ответа:**
- `200` — задача удалена
- `400` — нельзя удалить активную задачу (IN_QUEUE / IN_PROGRESS)
- `404` — задача не найдена
- `401` — ошибка аутентификации
---
## Параметры генерации
### Режимы работы
**I2V (Image to Video):**
- Генерация видео из одного изображения
- Передаётся только `image_base64`
**FLF2V (First-Last Frame to Video):**
- Генерация видео между двумя кадрами
- Передаются `image_base64` + `last_image_base64`
### Рекомендуемые значения
| Параметр | I2V | FLF2V | Описание |
|----------|-----|-------|----------|
| `width` | 528 | 528 | Ширина (кратно 16) |
| `height` | 768 | 768 | Высота (кратно 16) |
| `length` | 81 | 81 | Кол-во кадров (~5 сек при 16fps) |
| `steps` | 4 | 4 | DaSiWa оптимизирован под 4 шага |
| `cfg` | 1.0 | 1.0 | CFG scale (DaSiWa работает с 1.0) |
| `fps` | 16 | 16 | Кадров в секунду |
| `sampler_name` | euler | euler | Сэмплер |
| `scheduler` | linear_quadratic | linear_quadratic | Планировщик |
### Ограничения
- **Размеры:** должны быть кратны 16
- **Length:** рекомендуется кратно 8 + 1 (например: 81, 89, 97)
- **Steps:** DaSiWa Lightspeed оптимизирован под 4 шага (можно больше, но медленнее)
- **CFG:** значения > 2.0 могут давать артефакты
---
## Примеры использования
### Python (с библиотекой requests)
```python
import requests
import base64
import json
import time
from hmac_auth import sign_request
# Загрузка ключей
with open('keys.json') as f:
keys = json.load(f)
# Подготовка изображения
with open('photo.png', 'rb') as f:
image_b64 = base64.b64encode(f.read()).decode()
# Payload
payload = {
"image_base64": image_b64,
"prompt": "woman dancing gracefully",
"width": 528,
"height": 768,
"length": 81,
"steps": 4,
"cfg": 1.0,
"seed": -1,
"fps": 16
}
# 1. Submit job
body = json.dumps(payload).encode('utf-8')
auth_headers = sign_request(body, keys['secret_key'], keys['client_id'])
headers = {'Content-Type': 'application/json', **auth_headers}
response = requests.post(
'http://server:8080/run',
data=body,
headers=headers
)
job_id = response.json()['id']
print(f"Job ID: {job_id}")
# 2. Poll status
while True:
auth_headers = sign_request(b"", keys['secret_key'], keys['client_id'])
response = requests.get(
f'http://server:8080/status/{job_id}',
headers=auth_headers
)
data = response.json()
if data['status'] == 'COMPLETED':
video_b64 = data['output']['video']
video_bytes = base64.b64decode(video_b64)
with open('output.mp4', 'wb') as f:
f.write(video_bytes)
print(f"Video saved! Seed: {data['output']['seed']}")
break
elif data['status'] == 'FAILED':
print(f"Error: {data['error']}")
break
else:
print(f"Status: {data['status']}")
time.sleep(5)
# 3. Purge job
auth_headers = sign_request(b"{}", keys['secret_key'], keys['client_id'])
requests.post(
f'http://server:8080/purge/{job_id}',
json={},
headers={'Content-Type': 'application/json', **auth_headers}
)
```
### cURL
```bash
# 1. Submit job
curl -X POST http://server:8080/run \
-H "Content-Type: application/json" \
-H "X-Client-Id: your_client_id" \
-H "X-Timestamp: $(date +%s)" \
-H "X-Nonce: $(openssl rand -hex 16)" \
-H "X-Signature: <calculated_signature>" \
-d '{
"image_base64": "...",
"prompt": "woman dancing"
}'
# Response: {"id": "abc-123", "status": "IN_QUEUE"}
# 2. Check status
curl http://server:8080/status/abc-123 \
-H "X-Client-Id: your_client_id" \
-H "X-Timestamp: $(date +%s)" \
-H "X-Nonce: $(openssl rand -hex 16)" \
-H "X-Signature: <calculated_signature>"
# 3. Purge
curl -X POST http://server:8080/purge/abc-123 \
-H "Content-Type: application/json" \
-H "X-Client-Id: your_client_id" \
-H "X-Timestamp: $(date +%s)" \
-H "X-Nonce: $(openssl rand -hex 16)" \
-H "X-Signature: <calculated_signature>" \
-d '{}'
```
---
## Коды ошибок
| Код | Описание | Решение |
|-----|----------|---------|
| `400` | Нет входного изображения | Передайте `image_base64` |
| `401` | Invalid client ID | Проверьте `client_id` в `keys.json` |
| `401` | Invalid timestamp | Синхронизируйте время на клиенте и сервере |
| `401` | Nonce already used | Replay-атака или дублирующий запрос |
| `401` | Invalid signature | Проверьте `secret_key` и алгоритм подписи |
| `404` | Job not found | Job ID не существует или уже удалён |
| `500` | Internal server error | Проверьте логи сервера (`journalctl -u dasiwa-api`) |
---
## Best Practices
### 1. Polling интервал
- **Рекомендуется:** 5-10 секунд
- **Не рекомендуется:** < 2 секунд (нагрузка на сервер)
- Генерация обычно занимает 30-60 секунд
### 2. Timeout
- Установите timeout на polling: 30 минут (1800 секунд)
- Если задача не завершилась за это время — проверьте логи сервера
### 3. Purge после использования
- Всегда вызывайте `/purge/<id>` после получения видео
- Base64 видео занимает ~10-50 MB RAM на сервере
- Без purge память будет расти
### 4. Обработка ошибок
```python
try:
result = wait_for_completion(server, job_id, ...)
except RuntimeError as e:
if "Timeout" in str(e):
# Задача зависла — проверьте сервер
pass
elif "Job failed" in str(e):
# Ошибка генерации — проверьте параметры
pass
```
### 5. Retry логика
- При `401` ошибках — не retry (проблема с ключами)
- При `500` ошибках — retry с exponential backoff
- При `404` на `/status` — задача потеряна, не retry
### 6. Размер изображений
- Оптимально: 528x768 или 768x528
- Большие размеры → больше VRAM → медленнее
- Маленькие размеры → хуже качество
### 7. Seed для воспроизводимости
- Если нужен тот же результат — используйте тот же seed
- Seed из ответа `output.seed` — сохраните для повтора
### 8. Мониторинг очереди
```python
response = requests.get('http://server:8080/health')
queue_size = response.json()['queue']
if queue_size > 5:
print("Очередь большая, ожидайте дольше")
```
---
## Лимиты и производительность
### Текущие лимиты
- **Одновременные задачи:** 1 (1 GPU = 1 задача)
- **Размер очереди:** не ограничен (но рекомендуется < 10)
- **Размер изображения:** max 2048x2048 (теоретически)
- **Длина видео:** max ~300 кадров (ограничено VRAM)
### Производительность
| Параметры | Время генерации | VRAM |
|-----------|-----------------|------|
| 528x768, 81 frames, 4 steps | ~30-45s | ~18 GB |
| 768x528, 81 frames, 4 steps | ~30-45s | ~18 GB |
| 528x768, 161 frames, 4 steps | ~60-90s | ~24 GB |
*Время указано для RTX 4090 / A100*
---
## Troubleshooting
### Задача зависла в IN_PROGRESS
1. Проверьте логи сервера: `journalctl -u dasiwa-api -f`
2. Проверьте ComfyUI: `curl http://localhost:8188`
3. Перезапустите сервис: `systemctl restart dasiwa-api`
### Ошибка "Video generation failed"
- ComfyUI не запущен или недоступен
- Недостаточно VRAM
- Workflow файл повреждён
### Медленная генерация
- Проверьте загрузку GPU: `nvidia-smi`
- Убедитесь что модели загружены в VRAM (первый запрос медленнее)
- Уменьшите `length` или размеры
---
## Changelog
### v2.0 (2026-03-07)
- ✨ Асинхронный API (submit + poll)
- ✨ Endpoints: `/run`, `/status`, `/purge`
- ✨ Background worker thread
- ✨ Queue management
- 🔧 Обновлён на DaSiWa WAN 2.2 Lightspeed
- 🔧 Упрощён workflow (14 нод вместо 50+)
### v1.0 (2026-03-06)
- 🎉 Первый релиз
- ✅ Синхронный `/generate` endpoint
- ✅ HMAC аутентификация
- ✅ I2V и FLF2V режимы

77
README.md

@ -18,20 +18,47 @@ custom_comfyui/
├── dasiwa-api.service # Systemd сервис (автозапуск) ├── dasiwa-api.service # Systemd сервис (автозапуск)
├── requirements.txt # Python зависимости ├── requirements.txt # Python зависимости
├── keys.json # 🔒 Ключи (НЕ коммитить!) ├── keys.json # 🔒 Ключи (НЕ коммитить!)
└── workflow_api.json # 🎨 ComfyUI workflow (сделай сам) └── workflow_api.json # 🎨 ComfyUI workflow (DaSiWa WAN 2.2 Lightspeed)
``` ```
--- ---
## 🚀 Быстрый старт ## 🚀 Быстрый старт
### 1. Подготовка workflow ### 1. Загрузка моделей на сервер
В ComfyUI на сервере: ```powershell
1. Загрузи `DaSiWa WAN 2.2 i2v FastFidelity C-AiO-59.json` через UI $SERVER = "user@<ip_сервера>"
2. Настрой, проверь что работает
3. **Экспортируй API версию:** Menu → `Save (API Format)` → назови `workflow_api.json` # HIGH checkpoint (~13 GB)
4. Положи файл в эту папку (`custom_comfyui/`) scp "DasiwaWAN22I2V14BLightspeed_synthseductionHighV9.safetensors" ${SERVER}:/ComfyUI/models/checkpoints/
# LOW checkpoint (~13 GB)
scp "DasiwaWAN22I2V14BLightspeed_synthseductionLowV9.safetensors" ${SERVER}:/ComfyUI/models/checkpoints/
# VAE (~335 MB)
scp "wan_2.1_vae.safetensors" ${SERVER}:/ComfyUI/models/vae/
# Text Encoder (~4.7 GB)
scp "umt5_xxl_fp8_e4m3fn_scaled.safetensors" ${SERVER}:/ComfyUI/models/text_encoders/
```
**Скачать модели:**
- **Checkpoints:** [DaSiWa WAN 2.2 i2v 14B (S) Lightspeed](https://civitai.com/models/1981116)
- **VAE:** [wan_2.1_vae.safetensors](https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/blob/main/split_files/vae/wan_2.1_vae.safetensors)
- **Text Encoder:** [umt5_xxl_fp8_e4m3fn_scaled.safetensors](https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/text_encoders/umt5_xxl_fp8_e4m3fn_scaled.safetensors)
**Структура моделей на сервере:**
```
📂 ComfyUI/models/
├── checkpoints/
│ ├── DasiwaWAN22I2V14BLightspeed_synthseductionHighV9.safetensors
│ └── DasiwaWAN22I2V14BLightspeed_synthseductionLowV9.safetensors
├── vae/
│ └── wan_2.1_vae.safetensors
└── text_encoders/
└── umt5_xxl_fp8_e4m3fn_scaled.safetensors
```
### 2. Генерация ключей (на любом ПК с Python) ### 2. Генерация ключей (на любом ПК с Python)
@ -52,7 +79,7 @@ $SERVER = "root@<ip_сервера>"
scp -r custom_comfyui/ ${SERVER}:/root/ scp -r custom_comfyui/ ${SERVER}:/root/
# ИЛИ по файлам # ИЛИ по файлам
scp server.py hmac_auth.py generate_keys.py setup.sh nginx.conf requirements.txt keys.json workflow_api.json ${SERVER}:/root/custom_comfyui/ scp server.py hmac_auth.py generate_keys.py setup.sh requirements.txt keys.json workflow_api.json ${SERVER}:/root/custom_comfyui/
``` ```
### 4. Установка на сервере (одна команда) ### 4. Установка на сервере (одна команда)
@ -65,10 +92,9 @@ sudo ./setup.sh
``` ```
Скрипт автоматически: Скрипт автоматически:
- ✅ Установит Python, pip, nginx, wget - ✅ Установит Python, pip, wget
- ✅ Установит Python зависимости - ✅ Установит Python зависимости
- ✅ Настроит Nginx (порт 5000 → API) - ✅ Настроит Firewall (открыт 22 + 8080, закрыт 8188)
- ✅ Настроит Firewall (открыт 22 + 5000, закрыт 8188)
- ✅ Создаст systemd сервис с автозапуском - ✅ Создаст systemd сервис с автозапуском
- ✅ Сгенерирует ключи (если нет) - ✅ Сгенерирует ключи (если нет)
@ -87,7 +113,7 @@ scp root@<ip_сервера>:/root/custom_comfyui/keys.json .
curl http://localhost:8080/health curl http://localhost:8080/health
# Ответ: # Ответ:
# {"comfyui": "ok", "status": "ok", "timestamp": 1234567890} # {"comfyui": "ok", "status": "ok", "queue": 0, "timestamp": 1234567890}
``` ```
### 7. Генерация видео (с клиента) ### 7. Генерация видео (с клиента)
@ -111,6 +137,27 @@ python client.py \
--output transition.mp4 --output transition.mp4
``` ```
Клиент работает асинхронно (как RunPod): отправляет задачу → поллит статус → забирает видео.
---
## 🔄 API Endpoints (асинхронный, как RunPod)
| Endpoint | Method | Описание |
|----------|--------|----------|
| `/run` | POST | Поставить задачу в очередь → `{"id": "...", "status": "IN_QUEUE"}` |
| `/status/<id>` | GET | Получить статус: `IN_QUEUE` / `IN_PROGRESS` / `COMPLETED` / `FAILED` |
| `/purge/<id>` | POST | Удалить задачу из памяти (освободить RAM) |
| `/health` | GET | Health check (без авторизации) |
**Пример потока:**
```
1. POST /run {image_base64: "...", prompt: "..."} → {id: "abc-123", status: "IN_QUEUE"}
2. GET /status/abc-123 → {id: "abc-123", status: "IN_PROGRESS"}
3. GET /status/abc-123 → {id: "abc-123", status: "COMPLETED", output: {video: "base64...", seed: 42, ...}}
4. POST /purge/abc-123 → {id: "abc-123", purged: true}
```
--- ---
## ⚙ Параметры генерации ## ⚙ Параметры генерации
@ -200,9 +247,3 @@ systemctl restart dasiwa-api
**Ошибка авторизации (401):** **Ошибка авторизации (401):**
- Проверь что `keys.json` одинаковый на клиенте и сервере - Проверь что `keys.json` одинаковый на клиенте и сервере
- Проверь время на обоих машинах (`date` на сервере, часы на ПК) - Проверь время на обоих машинах (`date` на сервере, часы на ПК)
**Nginx ошибка:**
```bash
nginx -t
systemctl restart nginx
```

123
client.py

@ -1,11 +1,11 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Клиент для DaSiWa API Server. Клиент для DaSiWa API Server (асинхронный, как RunPod).
Запускается на ТВОЁМ ПК. Отправляет подписанные запросы на сервер. Запускается на ТВОЁМ ПК. Отправляет задачу, поллит статус, забирает результат.
Использование: Использование:
python client.py --server http://<ip>:5000 --image photo.png --prompt "woman dancing" python client.py --server http://<ip>:8080 --image photo.png --prompt "woman dancing"
python client.py --server http://<ip>:5000 --image start.png --last-image end.png --prompt "smooth transition" python client.py --server http://<ip>:8080 --image start.png --last-image end.png --prompt "smooth transition"
""" """
import argparse import argparse
@ -40,29 +40,58 @@ def image_to_base64(path: str) -> str:
return base64.b64encode(f.read()).decode() return base64.b64encode(f.read()).decode()
def send_request(server_url: str, payload: dict, client_id: str, secret_key: str) -> dict: def signed_post(server_url: str, path: str, payload: dict, client_id: str, secret_key: str):
"""Отправляет подписанный запрос на сервер.""" """Отправляет подписанный POST запрос."""
body = json.dumps(payload).encode("utf-8") body = json.dumps(payload).encode("utf-8")
auth_headers = sign_request(body, secret_key, client_id) auth_headers = sign_request(body, secret_key, client_id)
headers = {"Content-Type": "application/json", **auth_headers}
response = requests.post(f"{server_url}{path}", data=body, headers=headers, timeout=30)
return response.status_code, response.json()
headers = {
"Content-Type": "application/json",
**auth_headers
}
response = requests.post(
f"{server_url}/generate",
data=body,
headers=headers,
timeout=600
)
def signed_get(server_url: str, path: str, client_id: str, secret_key: str):
"""Отправляет подписанный GET запрос."""
body = b""
auth_headers = sign_request(body, secret_key, client_id)
response = requests.get(f"{server_url}{path}", headers=auth_headers, timeout=30)
return response.status_code, response.json() return response.status_code, response.json()
def submit_job(server_url: str, payload: dict, client_id: str, secret_key: str):
"""Отправляет задачу на генерацию. Возвращает job_id."""
code, data = signed_post(server_url, "/run", payload, client_id, secret_key)
if code != 200:
raise RuntimeError(f"Submit failed ({code}): {data.get('error', data)}")
return data["id"]
def wait_for_completion(server_url: str, job_id: str, client_id: str, secret_key: str,
poll_interval: int = 5, max_wait: int = 1800):
"""Поллит статус задачи до завершения."""
start = time.time()
while time.time() - start < max_wait:
code, data = signed_get(server_url, f"/status/{job_id}", client_id, secret_key)
if code != 200:
raise RuntimeError(f"Status check failed ({code}): {data}")
status = data.get("status")
elapsed = int(time.time() - start)
if status == "COMPLETED":
print(f"\r✅ COMPLETED ({elapsed}s)")
return data
elif status == "FAILED":
raise RuntimeError(f"Job failed: {data.get('error', 'Unknown error')}")
else:
print(f"\r{status}... ({elapsed}s)", end="", flush=True)
time.sleep(poll_interval)
raise RuntimeError(f"Timeout waiting for job ({max_wait}s)")
def main(): def main():
parser = argparse.ArgumentParser(description="DaSiWa API Client") parser = argparse.ArgumentParser(description="DaSiWa API Client (async)")
parser.add_argument("--server", required=True, help="Server URL, e.g. http://1.2.3.4:5000") parser.add_argument("--server", required=True, help="Server URL, e.g. http://1.2.3.4:8080")
parser.add_argument("--image", required=True, help="Path to first frame image") parser.add_argument("--image", required=True, help="Path to first frame image")
parser.add_argument("--last-image", default=None, help="Path to last frame image (FLF2V mode)") parser.add_argument("--last-image", default=None, help="Path to last frame image (FLF2V mode)")
parser.add_argument("--prompt", required=True, help="Text prompt") parser.add_argument("--prompt", required=True, help="Text prompt")
@ -74,10 +103,12 @@ def main():
parser.add_argument("--cfg", type=float, default=1.0) parser.add_argument("--cfg", type=float, default=1.0)
parser.add_argument("--seed", type=int, default=-1) parser.add_argument("--seed", type=int, default=-1)
parser.add_argument("--fps", type=int, default=16) parser.add_argument("--fps", type=int, default=16)
parser.add_argument("--poll-interval", type=int, default=5, help="Status poll interval (seconds)")
parser.add_argument("--output", "-o", default="output.mp4", help="Output video path") parser.add_argument("--output", "-o", default="output.mp4", help="Output video path")
args = parser.parse_args() args = parser.parse_args()
keys = load_keys() keys = load_keys()
cid, secret = keys["client_id"], keys["secret_key"]
# Формируем payload # Формируем payload
payload = { payload = {
@ -102,31 +133,45 @@ def main():
print(f"🎬 Режим: I2V (image to video)") print(f"🎬 Режим: I2V (image to video)")
print(f"📐 {args.width}x{args.height}, {args.length} frames, {args.steps} steps") print(f"📐 {args.width}x{args.height}, {args.length} frames, {args.steps} steps")
print(f"📤 Отправляю запрос на {args.server}...")
start = time.time() # 1. Submit job
status_code, result = send_request( print(f"📤 Отправляю задачу на {args.server}...")
args.server, payload, keys["client_id"], keys["secret_key"] try:
) job_id = submit_job(args.server, payload, cid, secret)
elapsed = time.time() - start except RuntimeError as e:
print(f"{e}")
if status_code != 200: sys.exit(1)
print(f"❌ Ошибка {status_code}: {result.get('error', 'Unknown')}") print(f"📝 Job ID: {job_id}")
if "detail" in result:
print(f" Детали: {result['detail']}") # 2. Poll for completion
print(f"⏳ Жду результат (поллинг каждые {args.poll_interval}s)...")
try:
result = wait_for_completion(args.server, job_id, cid, secret,
poll_interval=args.poll_interval)
except RuntimeError as e:
print(f"\n{e}")
sys.exit(1) sys.exit(1)
if "video" in result: # 3. Save video
video_bytes = base64.b64decode(result["video"]) output = result.get("output", {})
with open(args.output, "wb") as f: video_b64 = output.get("video")
f.write(video_bytes) if not video_b64:
print(f"✅ Видео сохранено: {args.output} ({len(video_bytes) / 1024 / 1024:.1f} MB)") print(f"❌ Нет видео в ответе")
print(f"⏱ Время: {elapsed:.1f}s (сервер: {result.get('elapsed', '?')}s)")
print(f"🌱 Seed: {result.get('seed', '?')}")
else:
print(f"❌ Ошибка: {result.get('error', 'No video in response')}")
sys.exit(1) sys.exit(1)
video_bytes = base64.b64decode(video_b64)
with open(args.output, "wb") as f:
f.write(video_bytes)
print(f"✅ Видео сохранено: {args.output} ({len(video_bytes) / 1024 / 1024:.1f} MB)")
print(f"⏱ Сервер: {output.get('elapsed', '?')}s | Seed: {output.get('seed', '?')} | Mode: {output.get('mode', '?')}")
# 4. Purge job from server memory
try:
signed_post(args.server, f"/purge/{job_id}", {}, cid, secret)
except Exception:
pass # не критично
if __name__ == "__main__": if __name__ == "__main__":
main() main()

2
nginx.conf

@ -1,2 +0,0 @@
# ЭТОТ ФАЙЛ БОЛЬШЕ НЕ ИСПОЛЬЗУЕТСЯ удали его
# API работает напрямую через Python на порту 8080

387
server.py

@ -3,8 +3,10 @@
DaSiWa I2V/FLF2V API Server для ComfyUI. DaSiWa I2V/FLF2V API Server для ComfyUI.
Работает рядом с ComfyUI на той же машине. Работает рядом с ComfyUI на той же машине.
Принимает HTTP запросы с HMAC авторизацией, Асинхронный API (как RunPod):
отправляет workflow в ComfyUI, возвращает видео. POST /run {"id": "job_id", "status": "IN_QUEUE"}
GET /status/ID {"id": ..., "status": "IN_QUEUE|IN_PROGRESS|COMPLETED|FAILED", "output": ...}
GET /health {"status": "ok", "comfyui": "ok", "queue": 0}
""" """
import os import os
@ -18,6 +20,8 @@ import random
import logging import logging
import binascii import binascii
import subprocess import subprocess
import threading
import queue
import urllib.request import urllib.request
import urllib.parse import urllib.parse
import websocket as ws_client import websocket as ws_client
@ -69,6 +73,14 @@ used_nonces = set()
# WebSocket client ID # WebSocket client ID
ws_client_id = str(uuid.uuid4()) ws_client_id = str(uuid.uuid4())
# ============================================================================
# Job Queue (асинхронная очередь как в RunPod)
# ============================================================================
job_queue = queue.Queue()
jobs = {} # job_id -> {status, input, output, error, created_at, started_at, completed_at}
jobs_lock = threading.Lock()
# ============================================================================ # ============================================================================
# Утилиты # Утилиты
@ -185,6 +197,162 @@ def generate_video(prompt):
return None return None
# ============================================================================
# Background Worker (обработка задач из очереди)
# ============================================================================
def build_prompt(job_input, image_path, last_image_path, use_flf2v):
"""Загружает workflow и патчит параметрами задачи."""
with open(WORKFLOW_FILE, "r") as f:
prompt = json.load(f)
width = to_nearest_multiple_of_16(job_input.get("width", 528))
height = to_nearest_multiple_of_16(job_input.get("height", 768))
length = job_input.get("length", 81)
steps = job_input.get("steps", 4)
cfg = job_input.get("cfg", 1.0)
seed = job_input.get("seed", -1)
fps = job_input.get("fps", 16)
sampler_name = job_input.get("sampler_name", "euler")
scheduler = job_input.get("scheduler", "linear_quadratic")
if seed == -1:
seed = random.randint(0, 2**63 - 1)
# Node 5: Positive prompt
prompt["5"]["inputs"]["text"] = job_input.get("prompt", "")
# Node 6: Negative prompt (use default or custom)
negative_prompt = job_input.get("negative_prompt", prompt["6"]["inputs"]["text"])
prompt["6"]["inputs"]["text"] = negative_prompt
# Node 7: Load first frame image
prompt["7"]["inputs"]["image"] = image_path
# Node 15: Load last frame image (for FLF2V mode)
if use_flf2v and last_image_path:
prompt["15"]["inputs"]["image"] = last_image_path
logger.info(f"🎬 FLF2V: last frame = {last_image_path}")
else:
# I2V mode: switch to WanImageToVideo, remove end_image
prompt["8"]["class_type"] = "WanImageToVideo"
if "end_image" in prompt["8"]["inputs"]:
del prompt["8"]["inputs"]["end_image"]
if "15" in prompt:
del prompt["15"]
logger.info("🎬 I2V: single image mode")
# Node 8: WanFirstLastFrameToVideo / WanImageToVideo
prompt["8"]["inputs"]["width"] = width
prompt["8"]["inputs"]["height"] = height
prompt["8"]["inputs"]["length"] = length
# Node 11: KSampler High
prompt["11"]["inputs"]["noise_seed"] = seed
prompt["11"]["inputs"]["steps"] = steps
prompt["11"]["inputs"]["cfg"] = cfg
prompt["11"]["inputs"]["sampler_name"] = sampler_name
prompt["11"]["inputs"]["scheduler"] = scheduler
prompt["11"]["inputs"]["end_at_step"] = steps // 2
# Node 12: KSampler Low
prompt["12"]["inputs"]["noise_seed"] = seed
prompt["12"]["inputs"]["steps"] = steps
prompt["12"]["inputs"]["cfg"] = cfg
prompt["12"]["inputs"]["sampler_name"] = sampler_name
prompt["12"]["inputs"]["scheduler"] = scheduler
prompt["12"]["inputs"]["start_at_step"] = steps // 2
# Node 14: Video output
prompt["14"]["inputs"]["frame_rate"] = fps
return prompt, seed, width, height
def cleanup_comfy_output():
"""Очистка output директории ComfyUI."""
try:
if os.path.exists(COMFY_OUTPUT_DIR):
for fname in os.listdir(COMFY_OUTPUT_DIR):
fpath = os.path.join(COMFY_OUTPUT_DIR, fname)
if os.path.isfile(fpath):
os.unlink(fpath)
elif os.path.isdir(fpath):
shutil.rmtree(fpath)
except Exception:
pass
def worker_loop():
"""Фоновый воркер — берёт задачи из очереди и выполняет по одной."""
logger.info(" Worker thread started")
while True:
job_id = job_queue.get() # блокируется пока нет задач
with jobs_lock:
job = jobs.get(job_id)
if not job:
continue
logger.info("=" * 60)
logger.info(f"🎬 Job {job_id}: Начинаем генерацию")
logger.info("=" * 60)
with jobs_lock:
job["status"] = "IN_PROGRESS"
job["started_at"] = time.time()
job_input = job["input"]
temp_dir = job["temp_dir"]
try:
# Обработка изображений
image_path, has_image = process_image_input(job_input, "image", temp_dir)
if not has_image:
raise ValueError("No input image provided")
last_image_path, use_flf2v = process_image_input(job_input, "last_image", temp_dir)
mode = "FLF2V" if use_flf2v else "I2V"
logger.info(f"🎬 Job {job_id}: Режим {mode}")
# Сборка промпта
prompt, seed, width, height = build_prompt(job_input, image_path, last_image_path, use_flf2v)
logger.info(f"📐 Job {job_id}: {width}x{height}, seed {seed}")
# Генерация
video_b64 = generate_video(prompt)
if not video_b64:
raise RuntimeError("Video generation failed — no output from ComfyUI")
elapsed = time.time() - job["started_at"]
logger.info(f"✅ Job {job_id}: Видео готово за {elapsed:.1f}s")
with jobs_lock:
job["status"] = "COMPLETED"
job["completed_at"] = time.time()
job["output"] = {
"video": video_b64,
"seed": seed,
"mode": mode,
"elapsed": round(elapsed, 1)
}
except Exception as e:
logger.error(f"❌ Job {job_id}: {e}", exc_info=True)
with jobs_lock:
job["status"] = "FAILED"
job["completed_at"] = time.time()
job["error"] = str(e)
finally:
# Очистка
if os.path.exists(temp_dir):
shutil.rmtree(temp_dir, ignore_errors=True)
cleanup_comfy_output()
job_queue.task_done()
# ============================================================================ # ============================================================================
# API Endpoints # API Endpoints
# ============================================================================ # ============================================================================
@ -222,146 +390,85 @@ def health():
return jsonify({ return jsonify({
"status": "ok", "status": "ok",
"comfyui": comfy_status, "comfyui": comfy_status,
"queue": job_queue.qsize(),
"timestamp": int(time.time()) "timestamp": int(time.time())
}) })
@app.route("/generate", methods=["POST"]) @app.route("/run", methods=["POST"])
def generate(): def run_job():
"""Основной endpoint для генерации видео.""" """Отправляет задачу в очередь. Возвращает job_id сразу."""
start_time = time.time()
job_input = request.json or {} job_input = request.json or {}
logger.info("=" * 60) # Валидация: должно быть хотя бы одно изображение
logger.info("🎬 Новый запрос на генерацию") has_image = any(k in job_input and job_input[k]
logger.info("=" * 60) for k in ("image_base64", "image_url", "image_path"))
if not has_image:
return jsonify({"error": "No input image. Use image_base64, image_url, or image_path"}), 400
job_id = str(uuid.uuid4())
temp_dir = os.path.join("/tmp", f"job_{job_id[:8]}")
# Логирование (без base64)
log_input = {k: (f"[{len(v)}chars]" if k.endswith("_base64") else v)
for k, v in job_input.items()}
logger.info(f"📥 Job {job_id}: поставлен в очередь")
logger.info(f" Параметры: {json.dumps(log_input, ensure_ascii=False)}")
with jobs_lock:
jobs[job_id] = {
"status": "IN_QUEUE",
"input": job_input,
"temp_dir": temp_dir,
"output": None,
"error": None,
"created_at": time.time(),
"started_at": None,
"completed_at": None,
}
job_queue.put(job_id)
# Логирование (без base64 данных) return jsonify({
log_input = {k: v for k, v in job_input.items() "id": job_id,
if not k.endswith("_base64")} "status": "IN_QUEUE"
logger.info(f"Параметры: {json.dumps(log_input, ensure_ascii=False)}") })
task_id = f"task_{uuid.uuid4().hex[:8]}"
temp_dir = os.path.join("/tmp", task_id)
try: @app.route("/status/<job_id>", methods=["GET"])
# === Обработка изображений === def job_status(job_id):
image_path, has_image = process_image_input(job_input, "image", temp_dir) """Получить статус задачи. Когда COMPLETED — возвращает результат."""
if not has_image: with jobs_lock:
return jsonify({"error": "No input image provided. Use image_base64, image_url, or image_path"}), 400 job = jobs.get(job_id)
last_image_path, use_flf2v = process_image_input(job_input, "last_image", temp_dir) if not job:
return jsonify({"error": "Job not found"}), 404
mode = "FLF2V" if use_flf2v else "I2V"
logger.info(f"🎬 Режим: {mode}") response = {
"id": job_id,
# === Загрузка workflow === "status": job["status"],
if not os.path.exists(WORKFLOW_FILE): }
return jsonify({"error": f"Workflow file not found: {WORKFLOW_FILE}"}), 500
if job["status"] == "COMPLETED":
with open(WORKFLOW_FILE, "r") as f: response["output"] = job["output"]
prompt = json.load(f) elif job["status"] == "FAILED":
response["error"] = job["error"]
# === Параметры генерации ===
width = to_nearest_multiple_of_16(job_input.get("width", 528)) return jsonify(response)
height = to_nearest_multiple_of_16(job_input.get("height", 768))
length = job_input.get("length", 81)
steps = job_input.get("steps", 4) @app.route("/purge/<job_id>", methods=["POST"])
cfg = job_input.get("cfg", 1.0) def purge_job(job_id):
seed = job_input.get("seed", -1) """Удалить завершённую задачу из памяти (освободить RAM от base64 видео)."""
fps = job_input.get("fps", 16) with jobs_lock:
sampler_name = job_input.get("sampler_name", "euler") job = jobs.get(job_id)
scheduler = job_input.get("scheduler", "linear_quadratic") if not job:
return jsonify({"error": "Job not found"}), 404
if seed == -1: if job["status"] in ("IN_QUEUE", "IN_PROGRESS"):
seed = random.randint(0, 2**63 - 1) return jsonify({"error": "Cannot purge active job"}), 400
del jobs[job_id]
logger.info(f"📐 {width}x{height}, {length} frames, {steps} steps, CFG {cfg}, seed {seed}")
return jsonify({"id": job_id, "purged": True})
# === Заполнение workflow ===
# Positive prompt
prompt["5"]["inputs"]["text"] = job_input.get("prompt", "")
# Negative prompt
negative_prompt = job_input.get("negative_prompt", prompt["6"]["inputs"]["text"])
prompt["6"]["inputs"]["text"] = negative_prompt
# First frame image
prompt["7"]["inputs"]["image"] = image_path
# FLF2V / I2V mode
if use_flf2v and last_image_path:
prompt["15"]["inputs"]["image"] = last_image_path
logger.info(f"🎬 FLF2V: last frame = {last_image_path}")
else:
prompt["8"]["class_type"] = "WanImageToVideo"
if "end_image" in prompt["8"]["inputs"]:
del prompt["8"]["inputs"]["end_image"]
if "15" in prompt:
del prompt["15"]
logger.info("🎬 I2V: single image mode")
# Video dimensions
prompt["8"]["inputs"]["width"] = width
prompt["8"]["inputs"]["height"] = height
prompt["8"]["inputs"]["length"] = length
# KSampler High
prompt["11"]["inputs"]["noise_seed"] = seed
prompt["11"]["inputs"]["steps"] = steps
prompt["11"]["inputs"]["cfg"] = cfg
prompt["11"]["inputs"]["sampler_name"] = sampler_name
prompt["11"]["inputs"]["scheduler"] = scheduler
prompt["11"]["inputs"]["end_at_step"] = steps // 2
# KSampler Low
prompt["12"]["inputs"]["noise_seed"] = seed
prompt["12"]["inputs"]["steps"] = steps
prompt["12"]["inputs"]["cfg"] = cfg
prompt["12"]["inputs"]["sampler_name"] = sampler_name
prompt["12"]["inputs"]["scheduler"] = scheduler
prompt["12"]["inputs"]["start_at_step"] = steps // 2
# Video output
prompt["14"]["inputs"]["frame_rate"] = fps
# === Генерация ===
video_b64 = generate_video(prompt)
if not video_b64:
return jsonify({"error": "Video generation failed — no output"}), 500
elapsed = time.time() - start_time
logger.info(f"✅ Видео сгенерировано за {elapsed:.1f}s")
return jsonify({
"video": video_b64,
"seed": seed,
"mode": mode,
"elapsed": round(elapsed, 1)
})
except Exception as e:
logger.error(f"❌ Ошибка: {e}", exc_info=True)
return jsonify({"error": str(e)}), 500
finally:
# Очистка temp файлов
if os.path.exists(temp_dir):
shutil.rmtree(temp_dir, ignore_errors=True)
# Очистка output ComfyUI
try:
if os.path.exists(COMFY_OUTPUT_DIR):
for fname in os.listdir(COMFY_OUTPUT_DIR):
fpath = os.path.join(COMFY_OUTPUT_DIR, fname)
if os.path.isfile(fpath):
os.unlink(fpath)
elif os.path.isdir(fpath):
shutil.rmtree(fpath)
except Exception:
pass
# ============================================================================ # ============================================================================
@ -370,10 +477,15 @@ def generate():
if __name__ == "__main__": if __name__ == "__main__":
logger.info("=" * 60) logger.info("=" * 60)
logger.info("🚀 DaSiWa API Server") logger.info("🚀 DaSiWa API Server (async worker mode)")
logger.info(f" ComfyUI: http://{COMFY_HOST}:{COMFY_PORT}") logger.info(f" ComfyUI: http://{COMFY_HOST}:{COMFY_PORT}")
logger.info(f" API Port: {API_PORT}") logger.info(f" API Port: {API_PORT}")
logger.info(f" Workflow: {WORKFLOW_FILE}") logger.info(f" Workflow: {WORKFLOW_FILE}")
logger.info(" Endpoints:")
logger.info(" POST /run → поставить задачу")
logger.info(" GET /status/<id> → статус / результат")
logger.info(" POST /purge/<id> → удалить задачу из памяти")
logger.info(" GET /health → здоровье")
logger.info("=" * 60) logger.info("=" * 60)
# Проверяем подключение к ComfyUI # Проверяем подключение к ComfyUI
@ -381,6 +493,11 @@ if __name__ == "__main__":
urllib.request.urlopen(f"http://{COMFY_HOST}:{COMFY_PORT}/", timeout=5) urllib.request.urlopen(f"http://{COMFY_HOST}:{COMFY_PORT}/", timeout=5)
logger.info("✅ ComfyUI доступен") logger.info("✅ ComfyUI доступен")
except Exception: except Exception:
logger.warning(" ComfyUI недоступен — запросы будут ждать") logger.warning(" ComfyUI недоступен — запросы будут ждать"
" пока ComfyUI запустится")
# Запуск фонового воркера
worker_thread = threading.Thread(target=worker_loop, daemon=True)
worker_thread.start()
app.run(host="0.0.0.0", port=API_PORT, debug=False) app.run(host="0.0.0.0", port=API_PORT, debug=False)

1051
workflow_api.json

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save