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 @@ @@ -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/ @@ -18,20 +18,47 @@ custom_comfyui/
├── dasiwa-api.service # Systemd сервис (автозапуск)
├── requirements.txt # Python зависимости
├── keys.json # 🔒 Ключи (НЕ коммитить!)
└── workflow_api.json # 🎨 ComfyUI workflow (сделай сам)
└── workflow_api.json # 🎨 ComfyUI workflow (DaSiWa WAN 2.2 Lightspeed)
```
---
## 🚀 Быстрый старт
### 1. Подготовка workflow
### 1. Загрузка моделей на сервер
В ComfyUI на сервере:
1. Загрузи `DaSiWa WAN 2.2 i2v FastFidelity C-AiO-59.json` через UI
2. Настрой, проверь что работает
3. **Экспортируй API версию:** Menu → `Save (API Format)` → назови `workflow_api.json`
4. Положи файл в эту папку (`custom_comfyui/`)
```powershell
$SERVER = "user@<ip_сервера>"
# HIGH checkpoint (~13 GB)
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)
@ -52,7 +79,7 @@ $SERVER = "root@<ip_сервера>" @@ -52,7 +79,7 @@ $SERVER = "root@<ip_сервера>"
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. Установка на сервере (одна команда)
@ -65,10 +92,9 @@ sudo ./setup.sh @@ -65,10 +92,9 @@ sudo ./setup.sh
```
Скрипт автоматически:
- ✅ Установит Python, pip, nginx, wget
- ✅ Установит Python, pip, wget
- ✅ Установит Python зависимости
- ✅ Настроит Nginx (порт 5000 → API)
- ✅ Настроит Firewall (открыт 22 + 5000, закрыт 8188)
- ✅ Настроит Firewall (открыт 22 + 8080, закрыт 8188)
- ✅ Создаст systemd сервис с автозапуском
- ✅ Сгенерирует ключи (если нет)
@ -87,7 +113,7 @@ scp root@<ip_сервера>:/root/custom_comfyui/keys.json . @@ -87,7 +113,7 @@ scp root@<ip_сервера>:/root/custom_comfyui/keys.json .
curl http://localhost:8080/health
# Ответ:
# {"comfyui": "ok", "status": "ok", "timestamp": 1234567890}
# {"comfyui": "ok", "status": "ok", "queue": 0, "timestamp": 1234567890}
```
### 7. Генерация видео (с клиента)
@ -111,6 +137,27 @@ python client.py \ @@ -111,6 +137,27 @@ python client.py \
--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 @@ -200,9 +247,3 @@ systemctl restart dasiwa-api
**Ошибка авторизации (401):**
- Проверь что `keys.json` одинаковый на клиенте и сервере
- Проверь время на обоих машинах (`date` на сервере, часы на ПК)
**Nginx ошибка:**
```bash
nginx -t
systemctl restart nginx
```

123
client.py

@ -1,11 +1,11 @@ @@ -1,11 +1,11 @@
#!/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>:5000 --image start.png --last-image end.png --prompt "smooth transition"
python client.py --server http://<ip>:8080 --image photo.png --prompt "woman dancing"
python client.py --server http://<ip>:8080 --image start.png --last-image end.png --prompt "smooth transition"
"""
import argparse
@ -40,29 +40,58 @@ def image_to_base64(path: str) -> str: @@ -40,29 +40,58 @@ def image_to_base64(path: str) -> str:
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")
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()
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():
parser = argparse.ArgumentParser(description="DaSiWa API Client")
parser.add_argument("--server", required=True, help="Server URL, e.g. http://1.2.3.4:5000")
parser = argparse.ArgumentParser(description="DaSiWa API Client (async)")
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("--last-image", default=None, help="Path to last frame image (FLF2V mode)")
parser.add_argument("--prompt", required=True, help="Text prompt")
@ -74,10 +103,12 @@ def main(): @@ -74,10 +103,12 @@ def main():
parser.add_argument("--cfg", type=float, default=1.0)
parser.add_argument("--seed", type=int, default=-1)
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")
args = parser.parse_args()
keys = load_keys()
cid, secret = keys["client_id"], keys["secret_key"]
# Формируем payload
payload = {
@ -102,31 +133,45 @@ def main(): @@ -102,31 +133,45 @@ def main():
print(f"🎬 Режим: I2V (image to video)")
print(f"📐 {args.width}x{args.height}, {args.length} frames, {args.steps} steps")
print(f"📤 Отправляю запрос на {args.server}...")
start = time.time()
status_code, result = send_request(
args.server, payload, keys["client_id"], keys["secret_key"]
)
elapsed = time.time() - start
if status_code != 200:
print(f"❌ Ошибка {status_code}: {result.get('error', 'Unknown')}")
if "detail" in result:
print(f" Детали: {result['detail']}")
# 1. Submit job
print(f"📤 Отправляю задачу на {args.server}...")
try:
job_id = submit_job(args.server, payload, cid, secret)
except RuntimeError as e:
print(f"{e}")
sys.exit(1)
print(f"📝 Job ID: {job_id}")
# 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)
if "video" in result:
video_bytes = base64.b64decode(result["video"])
with open(args.output, "wb") as f:
f.write(video_bytes)
print(f"✅ Видео сохранено: {args.output} ({len(video_bytes) / 1024 / 1024:.1f} MB)")
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')}")
# 3. Save video
output = result.get("output", {})
video_b64 = output.get("video")
if not video_b64:
print(f"❌ Нет видео в ответе")
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__":
main()

2
nginx.conf

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

387
server.py

@ -3,8 +3,10 @@ @@ -3,8 +3,10 @@
DaSiWa I2V/FLF2V API Server для ComfyUI.
Работает рядом с ComfyUI на той же машине.
Принимает HTTP запросы с HMAC авторизацией,
отправляет workflow в ComfyUI, возвращает видео.
Асинхронный API (как RunPod):
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
@ -18,6 +20,8 @@ import random @@ -18,6 +20,8 @@ import random
import logging
import binascii
import subprocess
import threading
import queue
import urllib.request
import urllib.parse
import websocket as ws_client
@ -69,6 +73,14 @@ used_nonces = set() @@ -69,6 +73,14 @@ used_nonces = set()
# WebSocket client ID
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): @@ -185,6 +197,162 @@ def generate_video(prompt):
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
# ============================================================================
@ -222,146 +390,85 @@ def health(): @@ -222,146 +390,85 @@ def health():
return jsonify({
"status": "ok",
"comfyui": comfy_status,
"queue": job_queue.qsize(),
"timestamp": int(time.time())
})
@app.route("/generate", methods=["POST"])
def generate():
"""Основной endpoint для генерации видео."""
start_time = time.time()
@app.route("/run", methods=["POST"])
def run_job():
"""Отправляет задачу в очередь. Возвращает job_id сразу."""
job_input = request.json or {}
logger.info("=" * 60)
logger.info("🎬 Новый запрос на генерацию")
logger.info("=" * 60)
# Валидация: должно быть хотя бы одно изображение
has_image = any(k in job_input and job_input[k]
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 данных)
log_input = {k: v for k, v in job_input.items()
if not k.endswith("_base64")}
logger.info(f"Параметры: {json.dumps(log_input, ensure_ascii=False)}")
return jsonify({
"id": job_id,
"status": "IN_QUEUE"
})
task_id = f"task_{uuid.uuid4().hex[:8]}"
temp_dir = os.path.join("/tmp", task_id)
try:
# === Обработка изображений ===
image_path, has_image = process_image_input(job_input, "image", temp_dir)
if not has_image:
return jsonify({"error": "No input image provided. Use image_base64, image_url, or image_path"}), 400
last_image_path, use_flf2v = process_image_input(job_input, "last_image", temp_dir)
mode = "FLF2V" if use_flf2v else "I2V"
logger.info(f"🎬 Режим: {mode}")
# === Загрузка workflow ===
if not os.path.exists(WORKFLOW_FILE):
return jsonify({"error": f"Workflow file not found: {WORKFLOW_FILE}"}), 500
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)
logger.info(f"📐 {width}x{height}, {length} frames, {steps} steps, CFG {cfg}, seed {seed}")
# === Заполнение 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
@app.route("/status/<job_id>", methods=["GET"])
def job_status(job_id):
"""Получить статус задачи. Когда COMPLETED — возвращает результат."""
with jobs_lock:
job = jobs.get(job_id)
if not job:
return jsonify({"error": "Job not found"}), 404
response = {
"id": job_id,
"status": job["status"],
}
if job["status"] == "COMPLETED":
response["output"] = job["output"]
elif job["status"] == "FAILED":
response["error"] = job["error"]
return jsonify(response)
@app.route("/purge/<job_id>", methods=["POST"])
def purge_job(job_id):
"""Удалить завершённую задачу из памяти (освободить RAM от base64 видео)."""
with jobs_lock:
job = jobs.get(job_id)
if not job:
return jsonify({"error": "Job not found"}), 404
if job["status"] in ("IN_QUEUE", "IN_PROGRESS"):
return jsonify({"error": "Cannot purge active job"}), 400
del jobs[job_id]
return jsonify({"id": job_id, "purged": True})
# ============================================================================
@ -370,10 +477,15 @@ def generate(): @@ -370,10 +477,15 @@ def generate():
if __name__ == "__main__":
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" API Port: {API_PORT}")
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)
# Проверяем подключение к ComfyUI
@ -381,6 +493,11 @@ if __name__ == "__main__": @@ -381,6 +493,11 @@ if __name__ == "__main__":
urllib.request.urlopen(f"http://{COMFY_HOST}:{COMFY_PORT}/", timeout=5)
logger.info("✅ ComfyUI доступен")
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)

1051
workflow_api.json

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