h3-blackwell-runtime/tools/serve_hot_runtime.py
Daniel Maddern 807bd64a82 Add direct first/last-frame (fl2va) keyframe conditioning
Wire full fl2va into the direct H3 runtime so first/last keyframes flow
through VAE encode -> Qwen vision tokens -> DiT cond segments:

- vae_encoder.py: direct encoder-only H3 video VAE (causal 3D convs,
  reflect spatial padding, causal temporal padding, single-frame tap
  truncation, tiling, FP32 moments + mean/std normalization).
- qwen3vl_vision.py: Qwen3-VL-32B visual tower (27 blocks, 2D rotary,
  deepstack mergers) ported to match the Comfy reference exactly
  (head_dim=72, no-bias proj, LayerNorm blocks, split-half apply_rope),
  plus Qwen image preprocess, mrope ids/freqs, DiT token tags, keyframe
  resize (first=stretch / last=center cover-crop matching Comfy
  common_upscale), and build_fl2va_presentation.
- qwen3vl_text.py: split-half apply_rope, _embed_rows/_run_layers,
  optional mrope position_ids + DeepStack injection at the first three
  decoder layers at visual positions.
- packing.py: H3PromptPacker builds [text | cond | audio | video] with
  tag-run text spans, cond rows (first/last cond_t anchors,
  VISUAL_COND_TIMESTEP=0.999 noise augmentation via CPU-seeded RNG),
  three-timestep row table (t_row*3 + modality_tag), and rope positions.
- runtime.py: load VAE encoder + vision tower; generate() accepts
  first_frame/last_frame, builds the fl2va presentation, encodes keyframes,
  and passes text_token_tags/cond_latents/frame_count/seed to the sampler.
- sampler.py: thread pack kwargs + seed.
- serve_hot_runtime.py / direct_t2v_preview.py: /generate and
  --first-frame/--last-frame accept image paths or base64.
2026-08-19 20:17:41 +07:00

166 lines
6.9 KiB
Python

"""Serve a resident prompt-only H3 runtime over a small JSON HTTP API."""
from __future__ import annotations
import argparse
import json
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlparse
import torch
from PIL import Image
from h3_blackwell_runtime.attention import AVAILABLE_BACKENDS, DEFAULT_ATTENTION_BACKEND
from h3_blackwell_runtime.runtime import H3HotRuntime, RuntimeConfig
def _load_image(value) -> torch.Tensor | None:
"""Accept a keyframe as on-disk path or base64 JPEG/PNG -> ``[1,3,H,W]`` float ``[0,1]``."""
if value in (None, ""):
return None
if isinstance(value, (list, tuple)):
value = value[0]
if isinstance(value, dict):
value = value.get("url") or value.get("path") or value.get("b64")
if isinstance(value, str) and "\n" not in value and len(value) < 2048 and not value.startswith("data:"):
path = Path(value)
if path.exists():
image = Image.open(path).convert("RGB")
import numpy as np
tensor = torch.from_numpy(np.array(image))[None].permute(0, 3, 1, 2).float() / 255.0
return tensor
data = value
if isinstance(value, str) and value.startswith("data:"):
data = value.split(",", 1)[1]
if isinstance(data, str) and len(data) >= 1024:
import base64
import io
raw = base64.b64decode(data)
image = Image.open(io.BytesIO(raw)).convert("RGB")
import numpy as np
return torch.from_numpy(np.array(image))[None].permute(0, 3, 1, 2).float() / 255.0
raise ValueError("first_frame/last_frame must be a path or a base64/data-URL image")
parser = argparse.ArgumentParser()
parser.add_argument("--host", default="0.0.0.0")
parser.add_argument("--port", type=int, default=8000)
parser.add_argument("--attention", choices=AVAILABLE_BACKENDS, default=DEFAULT_ATTENTION_BACKEND, help="Initial attention backend. Requests can switch with the JSON attention field.")
parser.add_argument("--vae-dtype", choices=("float32", "float16", "bfloat16"), default="float16")
parser.add_argument("--vae-tile-size", type=int, default=256)
parser.add_argument("--mlp-chunks", type=int, default=1)
parser.add_argument("--mlp-chunk-threshold", type=int, default=4096)
parser.add_argument("--warmup", action="store_true", help="Run a tiny generation before accepting traffic.")
parser.add_argument("--warmup-output", type=Path, default=Path("/output/h3-blackwell-runtime/hot-runtime-warmup.mp4"))
args = parser.parse_args()
runtime = H3HotRuntime(RuntimeConfig(attention=args.attention, vae_dtype=args.vae_dtype, vae_tile_size=args.vae_tile_size, mlp_chunks=args.mlp_chunks, mlp_chunk_threshold=args.mlp_chunk_threshold))
runtime_lock = threading.Lock()
warmup_result = None
if args.warmup:
warmup_result = runtime.generate(
prompt="A small warmup cat blinks in soft light.",
output=args.warmup_output,
width=320,
height=192,
frames=22,
steps=2,
seed=440501,
mux_audio=True,
)
def service_status() -> dict:
return {
"ready": True,
"attention_backends": list(AVAILABLE_BACKENDS),
"runtime": runtime.status(),
}
def write_json(handler: BaseHTTPRequestHandler, status: int, payload: dict) -> None:
body = json.dumps(payload, indent=2).encode("utf-8")
handler.send_response(status)
handler.send_header("Content-Type", "application/json")
handler.send_header("Content-Length", str(len(body)))
handler.end_headers()
handler.wfile.write(body)
class Handler(BaseHTTPRequestHandler):
def log_message(self, format: str, *args) -> None:
return
def do_GET(self) -> None:
path = urlparse(self.path).path
if path in {"/health", "/ready"}:
write_json(self, 200, {**service_status(), "warmup_result": warmup_result})
return
write_json(self, 404, {"error": "not found"})
def do_POST(self) -> None:
path = urlparse(self.path).path
if path != "/generate":
write_json(self, 404, {"error": "not found"})
return
try:
length = int(self.headers.get("Content-Length", "0"))
payload = json.loads(self.rfile.read(length).decode("utf-8")) if length else {}
prompt = payload["prompt"]
output = payload["output"]
width = int(payload.get("width", 960))
height = int(payload.get("height", 544))
frames = int(payload.get("frames", 124))
steps = int(payload.get("steps", 12))
seed = int(payload.get("seed", 440407))
attention = payload.get("attention")
if attention is not None and attention not in AVAILABLE_BACKENDS:
write_json(self, 400, {"error": "unsupported attention", "attention": attention, "available": list(AVAILABLE_BACKENDS)})
return
mux_audio = bool(payload.get("mux_audio", True))
ffmpeg_loglevel = payload.get("ffmpeg_loglevel", "error")
first_frame = _load_image(payload.get("first_frame"))
last_frame = _load_image(payload.get("last_frame"))
save_latent = payload.get("save_latent")
cache_mode = payload.get("cache_mode")
cache_threshold = float(payload.get("cache_threshold", 0.0))
cache_start_percent = float(payload.get("cache_start_percent", 0.0))
cache_end_percent = float(payload.get("cache_end_percent", 1.0))
cache_subsample_factor = int(payload.get("cache_subsample_factor", 2))
started = time.perf_counter()
with runtime_lock:
result = runtime.generate(
prompt=prompt,
output=output,
width=width,
height=height,
frames=frames,
steps=steps,
seed=seed,
attention=attention,
first_frame=first_frame,
last_frame=last_frame,
mux_audio=mux_audio,
ffmpeg_loglevel=ffmpeg_loglevel,
save_latent=save_latent,
cache_mode=cache_mode,
cache_threshold=cache_threshold,
cache_start_percent=cache_start_percent,
cache_end_percent=cache_end_percent,
cache_subsample_factor=cache_subsample_factor,
)
result["wall_seconds"] = time.perf_counter() - started
write_json(self, 200, result)
except Exception as exc:
write_json(self, 500, {"error": type(exc).__name__, "message": str(exc)})
server = ThreadingHTTPServer((args.host, args.port), Handler)
print(json.dumps({"serving": True, "host": args.host, "port": args.port, **service_status(), "warmup_result": warmup_result}, indent=2), flush=True)
server.serve_forever()