diff --git a/README.md b/README.md index fd86de63279ecdd391d7ff9f48da75296a87589a..378184b1faf260afdf6a27748a732018c584001e 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,13 @@ --- title: Streaming Code World Model -emoji: 🐨 -colorFrom: yellow -colorTo: indigo +emoji: 🌍 +colorFrom: blue +colorTo: red sdk: gradio -sdk_version: 6.30.0 -python_version: '3.12' +sdk_version: 5.50.0 app_file: app.py -pinned: false +short_description: Causal chunk-by-chunk world-model video streaming +python_version: "3.12" +startup_duration_timeout: 1h --- - -Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference +(prep stage) diff --git a/app.py b/app.py new file mode 100644 index 0000000000000000000000000000000000000000..664b7fe1dd71db6bb5bb6fb180f486d494289b6d --- /dev/null +++ b/app.py @@ -0,0 +1,124 @@ +"""TEMPORARY prep stage: encode the conditioning caches of a few released CWM examples, exactly as +`python -m rtcwm.eval.open_world prepare` does (Qwen3-VL-32B states of the static caption, Picture 1 latent, +proxy (Depth + Semantic-ID) latents, silent audio latent). Replaced by the real demo afterwards.""" +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import spaces +import io +import json +import shutil +import tempfile +import zipfile +from pathlib import Path + +import numpy as np +import torch +import gradio as gr +from huggingface_hub import hf_hub_download, snapshot_download +from PIL import Image +from safetensors.torch import save_file + +H3_REPO, H3_REV = "Comfy-Org/MiniMax-H3", "0543966fbdce5ba05709a8f2031c94bdba629b4a" +QWEN_REPO, QWEN_REV = "Qwen/Qwen3-VL-32B-Instruct", "0cfaf48183f594c314753d30a4c4974bc75f3ccb" +EX_REPO, EX_REV = "NTU-yiwen/code-world-model-inference-examples-40", "f8307bf50cb19bed191682734fdf444738160f91" +EXAMPLES = [3, 6, 8, 29, 31, 36] +CHUNKS = 24 +CLIP_FRAMES = 17 + +models = Path(snapshot_download(H3_REPO, revision=H3_REV, allow_patterns=[ + "text_encoders/qwen3vl_32b_minimax_h3_bf16.safetensors", "vae/minimax_h3_video_vae_fp16.safetensors", + "vae/minimax_h3_audio_vae_fp32.safetensors"])) +proc_dir = Path(snapshot_download(QWEN_REPO, revision=QWEN_REV, allow_patterns=[ + "chat_template.json", "config.json", "generation_config.json", "merges.txt", "preprocessor_config.json", + "tokenizer_config.json", "tokenizer.json", "video_preprocessor_config.json", "vocab.json"])) +src = Path(snapshot_download(EX_REPO, repo_type="dataset", revision=EX_REV, allow_patterns=[ + "manifest.json", "semantic_id_palette.json"] + [f"{i}/*" for i in EXAMPLES] + [f"{i}/**" for i in EXAMPLES])) + +import materialize_hf_examples as mat + +REL = Path("/tmp/release") +for i in EXAMPLES: + mat.materialize_example(src, REL, i, require_config=False, frame_limit=(CHUNKS + 1) * CLIP_FRAMES) +print("materialized", flush=True) + +from musubi_tuner.minimax_h3.audio_vae import load_audio_vae +from musubi_tuner.minimax_h3.text_encoder import load_h3_processor, load_h3_text_encoder +from musubi_tuner.minimax_h3.video_vae import encode_video_condition, load_video_vae + +vae = load_video_vae(models / "vae/minimax_h3_video_vae_fp16.safetensors", device="cpu", dtype=torch.float16).eval().requires_grad_(False) +vae.to("cuda") +avae = load_audio_vae(models / "vae/minimax_h3_audio_vae_fp32.safetensors", device="cpu", dtype=torch.float32).eval() +avae.to("cuda") +proc = load_h3_processor(str(proc_dir)) +qwen = load_h3_text_encoder(str(models / "text_encoders/qwen3vl_32b_minimax_h3_bf16.safetensors"), + processor_path=str(proc_dir), device="cpu", dtype=torch.bfloat16).eval() +qwen.to("cuda") +print("models loaded", flush=True) + + +def _prepare_pixels(frames): # musubi_tuner.minimax_h3_generate_video._prepare_pixels + if frames.dtype == torch.uint8: + frames = frames.float().div(127.5).sub(1.0) + else: + frames = frames.float().mul(2.0).sub(1.0) + return frames.permute(3, 0, 1, 2).unsqueeze(0).contiguous() + + +@spaces.GPU(duration=600, size="xlarge") +def prep() -> str: + """Encode the example caches and return a zip.""" + from cwm_h3_inference.cache import _load_anchor + from cwm_h3_inference.config import _canonical_caption + from cwm_h3_inference.constants import TARGET_HEIGHT, TARGET_WIDTH, WINDOW_AUDIO_SAMPLES + from cwm_h3_inference.duv import load_duv_frame + from rtcwm.eval.open_world import static_caption + from rtcwm.train.preencode import static_text_states + + dev = torch.device("cuda") + out = Path(tempfile.mkdtemp()) + with torch.inference_mode(): + a = avae.encode(torch.zeros(1, 2, WINDOW_AUDIO_SAMPLES, device=dev)).float().cpu()[0] + save_file({"audio": a.contiguous()}, str(out / "silence_audio.safetensors")) + log = [] + for i in EXAMPLES: + ex = REL / "examples" / f"example_{i:02d}" + cond = ex / "conditions" + prompt = (src / str(i) / "prompts" / "000.txt").read_text() + k = min(CHUNKS, len(list(cond.glob("*.semantic_id.png"))) // CLIP_FRAMES) + duv = np.stack([load_duv_frame(cond, f, for_qwen=False) for f in range(k * CLIP_FRAMES)]) + duv = np.concatenate([duv, np.repeat(duv[-1:], CLIP_FRAMES, 0)], 0) + with torch.inference_mode(): + ref = encode_video_condition(vae, _prepare_pixels(torch.from_numpy(duv)).to(dev, torch.float16))[0] + ref = ref.float().cpu()[:, :5 * k].contiguous() + picture1 = _load_anchor(ex / "first_frame.png", size=(TARGET_WIDTH, TARGET_HEIGHT)) + png = out / f"ex{i:02d}_anchor.png" + Image.fromarray(picture1).save(png) + with torch.inference_mode(): + lat = encode_video_condition(vae, _prepare_pixels(torch.from_numpy(picture1).unsqueeze(0)).to(dev, torch.float16)) + caption = _canonical_caption(static_caption(prompt), f"ow_example_{i:02d}/static_c00") + hidden, tags = static_text_states(proc, qwen, picture1, png, cond, 0, caption) + sem = np.stack([np.asarray(Image.open(cond / f"{f:06d}.semantic_id.png")) for f in range(k * CLIP_FRAMES)]) + np.savez_compressed(out / f"ex{i:02d}_semantic.npz", semantic_id=sem.astype(np.uint8)) + save_file({"duv": ref, "anchor": lat[0].float().cpu().contiguous(), + "hidden": hidden.to("cpu", torch.bfloat16).contiguous(), "tags": tags.to("cpu", torch.int64).contiguous()}, + str(out / f"ex{i:02d}.safetensors"), metadata={"chunks": str(k), "caption": caption}) + log.append(dict(id=i, chunks=k, duv=list(ref.shape), anchor=list(lat.shape), hidden=list(hidden.shape))) + print(log[-1], flush=True) + (out / "log.json").write_text(json.dumps(log)) + z = tempfile.NamedTemporaryFile(suffix=".zip", delete=False).name + with zipfile.ZipFile(z, "w", zipfile.ZIP_STORED) as zf: + for p in out.iterdir(): + zf.write(p, p.name) + return z + + +with gr.Blocks() as demo: + gr.Markdown("Preparing example caches (temporary).") + b = gr.Button("prep") + f = gr.File() + b.click(prep, None, f, api_name="prep") + +demo.launch() diff --git a/cwm_h3_inference/__init__.py b/cwm_h3_inference/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..f9b72494ad986cc8510cb4135f6ae9caa6e9e306 --- /dev/null +++ b/cwm_h3_inference/__init__.py @@ -0,0 +1,6 @@ +"""MiniMax H3 inference.""" + +from .config import RunConfig, load_config + +__all__ = ["RunConfig", "load_config"] +__version__ = "0.1.0" diff --git a/cwm_h3_inference/__main__.py b/cwm_h3_inference/__main__.py new file mode 100644 index 0000000000000000000000000000000000000000..eb53e2f31b2f703ad32ef64b8a41faa3e7d18e08 --- /dev/null +++ b/cwm_h3_inference/__main__.py @@ -0,0 +1,3 @@ +from .cli import main + +raise SystemExit(main()) diff --git a/cwm_h3_inference/cache.py b/cwm_h3_inference/cache.py new file mode 100644 index 0000000000000000000000000000000000000000..b8b6fd6dcd7d4d3c563866fb704b2ce4823c21b0 --- /dev/null +++ b/cwm_h3_inference/cache.py @@ -0,0 +1,321 @@ +"""Prepare the DUV, RGB-anchor, fixed-first-frame, and Qwen caches.""" + +from __future__ import annotations + +import gc +import hashlib +from io import BytesIO +import json +from pathlib import Path +from typing import Any + +from .config import RunConfig +from .constants import ( + ANCHOR_SHORT_EDGE, + CACHE_SCHEMA, + CANVAS_MULTIPLE, + CONDITION_HEIGHT, + CONDITION_WIDTH, + FPS, + QWEN_OFFSETS, + REFERENCE_LATENT_SHAPE, + SINGLE_FRAME_LATENT_SHAPE, + TARGET_HEIGHT, + TARGET_WIDTH, + VIDEO_LATENT_SHAPE, +) +from .duv import load_duv_window +from .io import atomic_json, atomic_safetensors, load_json +from .presentation import _system_prompt, build_ref2va_presentation + + +def official_short_edge_size(width: int, height: int) -> tuple[int, int]: + if min(width, height) <= 0 or width > 4 * height or height > 4 * width: + raise ValueError("anchor aspect ratio must be between 1:4 and 4:1") + scale = ANCHOR_SHORT_EDGE / min(width, height) + + def round_axis(value: float) -> int: + return max(CANVAS_MULTIPLE, round(value / CANVAS_MULTIPLE) * CANVAS_MULTIPLE) + + return round_axis(width * scale), round_axis(height * scale) + + +def _load_anchor(path: Path, *, size: tuple[int, int] | None = None): + import numpy as np + from PIL import Image + + payload = path.read_bytes() + with Image.open(BytesIO(payload)) as image: + image = image.convert("RGB") + target = official_short_edge_size(*image.size) if size is None else size + pixels = np.asarray(image.resize(target, Image.Resampling.LANCZOS), dtype=np.uint8).copy() + return pixels + + +def cache_paths(config: RunConfig, window_index: int) -> dict[str, Path]: + root = config.cache_dir + window = root / f"window_{window_index:03d}" + return { + "anchor": root / "anchor_reference.safetensors", + "input_video": root / "input_video.safetensors", + "reference": window / "duv_reference.safetensors", + "qwen": window / "qwen.safetensors", + "manifest": root / "manifest.json", + } + + +def _cache_contract(config: RunConfig) -> dict[str, Any]: + return { + "video_vae": str(config.models.video_vae), + "text_encoder": str(config.models.text_encoder), + "processor": str(config.models.processor), + "anchor_image": str(config.anchor_image), + "anchor_bytes": config.anchor_image.stat().st_size, + "condition_root": str(config.condition_root), + "system_prompt_w0": _system_prompt(0), + "system_prompt_wn": _system_prompt(1), + "windows": [ + {"prompt": window.prompt, "start_frame": window.start_frame} + for window in config.windows + ], + } + + +def _contract_id(value: dict[str, Any]) -> str: + payload = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode() + return hashlib.sha256(payload).hexdigest() + + +def _begin_manifest(config: RunConfig) -> dict[str, Any]: + path = cache_paths(config, 0)["manifest"] + contract = _cache_contract(config) + contract_id = _contract_id(contract) + if path.exists(): + existing = load_json(path) + if existing.get("schema") != CACHE_SCHEMA or existing.get("contract_id") != contract_id: + raise ValueError(f"cache directory belongs to another run: {config.cache_dir}") + return existing + if config.cache_dir.exists() and any(config.cache_dir.iterdir()): + raise ValueError(f"non-empty cache directory has no manifest: {config.cache_dir}") + manifest = { + "schema": CACHE_SCHEMA, + "status": "preparing", + "contract_id": contract_id, + "contract": contract, + "window_count": len(config.windows), + "output_frames": config.output_frames, + "files": [], + } + atomic_json(path, manifest) + return manifest + + +def _require_one_gpu(torch: Any) -> Any: + if not torch.cuda.is_available() or torch.cuda.device_count() != 1: + raise RuntimeError("cache preparation requires exactly one visible CUDA GPU") + torch.cuda.set_device(0) + return torch.device("cuda:0") + + +def _prepare_visual_caches(config: RunConfig) -> None: + import torch + from musubi_tuner.minimax_h3.video_vae import encode_video_condition, load_video_vae + from musubi_tuner.minimax_h3_generate_video import _prepare_pixels + + device = _require_one_gpu(torch) + vae = load_video_vae(config.models.video_vae, device=device, dtype=torch.float16, disable_mmap=False) + vae.eval().requires_grad_(False) + common = {"schema": CACHE_SCHEMA} + first_paths = cache_paths(config, 0) + + if not first_paths["anchor"].exists(): + anchor_pixels = _load_anchor(config.anchor_image) + frames = torch.from_numpy(anchor_pixels).unsqueeze(0) + with torch.inference_mode(): + latent = encode_video_condition(vae, _prepare_pixels(frames).to(device, torch.float16))[0].cpu() + if latent.ndim != 4 or tuple(latent.shape[:2]) != (24, 1): + raise RuntimeError(f"unexpected anchor latent shape: {tuple(latent.shape)}") + atomic_safetensors( + first_paths["anchor"], + {"latents_ref_000_image": latent.float().contiguous()}, + {**common, "kind": "anchor", "source": config.anchor_image.name}, + ) + del anchor_pixels, frames, latent + + if not first_paths["input_video"].exists(): + pixels = _load_anchor(config.anchor_image, size=(TARGET_WIDTH, TARGET_HEIGHT)) + frames = torch.from_numpy(pixels).unsqueeze(0) + prepared = _prepare_pixels(frames).to(device, torch.float16) + with torch.inference_mode(): + single = vae.encode(prepared).float().cpu() + if tuple(single.shape) == (1, *SINGLE_FRAME_LATENT_SHAPE): + single = single[0] + if tuple(single.shape) != SINGLE_FRAME_LATENT_SHAPE: + raise RuntimeError(f"unexpected first-frame latent shape: {tuple(single.shape)}") + full = torch.zeros(VIDEO_LATENT_SHAPE[1:], dtype=torch.float32) + full[:, 0:1] = single + atomic_safetensors( + first_paths["input_video"], + {"input_video_latents": full.contiguous()}, + { + **common, + "kind": "fixed_first_frame", + "fixed_video_latent_indices": "0", + "denoise_mask_semantics": "0_fixed_1_regenerate", + }, + ) + del pixels, frames, prepared, single, full + + for index, window in enumerate(config.windows): + path = cache_paths(config, index)["reference"] + if path.exists(): + continue + visual = load_duv_window(config.condition_root, window.start_frame, for_qwen=False) + pixels = _prepare_pixels(torch.from_numpy(visual.copy())).to(device, torch.float16) + with torch.inference_mode(): + latent = encode_video_condition(vae, pixels)[0].cpu() + if tuple(latent.shape) != REFERENCE_LATENT_SHAPE: + raise RuntimeError(f"unexpected DUV reference shape for window {index}: {tuple(latent.shape)}") + atomic_safetensors( + path, + {"latents_ref_001_video": latent.float().contiguous()}, + {**common, "kind": "duv_reference", "window_index": str(index)}, + ) + del visual, pixels, latent + gc.collect() + torch.cuda.empty_cache() + + del vae + gc.collect() + torch.cuda.empty_cache() + + +def _prepare_qwen_caches(config: RunConfig) -> None: + import numpy as np + import torch + from musubi_tuner.minimax_h3.media import H3AudioSource, H3Record, H3Reference + from musubi_tuner.minimax_h3.text_encoder import ( + H3TextVisual, + build_presentation, + encode_h3_presentation, + load_h3_processor, + load_h3_text_encoder, + ) + + pending = [index for index in range(len(config.windows)) if not cache_paths(config, index)["qwen"].exists()] + if not pending: + return + device = _require_one_gpu(torch) + processor = load_h3_processor(str(config.models.processor)) + model = load_h3_text_encoder( + str(config.models.text_encoder), + processor_path=str(config.models.processor), + device=device, + dtype=torch.bfloat16, + ) + model.eval().requires_grad_(False) + anchor_np = _load_anchor(config.anchor_image) + anchor = torch.from_numpy(np.ascontiguousarray(anchor_np)).unsqueeze(0) + for index in pending: + window = config.windows[index] + duv_np = load_duv_window(config.condition_root, window.start_frame, for_qwen=True) + duv = torch.from_numpy(np.ascontiguousarray(duv_np)) + image_key = config.anchor_image + video_key = config.condition_root / f"{window.start_frame:06d}.semantic_id.png" + placeholder = config.condition_root / ".h3-presentation-placeholder" + record = H3Record( + video_path=placeholder, + caption=window.prompt, + target_audio=H3AudioSource(path=placeholder, embedded=False), + references=( + H3Reference(type="image", path=image_key), + H3Reference(type="video", path=video_key, duration_seconds=124 / FPS), + ), + jsonl_line=0, + ) + plain = build_presentation( + record, + "ref2va", + { + image_key: H3TextVisual(anchor), + video_key: H3TextVisual(duv, tuple(offset / FPS for offset in QWEN_OFFSETS)), + }, + ) + presentation = build_ref2va_presentation(processor, plain, index, window.prompt) + with torch.inference_mode(): + hidden, tags = encode_h3_presentation(processor, model, presentation) + hidden = hidden.to(device="cpu", dtype=torch.bfloat16).contiguous() + tags = tags.to(device="cpu", dtype=torch.int64).contiguous() + if hidden.ndim != 2 or hidden.shape[1] != 5120 or tags.shape != (hidden.shape[0],): + raise RuntimeError(f"unexpected Qwen output shape for window {index}") + atomic_safetensors( + cache_paths(config, index)["qwen"], + {"varlen_mmh3_hidden_states_bfloat16": hidden, "varlen_mmh3_token_tags_int64": tags}, + { + "schema": CACHE_SCHEMA, + "kind": "qwen", + "window_index": str(index), + "system_prompt_role": "w0" if index == 0 else "wn", + "presentation_order": "picture1_anchor_then_video1_duv", + }, + ) + del duv_np, duv, plain, presentation, hidden, tags + gc.collect() + torch.cuda.empty_cache() + + del anchor_np, anchor, processor, model + gc.collect() + torch.cuda.empty_cache() + + +def prepare_caches(config: RunConfig) -> dict[str, Any]: + config.validate_files() + initial = _begin_manifest(config) + if initial.get("status") == "complete": + return validate_cache(config) + _prepare_visual_caches(config) + _prepare_qwen_caches(config) + files = [] + for index in range(len(config.windows)): + paths = cache_paths(config, index) + files.extend([paths["reference"], paths["qwen"]]) + files.extend([cache_paths(config, 0)["anchor"], cache_paths(config, 0)["input_video"]]) + missing = [str(path) for path in files if not path.is_file()] + if missing: + raise RuntimeError(f"cache preparation incomplete: {missing}") + manifest = { + "schema": CACHE_SCHEMA, + "status": "complete", + "contract_id": initial["contract_id"], + "contract": initial["contract"], + "window_count": len(config.windows), + "output_frames": config.output_frames, + "files": [str(path) for path in files], + } + atomic_json(cache_paths(config, 0)["manifest"], manifest) + return manifest + + +def validate_cache(config: RunConfig) -> dict[str, Any]: + manifest = load_json(cache_paths(config, 0)["manifest"]) + expected_id = _contract_id(_cache_contract(config)) + if ( + manifest.get("schema") != CACHE_SCHEMA + or manifest.get("status") != "complete" + or manifest.get("contract_id") != expected_id + ): + raise ValueError("cache manifest is incompatible with this release") + if manifest.get("window_count") != len(config.windows): + raise ValueError("cache window count does not match config") + expected_files: list[Path] = [] + for index in range(len(config.windows)): + paths = cache_paths(config, index) + expected_files.extend([paths["reference"], paths["qwen"]]) + expected_files.extend([cache_paths(config, 0)["anchor"], cache_paths(config, 0)["input_video"]]) + files = manifest.get("files") + if files != [str(path) for path in expected_files]: + raise ValueError("cache manifest file set does not match config") + missing = [str(path) for path in expected_files if not path.is_file()] + if missing: + raise ValueError(f"cache files are missing: {missing}") + return manifest diff --git a/cwm_h3_inference/cli.py b/cwm_h3_inference/cli.py new file mode 100644 index 0000000000000000000000000000000000000000..60385aba26af7b1842f2575efbd5e834e8955b92 --- /dev/null +++ b/cwm_h3_inference/cli.py @@ -0,0 +1,53 @@ +"""Command-line entry point.""" + +from __future__ import annotations + +import argparse +import json +from typing import Any + +from .cache import prepare_caches, validate_cache +from .config import load_config +from .engine import generate + + +def _emit(value: Any) -> None: + print(json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True)) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="cwm-h3-inference", + description="MiniMax H3 Ref2VA inference with a fixed first frame and Retake34 continuation.", + allow_abbrev=False, + ) + subparsers = parser.add_subparsers(dest="command", required=True) + for name in ("validate", "prepare", "generate"): + command = subparsers.add_parser(name, allow_abbrev=False) + command.add_argument("--config", required=True, help="JSON run configuration") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + config = load_config(args.config, check_files=True) + if args.command == "validate": + _emit( + { + "status": "pass", + "window_count": len(config.windows), + "output_frames": config.output_frames, + } + ) + return 0 + if args.command == "prepare": + _emit(prepare_caches(config)) + return 0 + if args.command == "generate": + _emit(generate(config)) + return 0 + raise AssertionError(f"unhandled command: {args.command}") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/cwm_h3_inference/config.py b/cwm_h3_inference/config.py new file mode 100644 index 0000000000000000000000000000000000000000..8f6df2f2ad9c50292577e91cc72d95ad7c83d729 --- /dev/null +++ b/cwm_h3_inference/config.py @@ -0,0 +1,174 @@ +"""Structured, path-parameterized configuration for one inference run.""" + +from __future__ import annotations + +from dataclasses import dataclass +import json +from pathlib import Path +from typing import Any + +from .constants import DEFAULT_STEPS, EXPECTED_LORA_MODULES, STRIDE_FRAMES, WINDOW_FRAMES + + +ALLOWED_ROOT_KEYS = { + "models", + "anchor_image", + "condition_root", + "cache_dir", + "output", + "windows", + "seed", + "steps", + "attention_mode", + "blocks_to_swap", + "expected_lora_modules", +} +ALLOWED_MODEL_KEYS = {"dit", "video_vae", "audio_vae", "text_encoder", "processor", "lora"} + + +def require(condition: bool, message: str) -> None: + if not condition: + raise ValueError(message) + + +def _path(value: Any, label: str, *, base: Path) -> Path: + require(isinstance(value, str) and bool(value.strip()), f"{label} must be a non-empty path") + path = Path(value) + return path if path.is_absolute() else (base / path).resolve() + + +def _canonical_caption(value: Any, label: str) -> str: + require(isinstance(value, str) and bool(value.strip()), f"{label} must be non-empty") + # The released Qwen caches were encoded from UTF-8 caption bytes whose + # internal line endings are CRLF. JSON editors and Path.read_text() commonly + # normalize those bytes to LF, which changes Qwen token IDs. Canonicalize the + # user caption here so both representations consume the released text. + normalized = value.strip().replace("\r\n", "\n").replace("\r", "\n") + return normalized.replace("\n", "\r\n") + + +@dataclass(frozen=True) +class ModelPaths: + dit: Path + video_vae: Path + audio_vae: Path + text_encoder: Path + processor: Path + lora: Path + + +@dataclass(frozen=True) +class Window: + prompt: str + start_frame: int + + +@dataclass(frozen=True) +class RunConfig: + source: Path + models: ModelPaths + anchor_image: Path + condition_root: Path + cache_dir: Path + output: Path + windows: tuple[Window, ...] + seed: int + steps: int + attention_mode: str + blocks_to_swap: int + expected_lora_modules: int + + @property + def output_frames(self) -> int: + from .constants import STRIDE_FRAMES + + return WINDOW_FRAMES + (len(self.windows) - 1) * STRIDE_FRAMES + + def validate_files(self) -> None: + for label, path in ( + ("dit", self.models.dit), + ("video_vae", self.models.video_vae), + ("audio_vae", self.models.audio_vae), + ("text_encoder", self.models.text_encoder), + ("processor", self.models.processor), + ("lora", self.models.lora), + ("anchor_image", self.anchor_image), + ("condition_root", self.condition_root), + ): + require(path.exists(), f"{label} does not exist: {path}") + from .presentation import _system_prompt + + _system_prompt(0) + _system_prompt(1) + + +def load_config(path: str | Path, *, check_files: bool = False) -> RunConfig: + source = Path(path).resolve() + raw = json.loads(source.read_text(encoding="utf-8")) + require(isinstance(raw, dict), "config root must be an object") + require(not (set(raw) - ALLOWED_ROOT_KEYS), f"unknown config keys: {sorted(set(raw) - ALLOWED_ROOT_KEYS)}") + base = source.parent + + model_raw = raw.get("models") + require(isinstance(model_raw, dict), "models must be an object") + require( + set(model_raw) == ALLOWED_MODEL_KEYS, + f"models must contain exactly: {sorted(ALLOWED_MODEL_KEYS)}", + ) + models = ModelPaths( + dit=_path(model_raw.get("dit"), "models.dit", base=base), + video_vae=_path(model_raw.get("video_vae"), "models.video_vae", base=base), + audio_vae=_path(model_raw.get("audio_vae"), "models.audio_vae", base=base), + text_encoder=_path(model_raw.get("text_encoder"), "models.text_encoder", base=base), + processor=_path(model_raw.get("processor"), "models.processor", base=base), + lora=_path(model_raw.get("lora"), "models.lora", base=base), + ) + + window_raw = raw.get("windows") + require(isinstance(window_raw, list) and bool(window_raw), "windows must be a non-empty list") + windows: list[Window] = [] + for index, item in enumerate(window_raw): + require(isinstance(item, dict), f"windows[{index}] must be an object") + prompt = item.get("prompt") + start = item.get("start_frame") + prompt = _canonical_caption(prompt, f"windows[{index}].prompt") + require(type(start) is int and start >= 0, f"windows[{index}].start_frame must be non-negative") + require(set(item) == {"prompt", "start_frame"}, f"windows[{index}] has unknown or missing keys") + if windows: + require( + start == windows[-1].start_frame + STRIDE_FRAMES, + f"windows[{index}].start_frame must advance by {STRIDE_FRAMES}", + ) + windows.append(Window(prompt=prompt, start_frame=start)) + + seed = raw.get("seed", 0) + steps = raw.get("steps", DEFAULT_STEPS) + attention_mode = raw.get("attention_mode", "flash3") + blocks_to_swap = raw.get("blocks_to_swap", 48) + expected_lora_modules = raw.get("expected_lora_modules", EXPECTED_LORA_MODULES) + require(type(seed) is int and 0 <= seed < 2**63, "seed must be an integer in [0, 2**63)") + require(type(steps) is int and steps == DEFAULT_STEPS, "this release fixes explicit Euler inference to 20 steps") + require(attention_mode == "flash3", "this release fixes attention_mode to flash3") + require(type(blocks_to_swap) is int and 0 <= blocks_to_swap <= 48, "blocks_to_swap must be in [0,48]") + require( + type(expected_lora_modules) is int and expected_lora_modules == EXPECTED_LORA_MODULES, + f"expected_lora_modules must be {EXPECTED_LORA_MODULES}", + ) + + config = RunConfig( + source=source, + models=models, + anchor_image=_path(raw.get("anchor_image"), "anchor_image", base=base), + condition_root=_path(raw.get("condition_root"), "condition_root", base=base), + cache_dir=_path(raw.get("cache_dir", "cache"), "cache_dir", base=base), + output=_path(raw.get("output", "output.mp4"), "output", base=base), + windows=tuple(windows), + seed=seed, + steps=steps, + attention_mode=attention_mode, + blocks_to_swap=blocks_to_swap, + expected_lora_modules=expected_lora_modules, + ) + if check_files: + config.validate_files() + return config diff --git a/cwm_h3_inference/constants.py b/cwm_h3_inference/constants.py new file mode 100644 index 0000000000000000000000000000000000000000..b76f5c87c820ac4f7caa6b51c864e3241b0121f1 --- /dev/null +++ b/cwm_h3_inference/constants.py @@ -0,0 +1,43 @@ +"""Frozen geometry for the released Ref2VA inference path.""" + +FPS = 24 +SAMPLE_RATE = 32_000 +WINDOW_FRAMES = 124 +OVERLAP_FRAMES = 34 +STRIDE_FRAMES = WINDOW_FRAMES - OVERLAP_FRAMES + +CONDITION_WIDTH = 336 +CONDITION_HEIGHT = 192 +TARGET_WIDTH = 1_344 +TARGET_HEIGHT = 768 +ANCHOR_SHORT_EDGE = 2_048 +CANVAS_MULTIPLE = 32 + +VIDEO_LATENT_SHAPE = (1, 24, 37, 48, 84) +AUDIO_LATENT_SHAPE = (1, 32, 2, 207) +REFERENCE_LATENT_SHAPE = (24, 37, 12, 21) +REFERENCE_RUNTIME_SHAPE = (1, 24, 37, 12, 22) +SINGLE_FRAME_LATENT_SHAPE = (24, 1, 48, 84) + +VIDEO_PREFIX_LATENTS = 10 +AUDIO_PREFIX_LATENTS = 57 +WINDOW_AUDIO_SAMPLES = round(WINDOW_FRAMES * SAMPLE_RATE / FPS) +OVERLAP_AUDIO_SAMPLES = round(OVERLAP_FRAMES * SAMPLE_RATE / FPS) +QWEN_OFFSETS = tuple(range(0, WINDOW_FRAMES, 12)) + +DEPTH_NEAR_METRES = 0.3 +DEPTH_FAR_METRES = 256.0 +DEPTH_VALID_EPSILON_METRES = 1.0e-3 +SEMANTIC_U = (32, 96, 160, 224) +SEMANTIC_V = (43, 128, 213) + +DEFAULT_STEPS = 20 +VIDEO_SHIFT = 12.0 +AUDIO_SHIFT = 3.0 +VISUAL_CONDITION_CLEAN = 0.999 +AUDIO_CONDITION_CLEAN = 1.0 +EXPECTED_LORA_MODULES = 200 + +CACHE_SCHEMA = "cwm_h3_inference_cache_v3" +RECOVERY_SCHEMA = "cwm_h3_inference_recovery_v3" +RESULT_SCHEMA = "cwm_h3_inference_result_v3" diff --git a/cwm_h3_inference/duv.py b/cwm_h3_inference/duv.py new file mode 100644 index 0000000000000000000000000000000000000000..5bfefe0b1f7028579dc43d8ef62a6a8fbd410384 --- /dev/null +++ b/cwm_h3_inference/duv.py @@ -0,0 +1,72 @@ +"""Depth + Semantic-ID to three-channel DUV conditioning.""" + +from __future__ import annotations + +import math +from pathlib import Path + +from .constants import ( + CONDITION_HEIGHT, + CONDITION_WIDTH, + DEPTH_FAR_METRES, + DEPTH_NEAR_METRES, + DEPTH_VALID_EPSILON_METRES, + QWEN_OFFSETS, + SEMANTIC_U, + SEMANTIC_V, + WINDOW_FRAMES, +) + + +def frame_paths(root: Path, ordinal: int) -> tuple[Path, Path]: + return root / f"{ordinal:06d}.depth.f32", root / f"{ordinal:06d}.semantic_id.png" + + +def load_duv_frame(root: Path, ordinal: int, *, for_qwen: bool): + import numpy as np + from PIL import Image + + depth_path, semantic_path = frame_paths(root, ordinal) + payload = depth_path.read_bytes() + expected_bytes = CONDITION_WIDTH * CONDITION_HEIGHT * 4 + if len(payload) != expected_bytes: + raise ValueError(f"raw depth byte count must be {expected_bytes}: {depth_path}") + metric = np.frombuffer(payload, dtype=" np.float32(DEPTH_VALID_EPSILON_METRES) + codes = np.zeros(metric.shape, dtype=np.uint16) + if bool(np.any(valid)): + clipped = np.clip(metric[valid].astype(np.float64), DEPTH_NEAR_METRES, DEPTH_FAR_METRES) + normalized = (math.log(DEPTH_FAR_METRES) - np.log(clipped)) / ( + math.log(DEPTH_FAR_METRES) - math.log(DEPTH_NEAR_METRES) + ) + codes[valid] = np.floor(np.clip(normalized, 0.0, 1.0) * 65535.0 + 0.5).astype(np.uint16) + depth = codes.reshape(CONDITION_HEIGHT, CONDITION_WIDTH) + + with Image.open(semantic_path) as image: + if image.mode != "L" or image.size != (CONDITION_WIDTH, CONDITION_HEIGHT): + raise ValueError(f"semantic ID must be L/{CONDITION_WIDTH}x{CONDITION_HEIGHT}: {semantic_path}") + semantic = np.asarray(image, dtype=np.uint8).copy() + if int(semantic.max()) > 11: + raise ValueError(f"semantic ID must be in [0,11]: {semantic_path}") + u = np.asarray(SEMANTIC_U, dtype=np.uint8)[semantic % 4] + v = np.asarray(SEMANTIC_V, dtype=np.uint8)[semantic // 4] + if for_qwen: + depth8 = np.floor(depth.astype(np.float64) / 257.0 + 0.5).astype(np.uint8) + return np.stack((depth8, u, v), axis=-1) + return np.stack( + ( + depth.astype(np.float32) / np.float32(65535.0), + u.astype(np.float32) / np.float32(255.0), + v.astype(np.float32) / np.float32(255.0), + ), + axis=-1, + ) + + +def load_duv_window(root: Path, start_frame: int, *, for_qwen: bool): + import numpy as np + + offsets = QWEN_OFFSETS if for_qwen else range(WINDOW_FRAMES) + return np.stack([load_duv_frame(root, start_frame + offset, for_qwen=for_qwen) for offset in offsets]) diff --git a/cwm_h3_inference/engine.py b/cwm_h3_inference/engine.py new file mode 100644 index 0000000000000000000000000000000000000000..3ed4a1cfa3d8535e9c4aac6b5ea2710d17de66ab --- /dev/null +++ b/cwm_h3_inference/engine.py @@ -0,0 +1,340 @@ +"""Single-GPU Ref2VA inference with a fixed first frame and Retake34 continuation.""" + +from __future__ import annotations + +import gc +import os +from pathlib import Path +import time +from typing import Any +import uuid + +from .cache import cache_paths, validate_cache +from .config import RunConfig +from .constants import ( + AUDIO_CONDITION_CLEAN, + AUDIO_LATENT_SHAPE, + AUDIO_PREFIX_LATENTS, + AUDIO_SHIFT, + FPS, + OVERLAP_AUDIO_SAMPLES, + OVERLAP_FRAMES, + RECOVERY_SCHEMA, + REFERENCE_LATENT_SHAPE, + REFERENCE_RUNTIME_SHAPE, + RESULT_SCHEMA, + STRIDE_FRAMES, + TARGET_HEIGHT, + TARGET_WIDTH, + VIDEO_LATENT_SHAPE, + VIDEO_PREFIX_LATENTS, + VIDEO_SHIFT, + VISUAL_CONDITION_CLEAN, + WINDOW_AUDIO_SAMPLES, + WINDOW_FRAMES, +) +from .io import atomic_json +from .runtime import build_runtime_batch, strict_merge_lora + + +def _require_one_gpu(torch: Any) -> Any: + if not torch.cuda.is_available() or torch.cuda.device_count() != 1: + raise RuntimeError("generation requires exactly one visible CUDA GPU") + torch.cuda.set_device(0) + return torch.device("cuda:0") + + +def _unique_key(handle: Any, *, exact: str | None = None, prefix: str | None = None) -> str: + keys = list(handle.keys()) + selected = [key for key in keys if key == exact] if exact else [key for key in keys if key.startswith(str(prefix))] + if len(selected) != 1: + raise ValueError(f"expected one tensor key, got {keys}") + return selected[0] + + +class Ref2VAEngine: + def __init__(self, config: RunConfig) -> None: + import torch + from musubi_tuner.minimax_h3.audio_vae import load_audio_vae + from musubi_tuner.minimax_h3.model import load_h3_transformer + from musubi_tuner.minimax_h3.video_vae import load_video_vae + from musubi_tuner.modules.custom_offloading_utils import BlockSwapConfig + + self.config = config + self.torch = torch + self.device = _require_one_gpu(torch) + transformer = load_h3_transformer( + config.models.dit, + device="cpu", + dtype=torch.bfloat16, + attn_mode=config.attention_mode, + split_attn=False, + disable_mmap=False, + ) + self.lora_gate = strict_merge_lora( + transformer, config.models.lora, config.expected_lora_modules + ) + swap = BlockSwapConfig(device=self.device, supports_backward=False, use_pinned_memory=True) + transformer.enable_block_swap(config.blocks_to_swap, swap) + transformer.move_to_device_except_swap_blocks(self.device) + transformer.prepare_block_swap_before_forward() + transformer.switch_block_swap_for_inference() + transformer.eval().requires_grad_(False) + self.transformer = transformer + self.video_vae = load_video_vae( + config.models.video_vae, device=self.device, dtype=torch.float16, disable_mmap=False + ).eval().requires_grad_(False) + self.audio_vae = load_audio_vae( + config.models.audio_vae, device=self.device, dtype=torch.float32, disable_mmap=False + ).eval().requires_grad_(False) + + def _load_window_cache(self, index: int): + import torch + from safetensors import safe_open + + paths = cache_paths(self.config, index) + with safe_open(str(paths["anchor"]), framework="pt", device="cpu") as handle: + anchor = handle.get_tensor(_unique_key(handle, prefix="latents_ref_")) + with safe_open(str(paths["reference"]), framework="pt", device="cpu") as handle: + reference = handle.get_tensor(_unique_key(handle, prefix="latents_ref_")) + with safe_open(str(paths["qwen"]), framework="pt", device="cpu") as handle: + hidden = handle.get_tensor(_unique_key(handle, exact="varlen_mmh3_hidden_states_bfloat16")) + tags = handle.get_tensor(_unique_key(handle, exact="varlen_mmh3_token_tags_int64")) + if tuple(reference.shape) != REFERENCE_LATENT_SHAPE or reference.dtype != torch.float32: + raise ValueError(f"window {index} DUV reference cache shape/dtype changed") + reference = torch.nn.functional.pad(reference.unsqueeze(0), (0, 1, 0, 0), value=0.0).contiguous() + if tuple(reference.shape) != REFERENCE_RUNTIME_SHAPE: + raise ValueError(f"window {index} padded DUV reference shape changed") + if anchor.ndim != 4 or tuple(anchor.shape[:2]) != (24, 1) or anchor.dtype != torch.float32: + raise ValueError("anchor cache shape/dtype changed") + if hidden.ndim != 2 or hidden.shape[1] != 5120 or hidden.dtype != torch.bfloat16: + raise ValueError(f"window {index} Qwen hidden states changed") + if tags.shape != (hidden.shape[0],) or tags.dtype != torch.int64: + raise ValueError(f"window {index} Qwen token tags changed") + if not all(torch.isfinite(value).all().item() for value in (anchor, reference, hidden)): + raise ValueError(f"window {index} cache contains non-finite values") + return anchor.unsqueeze(0), reference, hidden.unsqueeze(0), tags.unsqueeze(0) + + def _load_input_video(self): + import torch + from safetensors import safe_open + + path = cache_paths(self.config, 0)["input_video"] + with safe_open(str(path), framework="pt", device="cpu") as handle: + latent = handle.get_tensor(_unique_key(handle, exact="input_video_latents")) + if tuple(latent.shape) != VIDEO_LATENT_SHAPE[1:] or latent.dtype != torch.float32: + raise ValueError("fixed-first-frame latent shape/dtype changed") + if torch.count_nonzero(latent[:, 1:]).item() != 0: + raise ValueError("fixed-first-frame latent must contain data only at temporal index 0") + latent = latent.unsqueeze(0).to(self.device) + mask = torch.ones_like(latent, dtype=torch.bool) + mask[:, :, 0] = False + return latent, mask + + def _recovery_path(self, index: int) -> Path: + return self.config.cache_dir / "recovery" / f"window_{index:03d}.safetensors" + + def _load_recovery(self, index: int): + import torch + from safetensors import safe_open + + path = self._recovery_path(index) + with safe_open(str(path), framework="pt", device="cpu") as handle: + metadata = handle.metadata() or {} + if metadata.get("schema") != RECOVERY_SCHEMA or metadata.get("window_index") != str(index): + raise ValueError(f"recovery identity changed: {path}") + video = handle.get_tensor("video") + audio = handle.get_tensor("audio") + if tuple(video.shape) != VIDEO_LATENT_SHAPE[1:] or tuple(audio.shape) != AUDIO_LATENT_SHAPE[1:]: + raise ValueError(f"recovery shape changed: {path}") + if video.dtype != torch.float32 or audio.dtype != torch.float32: + raise ValueError(f"recovery dtype changed: {path}") + if not torch.isfinite(video).all().item() or not torch.isfinite(audio).all().item(): + raise ValueError(f"recovery contains non-finite values: {path}") + return video, audio + + def _retake_inputs(self, previous: tuple[Any, Any]): + torch = self.torch + previous_video, previous_audio = previous + torch.cuda.empty_cache() + with torch.inference_mode(): + decoded_video = self.video_vae.decode( + previous_video.unsqueeze(0).to(self.device, torch.float16) + )[:, :, :WINDOW_FRAMES] + if tuple(decoded_video.shape) != (1, 3, WINDOW_FRAMES, TARGET_HEIGHT, TARGET_WIDTH): + raise RuntimeError("Retake34 decoded video shape changed") + tail = decoded_video[:, :, -OVERLAP_FRAMES:] + padded = torch.cat((tail, tail[:, :, -1:].repeat(1, 1, STRIDE_FRAMES, 1, 1)), dim=2) + clean_video = self.video_vae.encode(padded.to(torch.float16)).float() + decoded_audio = self.audio_vae.decode( + previous_audio.unsqueeze(0).to(self.device, torch.float32) + )[:, :, :WINDOW_AUDIO_SAMPLES] + if tuple(decoded_audio.shape) != (1, 2, WINDOW_AUDIO_SAMPLES): + raise RuntimeError("Retake34 decoded audio shape changed") + prefix = decoded_audio[:, :, -OVERLAP_AUDIO_SAMPLES:] + prefix_audio = self.audio_vae.encode(prefix.to(torch.float32)).float() + if prefix_audio.shape[-1] != AUDIO_PREFIX_LATENTS: + raise RuntimeError("Retake34 audio prefix shape changed") + clean_audio = torch.cat( + (prefix_audio, prefix_audio[..., -1:].repeat(1, 1, 1, AUDIO_LATENT_SHAPE[-1] - AUDIO_PREFIX_LATENTS)), + dim=-1, + ) + if tuple(clean_video.shape) != VIDEO_LATENT_SHAPE or tuple(clean_audio.shape) != AUDIO_LATENT_SHAPE: + raise RuntimeError("Retake34 clean latent shape changed") + video_mask = torch.ones_like(clean_video, dtype=torch.bool) + video_mask[:, :, :VIDEO_PREFIX_LATENTS] = False + audio_mask = torch.ones_like(clean_audio, dtype=torch.bool) + audio_mask[..., :AUDIO_PREFIX_LATENTS] = False + return clean_video, video_mask, clean_audio, audio_mask + + def sample_window(self, index: int) -> Path: + import torch + from musubi_tuner.minimax_h3.sampling import initialize_target_latents, sample_joint_av + from safetensors.torch import save_file + + output = self._recovery_path(index) + if output.exists(): + self._load_recovery(index) + return output + anchor, reference, hidden, tags = self._load_window_cache(index) + runtime = build_runtime_batch(anchor, reference, hidden, tags) + window_seed = self.config.seed + index + initial_video, initial_audio = initialize_target_latents( + video_shape=VIDEO_LATENT_SHAPE, + audio_shape=AUDIO_LATENT_SHAPE, + seed=window_seed, + device=self.device, + video_dtype=torch.float32, + audio_dtype=torch.float32, + ) + ref = reference.to(self.device, torch.float32) + ref_generator = torch.Generator(device="cpu").manual_seed(self.config.seed + 1000 + index) + ref_noise = torch.randn(tuple(ref.shape), generator=ref_generator, dtype=torch.float32).to(self.device) + augmented_ref = VISUAL_CONDITION_CLEAN * ref + (1.0 - VISUAL_CONDITION_CLEAN) * ref_noise + anchor_ref = anchor.to(self.device, torch.float32) + anchor_generator = torch.Generator(device="cpu").manual_seed(self.config.seed + 3000 + index) + anchor_noise = torch.randn(tuple(anchor_ref.shape), generator=anchor_generator, dtype=torch.float32).to(self.device) + augmented_anchor = VISUAL_CONDITION_CLEAN * anchor_ref + (1.0 - VISUAL_CONDITION_CLEAN) * anchor_noise + retake = self._load_input_video() + (None, None) if index == 0 else self._retake_inputs(self._load_recovery(index - 1)) + with torch.inference_mode(): + sampled = sample_joint_av( + self.transformer, + layout=runtime.layout, + text_hidden_states=runtime.text_hidden_states.to(self.device, torch.bfloat16), + text_token_tags=runtime.text_token_tags.to(self.device), + initial_video=initial_video, + initial_audio=initial_audio, + steps=self.config.steps, + video_shift=VIDEO_SHIFT, + audio_shift=AUDIO_SHIFT, + visual_condition_latents=(augmented_anchor, augmented_ref), + audio_condition_latents=(), + visual_condition_clean=VISUAL_CONDITION_CLEAN, + audio_condition_clean=AUDIO_CONDITION_CLEAN, + input_video_latents=retake[0], + video_denoise_mask=retake[1], + input_audio_latents=retake[2], + audio_denoise_mask=retake[3], + ) + video = sampled.video[0].float().cpu().contiguous() + audio = sampled.audio[0].float().cpu().contiguous() + if not torch.isfinite(video).all().item() or not torch.isfinite(audio).all().item(): + raise RuntimeError(f"window {index} produced non-finite latents") + output.parent.mkdir(parents=True, exist_ok=True) + temporary = output.with_name(f".{output.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp") + try: + save_file( + {"video": video, "audio": audio}, + str(temporary), + metadata={ + "schema": RECOVERY_SCHEMA, + "window_index": str(index), + "seed": str(window_seed), + "steps": str(self.config.steps), + }, + ) + os.replace(temporary, output) + finally: + temporary.unlink(missing_ok=True) + del sampled, video, audio, anchor, reference, hidden, tags, runtime + del initial_video, initial_audio, ref, ref_noise, augmented_ref + del anchor_ref, anchor_noise, augmented_anchor, retake + gc.collect() + torch.cuda.empty_cache() + return output + + def write_video(self) -> dict[str, Any]: + import av + import torch + + output = self.config.output + if output.exists(): + raise FileExistsError(f"refusing to overwrite output: {output}") + output.parent.mkdir(parents=True, exist_ok=True) + temporary = output.with_name(f".{output.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp.mp4") + frame_count = 0 + try: + with av.open(str(temporary), mode="w") as container: + stream = container.add_stream("libx264", rate=FPS) + stream.width = TARGET_WIDTH + stream.height = TARGET_HEIGHT + stream.pix_fmt = "yuv420p" + for index in range(len(self.config.windows)): + video, _audio = self._load_recovery(index) + torch.cuda.empty_cache() + with torch.inference_mode(): + decoded = self.video_vae.decode( + video.unsqueeze(0).to(self.device, torch.float16) + )[0, :, :WINDOW_FRAMES].cpu() + pixels = ( + ((decoded.float().clamp(-1, 1) + 1.0) * 127.5) + .round() + .to(torch.uint8) + .permute(1, 2, 3, 0) + .contiguous() + ) + if index: + pixels = pixels[OVERLAP_FRAMES:] + for value in pixels: + frame = av.VideoFrame.from_ndarray(value.numpy(), format="rgb24") + for packet in stream.encode(frame): + container.mux(packet) + frame_count += 1 + del video, decoded, pixels + gc.collect() + torch.cuda.empty_cache() + for packet in stream.encode(): + container.mux(packet) + if frame_count != self.config.output_frames: + raise RuntimeError(f"stitched frame count mismatch: {frame_count}") + os.replace(temporary, output) + finally: + temporary.unlink(missing_ok=True) + receipt = { + "schema": RESULT_SCHEMA, + "solver": "explicit_euler", + "steps": self.config.steps, + "seed": self.config.seed, + "window_frames": WINDOW_FRAMES, + "overlap_frames": OVERLAP_FRAMES, + "output_frames": frame_count, + "fps": FPS, + "resolution": [TARGET_WIDTH, TARGET_HEIGHT], + "audio": False, + "output": str(output), + "bytes": output.stat().st_size, + "lora_gate": self.lora_gate, + } + atomic_json(output.with_suffix(output.suffix + ".json"), receipt) + return receipt + + +def generate(config: RunConfig) -> dict[str, Any]: + validate_cache(config) + started = time.monotonic() + engine = Ref2VAEngine(config) + for index in range(len(config.windows)): + engine.sample_window(index) + result = engine.write_video() + result["elapsed_seconds"] = time.monotonic() - started + return result diff --git a/cwm_h3_inference/io.py b/cwm_h3_inference/io.py new file mode 100644 index 0000000000000000000000000000000000000000..03361ea97ac88861ecf6fcca28944456d1359b4e --- /dev/null +++ b/cwm_h3_inference/io.py @@ -0,0 +1,44 @@ +"""Small atomic I/O helpers used by cache and generation commands.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Any, Mapping +import uuid + + +def atomic_json(path: Path, value: Mapping[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_name(f".{path.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp") + try: + with temporary.open("x", encoding="utf-8") as stream: + json.dump(value, stream, ensure_ascii=False, indent=2, sort_keys=True) + stream.write("\n") + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + finally: + temporary.unlink(missing_ok=True) + + +def atomic_safetensors(path: Path, tensors: Mapping[str, Any], metadata: Mapping[str, str]) -> None: + from safetensors.torch import save_file + + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_name(f".{path.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp") + try: + save_file(dict(tensors), str(temporary), metadata=dict(metadata)) + with temporary.open("rb") as stream: + os.fsync(stream.fileno()) + os.replace(temporary, path) + finally: + temporary.unlink(missing_ok=True) + + +def load_json(path: Path) -> dict[str, Any]: + value = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(value, dict): + raise ValueError(f"expected JSON object: {path}") + return value diff --git a/cwm_h3_inference/presentation.py b/cwm_h3_inference/presentation.py new file mode 100644 index 0000000000000000000000000000000000000000..471019e89a8034b0446e6432d39ec8355c3a40e7 --- /dev/null +++ b/cwm_h3_inference/presentation.py @@ -0,0 +1,63 @@ +"""Ref2VA chat presentation with Picture 1 before Video 1.""" + +from __future__ import annotations + +import hashlib +from pathlib import Path +from typing import Any + + +_PROMPT_ROOT = Path(__file__).with_name("prompts") +_PROMPT_PATHS = ( + _PROMPT_ROOT / "system_w0.txt", + _PROMPT_ROOT / "system_wn.txt", +) +_PROMPT_SHA256 = ( + "d488897872a5b190ff8d56b6acc255b66a78f67ae862c12582fcffc8a5dd4ddc", + "cd018def9793b4f73cd1c9da9d8ca9ca508dd94a260a19d915aaf9f80b97b6b2", +) + + +def _system_prompt(window_index: int) -> str: + if type(window_index) is not int or window_index < 0: + raise ValueError("window_index must be a non-negative integer") + prompt_index = 0 if window_index == 0 else 1 + path = _PROMPT_PATHS[prompt_index] + if not path.is_file(): + raise ValueError(f"packaged system prompt file is missing: {path}") + payload = path.read_bytes() + if hashlib.sha256(payload).hexdigest() != _PROMPT_SHA256[prompt_index]: + raise ValueError(f"packaged system prompt does not match this release: {path.name}") + prompt = payload.decode("utf-8").strip() + if not prompt: + raise ValueError(f"packaged system prompt is empty: {path.name}") + return prompt + + +def build_ref2va_presentation(processor: Any, base: Any, window_index: int, caption: str): + from musubi_tuner.minimax_h3.text_encoder import H3Presentation, IMAGE_PLACEHOLDER, VIDEO_PLACEHOLDER + + system_prompt = _system_prompt(window_index) + if len(base.videos) != 1 or len(base.images) != 1: + raise ValueError("Ref2VA requires exactly one image and one video reference") + text = base.processor_text + if text.count(IMAGE_PLACEHOLDER) != 1 or text.count(VIDEO_PLACEHOLDER) != 1: + raise ValueError("Ref2VA presentation requires one image and one video placeholder") + if text.count("") != 1 or text.count("