@mcoda/mswarm 0.1.102 → 0.1.103
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/invocation-token.d.ts.map +1 -1
- package/dist/invocation-token.js +2 -1
- package/dist/invocation-token.js.map +1 -1
- package/dist/runtime.d.ts +10 -0
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +123 -25
- package/dist/runtime.js.map +1 -1
- package/package.json +5 -3
- package/scripts/wan22-openai-server.py +901 -0
|
@@ -0,0 +1,901 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Loopback-only OpenAI-shaped bridge for stable-diffusion.cpp video jobs."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import base64
|
|
8
|
+
import binascii
|
|
9
|
+
import ipaddress
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import math
|
|
13
|
+
import secrets
|
|
14
|
+
import signal
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
import urllib.error
|
|
18
|
+
import urllib.parse
|
|
19
|
+
import urllib.request
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
22
|
+
from socketserver import TCPServer
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
DEFAULT_HOST = "127.0.0.1"
|
|
27
|
+
DEFAULT_PORT = 11448
|
|
28
|
+
DEFAULT_UPSTREAM_BASE_URL = "http://127.0.0.1:11447"
|
|
29
|
+
DEFAULT_MODEL_ID = "wan2.2-t2v-a14b-q4-k-m-local"
|
|
30
|
+
DIRECT_HTTP_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
|
31
|
+
ALLOWED_FIELDS = {
|
|
32
|
+
"model",
|
|
33
|
+
"prompt",
|
|
34
|
+
"negative_prompt",
|
|
35
|
+
"n",
|
|
36
|
+
"size",
|
|
37
|
+
"duration_seconds",
|
|
38
|
+
"fps",
|
|
39
|
+
"video_frames",
|
|
40
|
+
"response_format",
|
|
41
|
+
"seed",
|
|
42
|
+
"steps",
|
|
43
|
+
"high_noise_steps",
|
|
44
|
+
}
|
|
45
|
+
MIME_BY_FORMAT = {
|
|
46
|
+
"webm": "video/webm",
|
|
47
|
+
"webp": "image/webp",
|
|
48
|
+
"avi": "video/x-msvideo",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ApiError(Exception):
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
status: int,
|
|
56
|
+
message: str,
|
|
57
|
+
*,
|
|
58
|
+
error_type: str = "invalid_request_error",
|
|
59
|
+
param: str | None = None,
|
|
60
|
+
code: str | None = None,
|
|
61
|
+
) -> None:
|
|
62
|
+
super().__init__(message)
|
|
63
|
+
self.status = status
|
|
64
|
+
self.message = message
|
|
65
|
+
self.error_type = error_type
|
|
66
|
+
self.param = param
|
|
67
|
+
self.code = code
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class UpstreamError(Exception):
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class ServerState:
|
|
76
|
+
upstream_base_url: str
|
|
77
|
+
model_id: str
|
|
78
|
+
max_request_bytes: int
|
|
79
|
+
max_output_bytes: int
|
|
80
|
+
max_prompt_chars: int
|
|
81
|
+
max_negative_prompt_chars: int
|
|
82
|
+
allowed_sizes: set[tuple[int, int]]
|
|
83
|
+
min_video_frames: int
|
|
84
|
+
max_video_frames: int
|
|
85
|
+
default_video_frames: int
|
|
86
|
+
min_fps: int
|
|
87
|
+
max_fps: int
|
|
88
|
+
default_fps: int
|
|
89
|
+
default_steps: int
|
|
90
|
+
max_steps: int
|
|
91
|
+
default_high_noise_steps: int
|
|
92
|
+
max_high_noise_steps: int
|
|
93
|
+
poll_interval_seconds: float
|
|
94
|
+
inference_timeout_seconds: float
|
|
95
|
+
request_timeout_seconds: float
|
|
96
|
+
response_formats: set[str]
|
|
97
|
+
shutdown_requested: threading.Event = field(default_factory=threading.Event)
|
|
98
|
+
active_job_lock: threading.Lock = field(default_factory=threading.Lock)
|
|
99
|
+
active_job_id: str | None = None
|
|
100
|
+
inference_slots: threading.BoundedSemaphore = field(
|
|
101
|
+
default_factory=lambda: threading.BoundedSemaphore(1)
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class VideoHttpServer(ThreadingHTTPServer):
|
|
106
|
+
daemon_threads = True
|
|
107
|
+
request_queue_size = 8
|
|
108
|
+
|
|
109
|
+
def server_bind(self) -> None:
|
|
110
|
+
# Avoid HTTPServer's unnecessary reverse-DNS lookup on loopback.
|
|
111
|
+
TCPServer.server_bind(self)
|
|
112
|
+
host, port = self.server_address[:2]
|
|
113
|
+
self.server_name = str(host)
|
|
114
|
+
self.server_port = int(port)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _is_int(value: Any) -> bool:
|
|
118
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _validate_text(
|
|
122
|
+
value: Any,
|
|
123
|
+
*,
|
|
124
|
+
field_name: str,
|
|
125
|
+
max_chars: int,
|
|
126
|
+
allow_empty: bool,
|
|
127
|
+
) -> str:
|
|
128
|
+
if not isinstance(value, str) or (not allow_empty and not value.strip()):
|
|
129
|
+
qualifier = "a string" if allow_empty else "a non-empty string"
|
|
130
|
+
raise ApiError(
|
|
131
|
+
400,
|
|
132
|
+
f"{field_name} must be {qualifier}",
|
|
133
|
+
param=field_name,
|
|
134
|
+
code=f"invalid_{field_name}",
|
|
135
|
+
)
|
|
136
|
+
if len(value) > max_chars:
|
|
137
|
+
raise ApiError(
|
|
138
|
+
400,
|
|
139
|
+
f"{field_name} exceeds the configured limit",
|
|
140
|
+
param=field_name,
|
|
141
|
+
code=f"{field_name}_limit_exceeded",
|
|
142
|
+
)
|
|
143
|
+
if "\x00" in value:
|
|
144
|
+
raise ApiError(
|
|
145
|
+
400,
|
|
146
|
+
f"{field_name} must not contain a null character",
|
|
147
|
+
param=field_name,
|
|
148
|
+
code=f"invalid_{field_name}",
|
|
149
|
+
)
|
|
150
|
+
try:
|
|
151
|
+
value.encode("utf-8", errors="strict")
|
|
152
|
+
except UnicodeEncodeError as error:
|
|
153
|
+
raise ApiError(
|
|
154
|
+
400,
|
|
155
|
+
f"{field_name} must be valid UTF-8 text",
|
|
156
|
+
param=field_name,
|
|
157
|
+
code=f"invalid_{field_name}",
|
|
158
|
+
) from error
|
|
159
|
+
return value
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _effective_frame_count(requested: int) -> int:
|
|
163
|
+
return ((requested - 1) // 4) * 4 + 1
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _parse_generation_request(payload: Any, state: ServerState) -> dict[str, Any]:
|
|
167
|
+
if not isinstance(payload, dict):
|
|
168
|
+
raise ApiError(400, "request body must be a JSON object", code="invalid_json")
|
|
169
|
+
unknown = sorted(set(payload) - ALLOWED_FIELDS)
|
|
170
|
+
if unknown:
|
|
171
|
+
raise ApiError(
|
|
172
|
+
400,
|
|
173
|
+
f"unsupported request parameter: {unknown[0]}",
|
|
174
|
+
param=unknown[0],
|
|
175
|
+
code="unsupported_parameter",
|
|
176
|
+
)
|
|
177
|
+
if payload.get("model") != state.model_id:
|
|
178
|
+
raise ApiError(
|
|
179
|
+
400,
|
|
180
|
+
f"model must be {state.model_id}",
|
|
181
|
+
param="model",
|
|
182
|
+
code="model_not_found",
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
prompt = _validate_text(
|
|
186
|
+
payload.get("prompt"),
|
|
187
|
+
field_name="prompt",
|
|
188
|
+
max_chars=state.max_prompt_chars,
|
|
189
|
+
allow_empty=False,
|
|
190
|
+
)
|
|
191
|
+
negative_prompt = _validate_text(
|
|
192
|
+
payload.get("negative_prompt", ""),
|
|
193
|
+
field_name="negative_prompt",
|
|
194
|
+
max_chars=state.max_negative_prompt_chars,
|
|
195
|
+
allow_empty=True,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
n = payload.get("n", 1)
|
|
199
|
+
if not _is_int(n) or n != 1:
|
|
200
|
+
raise ApiError(400, "n must be 1", param="n", code="invalid_n")
|
|
201
|
+
|
|
202
|
+
size = payload.get("size", "832x480")
|
|
203
|
+
if not isinstance(size, str) or "x" not in size:
|
|
204
|
+
raise ApiError(400, "size must use WIDTHxHEIGHT", param="size", code="invalid_size")
|
|
205
|
+
try:
|
|
206
|
+
width_text, height_text = size.lower().split("x", 1)
|
|
207
|
+
width, height = int(width_text), int(height_text)
|
|
208
|
+
except ValueError as error:
|
|
209
|
+
raise ApiError(
|
|
210
|
+
400, "size must use WIDTHxHEIGHT", param="size", code="invalid_size"
|
|
211
|
+
) from error
|
|
212
|
+
if (width, height) not in state.allowed_sizes:
|
|
213
|
+
allowed = ", ".join(f"{w}x{h}" for w, h in sorted(state.allowed_sizes))
|
|
214
|
+
raise ApiError(
|
|
215
|
+
400,
|
|
216
|
+
f"size must be one of {allowed}",
|
|
217
|
+
param="size",
|
|
218
|
+
code="unsupported_size",
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
fps = payload.get("fps", state.default_fps)
|
|
222
|
+
if not _is_int(fps) or not state.min_fps <= fps <= state.max_fps:
|
|
223
|
+
raise ApiError(
|
|
224
|
+
400,
|
|
225
|
+
f"fps must be an integer between {state.min_fps} and {state.max_fps}",
|
|
226
|
+
param="fps",
|
|
227
|
+
code="invalid_fps",
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
if "duration_seconds" in payload and "video_frames" in payload:
|
|
231
|
+
raise ApiError(
|
|
232
|
+
400,
|
|
233
|
+
"duration_seconds and video_frames are mutually exclusive",
|
|
234
|
+
param="duration_seconds",
|
|
235
|
+
code="ambiguous_duration",
|
|
236
|
+
)
|
|
237
|
+
if "duration_seconds" in payload:
|
|
238
|
+
duration = payload["duration_seconds"]
|
|
239
|
+
if isinstance(duration, bool) or not isinstance(duration, (int, float)):
|
|
240
|
+
raise ApiError(
|
|
241
|
+
400,
|
|
242
|
+
"duration_seconds must be a finite positive number",
|
|
243
|
+
param="duration_seconds",
|
|
244
|
+
code="invalid_duration",
|
|
245
|
+
)
|
|
246
|
+
duration = float(duration)
|
|
247
|
+
if not math.isfinite(duration) or duration <= 0:
|
|
248
|
+
raise ApiError(
|
|
249
|
+
400,
|
|
250
|
+
"duration_seconds must be a finite positive number",
|
|
251
|
+
param="duration_seconds",
|
|
252
|
+
code="invalid_duration",
|
|
253
|
+
)
|
|
254
|
+
requested_frames = math.floor(duration * fps)
|
|
255
|
+
else:
|
|
256
|
+
requested_frames = payload.get("video_frames", state.default_video_frames)
|
|
257
|
+
if not _is_int(requested_frames):
|
|
258
|
+
raise ApiError(
|
|
259
|
+
400,
|
|
260
|
+
"video_frames must be an integer",
|
|
261
|
+
param="video_frames",
|
|
262
|
+
code="invalid_video_frames",
|
|
263
|
+
)
|
|
264
|
+
actual_frames = _effective_frame_count(requested_frames)
|
|
265
|
+
if not state.min_video_frames <= actual_frames <= state.max_video_frames:
|
|
266
|
+
raise ApiError(
|
|
267
|
+
400,
|
|
268
|
+
f"effective video_frames must be between {state.min_video_frames} and {state.max_video_frames}",
|
|
269
|
+
param="video_frames",
|
|
270
|
+
code="video_frames_limit_exceeded",
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
response_format = payload.get("response_format", "webm")
|
|
274
|
+
if not isinstance(response_format, str) or response_format not in state.response_formats:
|
|
275
|
+
allowed = ", ".join(sorted(state.response_formats))
|
|
276
|
+
raise ApiError(
|
|
277
|
+
400,
|
|
278
|
+
f"response_format must be one of {allowed}",
|
|
279
|
+
param="response_format",
|
|
280
|
+
code="unsupported_response_format",
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
seed = payload.get("seed")
|
|
284
|
+
if seed == -1 or seed is None:
|
|
285
|
+
seed = secrets.randbelow(2**31)
|
|
286
|
+
if not _is_int(seed) or not 0 <= seed <= 2**31 - 1:
|
|
287
|
+
raise ApiError(
|
|
288
|
+
400,
|
|
289
|
+
"seed must be -1 or an integer between 0 and 2147483647",
|
|
290
|
+
param="seed",
|
|
291
|
+
code="invalid_seed",
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
steps = payload.get("steps", state.default_steps)
|
|
295
|
+
if not _is_int(steps) or not 1 <= steps <= state.max_steps:
|
|
296
|
+
raise ApiError(
|
|
297
|
+
400,
|
|
298
|
+
f"steps must be an integer between 1 and {state.max_steps}",
|
|
299
|
+
param="steps",
|
|
300
|
+
code="invalid_steps",
|
|
301
|
+
)
|
|
302
|
+
high_noise_steps = payload.get(
|
|
303
|
+
"high_noise_steps", state.default_high_noise_steps
|
|
304
|
+
)
|
|
305
|
+
if (
|
|
306
|
+
not _is_int(high_noise_steps)
|
|
307
|
+
or not 1 <= high_noise_steps <= state.max_high_noise_steps
|
|
308
|
+
):
|
|
309
|
+
raise ApiError(
|
|
310
|
+
400,
|
|
311
|
+
"high_noise_steps must be an integer between "
|
|
312
|
+
f"1 and {state.max_high_noise_steps}",
|
|
313
|
+
param="high_noise_steps",
|
|
314
|
+
code="invalid_high_noise_steps",
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
return {
|
|
318
|
+
"prompt": prompt,
|
|
319
|
+
"negative_prompt": negative_prompt,
|
|
320
|
+
"width": width,
|
|
321
|
+
"height": height,
|
|
322
|
+
"seed": seed,
|
|
323
|
+
"video_frames": requested_frames,
|
|
324
|
+
"effective_video_frames": actual_frames,
|
|
325
|
+
"fps": fps,
|
|
326
|
+
"response_format": response_format,
|
|
327
|
+
"steps": steps,
|
|
328
|
+
"high_noise_steps": high_noise_steps,
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _bounded_json_request(
|
|
333
|
+
url: str,
|
|
334
|
+
*,
|
|
335
|
+
method: str,
|
|
336
|
+
timeout: float,
|
|
337
|
+
max_bytes: int,
|
|
338
|
+
payload: dict[str, Any] | None = None,
|
|
339
|
+
) -> tuple[int, dict[str, Any]]:
|
|
340
|
+
body = None
|
|
341
|
+
headers = {"accept": "application/json"}
|
|
342
|
+
if payload is not None:
|
|
343
|
+
body = json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
|
344
|
+
headers["content-type"] = "application/json"
|
|
345
|
+
request = urllib.request.Request(url, data=body, headers=headers, method=method)
|
|
346
|
+
try:
|
|
347
|
+
with DIRECT_HTTP_OPENER.open(request, timeout=timeout) as response:
|
|
348
|
+
raw = response.read(max_bytes + 1)
|
|
349
|
+
status = response.status
|
|
350
|
+
except urllib.error.HTTPError as error:
|
|
351
|
+
raw = error.read(min(max_bytes, 32_768) + 1)
|
|
352
|
+
status = error.code
|
|
353
|
+
except (OSError, TimeoutError) as error:
|
|
354
|
+
raise UpstreamError("stable-diffusion.cpp is unreachable") from error
|
|
355
|
+
if len(raw) > max_bytes:
|
|
356
|
+
raise UpstreamError("stable-diffusion.cpp response exceeds the configured limit")
|
|
357
|
+
try:
|
|
358
|
+
parsed = json.loads(raw)
|
|
359
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
|
360
|
+
raise UpstreamError("stable-diffusion.cpp returned invalid JSON") from error
|
|
361
|
+
if not isinstance(parsed, dict):
|
|
362
|
+
raise UpstreamError("stable-diffusion.cpp returned an invalid object")
|
|
363
|
+
return status, parsed
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _check_upstream_ready(state: ServerState) -> dict[str, Any]:
|
|
367
|
+
status, payload = _bounded_json_request(
|
|
368
|
+
f"{state.upstream_base_url}/sdcpp/v1/capabilities",
|
|
369
|
+
method="GET",
|
|
370
|
+
timeout=state.request_timeout_seconds,
|
|
371
|
+
max_bytes=1_048_576,
|
|
372
|
+
)
|
|
373
|
+
supported_modes = payload.get("supported_modes")
|
|
374
|
+
formats_by_mode = payload.get("output_formats_by_mode")
|
|
375
|
+
video_formats = formats_by_mode.get("vid_gen") if isinstance(formats_by_mode, dict) else None
|
|
376
|
+
if status != 200 or not isinstance(supported_modes, list) or "vid_gen" not in supported_modes:
|
|
377
|
+
raise UpstreamError("stable-diffusion.cpp does not advertise vid_gen")
|
|
378
|
+
if not isinstance(video_formats, list) or not state.response_formats.issubset(
|
|
379
|
+
{item for item in video_formats if isinstance(item, str)}
|
|
380
|
+
):
|
|
381
|
+
raise UpstreamError("stable-diffusion.cpp lacks the configured video output format")
|
|
382
|
+
return payload
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _native_request(request: dict[str, Any]) -> dict[str, Any]:
|
|
386
|
+
return {
|
|
387
|
+
"prompt": request["prompt"],
|
|
388
|
+
"negative_prompt": request["negative_prompt"],
|
|
389
|
+
"width": request["width"],
|
|
390
|
+
"height": request["height"],
|
|
391
|
+
"seed": request["seed"],
|
|
392
|
+
"video_frames": request["video_frames"],
|
|
393
|
+
"fps": request["fps"],
|
|
394
|
+
"moe_boundary": 0.875,
|
|
395
|
+
"sample_params": {
|
|
396
|
+
"scheduler": "discrete",
|
|
397
|
+
"sample_method": "euler",
|
|
398
|
+
"sample_steps": request["steps"],
|
|
399
|
+
"flow_shift": 3.0,
|
|
400
|
+
"guidance": {"txt_cfg": 3.5},
|
|
401
|
+
},
|
|
402
|
+
"high_noise_sample_params": {
|
|
403
|
+
"scheduler": "discrete",
|
|
404
|
+
"sample_method": "euler",
|
|
405
|
+
"sample_steps": request["high_noise_steps"],
|
|
406
|
+
"flow_shift": 3.0,
|
|
407
|
+
"guidance": {"txt_cfg": 3.5},
|
|
408
|
+
},
|
|
409
|
+
"output_format": request["response_format"],
|
|
410
|
+
"output_compression": 100,
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _cancel_job(state: ServerState, job_id: str) -> None:
|
|
415
|
+
try:
|
|
416
|
+
_bounded_json_request(
|
|
417
|
+
f"{state.upstream_base_url}/sdcpp/v1/jobs/{urllib.parse.quote(job_id, safe='')}/cancel",
|
|
418
|
+
method="POST",
|
|
419
|
+
timeout=state.request_timeout_seconds,
|
|
420
|
+
max_bytes=65_536,
|
|
421
|
+
payload={},
|
|
422
|
+
)
|
|
423
|
+
except UpstreamError:
|
|
424
|
+
pass
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _set_active_job(state: ServerState, job_id: str) -> None:
|
|
428
|
+
with state.active_job_lock:
|
|
429
|
+
state.active_job_id = job_id
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _clear_active_job(state: ServerState, job_id: str) -> None:
|
|
433
|
+
with state.active_job_lock:
|
|
434
|
+
if state.active_job_id == job_id:
|
|
435
|
+
state.active_job_id = None
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _generate_video(state: ServerState, request: dict[str, Any]) -> dict[str, Any]:
|
|
439
|
+
max_json_bytes = math.ceil(state.max_output_bytes / 3) * 4 + 65_536
|
|
440
|
+
status, submission = _bounded_json_request(
|
|
441
|
+
f"{state.upstream_base_url}/sdcpp/v1/vid_gen",
|
|
442
|
+
method="POST",
|
|
443
|
+
timeout=state.request_timeout_seconds,
|
|
444
|
+
max_bytes=1_048_576,
|
|
445
|
+
payload=_native_request(request),
|
|
446
|
+
)
|
|
447
|
+
if status == 429:
|
|
448
|
+
raise ApiError(
|
|
449
|
+
429,
|
|
450
|
+
"the local video generator queue is full",
|
|
451
|
+
error_type="rate_limit_error",
|
|
452
|
+
code="upstream_queue_saturated",
|
|
453
|
+
)
|
|
454
|
+
if status != 202:
|
|
455
|
+
raise UpstreamError(f"stable-diffusion.cpp rejected video submission ({status})")
|
|
456
|
+
job_id = submission.get("id")
|
|
457
|
+
poll_url = submission.get("poll_url")
|
|
458
|
+
expected_poll_url = f"/sdcpp/v1/jobs/{job_id}" if isinstance(job_id, str) else None
|
|
459
|
+
if (
|
|
460
|
+
not isinstance(job_id, str)
|
|
461
|
+
or not job_id
|
|
462
|
+
or poll_url != expected_poll_url
|
|
463
|
+
or submission.get("kind") != "vid_gen"
|
|
464
|
+
):
|
|
465
|
+
if isinstance(job_id, str) and job_id:
|
|
466
|
+
_cancel_job(state, job_id)
|
|
467
|
+
raise UpstreamError("stable-diffusion.cpp returned an invalid video job")
|
|
468
|
+
|
|
469
|
+
_set_active_job(state, job_id)
|
|
470
|
+
try:
|
|
471
|
+
deadline = time.monotonic() + state.inference_timeout_seconds
|
|
472
|
+
while time.monotonic() < deadline and not state.shutdown_requested.is_set():
|
|
473
|
+
status, job = _bounded_json_request(
|
|
474
|
+
f"{state.upstream_base_url}{poll_url}",
|
|
475
|
+
method="GET",
|
|
476
|
+
timeout=state.request_timeout_seconds,
|
|
477
|
+
max_bytes=max_json_bytes,
|
|
478
|
+
)
|
|
479
|
+
if status != 200:
|
|
480
|
+
raise UpstreamError(f"stable-diffusion.cpp video poll failed ({status})")
|
|
481
|
+
job_status = job.get("status")
|
|
482
|
+
if job_status in ("queued", "generating"):
|
|
483
|
+
state.shutdown_requested.wait(state.poll_interval_seconds)
|
|
484
|
+
continue
|
|
485
|
+
if job_status == "failed":
|
|
486
|
+
error = job.get("error")
|
|
487
|
+
code = error.get("code") if isinstance(error, dict) else None
|
|
488
|
+
raise UpstreamError(
|
|
489
|
+
f"stable-diffusion.cpp video generation failed ({code or 'generation_failed'})"
|
|
490
|
+
)
|
|
491
|
+
if job_status == "cancelled":
|
|
492
|
+
raise UpstreamError("stable-diffusion.cpp video generation was cancelled")
|
|
493
|
+
if job_status != "completed" or job.get("kind") != "vid_gen":
|
|
494
|
+
raise UpstreamError("stable-diffusion.cpp returned an invalid video job status")
|
|
495
|
+
result = job.get("result")
|
|
496
|
+
if not isinstance(result, dict):
|
|
497
|
+
raise UpstreamError("stable-diffusion.cpp completed without a video result")
|
|
498
|
+
return result
|
|
499
|
+
if state.shutdown_requested.is_set():
|
|
500
|
+
raise ApiError(
|
|
501
|
+
503,
|
|
502
|
+
"video generation cancelled during service shutdown",
|
|
503
|
+
error_type="server_error",
|
|
504
|
+
code="service_shutting_down",
|
|
505
|
+
)
|
|
506
|
+
raise ApiError(
|
|
507
|
+
504,
|
|
508
|
+
"video generation timed out",
|
|
509
|
+
error_type="server_error",
|
|
510
|
+
code="generation_timeout",
|
|
511
|
+
)
|
|
512
|
+
except Exception:
|
|
513
|
+
_cancel_job(state, job_id)
|
|
514
|
+
raise
|
|
515
|
+
finally:
|
|
516
|
+
_clear_active_job(state, job_id)
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _validate_result(
|
|
520
|
+
result: dict[str, Any], request: dict[str, Any], state: ServerState
|
|
521
|
+
) -> dict[str, Any]:
|
|
522
|
+
response_format = result.get("output_format")
|
|
523
|
+
mime_type = result.get("mime_type")
|
|
524
|
+
encoded = result.get("b64_json")
|
|
525
|
+
fps = result.get("fps")
|
|
526
|
+
frame_count = result.get("frame_count")
|
|
527
|
+
if response_format != request["response_format"]:
|
|
528
|
+
raise UpstreamError("stable-diffusion.cpp returned the wrong video format")
|
|
529
|
+
if mime_type != MIME_BY_FORMAT.get(response_format):
|
|
530
|
+
raise UpstreamError("stable-diffusion.cpp returned the wrong video MIME type")
|
|
531
|
+
if not isinstance(encoded, str) or not encoded:
|
|
532
|
+
raise UpstreamError("stable-diffusion.cpp returned no encoded video")
|
|
533
|
+
try:
|
|
534
|
+
decoded = base64.b64decode(encoded, validate=True)
|
|
535
|
+
except (ValueError, binascii.Error) as error:
|
|
536
|
+
raise UpstreamError("stable-diffusion.cpp returned invalid video base64") from error
|
|
537
|
+
if not decoded or len(decoded) > state.max_output_bytes:
|
|
538
|
+
raise ApiError(
|
|
539
|
+
413,
|
|
540
|
+
"generated video exceeds the configured output limit",
|
|
541
|
+
error_type="server_error",
|
|
542
|
+
code="output_too_large",
|
|
543
|
+
)
|
|
544
|
+
if response_format == "webm" and not decoded.startswith(b"\x1a\x45\xdf\xa3"):
|
|
545
|
+
raise UpstreamError("stable-diffusion.cpp returned an invalid WebM container")
|
|
546
|
+
if response_format == "webp" and not (
|
|
547
|
+
decoded.startswith(b"RIFF") and decoded[8:12] == b"WEBP"
|
|
548
|
+
):
|
|
549
|
+
raise UpstreamError("stable-diffusion.cpp returned an invalid WebP container")
|
|
550
|
+
if response_format == "avi" and not (
|
|
551
|
+
decoded.startswith(b"RIFF") and decoded[8:12] == b"AVI "
|
|
552
|
+
):
|
|
553
|
+
raise UpstreamError("stable-diffusion.cpp returned an invalid AVI container")
|
|
554
|
+
if not _is_int(fps) or fps <= 0 or not _is_int(frame_count) or frame_count <= 0:
|
|
555
|
+
raise UpstreamError("stable-diffusion.cpp returned invalid video metadata")
|
|
556
|
+
if frame_count != request["effective_video_frames"] or fps != request["fps"]:
|
|
557
|
+
raise UpstreamError("stable-diffusion.cpp returned unexpected video dimensions")
|
|
558
|
+
return {
|
|
559
|
+
"b64_video": encoded,
|
|
560
|
+
"mime_type": mime_type,
|
|
561
|
+
"response_format": response_format,
|
|
562
|
+
"fps": fps,
|
|
563
|
+
"frame_count": frame_count,
|
|
564
|
+
"duration_seconds": frame_count / fps,
|
|
565
|
+
"seed": request["seed"],
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def _handler_for(state: ServerState) -> type[BaseHTTPRequestHandler]:
|
|
570
|
+
class Handler(BaseHTTPRequestHandler):
|
|
571
|
+
server_version = "wan22-openai/1"
|
|
572
|
+
|
|
573
|
+
def log_message(self, format_string: str, *args: Any) -> None:
|
|
574
|
+
del format_string, args
|
|
575
|
+
|
|
576
|
+
def _send_json(self, status: int, payload: dict[str, Any]) -> None:
|
|
577
|
+
encoded = json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
|
578
|
+
self.send_response(status)
|
|
579
|
+
self.send_header("content-type", "application/json")
|
|
580
|
+
self.send_header("content-length", str(len(encoded)))
|
|
581
|
+
self.send_header("cache-control", "no-store")
|
|
582
|
+
self.end_headers()
|
|
583
|
+
try:
|
|
584
|
+
self.wfile.write(encoded)
|
|
585
|
+
except BrokenPipeError:
|
|
586
|
+
pass
|
|
587
|
+
|
|
588
|
+
def _send_error(self, error: ApiError) -> None:
|
|
589
|
+
self._send_json(
|
|
590
|
+
error.status,
|
|
591
|
+
{
|
|
592
|
+
"error": {
|
|
593
|
+
"message": error.message,
|
|
594
|
+
"type": error.error_type,
|
|
595
|
+
"param": error.param,
|
|
596
|
+
"code": error.code,
|
|
597
|
+
}
|
|
598
|
+
},
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
def do_GET(self) -> None:
|
|
602
|
+
if self.path == "/healthz":
|
|
603
|
+
try:
|
|
604
|
+
_check_upstream_ready(state)
|
|
605
|
+
except UpstreamError:
|
|
606
|
+
self._send_json(
|
|
607
|
+
503,
|
|
608
|
+
{"status": "unhealthy", "model": state.model_id},
|
|
609
|
+
)
|
|
610
|
+
return
|
|
611
|
+
self._send_json(
|
|
612
|
+
200,
|
|
613
|
+
{
|
|
614
|
+
"status": "ok",
|
|
615
|
+
"model": state.model_id,
|
|
616
|
+
"upstream": "stable-diffusion.cpp",
|
|
617
|
+
},
|
|
618
|
+
)
|
|
619
|
+
return
|
|
620
|
+
if self.path == "/v1/models":
|
|
621
|
+
self._send_json(
|
|
622
|
+
200,
|
|
623
|
+
{
|
|
624
|
+
"object": "list",
|
|
625
|
+
"data": [
|
|
626
|
+
{
|
|
627
|
+
"id": state.model_id,
|
|
628
|
+
"object": "model",
|
|
629
|
+
"owned_by": "local",
|
|
630
|
+
}
|
|
631
|
+
],
|
|
632
|
+
},
|
|
633
|
+
)
|
|
634
|
+
return
|
|
635
|
+
self._send_error(ApiError(404, "not found", error_type="not_found_error"))
|
|
636
|
+
|
|
637
|
+
def do_POST(self) -> None:
|
|
638
|
+
if self.path != "/v1/videos/generations":
|
|
639
|
+
self._send_error(ApiError(404, "not found", error_type="not_found_error"))
|
|
640
|
+
return
|
|
641
|
+
try:
|
|
642
|
+
if self.headers.get("transfer-encoding"):
|
|
643
|
+
raise ApiError(
|
|
644
|
+
400,
|
|
645
|
+
"chunked request bodies are not supported",
|
|
646
|
+
code="unsupported_transfer_encoding",
|
|
647
|
+
)
|
|
648
|
+
content_type = self.headers.get("content-type", "")
|
|
649
|
+
if content_type.split(";", 1)[0].strip().lower() != "application/json":
|
|
650
|
+
raise ApiError(
|
|
651
|
+
400,
|
|
652
|
+
"content-type must be application/json",
|
|
653
|
+
code="invalid_content_type",
|
|
654
|
+
)
|
|
655
|
+
length_header = self.headers.get("content-length")
|
|
656
|
+
if length_header is None:
|
|
657
|
+
raise ApiError(411, "content-length is required", code="length_required")
|
|
658
|
+
try:
|
|
659
|
+
content_length = int(length_header)
|
|
660
|
+
except ValueError as error:
|
|
661
|
+
raise ApiError(
|
|
662
|
+
400,
|
|
663
|
+
"content-length is invalid",
|
|
664
|
+
code="invalid_content_length",
|
|
665
|
+
) from error
|
|
666
|
+
if content_length < 1:
|
|
667
|
+
raise ApiError(400, "request body is empty", code="invalid_json")
|
|
668
|
+
if content_length > state.max_request_bytes:
|
|
669
|
+
raise ApiError(
|
|
670
|
+
413,
|
|
671
|
+
"request body exceeds the configured limit",
|
|
672
|
+
code="request_too_large",
|
|
673
|
+
)
|
|
674
|
+
raw = self.rfile.read(content_length)
|
|
675
|
+
if len(raw) != content_length:
|
|
676
|
+
raise ApiError(400, "request body is incomplete", code="invalid_json")
|
|
677
|
+
try:
|
|
678
|
+
payload = json.loads(raw)
|
|
679
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
|
680
|
+
raise ApiError(400, "request body is invalid JSON", code="invalid_json") from error
|
|
681
|
+
parsed = _parse_generation_request(payload, state)
|
|
682
|
+
except ApiError as error:
|
|
683
|
+
self._send_error(error)
|
|
684
|
+
return
|
|
685
|
+
|
|
686
|
+
if not state.inference_slots.acquire(blocking=False):
|
|
687
|
+
self._send_error(
|
|
688
|
+
ApiError(
|
|
689
|
+
429,
|
|
690
|
+
"the local video generator is busy",
|
|
691
|
+
error_type="rate_limit_error",
|
|
692
|
+
code="local_concurrency_saturated",
|
|
693
|
+
)
|
|
694
|
+
)
|
|
695
|
+
return
|
|
696
|
+
started_at = time.monotonic()
|
|
697
|
+
try:
|
|
698
|
+
native_result = _generate_video(state, parsed)
|
|
699
|
+
output = _validate_result(native_result, parsed, state)
|
|
700
|
+
self._send_json(
|
|
701
|
+
200,
|
|
702
|
+
{
|
|
703
|
+
"created": int(time.time()),
|
|
704
|
+
"model": state.model_id,
|
|
705
|
+
"data": [output],
|
|
706
|
+
},
|
|
707
|
+
)
|
|
708
|
+
logging.info(
|
|
709
|
+
"generation completed frames=%d fps=%d elapsed_ms=%d",
|
|
710
|
+
output["frame_count"],
|
|
711
|
+
output["fps"],
|
|
712
|
+
int((time.monotonic() - started_at) * 1000),
|
|
713
|
+
)
|
|
714
|
+
except ApiError as error:
|
|
715
|
+
self._send_error(error)
|
|
716
|
+
except UpstreamError as error:
|
|
717
|
+
logging.error("generation failed reason=%s", str(error))
|
|
718
|
+
self._send_error(
|
|
719
|
+
ApiError(
|
|
720
|
+
502,
|
|
721
|
+
"video generation failed",
|
|
722
|
+
error_type="server_error",
|
|
723
|
+
code="upstream_generation_failed",
|
|
724
|
+
)
|
|
725
|
+
)
|
|
726
|
+
finally:
|
|
727
|
+
state.inference_slots.release()
|
|
728
|
+
|
|
729
|
+
return Handler
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def _parse_size(value: str) -> tuple[int, int]:
|
|
733
|
+
try:
|
|
734
|
+
width_text, height_text = value.lower().split("x", 1)
|
|
735
|
+
width, height = int(width_text), int(height_text)
|
|
736
|
+
except ValueError as error:
|
|
737
|
+
raise argparse.ArgumentTypeError("size must use WIDTHxHEIGHT") from error
|
|
738
|
+
if width <= 0 or height <= 0:
|
|
739
|
+
raise argparse.ArgumentTypeError("size dimensions must be positive")
|
|
740
|
+
return width, height
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def _parse_args() -> argparse.Namespace:
|
|
744
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
745
|
+
parser.add_argument("--host", default=DEFAULT_HOST)
|
|
746
|
+
parser.add_argument("--port", type=int, default=DEFAULT_PORT)
|
|
747
|
+
parser.add_argument("--upstream-base-url", default=DEFAULT_UPSTREAM_BASE_URL)
|
|
748
|
+
parser.add_argument("--model-id", default=DEFAULT_MODEL_ID)
|
|
749
|
+
parser.add_argument("--max-request-bytes", type=int, default=131_072)
|
|
750
|
+
parser.add_argument("--max-output-bytes", type=int, default=67_108_864)
|
|
751
|
+
parser.add_argument("--max-prompt-chars", type=int, default=8_192)
|
|
752
|
+
parser.add_argument("--max-negative-prompt-chars", type=int, default=8_192)
|
|
753
|
+
parser.add_argument("--allowed-size", type=_parse_size, action="append")
|
|
754
|
+
parser.add_argument("--min-video-frames", type=int, default=9)
|
|
755
|
+
parser.add_argument("--max-video-frames", type=int, default=81)
|
|
756
|
+
parser.add_argument("--default-video-frames", type=int, default=33)
|
|
757
|
+
parser.add_argument("--min-fps", type=int, default=8)
|
|
758
|
+
parser.add_argument("--max-fps", type=int, default=24)
|
|
759
|
+
parser.add_argument("--default-fps", type=int, default=16)
|
|
760
|
+
parser.add_argument("--default-steps", type=int, default=10)
|
|
761
|
+
parser.add_argument("--max-steps", type=int, default=30)
|
|
762
|
+
parser.add_argument("--default-high-noise-steps", type=int, default=8)
|
|
763
|
+
parser.add_argument("--max-high-noise-steps", type=int, default=30)
|
|
764
|
+
parser.add_argument("--response-format", choices=tuple(MIME_BY_FORMAT), action="append")
|
|
765
|
+
parser.add_argument("--poll-interval-seconds", type=float, default=1.0)
|
|
766
|
+
parser.add_argument("--request-timeout-seconds", type=float, default=10.0)
|
|
767
|
+
parser.add_argument("--inference-timeout-seconds", type=float, default=3_600.0)
|
|
768
|
+
return parser.parse_args()
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def main() -> int:
|
|
772
|
+
args = _parse_args()
|
|
773
|
+
try:
|
|
774
|
+
address = ipaddress.ip_address(args.host)
|
|
775
|
+
except ValueError:
|
|
776
|
+
if args.host != "localhost":
|
|
777
|
+
raise SystemExit("--host must be a loopback IP address or localhost")
|
|
778
|
+
else:
|
|
779
|
+
if not address.is_loopback:
|
|
780
|
+
raise SystemExit("--host must resolve to a loopback address")
|
|
781
|
+
if not 1 <= args.port <= 65_535 and args.port != 0:
|
|
782
|
+
raise SystemExit("--port must be 0 or between 1 and 65535")
|
|
783
|
+
upstream = urllib.parse.urlsplit(args.upstream_base_url)
|
|
784
|
+
if (
|
|
785
|
+
upstream.scheme != "http"
|
|
786
|
+
or upstream.hostname not in ("127.0.0.1", "::1", "localhost")
|
|
787
|
+
or upstream.username
|
|
788
|
+
or upstream.password
|
|
789
|
+
or upstream.query
|
|
790
|
+
or upstream.fragment
|
|
791
|
+
or upstream.path not in ("", "/")
|
|
792
|
+
):
|
|
793
|
+
raise SystemExit("--upstream-base-url must be a loopback HTTP origin")
|
|
794
|
+
allowed_sizes = set(args.allowed_size or [(832, 480)])
|
|
795
|
+
response_formats = set(args.response_format or ["webm"])
|
|
796
|
+
integer_limits = (
|
|
797
|
+
args.max_request_bytes,
|
|
798
|
+
args.max_output_bytes,
|
|
799
|
+
args.max_prompt_chars,
|
|
800
|
+
args.max_negative_prompt_chars,
|
|
801
|
+
args.min_video_frames,
|
|
802
|
+
args.max_video_frames,
|
|
803
|
+
args.default_video_frames,
|
|
804
|
+
args.min_fps,
|
|
805
|
+
args.max_fps,
|
|
806
|
+
args.default_fps,
|
|
807
|
+
args.default_steps,
|
|
808
|
+
args.max_steps,
|
|
809
|
+
args.default_high_noise_steps,
|
|
810
|
+
args.max_high_noise_steps,
|
|
811
|
+
)
|
|
812
|
+
if any(value <= 0 for value in integer_limits):
|
|
813
|
+
raise SystemExit("request and generation limits must be positive")
|
|
814
|
+
if not (
|
|
815
|
+
args.min_video_frames
|
|
816
|
+
<= _effective_frame_count(args.default_video_frames)
|
|
817
|
+
<= args.max_video_frames
|
|
818
|
+
):
|
|
819
|
+
raise SystemExit("default video frames are outside the configured range")
|
|
820
|
+
if not args.min_fps <= args.default_fps <= args.max_fps:
|
|
821
|
+
raise SystemExit("default FPS is outside the configured range")
|
|
822
|
+
if not 1 <= args.default_steps <= args.max_steps:
|
|
823
|
+
raise SystemExit("default steps are outside the configured range")
|
|
824
|
+
if not 1 <= args.default_high_noise_steps <= args.max_high_noise_steps:
|
|
825
|
+
raise SystemExit("default high-noise steps are outside the configured range")
|
|
826
|
+
for value, name in (
|
|
827
|
+
(args.poll_interval_seconds, "poll interval"),
|
|
828
|
+
(args.request_timeout_seconds, "request timeout"),
|
|
829
|
+
(args.inference_timeout_seconds, "inference timeout"),
|
|
830
|
+
):
|
|
831
|
+
if not math.isfinite(value) or value <= 0:
|
|
832
|
+
raise SystemExit(f"{name} must be positive")
|
|
833
|
+
if not args.model_id.strip():
|
|
834
|
+
raise SystemExit("--model-id must be non-empty")
|
|
835
|
+
|
|
836
|
+
state = ServerState(
|
|
837
|
+
upstream_base_url=args.upstream_base_url.rstrip("/"),
|
|
838
|
+
model_id=args.model_id,
|
|
839
|
+
max_request_bytes=args.max_request_bytes,
|
|
840
|
+
max_output_bytes=args.max_output_bytes,
|
|
841
|
+
max_prompt_chars=args.max_prompt_chars,
|
|
842
|
+
max_negative_prompt_chars=args.max_negative_prompt_chars,
|
|
843
|
+
allowed_sizes=allowed_sizes,
|
|
844
|
+
min_video_frames=args.min_video_frames,
|
|
845
|
+
max_video_frames=args.max_video_frames,
|
|
846
|
+
default_video_frames=args.default_video_frames,
|
|
847
|
+
min_fps=args.min_fps,
|
|
848
|
+
max_fps=args.max_fps,
|
|
849
|
+
default_fps=args.default_fps,
|
|
850
|
+
default_steps=args.default_steps,
|
|
851
|
+
max_steps=args.max_steps,
|
|
852
|
+
default_high_noise_steps=args.default_high_noise_steps,
|
|
853
|
+
max_high_noise_steps=args.max_high_noise_steps,
|
|
854
|
+
poll_interval_seconds=args.poll_interval_seconds,
|
|
855
|
+
inference_timeout_seconds=args.inference_timeout_seconds,
|
|
856
|
+
request_timeout_seconds=args.request_timeout_seconds,
|
|
857
|
+
response_formats=response_formats,
|
|
858
|
+
)
|
|
859
|
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
|
860
|
+
logging.info("checking stable-diffusion.cpp video capabilities")
|
|
861
|
+
_check_upstream_ready(state)
|
|
862
|
+
server = VideoHttpServer((args.host, args.port), _handler_for(state))
|
|
863
|
+
|
|
864
|
+
def request_shutdown(signum: int, frame: Any) -> None:
|
|
865
|
+
del signum, frame
|
|
866
|
+
state.shutdown_requested.set()
|
|
867
|
+
|
|
868
|
+
def stop() -> None:
|
|
869
|
+
with state.active_job_lock:
|
|
870
|
+
active_job_id = state.active_job_id
|
|
871
|
+
if active_job_id:
|
|
872
|
+
_cancel_job(state, active_job_id)
|
|
873
|
+
server.shutdown()
|
|
874
|
+
|
|
875
|
+
threading.Thread(target=stop, daemon=True).start()
|
|
876
|
+
|
|
877
|
+
signal.signal(signal.SIGTERM, request_shutdown)
|
|
878
|
+
signal.signal(signal.SIGINT, request_shutdown)
|
|
879
|
+
host, port = server.server_address[:2]
|
|
880
|
+
print(
|
|
881
|
+
json.dumps(
|
|
882
|
+
{
|
|
883
|
+
"event": "listening",
|
|
884
|
+
"host": host,
|
|
885
|
+
"port": port,
|
|
886
|
+
"model": state.model_id,
|
|
887
|
+
"upstream": state.upstream_base_url,
|
|
888
|
+
},
|
|
889
|
+
separators=(",", ":"),
|
|
890
|
+
),
|
|
891
|
+
flush=True,
|
|
892
|
+
)
|
|
893
|
+
try:
|
|
894
|
+
server.serve_forever(poll_interval=0.5)
|
|
895
|
+
finally:
|
|
896
|
+
server.server_close()
|
|
897
|
+
return 0
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
if __name__ == "__main__":
|
|
901
|
+
raise SystemExit(main())
|