art-from-the-machine commited on
Commit
fa3e856
·
1 Parent(s): 12a8e99

Add Docker server deployment

Browse files
Files changed (9) hide show
  1. .dockerignore +40 -0
  2. .gitignore +29 -0
  3. Dockerfile +50 -0
  4. README.md +125 -64
  5. caller.py +151 -0
  6. generate_prequant.py +1 -0
  7. handler.py +283 -0
  8. requirements.txt +9 -0
  9. 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
- Pre-quantized NF4 weights for LingBot-World video generation model. This is a complete, self-contained package - no additional downloads required.
17
 
18
  ## Features
19
 
20
  - **4-bit NF4 quantization** via bitsandbytes - fits in 32GB VRAM
21
  - **Pre-quantized weights** - no runtime quantization overhead
22
- - **Complete package** - includes T5 encoder, VAE, and diffusion models
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
 
24
  ## Quick Start
25
 
26
- ```bash
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
- # Install dependencies
32
- pip install -r requirements.txt
33
 
34
- # Generate a video
35
- python generate_prequant.py \
36
- --image your_image.jpg \
37
- --prompt "A cinematic video of the scene" \
38
- --frame_num 81 \
39
- --output output.mp4
 
 
 
 
 
 
40
  ```
41
 
42
- ## Model Contents
43
 
44
- | File | Size | Description |
45
- |------|------|-------------|
46
- | `high_noise_model_bnb_nf4/model.safetensors` | ~9.6GB | NF4 quantized diffusion model (high noise) |
47
- | `low_noise_model_bnb_nf4/model.safetensors` | ~9.6GB | NF4 quantized diffusion model (low noise) |
48
- | `models_t5_umt5-xxl-enc-bf16.pth` | ~10.6GB | T5-XXL text encoder |
49
- | `Wan2.1_VAE.pth` | ~485MB | VAE encoder/decoder |
 
 
 
 
 
 
 
 
 
50
 
51
- **Total size: ~30GB** (vs ~85GB for full precision models)
52
 
53
- ## Usage
54
 
55
- ### Basic Generation
 
 
56
 
57
  ```bash
58
- python generate_prequant.py \
59
- --image input.jpg \
60
- --prompt "Your prompt here" \
 
61
  --frame_num 81 \
62
- --size "480*832" \
63
  --output output.mp4
64
  ```
65
 
66
- ### Parameters
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
 
68
- | Parameter | Default | Description |
69
- |-----------|---------|-------------|
70
- | `--image` | required | Input image path |
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
- ### With Camera Control
80
 
81
- ```bash
82
- python generate_prequant.py \
83
- --image input.jpg \
84
- --prompt "Your prompt" \
85
- --action_path /path/to/camera_poses/ \
86
- --frame_num 81
87
  ```
88
 
89
- Camera pose directory should contain:
90
- - `poses.npy`: Shape `[num_frames, 4, 4]` - camera transformation matrices
91
- - `intrinsics.npy`: Shape `[num_frames, 4]` - `[fx, fy, cx, cy]`
92
 
93
- ## Requirements
 
 
 
 
 
 
 
 
 
 
 
 
94
 
95
- - Python 3.10+
96
- - CUDA 11.8+ (tested with CUDA 12.x)
97
- - ~32GB VRAM (RTX 4090, RTX 5090, A100, etc.)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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)