Spaces:
Running on Zero
Running on Zero
Download app.py from igitagoz/MiniMax-H3: direct link, hf CLI and curl.
- Browser
- Download file 30.6 kB
-
https://huggingface.co/spaces/igitagoz/MiniMax-H3/resolve/main/app.py
- Command line
-
hf download hf://spaces/igitagoz/MiniMax-H3/app.py
-
curl -L -o app.py https://huggingface.co/spaces/igitagoz/MiniMax-H3/resolve/main/app.py
30.6 kB
| """MiniMax-H3 image-to-video (I2VA / FLF2VA) β slim ZeroGPU Space for protocol-generator. | |
| Stripped from RioShiina/MiniMax-H3: one Gradio endpoint (`generate_video`) that runs | |
| ComfyUI's `MiniMaxH3ImageToVideo` graph on the NVFP4-pruned FL2VA checkpoint. A start | |
| frame is required; an optional last frame turns the job into FLF2VA. No T2VA, REF2VA, | |
| keyframe guides, ControlNet, LoRA chains or MCP server β see README.md. | |
| """ | |
| import spaces # noqa: F401 β must be imported before torch (ZeroGPU patches CUDA) | |
| import asyncio | |
| import glob | |
| import json | |
| import math | |
| import os | |
| import pathlib | |
| import random | |
| import shutil | |
| import subprocess | |
| import sys | |
| import time | |
| import uuid | |
| from typing import Any | |
| import gradio as gr | |
| from huggingface_hub import HfApi, hf_hub_download | |
| from PIL import Image, ImageOps | |
| # ββ Paths / pins βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| APP_DIR = pathlib.Path(__file__).resolve().parent | |
| COMFY = APP_DIR / "ComfyUI" | |
| COMFY_MODELS = COMFY / "models" | |
| COMFY_INPUT = COMFY / "input" | |
| COMFY_OUTPUT = COMFY / "output" | |
| # ComfyUI master on 2026-09-12; requirements.txt mirrors this commit's own pins. | |
| COMFYUI_COMMIT = os.environ.get("COMFYUI_COMMIT", "c75d8c966c29cb0392259af791f43373315b72db") | |
| # Diffusion model conversion. The community NVFP4 file the Space started on lost prompt | |
| # adherence against Wavespeed's open-weights H3 (same prompt, image and settings, | |
| # 2026-10-16); Comfy-Org's own pruned int8_convrot is the file its I2V template | |
| # recommends. "pruned" drops the precomputable AdaLN branches, not quality. The model | |
| # card says to prefer int8_convrot where torch has cu130 and to use fp8_scaled only | |
| # when it can't β switch with the Space variable H3_DIFFUSION_MODEL (int8 | fp8 | nvfp4). | |
| DIFFUSION_MODELS = { | |
| "int8": ("minimax_h3_fl2va_pruned_int8_convrot.safetensors", | |
| "Comfy-Org/MiniMax-H3", "diffusion_models/minimax_h3_fl2va_pruned_int8_convrot.safetensors"), | |
| "fp8": ("minimax_h3_fl2va_pruned_fp8_scaled.safetensors", | |
| "Comfy-Org/MiniMax-H3", "diffusion_models/minimax_h3_fl2va_pruned_fp8_scaled.safetensors"), | |
| "nvfp4": ("minimax_h3_fl2va_pruned_nvfp4.safetensors", | |
| "lilcheaty/MiniMax-H3-NVFP4", "minimax_h3_fl2va_pruned_nvfp4.safetensors"), | |
| } | |
| DIFFUSION_MODEL = os.environ.get("H3_DIFFUSION_MODEL", "int8").strip().lower() | |
| if DIFFUSION_MODEL not in DIFFUSION_MODELS: | |
| print(f"[setup] unknown H3_DIFFUSION_MODEL={DIFFUSION_MODEL!r}, using int8", flush=True) | |
| DIFFUSION_MODEL = "int8" | |
| # (models/<subdir>, local filename, HF repo, path in repo) | |
| DOWNLOADS = [ | |
| ("diffusion_models", *DIFFUSION_MODELS[DIFFUSION_MODEL]), | |
| ("text_encoders", "qwen3vl_32b_minimax_h3_nvfp4_awq.safetensors", | |
| "Comfy-Org/MiniMax-H3", "text_encoders/qwen3vl_32b_minimax_h3_nvfp4_awq.safetensors"), | |
| ("vae", "minimax_h3_video_vae_fp16.safetensors", | |
| "Comfy-Org/MiniMax-H3", "vae/minimax_h3_video_vae_fp16.safetensors"), | |
| ("vae", "minimax_h3_audio_vae_fp32.safetensors", | |
| "Comfy-Org/MiniMax-H3", "vae/minimax_h3_audio_vae_fp32.safetensors"), | |
| ] | |
| UNET_NAME = DOWNLOADS[0][1] | |
| CLIP_NAME = DOWNLOADS[1][1] | |
| VIDEO_VAE_NAME = DOWNLOADS[2][1] | |
| AUDIO_VAE_NAME = DOWNLOADS[3][1] | |
| # Override with Space secret/variable H3_JOB_DATASET. Requires HF_TOKEN (write) on the Space. | |
| H3_JOB_DATASET = os.environ.get("H3_JOB_DATASET", "igitagoz/h3-job-outputs").strip() | |
| def _flag(name: str, default: str) -> bool: | |
| return os.environ.get(name, default).strip().lower() in ("1", "true", "yes") | |
| # Load the four checkpoints into CPU RAM once at startup so the forked GPU worker | |
| # inherits them instead of re-reading ~34 GB from disk inside every job's GPU window. | |
| # On 2026-10-16 that read, plus the text encode, took 94 s of a 248 s job β 38% of the | |
| # quota. Default on; H3_PRELOAD_MODELS=0 goes back to loading inside the window. | |
| PRELOAD_MODELS = _flag("H3_PRELOAD_MODELS", "1") | |
| # Go one step further and place the preloaded weights on `cuda` at module level, the | |
| # way ZeroGPU's docs say to (the CUDA emulation outside @spaces.GPU holds them and | |
| # restores them on allocation β this is what the Wan Space does with its diffusers | |
| # pipeline). Here it means asking ComfyUI's model management to load the four patchers | |
| # in the parent process. Opt-in until it has been seen to work with ComfyUI under the | |
| # emulation; a failure falls back to the CPU preload. | |
| GPU_RESIDENT = PRELOAD_MODELS and _flag("H3_GPU_RESIDENT", "0") | |
| # SageAttention is quantised attention (INT8 QK) β faster, near-lossless, not bit-exact. | |
| # Off unless asked for, so the default output stays ComfyUI's reference attention. | |
| SAGE_ATTENTION = _flag("H3_SAGE_ATTENTION", "0") | |
| # ββ Model geometry (mirrored in src/shared/pipelineCost.ts β keep in lockstep) ββ | |
| FPS = 24 | |
| CANVAS_MULTIPLE = 32 | |
| MIN_SECONDS = 1.0 | |
| MAX_SECONDS = 15.0 | |
| MIN_STEPS = 1 | |
| MAX_STEPS = 50 | |
| MIN_MAX_DIM = 256 | |
| MAX_MAX_DIM = 1920 | |
| # ZeroGPU declaration = kill timeout. Calibrated on 2026-10-16 from a 960Γ640 / 124 | |
| # frames / 20 steps job that held the GPU for 248 s: 94 s before the first sampling | |
| # step (worker start, checkpoint load, text encode) and 151 s for the 20 steps plus | |
| # the video + audio VAE decode. The old anchor (upstream's "544p / 20 steps / 5 s fits | |
| # in 120 s") declared 205 s for that job. BASE covers the load phase while the models | |
| # are loaded inside the window; once the preload is confirmed to cut it, lower BASE β | |
| # and `estimateH3SpaceAllocSecsFromDims` in src/shared/pipelineCost.ts in lockstep. | |
| # The app fits a residual on top for the *cost* figure. | |
| MIN_GPU_SECONDS = int(os.environ.get("MIN_GPU_SECONDS", "60")) | |
| MAX_GPU_SECONDS = int(os.environ.get("MAX_GPU_SECONDS", "1800")) | |
| H3_REF_WORK = 960 * 544 * 124 | |
| H3_BASE_SECS = 100.0 | |
| H3_PER_STEP_SECS = 6.5 | |
| H3_WORK_EXPONENT = 1.3 | |
| H3_SAFETY = 1.25 | |
| def frame_count(seconds: float) -> int: | |
| """24 fps frame count snapped *up* to the model's 17k+5 grid: 5, 22, β¦, 124 (β5s), 362 (β15s).""" | |
| raw = int(round(float(seconds) * FPS)) | |
| if raw <= 5: | |
| return 5 | |
| return 5 + 17 * math.ceil((raw - 5) / 17) | |
| def fit_canvas(image_w: int, image_h: int, max_dim: int) -> tuple[int, int]: | |
| """Longest side = max_dim, short side follows the input aspect ratio, both snapped to 32.""" | |
| long_side = max(CANVAS_MULTIPLE, int(max_dim)) | |
| scale = long_side / max(image_w, image_h) | |
| def snap(v: float) -> int: | |
| return max(CANVAS_MULTIPLE, round(v * scale / CANVAS_MULTIPLE) * CANVAS_MULTIPLE) | |
| return snap(image_w), snap(image_h) | |
| def estimate_gpu_seconds(width: int, height: int, frames: int, steps: int) -> int: | |
| work = (width * height * frames) / H3_REF_WORK | |
| est = (H3_BASE_SECS + H3_PER_STEP_SECS * steps * work ** H3_WORK_EXPONENT) * H3_SAFETY | |
| return max(MIN_GPU_SECONDS, int(math.ceil(est))) | |
| # ββ SageAttention (optional; wheel from RioShiina's ZeroGPU build) βββββββββββ | |
| if SAGE_ATTENTION and "--use-sage-attention" not in sys.argv: | |
| sys.argv.append("--use-sage-attention") | |
| _sage_patched = False | |
| def apply_sage_attention_patch() -> str: | |
| global _sage_patched | |
| if not SAGE_ATTENTION: | |
| return "sage attention off (H3_SAGE_ATTENTION=0) β default attention" | |
| if _sage_patched: | |
| return "sage attention already enabled" | |
| try: | |
| from comfy import model_management | |
| import sageattention # noqa: F401 | |
| model_management.sage_attention_enabled = lambda: True | |
| model_management.pytorch_attention_enabled = lambda: False | |
| _sage_patched = True | |
| return "sage attention enabled" | |
| except ImportError: | |
| return "sageattention not installed β default attention" | |
| except Exception as exc: # pragma: no cover | |
| return f"sage attention patch failed: {exc}" | |
| # ββ ComfyUI bootstrap ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _run(cmd: list[str], cwd: pathlib.Path | None = None, check: bool = True) -> None: | |
| print(f"[setup] $ {' '.join(cmd)}", flush=True) | |
| subprocess.run(cmd, cwd=str(cwd) if cwd else None, check=check) | |
| def ensure_comfy() -> None: | |
| if not COMFY.exists(): | |
| _run(["git", "clone", "--depth", "1", "https://github.com/comfyanonymous/ComfyUI.git", str(COMFY)]) | |
| _run(["git", "fetch", "--depth", "1", "origin", COMFYUI_COMMIT], cwd=COMFY, check=False) | |
| _run(["git", "checkout", COMFYUI_COMMIT], cwd=COMFY, check=False) | |
| for sub in {d[0] for d in DOWNLOADS}: | |
| (COMFY_MODELS / sub).mkdir(parents=True, exist_ok=True) | |
| COMFY_INPUT.mkdir(parents=True, exist_ok=True) | |
| COMFY_OUTPUT.mkdir(parents=True, exist_ok=True) | |
| def ensure_models(progress: gr.Progress | None = None) -> None: | |
| token = os.environ.get("HF_TOKEN") or os.environ.get("HUGGINGFACE_HUB_TOKEN") | |
| for index, (sub, name, repo, repo_path) in enumerate(DOWNLOADS): | |
| dest = COMFY_MODELS / sub / name | |
| if dest.is_symlink() and not dest.exists(): | |
| dest.unlink() | |
| if dest.exists(): | |
| continue | |
| if progress: | |
| progress(index / len(DOWNLOADS), desc=f"downloading {name}") | |
| print(f"[setup] downloading {repo}/{repo_path}", flush=True) | |
| cached = hf_hub_download(repo_id=repo, filename=repo_path, token=token) | |
| dest.parent.mkdir(parents=True, exist_ok=True) | |
| os.symlink(cached, dest) | |
| _nodes_ready = False | |
| NODE_CLASS_MAPPINGS: dict[str, Any] = {} | |
| def init_comfy_nodes() -> None: | |
| """Import ComfyUI in-process (cwd = ComfyUI/ so folder_paths resolves models/, input/, output/).""" | |
| global _nodes_ready, NODE_CLASS_MAPPINGS | |
| if _nodes_ready: | |
| return | |
| comfy_path = str(COMFY) | |
| sys.path = [p for p in sys.path if p != comfy_path] | |
| sys.path.insert(0, comfy_path) | |
| for module_name in list(sys.modules): | |
| if module_name == "utils" or module_name.startswith("utils."): | |
| del sys.modules[module_name] | |
| os.chdir(COMFY) | |
| import execution | |
| import nodes | |
| import server | |
| loop = asyncio.new_event_loop() | |
| asyncio.set_event_loop(loop) | |
| server_instance = server.PromptServer(loop) | |
| execution.PromptQueue(server_instance) | |
| loop.run_until_complete(nodes.init_extra_nodes()) | |
| NODE_CLASS_MAPPINGS = nodes.NODE_CLASS_MAPPINGS | |
| for required in ("MiniMaxH3ImageToVideo", "VAEDecodeAudio", "CreateVideo", "SaveVideo"): | |
| if required not in NODE_CLASS_MAPPINGS: | |
| raise RuntimeError(f"ComfyUI @ {COMFYUI_COMMIT} has no '{required}' node") | |
| _nodes_ready = True | |
| print("[setup] ComfyUI nodes ready", flush=True) | |
| # ββ Workflow (the FL2VA recipe, inlined) ββββββββββββββββββββββββββββββββββββ | |
| class _DummyHidden: | |
| extra_pnginfo = None | |
| prompt = None | |
| def node_template(class_type: str) -> dict[str, Any]: | |
| """Node dict pre-filled with every input's declared default, like the ComfyUI API format.""" | |
| if class_type not in NODE_CLASS_MAPPINGS: | |
| raise ValueError(f"node class '{class_type}' not found") | |
| node_class = NODE_CLASS_MAPPINGS[class_type] | |
| input_types = node_class.INPUT_TYPES() | |
| inputs: dict[str, Any] = {} | |
| for name, details in {**input_types.get("required", {}), **input_types.get("optional", {})}.items(): | |
| config = details[1] if len(details) > 1 and isinstance(details[1], dict) else {} | |
| inputs[name] = config.get("default") | |
| return {"inputs": inputs, "class_type": class_type, "_meta": {"title": class_type}} | |
| def build_workflow( | |
| *, | |
| prompt: str, | |
| width: int, | |
| height: int, | |
| frames: int, | |
| steps: int, | |
| seed: int, | |
| first_frame_name: str, | |
| last_frame_name: str | None, | |
| filename_prefix: str, | |
| ) -> dict[str, Any]: | |
| wf: dict[str, Any] = {} | |
| def add(node_id: str, class_type: str, title: str, **inputs: Any) -> str: | |
| node = node_template(class_type) | |
| node["_meta"]["title"] = title | |
| node["inputs"].update(inputs) | |
| wf[node_id] = node | |
| return node_id | |
| add("unet", "UNETLoader", "Load Diffusion Model", unet_name=UNET_NAME, weight_dtype="default") | |
| add("clip", "CLIPLoader", "Load CLIP", clip_name=CLIP_NAME, type="minimax", device="default") | |
| add("vvae", "VAELoader", "Load Video VAE", vae_name=VIDEO_VAE_NAME) | |
| add("avae", "VAELoader", "Load Audio VAE", vae_name=AUDIO_VAE_NAME) | |
| add("ff_load", "LoadImage", "Load First Frame", image=first_frame_name) | |
| # lanczos, not nearest-exact: the keyframes are usually downscaled to the canvas and | |
| # nearest sampling aliases them (the Wan Space resizes its keyframes with LANCZOS). | |
| add("ff_scale", "ImageScale", "Scale First Frame", image=["ff_load", 0], | |
| upscale_method="lanczos", width=width, height=height, crop="center") | |
| h3_inputs: dict[str, Any] = dict( | |
| clip=["clip", 0], vae=["vvae", 0], prompt=prompt, | |
| width=width, height=height, length=frames, first_frame=["ff_scale", 0], | |
| ) | |
| if last_frame_name: | |
| add("lf_load", "LoadImage", "Load Last Frame", image=last_frame_name) | |
| add("lf_scale", "ImageScale", "Scale Last Frame", image=["lf_load", 0], | |
| upscale_method="lanczos", width=width, height=height, crop="center") | |
| h3_inputs["last_frame"] = ["lf_scale", 0] | |
| add("h3", "MiniMaxH3ImageToVideo", "MiniMax H3 Image to Video", **h3_inputs) | |
| add("guider", "BasicGuider", "Basic Guider", model=["unet", 0], conditioning=["h3", 0]) | |
| add("sampler", "KSamplerSelect", "KSamplerSelect", sampler_name="res_multistep") | |
| add("sigmas", "BasicScheduler", "BasicScheduler", model=["unet", 0], | |
| scheduler="simple", steps=steps, denoise=1.0) | |
| add("noise", "RandomNoise", "RandomNoise", noise_seed=seed) | |
| add("sample", "SamplerCustomAdvanced", "SamplerCustomAdvanced", noise=["noise", 0], | |
| guider=["guider", 0], sampler=["sampler", 0], sigmas=["sigmas", 0], latent_image=["h3", 1]) | |
| add("vdec", "VAEDecode", "VAE Decode", samples=["sample", 0], vae=["vvae", 0]) | |
| add("adec", "VAEDecodeAudio", "VAE Decode Audio", samples=["sample", 0], vae=["avae", 0]) | |
| add("video", "CreateVideo", "Create Video", images=["vdec", 0], audio=["adec", 0], fps=FPS) | |
| add("save", "SaveVideo", "Save Video", video=["video", 0], filename_prefix=filename_prefix, | |
| format="mp4", codec={"codec": "h264"}) | |
| return wf | |
| # ββ Executor (upstream's minimal topological runner β no PromptServer round-trip) ββ | |
| def _value_at(obj: Any, index: int) -> Any: | |
| try: | |
| return obj[index] | |
| except (KeyError, IndexError, TypeError): | |
| try: | |
| return obj["result"][index] | |
| except (KeyError, IndexError, TypeError): | |
| return None | |
| def _topological_order(workflow: dict[str, Any]) -> list[str]: | |
| from collections import defaultdict, deque | |
| graph: dict[str, list[str]] = defaultdict(list) | |
| in_degree = {node_id: 0 for node_id in workflow} | |
| for node_id, node in workflow.items(): | |
| for value in node.get("inputs", {}).values(): | |
| if isinstance(value, list) and len(value) == 2 and isinstance(value[0], str) and value[0] in workflow: | |
| graph[value[0]].append(node_id) | |
| in_degree[node_id] += 1 | |
| queue = deque(n for n, d in in_degree.items() if d == 0) | |
| ordered: list[str] = [] | |
| while queue: | |
| current = queue.popleft() | |
| ordered.append(current) | |
| for nxt in graph[current]: | |
| in_degree[nxt] -= 1 | |
| if in_degree[nxt] == 0: | |
| queue.append(nxt) | |
| if len(ordered) != len(workflow): | |
| raise RuntimeError("workflow contains a cycle") | |
| return ordered | |
| def execute_workflow(workflow: dict[str, Any], initial_objects: dict[str, Any] | None = None) -> Any: | |
| import torch | |
| computed: dict[str, Any] = dict(initial_objects or {}) | |
| with torch.no_grad(): | |
| order = _topological_order(workflow) | |
| for node_id in order: | |
| if node_id in computed: | |
| continue | |
| node = workflow[node_id] | |
| node_class = NODE_CLASS_MAPPINGS.get(node["class_type"]) | |
| if node_class is None: | |
| raise RuntimeError(f"unknown node class '{node['class_type']}'") | |
| instance = node_class() | |
| for target in (node_class, instance): | |
| if getattr(target, "hidden", None) is None: | |
| try: | |
| setattr(target, "hidden", _DummyHidden()) | |
| except Exception: | |
| pass | |
| kwargs: dict[str, Any] = {} | |
| for name, value in node["inputs"].items(): | |
| if isinstance(value, list) and len(value) == 2 and isinstance(value[0], str) and value[0] in workflow: | |
| src_id, out_idx = value | |
| if src_id not in computed: | |
| raise RuntimeError(f"output of node {src_id} needed by {node_id} but not computed") | |
| actual = _value_at(computed[src_id], out_idx) | |
| else: | |
| actual = value | |
| if "." in name: | |
| parent, child = name.split(".", 1) | |
| kwargs.setdefault(parent, {})[child] = actual | |
| else: | |
| kwargs[name] = actual | |
| fn = getattr(instance, getattr(node_class, "FUNCTION")) | |
| print(f"[run] {node_id} ({node['class_type']})", flush=True) | |
| computed[node_id] = fn(**kwargs) | |
| return computed.get("save") | |
| def extract_video_path(out: Any) -> str | None: | |
| """Locate the mp4 SaveVideo wrote (ui payload, object attrs, or newest file in output/).""" | |
| found: list[str] = [] | |
| def visit(item: Any) -> None: | |
| if item is None or found: | |
| return | |
| if isinstance(item, (list, tuple)): | |
| for sub in item: | |
| visit(sub) | |
| return | |
| if isinstance(item, str): | |
| if os.path.exists(item): | |
| found.append(item) | |
| return | |
| if isinstance(item, dict): | |
| for key in ("images", "videos", "video"): | |
| for sub in item.get("ui", {}).get(key, []): | |
| if isinstance(sub, dict) and sub.get("filename"): | |
| candidate = COMFY_OUTPUT / sub.get("subfolder", "") / sub["filename"] | |
| if candidate.exists(): | |
| found.append(str(candidate)) | |
| return | |
| return | |
| ui = getattr(item, "ui", None) | |
| if ui is not None: | |
| visit(ui if isinstance(ui, dict) else getattr(ui, "as_dict", lambda: None)()) | |
| for attr in ("path", "saved_path", "filepath", "filename", "full_path"): | |
| val = getattr(item, attr, None) | |
| if isinstance(val, str): | |
| for candidate in (pathlib.Path(val), COMFY_OUTPUT / val): | |
| if candidate.exists(): | |
| found.append(str(candidate)) | |
| return | |
| visit(out) | |
| if found: | |
| return found[0] | |
| files = glob.glob(str(COMFY_OUTPUT / "**" / "*.mp4"), recursive=True) | |
| if not files: | |
| return None | |
| files.sort(key=os.path.getmtime, reverse=True) | |
| return files[0] | |
| # ββ Preload of the four loaders (see PRELOAD_MODELS / GPU_RESIDENT) βββββββββ | |
| PRELOADED: dict[str, Any] = {} | |
| LOADER_NODES = ("unet", "clip", "vvae", "avae") | |
| def preload_models() -> None: | |
| """Run the four loader nodes in the parent process (outside any GPU window).""" | |
| template = build_workflow(prompt="", width=960, height=544, frames=124, steps=1, seed=0, | |
| first_frame_name="", last_frame_name=None, filename_prefix="") | |
| t0 = time.time() | |
| for node_id in LOADER_NODES: | |
| node = template[node_id] | |
| node_class = NODE_CLASS_MAPPINGS[node["class_type"]] | |
| instance = node_class() | |
| fn = getattr(instance, getattr(node_class, "FUNCTION")) | |
| print(f"[setup] preloading {node_id} ({node['class_type']})", flush=True) | |
| PRELOADED[node_id] = fn(**node["inputs"]) | |
| print(f"[setup] preloaded {len(PRELOADED)} loaders into CPU RAM in {time.time() - t0:.0f}s", flush=True) | |
| def make_models_gpu_resident() -> None: | |
| """Ask ComfyUI to place the preloaded patchers on the (emulated) GPU at module level. | |
| ZeroGPU then holds those tensors and restores them into the real GPU at allocation, | |
| so the worker starts with the weights in place instead of copying them up from the | |
| parent's RAM inside the billed window. | |
| """ | |
| from comfy import model_management | |
| patchers = [] | |
| for node_id in LOADER_NODES: | |
| obj = PRELOADED[node_id][0] | |
| patchers.append(getattr(obj, "patcher", obj)) | |
| t0 = time.time() | |
| try: | |
| model_management.load_models_gpu(patchers, force_full_load=True) | |
| except TypeError: # older signature without force_full_load | |
| model_management.load_models_gpu(patchers) | |
| print(f"[setup] {len(patchers)} patchers placed on cuda in {time.time() - t0:.0f}s", flush=True) | |
| # ββ Durable outputs (same scheme as the Wan / LTX Spaces) βββββββββββββββββββ | |
| def persist_job_output(job_id: str, video_path: str, seed: int) -> None: | |
| """Upload the finished mp4 (+ tiny sidecar) to a Hub dataset keyed by job_id so a | |
| client that lost the SSE stream can later download `{job_id}.mp4`.""" | |
| job_id = (job_id or "").strip() | |
| if not job_id or not video_path or not os.path.isfile(video_path): | |
| return | |
| token = os.environ.get("HF_TOKEN") or os.environ.get("HUGGINGFACE_HUB_TOKEN") | |
| if not token: | |
| print(f"[persist] skip {job_id}: no HF_TOKEN on Space", flush=True) | |
| return | |
| if not H3_JOB_DATASET: | |
| print(f"[persist] skip {job_id}: H3_JOB_DATASET empty", flush=True) | |
| return | |
| try: | |
| api = HfApi(token=token) | |
| api.upload_file(path_or_fileobj=video_path, path_in_repo=f"{job_id}.mp4", | |
| repo_id=H3_JOB_DATASET, repo_type="dataset") | |
| meta = json.dumps({"job_id": job_id, "seed": int(seed)}, separators=(",", ":")) | |
| api.upload_file(path_or_fileobj=meta.encode("utf-8"), path_in_repo=f"{job_id}.json", | |
| repo_id=H3_JOB_DATASET, repo_type="dataset") | |
| print(f"[persist] uploaded {job_id} β dataset {H3_JOB_DATASET}", flush=True) | |
| except Exception as exc: | |
| print(f"[persist] failed for {job_id}: {exc}", flush=True) | |
| # ββ Job helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def stage_input_image(path: str, tag: str) -> tuple[str, int, int]: | |
| """Copy an upload into ComfyUI/input as an oriented RGB PNG; returns (name, w, h).""" | |
| with Image.open(path) as img: | |
| img = ImageOps.exif_transpose(img).convert("RGB") | |
| name = f"h3_{tag}_{uuid.uuid4().hex}.png" | |
| img.save(COMFY_INPUT / name, "PNG") | |
| return name, img.width, img.height | |
| def prune_old_files(directory: pathlib.Path, max_age_secs: int) -> None: | |
| cutoff = time.time() - max_age_secs | |
| for p in directory.rglob("*"): | |
| try: | |
| if p.is_file() and p.stat().st_mtime < cutoff: | |
| p.unlink() | |
| except OSError: | |
| pass | |
| def _gpu_budget(workflow: dict[str, Any], budget: int, progress: gr.Progress | None = None) -> int: | |
| return int(budget) | |
| def run_workflow_gpu(workflow: dict[str, Any], budget: int, progress: gr.Progress | None = None) -> str | None: | |
| # PRELOADED is read from the module global, never passed in: ZeroGPU pickles every | |
| # argument through a queue to the worker, and ComfyUI's model objects can't be pickled | |
| # ("Can't get local object 'mixed_precision_ops.<locals>.MixedPrecisionOps.Linear'", | |
| # 2026-10-16). The worker is forked from this process, so it inherits the global. | |
| print(f"[gpu] {apply_sage_attention_patch()} β budget {budget}s, " | |
| f"preloaded={','.join(PRELOADED) or 'none'}", flush=True) | |
| out = execute_workflow(workflow, PRELOADED or None) | |
| return extract_video_path(out) | |
| def generate_video( | |
| first_frame: str | None, | |
| last_frame: str | None, | |
| prompt: str, | |
| max_dim: float, | |
| seconds: float, | |
| steps: float, | |
| seed: float, | |
| randomize_seed: bool, | |
| job_id: str = "", | |
| progress: gr.Progress = gr.Progress(track_tqdm=True), | |
| ) -> tuple[str, int, str, int]: | |
| """api_name=generate_video β returns (video, used_seed, job_id, duration_ms).""" | |
| t0 = time.time() | |
| if not first_frame: | |
| raise gr.Error("upload a start image first") | |
| if not str(prompt or "").strip(): | |
| raise gr.Error("write a prompt first") | |
| try: | |
| max_dim_i = int(max_dim) | |
| seconds_f = float(seconds) | |
| steps_i = int(steps) | |
| except (TypeError, ValueError): | |
| raise gr.Error("max_dim, seconds and steps must be numbers") | |
| if not (MIN_MAX_DIM <= max_dim_i <= MAX_MAX_DIM): | |
| raise gr.Error(f"max_dim must be between {MIN_MAX_DIM} and {MAX_MAX_DIM}") | |
| if not (MIN_SECONDS <= seconds_f <= MAX_SECONDS): | |
| raise gr.Error(f"seconds must be between {MIN_SECONDS:g} and {MAX_SECONDS:g}") | |
| if not (MIN_STEPS <= steps_i <= MAX_STEPS): | |
| raise gr.Error(f"steps must be between {MIN_STEPS} and {MAX_STEPS}") | |
| used_seed = random.randint(0, 2**32 - 1) if randomize_seed or int(seed) < 0 else int(seed) | |
| progress(0.02, desc="Preparing inputs") | |
| prune_old_files(COMFY_INPUT, 6 * 3600) | |
| prune_old_files(COMFY_OUTPUT, 6 * 3600) | |
| ff_name, in_w, in_h = stage_input_image(first_frame, "first") | |
| lf_name = stage_input_image(last_frame, "last")[0] if last_frame else None | |
| width, height = fit_canvas(in_w, in_h, max_dim_i) | |
| frames = frame_count(seconds_f) | |
| budget = estimate_gpu_seconds(width, height, frames, steps_i) | |
| if budget > MAX_GPU_SECONDS: | |
| raise gr.Error( | |
| f"this job needs ~{budget}s of GPU time, above the Space cap of {MAX_GPU_SECONDS}s β " | |
| f"lower the resolution ({width}Γ{height}), duration ({frames} frames) or steps ({steps_i})" | |
| ) | |
| print(f"[job] {job_id or '-'} {width}x{height} frames={frames} steps={steps_i} seed={used_seed} " | |
| f"last_frame={'yes' if lf_name else 'no'} budget={budget}s", flush=True) | |
| progress(0.05, desc="Checking models") | |
| ensure_models(progress) | |
| workflow = build_workflow( | |
| prompt=str(prompt), width=width, height=height, frames=frames, steps=steps_i, seed=used_seed, | |
| first_frame_name=ff_name, last_frame_name=lf_name, | |
| filename_prefix=f"video/h3_{job_id.strip() or uuid.uuid4().hex}", | |
| ) | |
| progress(0.1, desc=f"Generating ({width}Γ{height}, {frames} frames, {steps_i} steps)") | |
| try: | |
| video = run_workflow_gpu(workflow, budget, progress=progress) | |
| finally: | |
| for name in (ff_name, lf_name): | |
| if name: | |
| try: | |
| (COMFY_INPUT / name).unlink() | |
| except OSError: | |
| pass | |
| if not video: | |
| raise gr.Error("generation finished without an output video β check the Space logs") | |
| persist_job_output(job_id, video, used_seed) | |
| duration_ms = int((time.time() - t0) * 1000) | |
| print(f"[job] {job_id or '-'} done in {duration_ms}ms β {video}", flush=True) | |
| return video, int(used_seed), job_id or "", duration_ms | |
| # ββ Startup ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| print(f"[setup] ComfyUI bootstrap β diffusion model {DIFFUSION_MODEL} ({UNET_NAME})", flush=True) | |
| ensure_comfy() | |
| ensure_models() | |
| init_comfy_nodes() | |
| print(f"[setup] {apply_sage_attention_patch()}", flush=True) | |
| if PRELOAD_MODELS: | |
| try: | |
| preload_models() | |
| except Exception as exc: # fall back to loading inside the GPU window | |
| PRELOADED.clear() | |
| print(f"[setup] preload failed, loaders will run on GPU: {exc}", flush=True) | |
| if PRELOADED and GPU_RESIDENT: | |
| try: | |
| make_models_gpu_resident() | |
| except Exception as exc: # keep the CPU preload | |
| print(f"[setup] cuda placement failed, weights stay in CPU RAM: {exc}", flush=True) | |
| else: | |
| print("[setup] H3_PRELOAD_MODELS=0 β loaders run inside each job's GPU window", flush=True) | |
| # ββ UI (the visible form IS the API β inputs are positional in this order) ββ | |
| with gr.Blocks(title="MiniMax-H3 I2V") as demo: | |
| gr.Markdown( | |
| "# MiniMax-H3 Β· image-to-video\n" | |
| "Start image required; add a last image for first-to-last-frame. Native audio. " | |
| "Slim fork of [RioShiina/MiniMax-H3](https://huggingface.co/spaces/RioShiina/MiniMax-H3) " | |
| "for protocol-generator β I2VA / FLF2VA only." | |
| ) | |
| with gr.Row(): | |
| first_frame = gr.Image(type="filepath", label="Start image", height=240) | |
| last_frame = gr.Image(type="filepath", label="Last image (optional)", height=240) | |
| prompt = gr.Textbox(label="Prompt", lines=4) | |
| with gr.Row(): | |
| max_dim = gr.Number(label="Longest side (px, Γ32)", value=960, precision=0, | |
| info="960 β 544p at 16:9 Β· 1344 β 768p Β· 1920 β 1080p") | |
| seconds = gr.Slider(label="Duration (s)", minimum=MIN_SECONDS, maximum=MAX_SECONDS, step=0.5, value=5.0) | |
| steps = gr.Slider(label="Steps", minimum=MIN_STEPS, maximum=MAX_STEPS, step=1, value=20) | |
| with gr.Row(): | |
| seed = gr.Number(label="Seed (-1 = random)", value=-1, precision=0) | |
| randomize_seed = gr.Checkbox(label="Randomize seed", value=True) | |
| job_id = gr.Textbox(label="job_id", value="", visible=False) | |
| run_btn = gr.Button("Generate", variant="primary") | |
| video = gr.Video(label="Result", interactive=False) | |
| used_seed = gr.Number(label="used_seed", precision=0, visible=False) | |
| job_id_out = gr.Textbox(label="job_id", visible=False) | |
| duration_ms = gr.Number(label="duration_ms", precision=0, visible=False) | |
| run_btn.click( | |
| fn=generate_video, | |
| inputs=[first_frame, last_frame, prompt, max_dim, seconds, steps, seed, randomize_seed, job_id], | |
| outputs=[video, used_seed, job_id_out, duration_ms], | |
| api_name="generate_video", | |
| ) | |
| demo.queue() | |
| if __name__ == "__main__": | |
| demo.launch() | |