art-from-the-machine commited on
Commit ·
fa3e856
1
Parent(s): 12a8e99
Add Docker server deployment
Browse files- .dockerignore +40 -0
- .gitignore +29 -0
- Dockerfile +50 -0
- README.md +125 -64
- caller.py +151 -0
- generate_prequant.py +1 -0
- handler.py +283 -0
- requirements.txt +9 -0
- wan/modules/t5.py +1 -1
.dockerignore
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Version control
|
| 2 |
+
.git/
|
| 3 |
+
.gitignore
|
| 4 |
+
|
| 5 |
+
# Python caches
|
| 6 |
+
__pycache__/
|
| 7 |
+
*.pyc
|
| 8 |
+
*.pyo
|
| 9 |
+
*.egg-info/
|
| 10 |
+
dist/
|
| 11 |
+
build/
|
| 12 |
+
|
| 13 |
+
# Virtual environments
|
| 14 |
+
.venv/
|
| 15 |
+
venv/
|
| 16 |
+
env/
|
| 17 |
+
|
| 18 |
+
# IDE / editor
|
| 19 |
+
.vscode/
|
| 20 |
+
.idea/
|
| 21 |
+
*.swp
|
| 22 |
+
*.swo
|
| 23 |
+
*~
|
| 24 |
+
|
| 25 |
+
# Local test outputs
|
| 26 |
+
output*.mp4
|
| 27 |
+
*.mp4
|
| 28 |
+
|
| 29 |
+
# Docs (not needed in image)
|
| 30 |
+
README.md
|
| 31 |
+
LICENSE*
|
| 32 |
+
*.pdf
|
| 33 |
+
*.md
|
| 34 |
+
|
| 35 |
+
# Example files
|
| 36 |
+
examples/
|
| 37 |
+
|
| 38 |
+
# OS artifacts
|
| 39 |
+
Thumbs.db
|
| 40 |
+
.DS_Store
|
.gitignore
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.pyc
|
| 4 |
+
*.pyo
|
| 5 |
+
*.egg-info/
|
| 6 |
+
dist/
|
| 7 |
+
build/
|
| 8 |
+
|
| 9 |
+
# Virtual environments
|
| 10 |
+
.venv/
|
| 11 |
+
venv/
|
| 12 |
+
env/
|
| 13 |
+
|
| 14 |
+
# IDE / editor
|
| 15 |
+
.vscode/
|
| 16 |
+
.idea/
|
| 17 |
+
*.swp
|
| 18 |
+
*.swo
|
| 19 |
+
*~
|
| 20 |
+
|
| 21 |
+
# Generated videos
|
| 22 |
+
*.mp4
|
| 23 |
+
|
| 24 |
+
# Local outputs
|
| 25 |
+
outputs/
|
| 26 |
+
|
| 27 |
+
# OS artifacts
|
| 28 |
+
Thumbs.db
|
| 29 |
+
.DS_Store
|
Dockerfile
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM nvidia/cuda:12.8.0-runtime-ubuntu22.04
|
| 2 |
+
|
| 3 |
+
ENV DEBIAN_FRONTEND=noninteractive
|
| 4 |
+
ENV PYTHONUNBUFFERED=1
|
| 5 |
+
ENV MODEL_DIR=/app
|
| 6 |
+
|
| 7 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 8 |
+
python3.10 python3.10-dev python3-pip \
|
| 9 |
+
ffmpeg gcc libc6-dev \
|
| 10 |
+
&& update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.10 1 \
|
| 11 |
+
&& update-alternatives --install /usr/bin/python python /usr/bin/python3.10 1 \
|
| 12 |
+
&& python -m pip install --upgrade pip setuptools wheel \
|
| 13 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 14 |
+
|
| 15 |
+
WORKDIR /app
|
| 16 |
+
|
| 17 |
+
# Model weights
|
| 18 |
+
COPY high_noise_model_bnb_nf4/ /app/high_noise_model_bnb_nf4/
|
| 19 |
+
COPY low_noise_model_bnb_nf4/ /app/low_noise_model_bnb_nf4/
|
| 20 |
+
COPY models_t5_umt5-xxl-enc-bf16.pth /app/models_t5_umt5-xxl-enc-bf16.pth
|
| 21 |
+
COPY Wan2.1_VAE.pth /app/Wan2.1_VAE.pth
|
| 22 |
+
COPY tokenizer/ /app/tokenizer/
|
| 23 |
+
|
| 24 |
+
# Python packages
|
| 25 |
+
COPY requirements.txt /app/requirements.txt
|
| 26 |
+
|
| 27 |
+
# Pin PyTorch to 2.7.1+cu128 (must match the flash-attn prebuilt wheel)
|
| 28 |
+
RUN pip install --no-cache-dir \
|
| 29 |
+
torch==2.7.1+cu128 torchvision torchaudio \
|
| 30 |
+
--index-url https://download.pytorch.org/whl/cu128
|
| 31 |
+
|
| 32 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 33 |
+
|
| 34 |
+
# Prebuilt flash-attn wheel: Python 3.10 / PyTorch 2.7 / CUDA 12 / cxx11abiTRUE
|
| 35 |
+
RUN pip install --no-cache-dir \
|
| 36 |
+
https://github.com/Dao-AILab/flash-attention/releases/download/v2.7.4.post1/flash_attn-2.7.4.post1+cu12torch2.7cxx11abiTRUE-cp310-cp310-linux_x86_64.whl
|
| 37 |
+
|
| 38 |
+
# Application source code
|
| 39 |
+
COPY wan/ /app/wan/
|
| 40 |
+
COPY generate_prequant.py /app/generate_prequant.py
|
| 41 |
+
COPY load_prequant.py /app/load_prequant.py
|
| 42 |
+
COPY handler.py /app/handler.py
|
| 43 |
+
|
| 44 |
+
CMD ["gunicorn", "handler:app", \
|
| 45 |
+
"--bind", "0.0.0.0:8080", \
|
| 46 |
+
"--workers", "1", \
|
| 47 |
+
"--threads", "4", \
|
| 48 |
+
"--timeout", "0", \
|
| 49 |
+
"--access-logfile", "-", \
|
| 50 |
+
"--error-logfile", "-"]
|
README.md
CHANGED
|
@@ -11,90 +11,161 @@ tags:
|
|
| 11 |
pipeline_tag: image-to-video
|
| 12 |
---
|
| 13 |
|
| 14 |
-
# LingBot-World NF4 Quantized
|
| 15 |
|
| 16 |
-
|
| 17 |
|
| 18 |
## Features
|
| 19 |
|
| 20 |
- **4-bit NF4 quantization** via bitsandbytes - fits in 32GB VRAM
|
| 21 |
- **Pre-quantized weights** - no runtime quantization overhead
|
| 22 |
-
- **
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
## Quick Start
|
| 25 |
|
| 26 |
-
|
| 27 |
-
# Clone the repo
|
| 28 |
-
git clone https://huggingface.co/cahlen/lingbot-world-base-cam-nf4
|
| 29 |
-
cd lingbot-world-base-cam-nf4
|
| 30 |
|
| 31 |
-
|
| 32 |
-
pip install -r requirements.txt
|
| 33 |
|
| 34 |
-
#
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
```
|
| 41 |
|
| 42 |
-
##
|
| 43 |
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
|
| 51 |
-
|
| 52 |
|
| 53 |
-
##
|
| 54 |
|
| 55 |
-
|
|
|
|
|
|
|
| 56 |
|
| 57 |
```bash
|
| 58 |
-
python
|
| 59 |
-
--
|
| 60 |
-
--
|
|
|
|
| 61 |
--frame_num 81 \
|
| 62 |
-
--size "480*832" \
|
| 63 |
--output output.mp4
|
| 64 |
```
|
| 65 |
|
| 66 |
-
###
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
| `--prompt` | required | Text prompt describing the video |
|
| 72 |
-
| `--frame_num` | 81 | Number of frames (81 = ~5 seconds at 16fps) |
|
| 73 |
-
| `--size` | "480*832" | Output resolution (height*width) |
|
| 74 |
-
| `--sampling_steps` | 40 | Diffusion sampling steps |
|
| 75 |
-
| `--guide_scale` | 5.0 | Classifier-free guidance scale |
|
| 76 |
-
| `--seed` | -1 | Random seed (-1 for random) |
|
| 77 |
-
| `--output` | "output.mp4" | Output video path |
|
| 78 |
|
| 79 |
-
###
|
| 80 |
|
| 81 |
-
```
|
| 82 |
-
|
| 83 |
-
--image input.jpg \
|
| 84 |
-
--prompt "Your prompt" \
|
| 85 |
-
--action_path /path/to/camera_poses/ \
|
| 86 |
-
--frame_num 81
|
| 87 |
```
|
| 88 |
|
| 89 |
-
|
| 90 |
-
- `poses.npy`: Shape `[num_frames, 4, 4]` - camera transformation matrices
|
| 91 |
-
- `intrinsics.npy`: Shape `[num_frames, 4]` - `[fx, fy, cx, cy]`
|
| 92 |
|
| 93 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
|
| 99 |
## Quantization Details
|
| 100 |
|
|
@@ -114,13 +185,3 @@ This achieves ~3.9x compression while maintaining generation quality.
|
|
| 114 |
## License
|
| 115 |
|
| 116 |
This model is based on [LingBot-World](https://github.com/robbyant/lingbot-world) and follows its license terms.
|
| 117 |
-
|
| 118 |
-
## Citation
|
| 119 |
-
|
| 120 |
-
```bibtex
|
| 121 |
-
@misc{lingbot-world-nf4,
|
| 122 |
-
title={LingBot-World NF4 Quantized},
|
| 123 |
-
year={2025},
|
| 124 |
-
url={https://huggingface.co/cahlen/lingbot-world-base-cam-nf4}
|
| 125 |
-
}
|
| 126 |
-
```
|
|
|
|
| 11 |
pipeline_tag: image-to-video
|
| 12 |
---
|
| 13 |
|
| 14 |
+
# LingBot-World Base Cam NF4 Quantized Server
|
| 15 |
|
| 16 |
+
Docker-ready inference server for [LingBot-World video generation model](https://github.com/robbyant/lingbot-world) with [pre-quantized NF4 weights](https://huggingface.co/cahlen/lingbot-world-base-cam-nf4).
|
| 17 |
|
| 18 |
## Features
|
| 19 |
|
| 20 |
- **4-bit NF4 quantization** via bitsandbytes - fits in 32GB VRAM
|
| 21 |
- **Pre-quantized weights** - no runtime quantization overhead
|
| 22 |
+
- **Docker image with HTTP API** - deploy on any machine with an NVIDIA GPU
|
| 23 |
+
- **Optional cloud upload** - upload finished videos to Cloudflare R2, or download directly via HTTP
|
| 24 |
+
|
| 25 |
+
## Model Contents
|
| 26 |
+
|
| 27 |
+
| File | Size | Description |
|
| 28 |
+
|------|------|-------------|
|
| 29 |
+
| `high_noise_model_bnb_nf4/model.safetensors` | ~9.6 GB | NF4 quantized diffusion model (high noise) |
|
| 30 |
+
| `low_noise_model_bnb_nf4/model.safetensors` | ~9.6 GB | NF4 quantized diffusion model (low noise) |
|
| 31 |
+
| `models_t5_umt5-xxl-enc-bf16.pth` | ~10.6 GB | T5-XXL text encoder (bfloat16) |
|
| 32 |
+
| `Wan2.1_VAE.pth` | ~485 MB | VAE encoder/decoder |
|
| 33 |
+
|
| 34 |
+
**Total: ~30 GB** (vs ~85 GB for full-precision models)
|
| 35 |
+
|
| 36 |
+
## Requirements
|
| 37 |
+
|
| 38 |
+
- Python 3.10+
|
| 39 |
+
- CUDA 11.8+ (tested with CUDA 12.x)
|
| 40 |
+
- ~32GB VRAM (RTX 5090, A100, etc)
|
| 41 |
+
- [A lot of RAM](https://huggingface.co/cahlen/lingbot-world-base-cam-nf4/discussions/2) (>64GB)
|
| 42 |
|
| 43 |
## Quick Start
|
| 44 |
|
| 45 |
+
### Without Docker
|
|
|
|
|
|
|
|
|
|
| 46 |
|
| 47 |
+
To run this package without Docker, see the Lingbot-World pre-quantized page [here](https://huggingface.co/cahlen/lingbot-world-base-cam-nf4).
|
|
|
|
| 48 |
|
| 49 |
+
### Runpod Template
|
| 50 |
+
|
| 51 |
+
https://console.runpod.io/deploy?template=j6rpw8zhj2&ref=szjabwfp
|
| 52 |
+
|
| 53 |
+
## Docker Deployment
|
| 54 |
+
|
| 55 |
+
The included Dockerfile builds a self-contained image (~35 GB) with all weights baked in. Once built, you can run it on any machine with an NVIDIA GPU.
|
| 56 |
+
|
| 57 |
+
### Build
|
| 58 |
+
|
| 59 |
+
```bash
|
| 60 |
+
docker build --platform linux/amd64 -t lingbot-nf4 .
|
| 61 |
```
|
| 62 |
|
| 63 |
+
### Run
|
| 64 |
|
| 65 |
+
```bash
|
| 66 |
+
# Basic — videos saved to /app/outputs/ inside the container
|
| 67 |
+
docker run --gpus all -p 8080:8080 lingbot-nf4
|
| 68 |
+
|
| 69 |
+
# With a local directory mounted for output
|
| 70 |
+
docker run --gpus all -p 8080:8080 -v ./outputs:/app/outputs lingbot-nf4
|
| 71 |
+
|
| 72 |
+
# With Cloudflare R2 upload (optional)
|
| 73 |
+
docker run --gpus all -p 8080:8080 \
|
| 74 |
+
-e R2_ACCOUNT_ID=your_account_id \
|
| 75 |
+
-e R2_ACCESS_KEY=your_access_key \
|
| 76 |
+
-e R2_SECRET_KEY=your_secret_key \
|
| 77 |
+
-e R2_BUCKET=your_bucket_name \
|
| 78 |
+
lingbot-nf4
|
| 79 |
+
```
|
| 80 |
|
| 81 |
+
The server starts on port 8080 once the model is loaded.
|
| 82 |
|
| 83 |
+
### API
|
| 84 |
|
| 85 |
+
The server exposes an async job queue to handle long-running generation.
|
| 86 |
+
|
| 87 |
+
#### Using the client script
|
| 88 |
|
| 89 |
```bash
|
| 90 |
+
python caller.py \
|
| 91 |
+
--url http://localhost:8080 \
|
| 92 |
+
--image photo.jpg \
|
| 93 |
+
--prompt "A cinematic shot of the scene" \
|
| 94 |
--frame_num 81 \
|
|
|
|
| 95 |
--output output.mp4
|
| 96 |
```
|
| 97 |
|
| 98 |
+
#### Without the client script
|
| 99 |
+
|
| 100 |
+
```
|
| 101 |
+
POST /generate
|
| 102 |
+
Content-Type: application/json
|
| 103 |
+
|
| 104 |
+
{
|
| 105 |
+
"image": "<base64-encoded JPEG/PNG>",
|
| 106 |
+
"prompt": "A cinematic video of the scene",
|
| 107 |
+
"frame_num": 81,
|
| 108 |
+
"size": "480*832",
|
| 109 |
+
"seed": -1,
|
| 110 |
+
"guide_scale": 5.0,
|
| 111 |
+
"sampling_steps": 40,
|
| 112 |
+
"action_poses": "<base64-encoded poses.npy (optional)>",
|
| 113 |
+
"action_intrinsics": "<base64-encoded intrinsics.npy (optional)>"
|
| 114 |
+
}
|
| 115 |
+
```
|
| 116 |
+
|
| 117 |
+
Returns immediately with HTTP 202:
|
| 118 |
|
| 119 |
+
```json
|
| 120 |
+
{"id": "job-uuid", "status": "IN_PROGRESS"}
|
| 121 |
+
```
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
|
| 123 |
+
#### Poll for result
|
| 124 |
|
| 125 |
+
```
|
| 126 |
+
GET /status/<job-id>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
```
|
| 128 |
|
| 129 |
+
Returns:
|
|
|
|
|
|
|
| 130 |
|
| 131 |
+
```json
|
| 132 |
+
{
|
| 133 |
+
"id": "job-uuid",
|
| 134 |
+
"status": "COMPLETED",
|
| 135 |
+
"output": {
|
| 136 |
+
"video_url": "https://...",
|
| 137 |
+
"seed": 42,
|
| 138 |
+
"duration_sec": 185.3,
|
| 139 |
+
"frame_num": 81,
|
| 140 |
+
"size": "480*832"
|
| 141 |
+
}
|
| 142 |
+
}
|
| 143 |
+
```
|
| 144 |
|
| 145 |
+
If R2 is not configured, `video_path` is returned instead of `video_url`. The client script (`caller.py`) will automatically download the video via the `/download` endpoint in this case.
|
| 146 |
+
|
| 147 |
+
#### Download video
|
| 148 |
+
|
| 149 |
+
When R2 is not configured, you can download completed videos directly:
|
| 150 |
+
|
| 151 |
+
```
|
| 152 |
+
GET /download/<job-id>
|
| 153 |
+
```
|
| 154 |
+
|
| 155 |
+
Returns the MP4 file as a download.
|
| 156 |
+
|
| 157 |
+
#### Health check
|
| 158 |
+
|
| 159 |
+
```
|
| 160 |
+
GET /health
|
| 161 |
+
```
|
| 162 |
+
|
| 163 |
+
### Cloud Deployment (eg RunPod)
|
| 164 |
+
|
| 165 |
+
1. Push the image to a container registry (Docker Hub, etc)
|
| 166 |
+
2. Create a GPU pod/instance with the image
|
| 167 |
+
3. Expose port 8080
|
| 168 |
+
4. Use `caller.py` with the pod's public URL
|
| 169 |
|
| 170 |
## Quantization Details
|
| 171 |
|
|
|
|
| 185 |
## License
|
| 186 |
|
| 187 |
This model is based on [LingBot-World](https://github.com/robbyant/lingbot-world) and follows its license terms.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
caller.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Submit a generation job to a LingBot-World server.
|
| 3 |
+
|
| 4 |
+
Sends a POST to /generate, then polls /status/<id> until the video is ready.
|
| 5 |
+
|
| 6 |
+
Usage:
|
| 7 |
+
python caller.py --url http://localhost:8080 \\
|
| 8 |
+
--image examples/00/image.jpg \\
|
| 9 |
+
--prompt "A woman walks forward slowly" \\
|
| 10 |
+
--action_dir examples/00/
|
| 11 |
+
|
| 12 |
+
# Through a RunPod proxy:
|
| 13 |
+
python caller.py --url https://YOUR-POD-8080.proxy.runpod.net \\
|
| 14 |
+
--image photo.jpg --prompt "..."
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
import argparse
|
| 18 |
+
import base64
|
| 19 |
+
import json
|
| 20 |
+
import sys
|
| 21 |
+
import time
|
| 22 |
+
from pathlib import Path
|
| 23 |
+
|
| 24 |
+
import requests
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def encode_file_b64(path: str) -> str:
|
| 28 |
+
with open(path, "rb") as f:
|
| 29 |
+
return base64.b64encode(f.read()).decode("utf-8")
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def build_payload(args) -> dict:
|
| 33 |
+
payload = {
|
| 34 |
+
"image": encode_file_b64(args.image),
|
| 35 |
+
"prompt": args.prompt,
|
| 36 |
+
"frame_num": args.frame_num,
|
| 37 |
+
"size": args.size,
|
| 38 |
+
"seed": args.seed,
|
| 39 |
+
"guide_scale": args.guide_scale,
|
| 40 |
+
"sampling_steps": args.sampling_steps,
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
if args.action_dir:
|
| 44 |
+
action_dir = Path(args.action_dir)
|
| 45 |
+
poses_path = action_dir / "poses.npy"
|
| 46 |
+
intrinsics_path = action_dir / "intrinsics.npy"
|
| 47 |
+
|
| 48 |
+
if not poses_path.exists() or not intrinsics_path.exists():
|
| 49 |
+
print(f"ERROR: Expected poses.npy and intrinsics.npy in {action_dir}")
|
| 50 |
+
sys.exit(1)
|
| 51 |
+
|
| 52 |
+
payload["action_poses"] = encode_file_b64(str(poses_path))
|
| 53 |
+
payload["action_intrinsics"] = encode_file_b64(str(intrinsics_path))
|
| 54 |
+
print(f"Camera actions loaded from {action_dir}")
|
| 55 |
+
|
| 56 |
+
return payload
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def submit(url: str, payload: dict, poll_interval: int = 10, output_path: str | None = None):
|
| 60 |
+
"""POST the job, then poll until completion."""
|
| 61 |
+
submit_url = f"{url.rstrip('/')}/generate"
|
| 62 |
+
|
| 63 |
+
print(f"Submitting to {submit_url} ...")
|
| 64 |
+
resp = requests.post(submit_url, json=payload, timeout=30)
|
| 65 |
+
|
| 66 |
+
if resp.status_code not in (200, 202):
|
| 67 |
+
print(f"ERROR {resp.status_code}: {resp.text[:500]}")
|
| 68 |
+
sys.exit(1)
|
| 69 |
+
|
| 70 |
+
result = resp.json()
|
| 71 |
+
if "error" in result:
|
| 72 |
+
print(f"ERROR: {result['error']}")
|
| 73 |
+
sys.exit(1)
|
| 74 |
+
|
| 75 |
+
job_id = result.get("id")
|
| 76 |
+
print(f"Job accepted — ID: {job_id}")
|
| 77 |
+
print("Polling for completion ...")
|
| 78 |
+
|
| 79 |
+
status_url = f"{url.rstrip('/')}/status/{job_id}"
|
| 80 |
+
while True:
|
| 81 |
+
time.sleep(poll_interval)
|
| 82 |
+
try:
|
| 83 |
+
resp = requests.get(status_url, timeout=15)
|
| 84 |
+
data = resp.json()
|
| 85 |
+
except Exception as e:
|
| 86 |
+
print(f" Poll error (will retry): {e}")
|
| 87 |
+
continue
|
| 88 |
+
|
| 89 |
+
status = data.get("status")
|
| 90 |
+
print(f" Status: {status}")
|
| 91 |
+
|
| 92 |
+
if status == "COMPLETED":
|
| 93 |
+
output = data.get("output", {})
|
| 94 |
+
print(f"\nDone!")
|
| 95 |
+
if output.get("video_url"):
|
| 96 |
+
print(f" Video URL : {output['video_url']}")
|
| 97 |
+
if output.get("video_path"):
|
| 98 |
+
print(f" Video path: {output['video_path']}")
|
| 99 |
+
# Auto-download the video from the server
|
| 100 |
+
download_url = f"{url.rstrip('/')}/download/{job_id}"
|
| 101 |
+
dest = output_path or f"{job_id}.mp4"
|
| 102 |
+
print(f" Downloading to {dest} ...")
|
| 103 |
+
try:
|
| 104 |
+
dl = requests.get(download_url, stream=True, timeout=300)
|
| 105 |
+
dl.raise_for_status()
|
| 106 |
+
with open(dest, "wb") as f:
|
| 107 |
+
for chunk in dl.iter_content(chunk_size=1024 * 1024):
|
| 108 |
+
f.write(chunk)
|
| 109 |
+
print(f" Saved to {dest}")
|
| 110 |
+
except Exception as e:
|
| 111 |
+
print(f" Download failed: {e}")
|
| 112 |
+
print(f" Seed : {output.get('seed')}")
|
| 113 |
+
print(f" Duration : {output.get('duration_sec', 0):.1f}s")
|
| 114 |
+
return
|
| 115 |
+
elif status == "FAILED":
|
| 116 |
+
output = data.get("output", {})
|
| 117 |
+
print(f"\nJob failed: {output.get('error', 'Unknown error')}")
|
| 118 |
+
sys.exit(1)
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def main():
|
| 122 |
+
parser = argparse.ArgumentParser(
|
| 123 |
+
description="Submit a video generation job to a LingBot-World server"
|
| 124 |
+
)
|
| 125 |
+
parser.add_argument("--url", required=True,
|
| 126 |
+
help="Server URL (e.g. http://localhost:8080 or https://xyz-8080.proxy.runpod.net)")
|
| 127 |
+
parser.add_argument("--image", required=True, help="Path to input image (JPEG/PNG)")
|
| 128 |
+
parser.add_argument("--prompt", required=True, help="Text prompt")
|
| 129 |
+
parser.add_argument("--action_dir", default=None,
|
| 130 |
+
help="Directory containing poses.npy and intrinsics.npy")
|
| 131 |
+
parser.add_argument("--frame_num", type=int, default=81, help="Number of frames (default: 81)")
|
| 132 |
+
parser.add_argument("--size", default="480*832", help="Output size: 480*832 or 720*1280")
|
| 133 |
+
parser.add_argument("--seed", type=int, default=-1, help="RNG seed (-1 for random)")
|
| 134 |
+
parser.add_argument("--guide_scale", type=float, default=5.0)
|
| 135 |
+
parser.add_argument("--sampling_steps", type=int, default=40)
|
| 136 |
+
parser.add_argument("--poll_interval", type=int, default=10,
|
| 137 |
+
help="Seconds between status polls (default: 10)")
|
| 138 |
+
parser.add_argument("--output", default=None,
|
| 139 |
+
help="Local path to save downloaded video (default: <job_id>.mp4)")
|
| 140 |
+
|
| 141 |
+
args = parser.parse_args()
|
| 142 |
+
|
| 143 |
+
print(f" Prompt: {args.prompt[:80]}...")
|
| 144 |
+
print(f" Frames: {args.frame_num}, Size: {args.size}, Steps: {args.sampling_steps}")
|
| 145 |
+
|
| 146 |
+
payload = build_payload(args)
|
| 147 |
+
submit(args.url, payload, poll_interval=args.poll_interval, output_path=args.output)
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
if __name__ == "__main__":
|
| 151 |
+
main()
|
generate_prequant.py
CHANGED
|
@@ -111,6 +111,7 @@ class WanI2V_PreQuant:
|
|
| 111 |
)
|
| 112 |
|
| 113 |
# Load to CPU first, we'll swap to GPU as needed
|
|
|
|
| 114 |
self.low_noise_model = load_quantized_model(low_noise_dir, device="cpu")
|
| 115 |
self.high_noise_model = load_quantized_model(high_noise_dir, device="cpu")
|
| 116 |
|
|
|
|
| 111 |
)
|
| 112 |
|
| 113 |
# Load to CPU first, we'll swap to GPU as needed
|
| 114 |
+
logger.info("Loading models to CPU (swapping to GPU as needed during sampling)...")
|
| 115 |
self.low_noise_model = load_quantized_model(low_noise_dir, device="cpu")
|
| 116 |
self.high_noise_model = load_quantized_model(high_noise_dir, device="cpu")
|
| 117 |
|
handler.py
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
HTTP server for LingBot-World Base Cam NF4.
|
| 4 |
+
|
| 5 |
+
Runs a Flask server with an async job queue.
|
| 6 |
+
|
| 7 |
+
Endpoints:
|
| 8 |
+
POST /generate Submit a generation job (returns job ID immediately)
|
| 9 |
+
GET /status/<id> Poll for job result
|
| 10 |
+
GET /download/<id> Download completed video (if available on server)
|
| 11 |
+
GET /health Liveness check
|
| 12 |
+
|
| 13 |
+
Video output:
|
| 14 |
+
If R2_ACCOUNT_ID / R2_ACCESS_KEY / R2_SECRET_KEY / R2_BUCKET env vars are set, the
|
| 15 |
+
finished video is uploaded to Cloudflare R2 and a presigned URL is returned.
|
| 16 |
+
Otherwise the video is saved to OUTPUT_DIR (default /app/outputs/) and the
|
| 17 |
+
response includes a local file path.
|
| 18 |
+
|
| 19 |
+
Environment variables:
|
| 20 |
+
MODEL_DIR Path to model weights (default: /app)
|
| 21 |
+
OUTPUT_DIR Local output directory (default: /app/outputs)
|
| 22 |
+
PORT HTTP listen port (default: 8080)
|
| 23 |
+
R2_ACCOUNT_ID Cloudflare account ID (optional)
|
| 24 |
+
R2_ACCESS_KEY R2 API access key (optional)
|
| 25 |
+
R2_SECRET_KEY R2 API secret key (optional)
|
| 26 |
+
R2_BUCKET R2 bucket name (default: lingbot-outputs)
|
| 27 |
+
URL_EXPIRY Presigned URL lifetime secs (default: 86400)
|
| 28 |
+
"""
|
| 29 |
+
|
| 30 |
+
import base64
|
| 31 |
+
import io
|
| 32 |
+
import logging
|
| 33 |
+
import os
|
| 34 |
+
import shutil
|
| 35 |
+
import sys
|
| 36 |
+
import tempfile
|
| 37 |
+
import threading
|
| 38 |
+
import time
|
| 39 |
+
import uuid
|
| 40 |
+
|
| 41 |
+
import numpy as np
|
| 42 |
+
import torch
|
| 43 |
+
from flask import Flask, jsonify, request, send_file
|
| 44 |
+
from PIL import Image
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
logging.basicConfig(
|
| 48 |
+
level=logging.INFO,
|
| 49 |
+
format="%(asctime)s [%(levelname)s] %(message)s",
|
| 50 |
+
)
|
| 51 |
+
logger = logging.getLogger(__name__)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
MODEL_DIR = os.environ.get("MODEL_DIR", "/app")
|
| 55 |
+
OUTPUT_DIR = os.environ.get("OUTPUT_DIR", "/app/outputs")
|
| 56 |
+
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
| 57 |
+
|
| 58 |
+
R2_CONFIGURED = all(
|
| 59 |
+
os.environ.get(k) for k in ("R2_ACCOUNT_ID", "R2_ACCESS_KEY", "R2_SECRET_KEY")
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
sys.path.insert(0, MODEL_DIR)
|
| 64 |
+
from generate_prequant import WanI2V_PreQuant, save_video
|
| 65 |
+
|
| 66 |
+
logger.info("Initializing WanI2V_PreQuant pipeline ...")
|
| 67 |
+
_t0 = time.time()
|
| 68 |
+
pipeline = WanI2V_PreQuant(checkpoint_dir=MODEL_DIR, device_id=0, t5_cpu=True)
|
| 69 |
+
logger.info(f"Pipeline ready in {time.time() - _t0:.1f}s")
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _get_r2_client():
|
| 73 |
+
import boto3
|
| 74 |
+
from botocore.config import Config as BotoConfig
|
| 75 |
+
|
| 76 |
+
return boto3.client(
|
| 77 |
+
"s3",
|
| 78 |
+
endpoint_url=f"https://{os.environ['R2_ACCOUNT_ID']}.r2.cloudflarestorage.com",
|
| 79 |
+
aws_access_key_id=os.environ["R2_ACCESS_KEY"],
|
| 80 |
+
aws_secret_access_key=os.environ["R2_SECRET_KEY"],
|
| 81 |
+
config=BotoConfig(signature_version="s3v4"),
|
| 82 |
+
region_name="auto",
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def _upload_to_r2(local_path: str, object_key: str) -> str:
|
| 87 |
+
"""Upload *local_path* to R2 and return a presigned download URL."""
|
| 88 |
+
bucket = os.environ.get("R2_BUCKET", "lingbot-outputs")
|
| 89 |
+
expiry = int(os.environ.get("URL_EXPIRY", 86400))
|
| 90 |
+
client = _get_r2_client()
|
| 91 |
+
client.upload_file(local_path, bucket, object_key, ExtraArgs={"ContentType": "video/mp4"})
|
| 92 |
+
logger.info(f"Uploaded {object_key} -> R2 bucket '{bucket}'")
|
| 93 |
+
return client.generate_presigned_url(
|
| 94 |
+
"get_object", Params={"Bucket": bucket, "Key": object_key}, ExpiresIn=expiry,
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
VALID_SIZES = {"480*832", "720*1280"}
|
| 99 |
+
|
| 100 |
+
def _validate(inp: dict) -> str | None:
|
| 101 |
+
if not inp.get("image"):
|
| 102 |
+
return "Missing required field: 'image' (base64-encoded JPEG/PNG)"
|
| 103 |
+
if not inp.get("prompt"):
|
| 104 |
+
return "Missing required field: 'prompt'"
|
| 105 |
+
size = inp.get("size", "480*832")
|
| 106 |
+
if size not in VALID_SIZES:
|
| 107 |
+
return f"Invalid size '{size}'. Must be one of {VALID_SIZES}"
|
| 108 |
+
frame_num = inp.get("frame_num", 81)
|
| 109 |
+
if not isinstance(frame_num, int) or frame_num < 5:
|
| 110 |
+
return f"frame_num must be an integer >= 5, got {frame_num}"
|
| 111 |
+
return None
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def generate_video(job_input: dict, job_id: str | None = None) -> dict:
|
| 115 |
+
"""
|
| 116 |
+
Run the diffusion pipeline and deliver the result.
|
| 117 |
+
|
| 118 |
+
Returns a dict with ``video_url`` (R2) or ``video_path`` (local) on
|
| 119 |
+
success, or ``error`` on failure.
|
| 120 |
+
"""
|
| 121 |
+
if job_id is None:
|
| 122 |
+
job_id = str(uuid.uuid4())
|
| 123 |
+
|
| 124 |
+
error = _validate(job_input)
|
| 125 |
+
if error:
|
| 126 |
+
return {"error": error}
|
| 127 |
+
|
| 128 |
+
# Decode image
|
| 129 |
+
try:
|
| 130 |
+
image_bytes = base64.b64decode(job_input["image"])
|
| 131 |
+
input_image = Image.open(io.BytesIO(image_bytes)).convert("RGB")
|
| 132 |
+
except Exception as exc:
|
| 133 |
+
return {"error": f"Failed to decode image: {exc}"}
|
| 134 |
+
|
| 135 |
+
# Parameters
|
| 136 |
+
prompt = job_input["prompt"]
|
| 137 |
+
frame_num = job_input.get("frame_num", 81)
|
| 138 |
+
size = job_input.get("size", "480*832")
|
| 139 |
+
seed = job_input.get("seed", -1)
|
| 140 |
+
guide_scale = job_input.get("guide_scale", 5.0)
|
| 141 |
+
sampling_steps = job_input.get("sampling_steps", 40)
|
| 142 |
+
h, w = map(int, size.split("*"))
|
| 143 |
+
|
| 144 |
+
# Optional camera poses
|
| 145 |
+
action_path = None
|
| 146 |
+
tmp_action_dir = None
|
| 147 |
+
if job_input.get("action_poses") and job_input.get("action_intrinsics"):
|
| 148 |
+
try:
|
| 149 |
+
tmp_action_dir = tempfile.mkdtemp(prefix="lingbot_actions_")
|
| 150 |
+
for name, key in [("poses.npy", "action_poses"),
|
| 151 |
+
("intrinsics.npy", "action_intrinsics")]:
|
| 152 |
+
with open(os.path.join(tmp_action_dir, name), "wb") as f:
|
| 153 |
+
f.write(base64.b64decode(job_input[key]))
|
| 154 |
+
action_path = tmp_action_dir
|
| 155 |
+
except Exception as exc:
|
| 156 |
+
return {"error": f"Failed to decode camera actions: {exc}"}
|
| 157 |
+
|
| 158 |
+
# Run pipeline
|
| 159 |
+
logger.info(
|
| 160 |
+
f"Job {job_id}: prompt='{prompt[:80]}...' frames={frame_num} "
|
| 161 |
+
f"size={size} seed={seed} steps={sampling_steps}"
|
| 162 |
+
)
|
| 163 |
+
t0 = time.time()
|
| 164 |
+
try:
|
| 165 |
+
video = pipeline.generate(
|
| 166 |
+
input_prompt=prompt,
|
| 167 |
+
img=input_image,
|
| 168 |
+
action_path=action_path,
|
| 169 |
+
max_area=h * w,
|
| 170 |
+
frame_num=frame_num,
|
| 171 |
+
sampling_steps=sampling_steps,
|
| 172 |
+
guide_scale=guide_scale,
|
| 173 |
+
seed=seed,
|
| 174 |
+
)
|
| 175 |
+
except torch.cuda.OutOfMemoryError:
|
| 176 |
+
return {"error": "CUDA out of memory. Try fewer frames or 480*832."}
|
| 177 |
+
except Exception as exc:
|
| 178 |
+
return {"error": f"Generation failed: {exc}"}
|
| 179 |
+
finally:
|
| 180 |
+
if tmp_action_dir:
|
| 181 |
+
shutil.rmtree(tmp_action_dir, ignore_errors=True)
|
| 182 |
+
|
| 183 |
+
gen_secs = time.time() - t0
|
| 184 |
+
logger.info(f"Job {job_id}: done in {gen_secs:.1f}s")
|
| 185 |
+
|
| 186 |
+
# Save video
|
| 187 |
+
output_path = os.path.join(OUTPUT_DIR, f"{job_id}.mp4")
|
| 188 |
+
try:
|
| 189 |
+
save_video(video, output_path, fps=16)
|
| 190 |
+
except Exception as exc:
|
| 191 |
+
return {"error": f"Failed to save video: {exc}"}
|
| 192 |
+
|
| 193 |
+
result = {
|
| 194 |
+
"seed": seed,
|
| 195 |
+
"duration_sec": round(gen_secs, 1),
|
| 196 |
+
"frame_num": frame_num,
|
| 197 |
+
"size": size,
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
# Upload to R2 (if configured) or keep local
|
| 201 |
+
if R2_CONFIGURED:
|
| 202 |
+
try:
|
| 203 |
+
object_key = f"outputs/{job_id}.mp4"
|
| 204 |
+
result["video_url"] = _upload_to_r2(output_path, object_key)
|
| 205 |
+
os.remove(output_path)
|
| 206 |
+
except Exception as exc:
|
| 207 |
+
logger.warning(f"Job {job_id}: R2 upload failed ({exc}), keeping local file")
|
| 208 |
+
result["video_path"] = output_path
|
| 209 |
+
else:
|
| 210 |
+
result["video_path"] = output_path
|
| 211 |
+
|
| 212 |
+
return result
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
app = Flask(__name__)
|
| 216 |
+
|
| 217 |
+
_jobs: dict[str, dict] = {}
|
| 218 |
+
_jobs_lock = threading.Lock()
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def _run_job(job_id: str, job_input: dict):
|
| 222 |
+
"""Background thread target."""
|
| 223 |
+
try:
|
| 224 |
+
result = generate_video(job_input, job_id)
|
| 225 |
+
except Exception as exc:
|
| 226 |
+
result = {"error": f"Unexpected failure: {exc}"}
|
| 227 |
+
with _jobs_lock:
|
| 228 |
+
_jobs[job_id]["status"] = "FAILED" if "error" in result else "COMPLETED"
|
| 229 |
+
_jobs[job_id]["result"] = result
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
@app.route("/health", methods=["GET"])
|
| 233 |
+
def health():
|
| 234 |
+
return jsonify({"status": "healthy", "gpu": torch.cuda.get_device_name(0)})
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
@app.route("/generate", methods=["POST"])
|
| 238 |
+
def generate():
|
| 239 |
+
"""Accept a job and return immediately with a job ID (HTTP 202)."""
|
| 240 |
+
body = request.get_json(silent=True)
|
| 241 |
+
if body is None:
|
| 242 |
+
return jsonify({"error": "Request body must be JSON"}), 400
|
| 243 |
+
job_input = body.get("input", body)
|
| 244 |
+
|
| 245 |
+
error = _validate(job_input)
|
| 246 |
+
if error:
|
| 247 |
+
return jsonify({"error": error}), 400
|
| 248 |
+
|
| 249 |
+
job_id = str(uuid.uuid4())
|
| 250 |
+
with _jobs_lock:
|
| 251 |
+
_jobs[job_id] = {"status": "IN_PROGRESS", "result": None, "submitted_at": time.time()}
|
| 252 |
+
|
| 253 |
+
logger.info(f"Job {job_id}: accepted")
|
| 254 |
+
threading.Thread(target=_run_job, args=(job_id, job_input), daemon=True).start()
|
| 255 |
+
return jsonify({"id": job_id, "status": "IN_PROGRESS"}), 202
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
@app.route("/status/<job_id>", methods=["GET"])
|
| 259 |
+
def status(job_id):
|
| 260 |
+
"""Poll for job result."""
|
| 261 |
+
with _jobs_lock:
|
| 262 |
+
job = _jobs.get(job_id)
|
| 263 |
+
if job is None:
|
| 264 |
+
return jsonify({"error": f"Job {job_id} not found"}), 404
|
| 265 |
+
resp = {"id": job_id, "status": job["status"]}
|
| 266 |
+
if job["result"] is not None:
|
| 267 |
+
resp["output"] = job["result"]
|
| 268 |
+
return jsonify(resp)
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
@app.route("/download/<job_id>", methods=["GET"])
|
| 272 |
+
def download(job_id):
|
| 273 |
+
"""Download a completed video by job ID."""
|
| 274 |
+
video_path = os.path.join(OUTPUT_DIR, f"{job_id}.mp4")
|
| 275 |
+
if not os.path.isfile(video_path):
|
| 276 |
+
return jsonify({"error": f"Video for job {job_id} not found (may have been uploaded to R2)"}), 404
|
| 277 |
+
return send_file(video_path, mimetype="video/mp4", as_attachment=True, download_name=f"{job_id}.mp4")
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
if __name__ == "__main__":
|
| 281 |
+
port = int(os.environ.get("PORT", 8080))
|
| 282 |
+
logger.info(f"Starting server on port {port} ...")
|
| 283 |
+
app.run(host="0.0.0.0", port=port)
|
requirements.txt
CHANGED
|
@@ -13,3 +13,12 @@ pillow
|
|
| 13 |
numpy
|
| 14 |
tqdm
|
| 15 |
easydict
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
numpy
|
| 14 |
tqdm
|
| 15 |
easydict
|
| 16 |
+
scipy
|
| 17 |
+
flask
|
| 18 |
+
gunicorn
|
| 19 |
+
ftfy
|
| 20 |
+
regex
|
| 21 |
+
requests
|
| 22 |
+
packaging
|
| 23 |
+
peft
|
| 24 |
+
boto3
|
wan/modules/t5.py
CHANGED
|
@@ -491,7 +491,7 @@ class T5EncoderModel:
|
|
| 491 |
dtype=dtype,
|
| 492 |
device=device).eval().requires_grad_(False)
|
| 493 |
logging.info(f'loading {checkpoint_path}')
|
| 494 |
-
model.load_state_dict(torch.load(checkpoint_path, map_location='cpu'))
|
| 495 |
self.model = model
|
| 496 |
if shard_fn is not None:
|
| 497 |
self.model = shard_fn(self.model, sync_module_states=False)
|
|
|
|
| 491 |
dtype=dtype,
|
| 492 |
device=device).eval().requires_grad_(False)
|
| 493 |
logging.info(f'loading {checkpoint_path}')
|
| 494 |
+
model.load_state_dict(torch.load(checkpoint_path, map_location='cpu', weights_only=False))
|
| 495 |
self.model = model
|
| 496 |
if shard_fn is not None:
|
| 497 |
self.model = shard_fn(self.model, sync_module_states=False)
|