fix workflow
This commit is contained in:
+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 режимы
|
||||||
@@ -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
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -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}
|
||||||
headers = {
|
response = requests.post(f"{server_url}{path}", data=body, headers=headers, timeout=30)
|
||||||
"Content-Type": "application/json",
|
|
||||||
**auth_headers
|
|
||||||
}
|
|
||||||
|
|
||||||
response = requests.post(
|
|
||||||
f"{server_url}/generate",
|
|
||||||
data=body,
|
|
||||||
headers=headers,
|
|
||||||
timeout=600
|
|
||||||
)
|
|
||||||
|
|
||||||
return response.status_code, response.json()
|
return response.status_code, response.json()
|
||||||
|
|
||||||
|
|
||||||
|
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():
|
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}")
|
||||||
|
sys.exit(1)
|
||||||
|
print(f"📝 Job ID: {job_id}")
|
||||||
|
|
||||||
if status_code != 200:
|
# 2. Poll for completion
|
||||||
print(f"❌ Ошибка {status_code}: {result.get('error', 'Unknown')}")
|
print(f"⏳ Жду результат (поллинг каждые {args.poll_interval}s)...")
|
||||||
if "detail" in result:
|
try:
|
||||||
print(f" Детали: {result['detail']}")
|
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()
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
# ЭТОТ ФАЙЛ БОЛЬШЕ НЕ ИСПОЛЬЗУЕТСЯ — удали его
|
|
||||||
# API работает напрямую через Python на порту 8080
|
|
||||||
@@ -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
|
||||||
|
|
||||||
# Логирование (без base64 данных)
|
job_id = str(uuid.uuid4())
|
||||||
log_input = {k: v for k, v in job_input.items()
|
temp_dir = os.path.join("/tmp", f"job_{job_id[:8]}")
|
||||||
if not k.endswith("_base64")}
|
|
||||||
logger.info(f"Параметры: {json.dumps(log_input, ensure_ascii=False)}")
|
|
||||||
|
|
||||||
task_id = f"task_{uuid.uuid4().hex[:8]}"
|
# Логирование (без base64)
|
||||||
temp_dir = os.path.join("/tmp", task_id)
|
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)}")
|
||||||
|
|
||||||
try:
|
with jobs_lock:
|
||||||
# === Обработка изображений ===
|
jobs[job_id] = {
|
||||||
image_path, has_image = process_image_input(job_input, "image", temp_dir)
|
"status": "IN_QUEUE",
|
||||||
if not has_image:
|
"input": job_input,
|
||||||
return jsonify({"error": "No input image provided. Use image_base64, image_url, or image_path"}), 400
|
"temp_dir": temp_dir,
|
||||||
|
"output": None,
|
||||||
|
"error": None,
|
||||||
|
"created_at": time.time(),
|
||||||
|
"started_at": None,
|
||||||
|
"completed_at": None,
|
||||||
|
}
|
||||||
|
|
||||||
last_image_path, use_flf2v = process_image_input(job_input, "last_image", temp_dir)
|
job_queue.put(job_id)
|
||||||
|
|
||||||
mode = "FLF2V" if use_flf2v else "I2V"
|
return jsonify({
|
||||||
logger.info(f"🎬 Режим: {mode}")
|
"id": job_id,
|
||||||
|
"status": "IN_QUEUE"
|
||||||
|
})
|
||||||
|
|
||||||
# === Загрузка 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:
|
@app.route("/status/<job_id>", methods=["GET"])
|
||||||
prompt = json.load(f)
|
def job_status(job_id):
|
||||||
|
"""Получить статус задачи. Когда COMPLETED — возвращает результат."""
|
||||||
|
with jobs_lock:
|
||||||
|
job = jobs.get(job_id)
|
||||||
|
|
||||||
# === Параметры генерации ===
|
if not job:
|
||||||
width = to_nearest_multiple_of_16(job_input.get("width", 528))
|
return jsonify({"error": "Job not found"}), 404
|
||||||
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:
|
response = {
|
||||||
seed = random.randint(0, 2**63 - 1)
|
"id": job_id,
|
||||||
|
"status": job["status"],
|
||||||
|
}
|
||||||
|
|
||||||
logger.info(f"📐 {width}x{height}, {length} frames, {steps} steps, CFG {cfg}, seed {seed}")
|
if job["status"] == "COMPLETED":
|
||||||
|
response["output"] = job["output"]
|
||||||
|
elif job["status"] == "FAILED":
|
||||||
|
response["error"] = job["error"]
|
||||||
|
|
||||||
# === Заполнение workflow ===
|
return jsonify(response)
|
||||||
|
|
||||||
# Positive prompt
|
|
||||||
prompt["5"]["inputs"]["text"] = job_input.get("prompt", "")
|
|
||||||
|
|
||||||
# Negative prompt
|
@app.route("/purge/<job_id>", methods=["POST"])
|
||||||
negative_prompt = job_input.get("negative_prompt", prompt["6"]["inputs"]["text"])
|
def purge_job(job_id):
|
||||||
prompt["6"]["inputs"]["text"] = negative_prompt
|
"""Удалить завершённую задачу из памяти (освободить 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]
|
||||||
|
|
||||||
# First frame image
|
return jsonify({"id": job_id, "purged": True})
|
||||||
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)
|
||||||
|
|||||||
+108
-971
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user