@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.
@@ -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())