h3-blackwell-runtime/tools/direct_t2v_preview.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

253 lines
12 KiB
Python

"""Generate a minimal direct, video-only H3 T2V preview without ComfyUI."""
import argparse
import os
from pathlib import Path
import subprocess
import time
from datetime import datetime, timezone
import warnings
import numpy as np
warnings.filterwarnings("ignore", message="Found GPU0 NVIDIA GB10 which is of cuda capability 12.1.*", category=UserWarning)
import torch
from h3_blackwell_runtime.checkpoint import H3Checkpoint
from h3_blackwell_runtime.audio_vae_decoder import MiniMaxH3AudioVAE
from h3_blackwell_runtime.attention import AVAILABLE_BACKENDS, DEFAULT_ATTENTION_BACKEND
from h3_blackwell_runtime.block import configure_mlp_chunking
from h3_blackwell_runtime.denoiser import H3PackedDenoiser
from h3_blackwell_runtime.packing import H3PromptPacker
from h3_blackwell_runtime.qwen3vl_text import Qwen3VLPromptConditioner
from h3_blackwell_runtime.sampler import sample_video_res_multistep
from h3_blackwell_runtime.t2v import random_av_latents
from h3_blackwell_runtime.qwen3vl_vision import build_fl2va_presentation, resize_keyframe
from h3_blackwell_runtime.token_refiner import H3TokenRefiner
from h3_blackwell_runtime.vae_decoder import MiniMaxH3VideoVAE, dtype_from_name
from h3_blackwell_runtime.vae_encoder import MiniMaxH3VideoVAEEncoder
parser = argparse.ArgumentParser()
parser.add_argument("--prompt", default="A brass-and-paper dragon flies above a rain-washed old city at blue hour.")
parser.add_argument("--output", type=Path, default=Path("/output/direct-h3-preview.mp4"))
parser.add_argument("--width", type=int, default=320)
parser.add_argument("--height", type=int, default=192)
parser.add_argument("--frames", type=int, default=22)
parser.add_argument("--steps", type=int, default=12)
parser.add_argument("--seed", type=int, default=440204)
parser.add_argument("--first-frame", type=Path, help="First keyframe image (fl2va), PNG/JPG path.")
parser.add_argument("--last-frame", type=Path, help="Last keyframe image (fl2va), PNG/JPG path.")
parser.add_argument("--attention", choices=AVAILABLE_BACKENDS, default=DEFAULT_ATTENTION_BACKEND)
parser.add_argument("--model-timesteps-capture", type=Path, help="Directory containing captured input_XX.pt H3 timesteps for strict parity checks.")
parser.add_argument("--progress", action="store_true", help="Print per-step sampler progress.")
parser.add_argument("--profile-memory", action="store_true")
parser.add_argument("--ffmpeg-loglevel", default="error", help="ffmpeg loglevel, e.g. error, warning, info.")
parser.add_argument("--quiet", action="store_true", help="Suppress JSON summary lines.")
parser.add_argument("--save-latent", type=Path)
parser.add_argument("--save-audio-latent", type=Path)
parser.add_argument("--audio-output", type=Path)
parser.add_argument("--mux-audio", action="store_true")
parser.add_argument("--skip-decode", action="store_true")
parser.add_argument("--vae-dtype", choices=("float32", "float16", "bfloat16"), default=os.getenv("H3_VAE_DTYPE", "float16"))
parser.add_argument("--vae-tile-size", type=int, default=int(os.getenv("H3_VAE_TILE_SIZE", "256")))
parser.add_argument("--mlp-chunks", type=int, default=int(os.getenv("H3_MLP_CHUNKS", "1")))
parser.add_argument("--mlp-chunk-threshold", type=int, default=int(os.getenv("H3_MLP_CHUNK_THRESHOLD", "4096")))
parser.add_argument("--cache-mode", choices=("disabled", "easycache", "h3_cache"), default="disabled")
parser.add_argument("--cache-threshold", type=float, default=0.0)
parser.add_argument("--cache-start-percent", type=float, default=0.0)
parser.add_argument("--cache-end-percent", type=float, default=1.0)
parser.add_argument("--cache-subsample-factor", type=int, default=2)
args = parser.parse_args()
started = time.perf_counter()
last_report = started
def report_memory(stage: str) -> None:
global last_report
if not args.profile_memory:
return
now = time.perf_counter()
rss_kb = 0
try:
with open("/proc/self/status", encoding="utf-8") as file:
for line in file:
if line.startswith("VmRSS:"):
rss_kb = int(line.split()[1])
break
except OSError:
pass
cuda_alloc = torch.cuda.memory_allocated() / 1024**3 if torch.cuda.is_available() else 0.0
cuda_reserved = torch.cuda.memory_reserved() / 1024**3 if torch.cuda.is_available() else 0.0
print({"stage": stage, "ts": datetime.now(timezone.utc).isoformat(), "epoch_s": round(time.time(), 3), "elapsed_s": round(now - started, 3), "delta_s": round(now - last_report, 3), "rss_gb": round(rss_kb / 1024**2, 3), "cuda_alloc_gb": round(cuda_alloc, 3), "cuda_reserved_gb": round(cuda_reserved, 3), "fast_safetensors": os.getenv("H3_FAST_SAFETENSORS", ""), "disable_mmap": os.getenv("H3_DISABLE_MMAP", "")}, flush=True)
last_report = now
def report(payload: dict) -> None:
if not args.quiet:
print(payload, flush=True)
def ffmpeg_command(*parts: str) -> list[str]:
return ["ffmpeg", "-hide_banner", "-loglevel", args.ffmpeg_loglevel, *parts]
report_memory("start")
checkpoint = H3Checkpoint("/models/minimax_h3_fl2va_pruned_nvfp4.safetensors")
conditioner = Qwen3VLPromptConditioner(
"/text-encoders/qwen3vl_32b_minimax_h3_nvfp4_awq.safetensors",
"/opt/h3-blackwell-runtime/src/h3_blackwell_runtime/qwen25_tokenizer",
)
report_memory("qwen_loaded")
video, audio, frames = random_av_latents(args.width, args.height, args.frames, args.seed)
model = H3PackedDenoiser.from_checkpoint(checkpoint, attention_backend=args.attention).eval()
configure_mlp_chunking(model, args.mlp_chunks, args.mlp_chunk_threshold)
report_memory("h3_loaded")
model_timesteps = None
if args.model_timesteps_capture is not None:
model_timesteps = [
torch.load(args.model_timesteps_capture / f"input_{index:02d}.pt", map_location="cuda", weights_only=False)["timesteps"]
for index in range(args.steps)
]
want_audio = args.save_audio_latent is not None or args.audio_output is not None or args.mux_audio
cache_stats = {}
refiner = H3TokenRefiner(checkpoint, attention_backend=args.attention)
packer = H3PromptPacker(checkpoint)
if args.first_frame is not None or args.last_frame is not None:
from PIL import Image
def load_image(path: Path) -> torch.Tensor:
img = Image.open(path).convert("RGB")
return torch.from_numpy(np.array(img)).permute(2, 0, 1).unsqueeze(0).float() / 255.0
first = load_image(args.first_frame) if args.first_frame is not None else None
last = load_image(args.last_frame) if args.last_frame is not None else None
vae_encoder = MiniMaxH3VideoVAEEncoder.from_safetensors("/vae/minimax_h3_video_vae_fp16.safetensors", device="cuda").eval()
report_memory("vae_encoder_loaded")
from h3_blackwell_runtime.qwen3vl_vision import Qwen3VL32BVision
vision_tower = Qwen3VL32BVision(
"/text-encoders/qwen3vl_32b_minimax_h3_nvfp4_awq.safetensors", device="cuda", dtype=torch.bfloat16
)
report_memory("vision_tower_loaded")
presentation = build_fl2va_presentation(
args.prompt,
first,
last,
width=args.width,
height=args.height,
frame_count=frames,
tokenizer=conditioner.tokenizer,
vision=vision_tower,
text_encoder=conditioner.encoder,
device="cuda",
)
cond_latents = []
for kf in presentation.keyframes:
resized = resize_keyframe(kf["image"].cuda(), args.width, args.height, crop="disabled" if kf["resolved_frame_index"] == 0 else "center")
cond_latents.append(vae_encoder.encode(resized.movedim(-1, 1).cuda().float()))
report_memory("fl2va_conditioned")
text = refiner(presentation.text_states)
report_memory("text_conditioned")
seed = args.seed
sampled = sample_video_res_multistep(
model,
packer,
text,
video,
audio,
steps=args.steps,
model_timesteps=model_timesteps,
return_audio=want_audio,
progress=args.progress,
seed=seed,
text_token_tags=presentation.text_token_tags,
cond_latents=cond_latents,
frame_count=frames,
cache_mode=args.cache_mode,
cache_threshold=args.cache_threshold,
cache_start_percent=args.cache_start_percent,
cache_end_percent=args.cache_end_percent,
cache_subsample_factor=args.cache_subsample_factor,
cache_stats=cache_stats,
)
else:
text = refiner(conditioner(args.prompt))
report_memory("text_conditioned")
sampled = sample_video_res_multistep(
model,
packer,
text,
video,
audio,
steps=args.steps,
model_timesteps=model_timesteps,
return_audio=want_audio,
progress=args.progress,
seed=args.seed,
cache_mode=args.cache_mode,
cache_threshold=args.cache_threshold,
cache_start_percent=args.cache_start_percent,
cache_end_percent=args.cache_end_percent,
cache_subsample_factor=args.cache_subsample_factor,
cache_stats=cache_stats,
)
if cache_stats:
report({"cache": cache_stats})
if want_audio:
latent, audio_latent = sampled
else:
latent, audio_latent = sampled, None
report_memory("sampled")
if args.save_latent is not None:
args.save_latent.parent.mkdir(parents=True, exist_ok=True)
state = {"latent": latent.detach().cpu(), "frames": frames, "width": args.width, "height": args.height, "prompt": args.prompt, "seed": args.seed}
if audio_latent is not None:
state["audio_latent"] = audio_latent.detach().cpu()
torch.save(state, args.save_latent)
report({"latent": str(args.save_latent)})
if args.save_audio_latent is not None:
if audio_latent is None:
raise RuntimeError("audio latent was not sampled")
args.save_audio_latent.parent.mkdir(parents=True, exist_ok=True)
torch.save({"audio_latent": audio_latent.detach().cpu(), "frames": frames, "prompt": args.prompt, "seed": args.seed}, args.save_audio_latent)
report({"audio_latent": str(args.save_audio_latent)})
if args.skip_decode:
raise SystemExit(0)
vae = MiniMaxH3VideoVAE.from_safetensors("/vae/minimax_h3_video_vae_fp16.safetensors", device="cuda", dtype=dtype_from_name(args.vae_dtype)).eval()
vae.tile_size = args.vae_tile_size
report_memory("vae_loaded")
with torch.inference_mode():
pixels = vae.decode(latent.to(next(vae.parameters()).dtype))[:, :, :frames]
report_memory("vae_decoded")
del vae
torch.cuda.empty_cache()
pixels = ((pixels[0].permute(1, 2, 3, 0).clamp(-1, 1) + 1) * 127.5).to(torch.uint8).cpu()
report_memory("pixels_cpu")
args.output.parent.mkdir(parents=True, exist_ok=True)
raw = args.output.with_suffix(".rgb")
video_output = args.output.with_name(args.output.stem + ".video.mp4") if args.mux_audio else args.output
pixels.numpy().tofile(raw)
subprocess.run(ffmpeg_command("-y", "-f", "rawvideo", "-pixel_format", "rgb24", "-video_size", f"{pixels.shape[2]}x{pixels.shape[1]}", "-framerate", "24", "-i", str(raw), "-an", "-c:v", "libx264", "-pix_fmt", "yuv420p", str(video_output)), check=True)
raw.unlink()
audio_path = args.audio_output
if args.mux_audio and audio_path is None:
audio_path = args.output.with_suffix(".wav")
if audio_path is not None:
if audio_latent is None:
raise RuntimeError("audio latent was not sampled")
audio_vae = MiniMaxH3AudioVAE.from_safetensors("/vae/minimax_h3_audio_vae_fp32.safetensors", device="cuda").eval()
report_memory("audio_vae_loaded")
with torch.inference_mode():
waveform = audio_vae.decode(audio_latent.to(next(audio_vae.parameters()).dtype)).clamp(-1, 1).cpu()[0]
report_memory("audio_decoded")
audio_path.parent.mkdir(parents=True, exist_ok=True)
audio_raw = audio_path.with_suffix(".f32le")
waveform.transpose(0, 1).contiguous().numpy().tofile(audio_raw)
subprocess.run(ffmpeg_command("-y", "-f", "f32le", "-ar", "32000", "-ac", "2", "-i", str(audio_raw), str(audio_path)), check=True)
audio_raw.unlink()
report({"audio_output": str(audio_path), "sample_rate": 32000, "audio_shape": tuple(waveform.shape)})
if args.mux_audio:
subprocess.run(ffmpeg_command("-y", "-i", str(video_output), "-i", str(audio_path), "-c:v", "copy", "-c:a", "aac", "-shortest", str(args.output)), check=True)
video_output.unlink()
report({"output": str(args.output), "frames": frames, "shape": tuple(pixels.shape)})