yycc's picture
viggle-animate workflow (by @akhaliq , PR #1 on viggle-animate)
7535c4f verified
Raw History Blame Contribute Delete
63.7 kB
# SPDX-License-Identifier: Apache-2.0
"""Viggle-Animate on ZeroGPU: put the person from a still into a driving video.
Two stages, deliberately split so the OpenAI call never burns GPU quota:
prepare (CPU) frame 0 of the driving clip is handed to gpt-image-2 together with the
character still, and comes back with the performer replaced. That
repainted frame is the model's reference -- the whole "no pose, no
segmentation, no mask" claim rests on this being the only preprocessing.
A frame painted elsewhere can be supplied directly instead, which is what
the bundled examples do: it costs nothing and it renders deterministically.
render (GPU) 4 sampling steps against the driving clip. ~62 GiB of weights, so this
needs ZeroGPU `xlarge` (the full 96 GB card); `large` is 48 GB and
cannot hold it.
Measured on this hardware: weights download at ~1.05 GiB/s and load in ~85 s, for 73.5 GiB
of parameters against a 95.0 GiB card.
The load is at MODULE level because `@spaces.GPU` bodies run in a worker process forked on
the first GPU request -- it inherits everything import time set up, and nothing a request
handler later assigns to a global. The weights stay on the CPU at startup; pre-staging them
through ZeroGPU's tensor pack was tried and does not fit here -- see the note at the bottom
of the load.
"""
import base64
import contextlib
import inspect
import io
import json
import os
import shutil
import subprocess
import tempfile
import time
from pathlib import Path
# 243 frames peaks at 86.1 GiB allocated against a 95.0 GiB card. That margin only survives if
# the allocator can grow segments instead of stranding them, so this is set before torch loads.
# Both spellings: 2.9 renamed the variable and warns on the old one, 2.8 only knows the old one.
os.environ.setdefault("PYTORCH_ALLOC_CONF", "expandable_segments:True")
os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
import gradio as gr
import spaces
import torch
from huggingface_hub import HfApi, hf_hub_download, snapshot_download
from huggingface_hub.errors import EntryNotFoundError
from openai import OpenAI
from PIL import Image
# A private module: LazyAOTIModel and PACKAGE_FILENAME are not among the package's lazy
# top-level exports. The builder pins `spaces` itself (0.51.1 at this writing -- listing it in
# requirements.txt at any other version is a pip conflict); this module is byte-identical in
# 0.51.1 and 0.51.3, the versions the code was written against.
from spaces.zero.torch.aoti import (PACKAGE_FILENAME, LazyAOTIModel, aoti_capture,
aoti_compile_and_save, aoti_patch)
H3 = "/tmp/h3"
VA = "/tmp/viggle"
MODEL_CARD = "https://huggingface.co/Viggle/Viggle-Animate"
SITE = "https://viggle.ai/h3"
EX = Path(__file__).parent / "examples"
# Every case ships as driving clip + already-painted frame 0 + the render those two produce,
# so clicking one costs no GPU and no OpenAI call. Several share a driving clip, hence the
# name -> (drive file prefix, caption) shape: `corgi` and `clay` reuse `costume`'s clip, and
# the second take of each two-character pair reuses its partner's.
# Order is what a visitor reads top to bottom. `lipsync` is first because it is the only case
# whose driving clip carries a soundtrack, and therefore the only one where the mouth has
# anything to track: every other output's audio track is this finetune's digital silence
# (-105 dBFS, against -22 for this one). Then the swaps where the target is not a person -- one
# paint and one render each, no best-of-N -- and last the human cases the model card compares
# against Wan2.2-Animate. Within that, aspect ratio is grouped, because the output rows below
# are split on these indices.
CASES = {
"lipsync": ("lipsync", "the driving clip has sound, so the mouth has something to say "
"— turn the volume on"),
"corgi": ("costume", "a corgi"),
"panda": ("panda", "a giant panda"),
"penguin": ("penguin", "a penguin"),
"plane": ("plane", "an airliner — wings for arms, landing gear for legs"),
"robot": ("robot", "a walking robot"),
"clay": ("costume", "claymation"),
"duo_anime": ("duo_anime", "two characters at once — cel-shaded cat and corgi"),
"duo_real": ("duo_anime", "the same frame, painted photoreal over the anime set"),
"wushu_animals": ("wushu_animals", "a wide arena shot, both fighters replaced"),
"wushu_robots": ("wushu_animals", "the same shot, gold and chrome robots"),
"prop": ("prop", "a light-up toy, held in both hands"),
"costume": ("costume", "a bow headwrap and floral leggings, driven by a bearded man"),
"style": ("style", "a flat 2D anime character"),
}
FPS = 24
# The video VAE only encodes frame counts of the form 17n+5, and the pipeline refuses anything
# under 5.0 s, so 124 (5.167 s) is the shortest render that exists -- not a choice made here.
# Uploads are held out to the next such count and the render is cut back, so any length between
# MIN_UPLOAD_S and MAX_FRAMES/FPS comes back at its own length.
#
# The ceiling is VRAM, not the model, which allows up to 345. Measured peak allocated on one
# B200 at 480x832: 79.9 GiB at 124 frames, 82.5 at 175, 86.1 at 243, and 86.8 at 243 frames on a
# 480x864 canvas -- 414,720 px, wider than anything LADDER can now produce, so that 86.8 GiB is a
# true upper bound with 8.2 GiB of headroom against this card's 95.0 GiB. Hence 243, not 345.
CHUNK, TAIL = 17, 5
MIN_FRAMES, MAX_FRAMES = 124, 243 # 5.167 s .. 10.125 s
MIN_UPLOAD_S = 4.0
# The 480-class bucket ladder, copied from `ft/buckets.py`. These nine geometries are the only ones
# the model was ever trained or evaluated on. They are 32-aligned by construction and all spend the
# same ~390 rows per latent frame -- the ladder holds *area* constant, not the short edge.
LADDER = [(416, 960), (448, 896), (480, 832), (544, 736), (640, 640),
(736, 544), (832, 480), (896, 448), (960, 416)]
# Measured on this card (renders of 2026-09-01/05): the denoise loop runs 1.6x the B200 timings
# `budget` extrapolates from (42 s at 124 frames), and the host-to-device copy is 22 s from warm
# page cache, 75 s typical, 154 s cold -- and one 261 s outlier that overran the request and drew
# only a warning. Both are set high on purpose -- the padded request is what locks the free tier
# out at admission; see `budget`.
SLOWDOWN = float(os.environ.get("SLOWDOWN", "1.6")) # this card vs the B200 the timings came from
H2D = float(os.environ.get("H2D", "170")) # seconds to move the weights onto the card
# Measured good on this card: the first render logged no fallback, so sm_120 serves the shape.
ATTN = os.environ.get("ATTN", "_native_cudnn") # set to "" to stay on torch's default
DAILY = int(os.environ.get("DAILY_LIMIT", "5"))
# Who may press the AOTI build button, which holds a write token for the weights repo.
OWNERS = {u.strip().lower() for u in os.environ.get("OWNERS", "yycc").split(",") if u.strip()}
# The repaint is downscaled to a ladder canvas -- 480x832 at most -- before the model ever sees
# it, so the detail the higher tiers buy is resampled away. What has to survive that downscale is
# the pose and the identity, not the texture. "low", "medium", "high" or "auto".
QUALITY = os.environ.get("IMAGE_QUALITY", "low")
# There is no `input_fidelity` here on purpose. The openai SDK accepts the parameter, but
# gpt-image-2 rejects it -- `invalid_input_fidelity_model`, a 400 on every request, not a
# warning. It is a gpt-image-1 knob. Do not add it back.
FONT = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
# Fail at boot, not in `finish` -- which runs after the GPU money is already spent.
assert Path(FONT).exists(), f"watermark font missing: {FONT}"
t = time.time()
snapshot_download("MiniMaxAI/MiniMax-H3", local_dir=H3,
allow_patterns=["*.json", "vae/*", "audio_vae/*",
"scheduler/*", "audio_scheduler/*"])
snapshot_download("Viggle/Viggle-Animate", local_dir=VA,
allow_patterns=["transformer/*", "lora/*", "assets/*"])
print(f"[boot] weights in {time.time()-t:.0f}s", flush=True)
from diffusers import MiniMaxH3Transformer3DModel, ModularPipeline, attention_backend
from diffusers.modular_pipelines.minimax_h3 import (MiniMaxH3ImageReference,
MiniMaxH3VideoReference)
from diffusers.modular_pipelines.minimax_h3.before_denoise import MiniMaxH3SetTimestepsStep
from diffusers.modular_pipelines.minimax_h3.decoders import MiniMaxH3VideoDecodeStep
from diffusers.modular_pipelines.minimax_h3.denoise import MiniMaxH3LoopSchedulerStep
from diffusers.modular_pipelines.minimax_h3.encoders import MiniMaxH3Ref2VATextEncoderStep
from diffusers.modular_pipelines.minimax_h3.modular_pipeline import audio_latent_num_frames
from diffusers.utils.export_utils import encode_video
# The text encoder is never loaded: conditioning is a frozen 362x5120 tensor computed once
# from a fixed prompt, so Qwen3-VL (63 GB of the base repo) stays on disk. Nothing in the
# output comes from text anyone types here -- there is no prompt box for that reason.
FIXED = torch.load(f"{VA}/assets/fixed_embed_fwd_anyframe.pt", weights_only=False)
def use_fixed_embeds(self, components, state):
block_state = self.get_block_state(state)
block_state.prompt_embeds = FIXED["prompt_embeds"].to(components._execution_device,
torch.bfloat16)
block_state.text_token_tags = FIXED["text_token_tags"]
self.set_block_state(state, block_state)
return components, state
MiniMaxH3Ref2VATextEncoderStep.__call__ = use_fixed_embeds
@torch.no_grad() # stock has it too; the pipeline already runs under no_grad, so belt-and-braces
def decode_to_uint8(self, components, state):
"""The stock step ends in `postprocess_video`, which walks the decoded clip to fp32 numpy on
the CPU -- measured 9.2 s at 243 frames against 0.03 s for quantizing on the card
(REALTIME_PIPELINE.md: "pure format conversion, buys nothing"). Everything through the fp32
denormalize below is the stock code, and fp32 round is round-half-to-even in numpy and torch
alike, so the u8 frames are bit-identical to what the old path fed x264 -- only the CPU round
trip is gone. `encode_video` takes the [T,H,W,C] uint8 tensor as-is. `output_type` is
ignored: this app is the only caller and it always wants frames.
"""
block_state = self.get_block_state(state)
device = components._execution_device
latents_mean = torch.tensor(components.vae.config.latents_mean, device=device).view(1, -1, 1, 1, 1)
latents_std = torch.tensor(components.vae.config.latents_std, device=device).view(1, -1, 1, 1, 1)
latents = block_state.latents * latents_std + latents_mean
with torch.autocast(device_type=device.type, dtype=torch.float16, enabled=device.type == "cuda"):
video = components.vae.decode(latents, return_dict=False)[0]
pixel_mean = torch.tensor(components.pixel_mean, device=device).view(1, -1, 1, 1, 1)
pixel_std = torch.tensor(components.pixel_std, device=device).view(1, -1, 1, 1, 1)
video = (video.float() * pixel_std + pixel_mean).clamp(0, 1)
# In-place on purpose: `video` is a fresh tensor never read again, and the out-of-place
# spelling would hold two extra full-size fp32 temporaries at the peak.
block_state.videos = [v.mul_(255).round_().to(torch.uint8).permute(1, 2, 3, 0).contiguous().cpu()
for v in video]
self.set_block_state(state, block_state)
return components, state
MiniMaxH3VideoDecodeStep.__call__ = decode_to_uint8
# ---------------------------------------------------------------------------------------------
# The audio pin. When the upload has a soundtrack it is VAE-encoded and written into the render's
# *target* audio rows as a clean latent -- t=1, H3's flow being reversed -- and held there for the
# whole loop, instead of being denoised out of noise like every other target row. The video branch
# attends to those rows, so the picture is driven by the real speech. That is what improves
# lip-sync, and it is measurable rather than hoped for: against a control-vs-control floor of
# 50.4 dB, swapping the pinned track for encoded silence moves the video by 23.0-32.8 dB across
# four shot/canvas combinations. It costs nothing -- peak VRAM matched the control to 0.1 GiB and
# wall clock to within run-to-run noise. `AUDIO_PIN.md` in the repo has the falsification runs.
#
# Both patches are installed once, at import, and read PIN at call time. Installing them per
# request would leak across visitors: a class patch is process-global and one worker serves every
# request, so an upload that had audio would silently pin the next upload that did not.
PIN = False
_build_row_timesteps = MiniMaxH3SetTimestepsStep.build_row_timesteps
def pinned_row_timesteps(*a):
"""Tell the transformer the target audio rows are already clean.
Positional arg 6 is `audio_timestep`, and `before_denoise.py` passes all nine positionally.
Without this the rows hold real audio while the model is told they are noise at the current
sigma, and it denoises against a premise that is false.
"""
return _build_row_timesteps(*a[:6], 1.0, *a[7:]) if PIN else _build_row_timesteps(*a)
MiniMaxH3SetTimestepsStep.build_row_timesteps = staticmethod(pinned_row_timesteps)
_loop_step = MiniMaxH3LoopSchedulerStep.__call__
@torch.no_grad()
def video_only_step(self, components, block_state, i, t):
"""Step the video rows only, so the pinned audio rows survive every step untouched.
The obvious alternative -- raising `num_condition_audio_rows` so the loop treats them as
conditioning -- does not work: it empties the decoder's slice
`audio_latents[num_condition_audio_rows:]` and trips the reference-count assert in
`MiniMaxH3PrepareLatentsStep`. That count stays 0.
"""
if not PIN:
return _loop_step(self, components, block_state, i, t)
n = block_state.num_condition_video_rows
block_state.latents[n:] = components.scheduler.step(
block_state.noise_pred[0, n:].float(), t, block_state.latents[n:], return_dict=False)[0]
return components, block_state
MiniMaxH3LoopSchedulerStep.__call__ = video_only_step
t = time.time()
PIPE = ModularPipeline.from_pretrained(H3, workflow="ref2va")
PIPE.load_components(names=["vae", "audio_vae", "scheduler", "audio_scheduler"],
pretrained_model_name_or_path=H3, torch_dtype=torch.bfloat16)
PIPE.transformer_ref = MiniMaxH3Transformer3DModel.from_pretrained(
f"{VA}/transformer", torch_dtype=torch.bfloat16)
# `prefix=None` and the explicit `weight_name` are both required: the loader defaults to a
# `.bin` name (raises) and to filtering keys for a `transformer.` prefix these bare keys do
# not have -- that mismatch loads nothing and only warns, i.e. silently renders the
# un-distilled model at 4 steps, which looks like noise.
PIPE.transformer_ref.load_lora_adapter(f"{VA}/lora",
weight_name="pytorch_lora_weights.safetensors",
prefix=None)
PIPE.scheduler.set_shift(3.0) # the base default is 12; the few-step student wants 3
ASR = PIPE.audio_vae.config.sampling_rate # 32 kHz; the rate the pin has to be fed at
# AOT-compiled copies of the transformer's two repeated block classes, built ON THIS HARDWARE by
# the maintenance button below (a package compiled on another arch will not load) and stored in
# the weights repo as <ClassName>/package.pt2 -- code only, tens of MB: the weights come from
# each block's own state_dict at patch time. Missing packages mean an eager Space, not a broken
# one. Only the *download* happens here; the patching waits for the GPU worker, because
# `aoti_patch` snapshots `state_dict()` and until `PIPE.to("cuda")` in `render` those storages
# are the CPU ones the copy abandons -- patched at boot, the compiled blocks would read CPU
# weights forever. See `wire_aoti`.
AOTI_REPO = "Viggle/Viggle-Animate"
class _Cell:
# Stand-in for the ContextVars inside LazyAOTIModel. The worker runs every GPU task in a
# fresh copy_context(), so a ContextVar set during one render is invisible to the next --
# the dlopen'd package would be re-extracted and re-loaded inside every billed window, and
# each extraction is another copy charged against the container's hidden write quota. A
# plain attribute survives across tasks; the parent never calls these models, so the
# context isolation being discarded here protected nothing.
def __init__(self):
self.v = None
def get(self):
return self.v
def set(self, v):
self.v = v
AOTI_BLOCKS = {}
# `ATTN=""` must keep meaning "no cuDNN anywhere": the packages baked cuDNN in at build time
# and ignore `set_attention_backend`, so with the kill switch set they may not be wired at all.
if os.environ.get("AOTI", "1") == "1" and ATTN:
try:
AOTI_BLOCKS = {name: LazyAOTIModel(hf_hub_download(AOTI_REPO, PACKAGE_FILENAME,
subfolder=name))
for name in MiniMaxH3Transformer3DModel._repeated_blocks}
for lazy in AOTI_BLOCKS.values():
lazy.compiled_model, lazy.loaded_weights = _Cell(), _Cell()
print(f"[boot] aoti packages loaded: {', '.join(AOTI_BLOCKS)}", flush=True)
except EntryNotFoundError:
AOTI_BLOCKS = {}
print("[boot] no aoti packages on the hub -- rendering eager", flush=True)
# The weights stay on the CPU until `render`'s `PIPE.to("cuda")`. Pre-staging them through
# ZeroGPU's tensor pack (`PIPE.to("cuda")` here at import) was tried 2026-09-01 and cannot fit:
# the pack `posix_fallocate`s 78.6 GB while the 80 GB of sources still sit on the same disk, and
# the container has a hidden ~100 GB write quota that statvfs does not report -- the boot line
# below prints >1.3 TB free while the fallocate dies ENOSPC instantly. The prize was small
# anyway: the pack loads via O_DIRECT disk reads, the copy it replaces runs from warm page cache.
print(f"[boot] pipeline in {time.time()-t:.0f}s, "
f"disk free {shutil.disk_usage('/tmp').free/1e9:.0f} GB /tmp, "
f"{shutil.disk_usage(Path.home()).free/1e9:.0f} GB {Path.home()}", flush=True)
def run(*cmd):
# ffmpeg says why it failed on stderr, and `capture_output` swallows it -- a bare
# CalledProcessError names the exit code and nothing else, which is useless for a codec or
# container the uploader chose.
p = subprocess.run(cmd, capture_output=True, text=True, errors="replace")
if p.returncode:
raise gr.Error(f"{cmd[0]} failed: {p.stderr.strip().splitlines()[-1] if p.stderr.strip() else p.returncode}")
def sweep(hours=6):
"""Drop working directories older than `hours`.
Each request leaves one behind holding a few hundred MB of intermediate video, and the
container runs for weeks. Pruning by age rather than tracking them is deliberate: a render
in flight still owns its directory, and age is the one property that cannot mistake a live
job for a dead one.
"""
cutoff = time.time() - hours * 3600
for d in Path(tempfile.gettempdir()).glob("tmp*"):
# Two concurrent requests both sweep, so a directory can disappear between the glob and
# the stat. That is not a reason to fail somebody's render.
with contextlib.suppress(OSError):
if d.is_dir() and d.stat().st_mtime < cutoff:
shutil.rmtree(d, ignore_errors=True)
def probe(path):
out = subprocess.run(
["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries",
"stream=width,height", "-of", "json", path],
check=True, capture_output=True, text=True).stdout
s = json.loads(out)["streams"][0]
return s["width"], s["height"]
def nframes(path):
return int(subprocess.run(
["ffprobe", "-v", "error", "-select_streams", "v:0", "-count_frames",
"-show_entries", "stream=nb_read_frames", "-of", "csv=p=0", path],
check=True, capture_output=True, text=True).stdout.strip())
def has_audio(path):
"""Whether the upload carries a soundtrack, which is what decides if the pin is used."""
return bool(subprocess.run(
["ffprobe", "-v", "error", "-select_streams", "a:0", "-show_entries",
"stream=codec_type", "-of", "csv=p=0", path],
check=True, capture_output=True, text=True).stdout.strip())
def grid(n):
"""Smallest frame count the video VAE accepts (17k+5) that still covers `n` frames.
Rounding *up* costs at most 16 frames of held-still tail, which gets trimmed off the render.
Rounding down would instead silently shorten every clip that is not already on the grid.
"""
return min(MAX_FRAMES, max(MIN_FRAMES, -(-(n - TAIL) // CHUNK) * CHUNK + TAIL))
def canvas(w, h):
"""The ladder geometry whose aspect is closest to the upload's.
Not "scale to a 480 short edge", which is the obvious rule and the wrong one. The ladder
holds area constant, not the short edge, so the two rules agree only near 480x832 and drift
apart everywhere else -- a square clip on a 480 short edge is 480x480, which is 225 rows per
latent frame against the ladder's 390. `ft/dataset_fwd.py:59` records training doing exactly
that and undoing it: "1:1 used to resize to 480x480 [...] 42% of the budget thrown away on
the one class that could most afford the pixels. Index 4 is 640x640."
Snapping to the ladder also removes two failure modes for free. Every entry is a multiple of
32, so the patchify guard can no longer fire -- it is what the 16-grid 464 case used to trip.
And the aspects are bounded to 0.43..2.31, so a panorama or a column no longer produces a
canvas the model has never seen; it gets cover-cropped to the nearest one it has.
"""
return min(LADDER, key=lambda b: abs(b[0] / b[1] - w / h))
def fit(img, w, h):
"""Land a reference frame on exactly w x h without stretching it.
Stretching would be the quiet failure here: the reference is compared against the driving
clip frame by frame, so a horizontally squeezed body is a body that disagrees with every
frame of the motion. Off-aspect input is cover-cropped instead, losing edges rather than
proportions.
"""
if abs(img.width / img.height - w / h) > 0.01:
s = max(w / img.width, h / img.height)
img = img.resize((round(img.width * s), round(img.height * s)), Image.LANCZOS)
img = img.crop(((img.width - w) // 2, (img.height - h) // 2,
(img.width - w) // 2 + w, (img.height - h) // 2 + h))
return img.resize((w, h), Image.LANCZOS)
# The repainted frame is a motion anchor before it is a picture. Whatever geometry it shows is
# what the video model tries to reconcile with the driving clip, and when the two disagree the
# driving clip wins -- an editor that quietly re-poses or re-centres the subject produces a
# reference that fights every frame. So the pose is stated as the constraint and the identity as
# the single edit, rather than both being items in one list.
REPAINT = (
"Edit the first image, which is one frame of a video. Change exactly one thing: who the "
"person is. Give them the face, hair, skin tone and clothing of the person in the second "
"image.\n"
"The body does not move. Keep the pose identical -- every limb, both hands, the head angle "
"and the direction of gaze stay exactly where they are, and the person keeps the same "
"position in the frame and the same size. Do not re-pose, straighten, re-centre or redraw "
"the body: repaint the person who is already standing there, do not draw a new one.\n"
"Nothing else changes. Same camera angle, same framing and crop, same background, same "
"lighting, shadows and colour grade. Anything the person is holding or touching stays "
"exactly as it is, in the same hand, at the same angle.\n"
"Do not zoom or crop, and do not add text, captions or watermarks."
)
SEEN = {}
def gate(request):
"""One repaint counter per client IP per day.
This is a spend limit on the owner's image-model budget, not on the GPU. The gpt-image-2
repaint happens in `prepare`, on the CPU, before any `@spaces.GPU` call -- ZeroGPU's quota
never sees it, and only this counter stops it. The GPU needs no gate here at all: ZeroGPU's
own admission check turns visitors away by their Hub quota (see `budget`).
An IP is a weaker key than the Hub username this used to count -- a forwarded header can be
rewritten, and one NAT can hide a classroom -- but the username needed a sign-in, and the
sign-in was judged to cost more visitors than the key was worth. A brake, not a wall.
"""
key = _client_key(request)
used = SEEN.get(key, 0)
if used >= DAILY:
raise gr.Error(f"daily repaint limit reached ({DAILY}). Try again tomorrow, or supply "
"a first frame you painted elsewhere -- that path is not limited.")
# Yesterday's counters are dead weight in a process that stays up for weeks.
for k in [k for k in SEEN if k[1] != key[1]]:
del SEEN[k]
SEEN[key] = used + 1
return DAILY - used - 1
def _client_key(request):
ip = (request.headers.get("x-forwarded-for", "").split(",")[0].strip()
or request.client.host) if request else "?"
return ip, time.strftime("%Y-%m-%d")
def refund(request):
# A repaint the server failed to deliver should not cost one of the day's five.
key = _client_key(request)
if SEEN.get(key, 0) > 0:
SEEN[key] -= 1
# A Space restart empties /tmp/gradio, but pages opened before it still reference files there.
# The re-submitted click then dies inside the components' own preprocess (PIL.Image.open --
# production, 2026-09-05), *before* any handler runs, so only a wrapper here can turn the raw
# FileNotFoundError into something a visitor can act on.
_GONE = ("this page predates a Space restart and its uploads are gone -- "
"reload the page and upload the files again")
for _cls in (gr.Image, gr.Video):
def _guarded(self, *a, _orig=_cls.preprocess, **k):
try:
return _orig(self, *a, **k)
except FileNotFoundError:
raise gr.Error(_GONE)
_cls.preprocess = _guarded
def prepare(video, character, painted, request: gr.Request = None):
# `request` is not wired to any component: gradio fills it in from the type annotation.
if not video:
raise gr.Error("upload a driving video")
if not character and not painted:
raise gr.Error("give either a character image to paint in, or a first frame you have "
"already painted yourself")
# Backstop for the preprocess guard below: covers any stale path that slips through as a
# bare filepath without gradio touching the file first.
if any(p and not os.path.exists(p) for p in (video, character, painted)):
raise gr.Error(_GONE)
sweep()
work = Path(tempfile.mkdtemp())
sw, sh = probe(video)
w, h = canvas(sw, sh)
# For any ordinary aspect the nearest ladder entry is within ~3%, which is a trim nobody
# notices. A panorama or a tall column is a different matter: it is far from every entry, so
# cover-cropping to one throws away most of the frame. Say so rather than let someone wonder
# where their shot went -- the alternative, refusing, would be rendering nothing at all.
lost = 1 - min((sw / sh) / (w / h), (w / h) / (sw / sh))
note = f" — cropped {lost*100:.0f}% to reach it" if lost > 0.15 else ""
# Cover-crop, not stretch, for the same reason `fit` does it to the reference: the two are
# compared frame by frame, so they have to be squeezed the same way or not at all. `canvas`
# picks the nearest ladder aspect, so what is cropped here is the few percent by which the
# upload misses it -- unless the upload is a panorama, which loses its edges by design.
full = str(work / "full.mp4")
run("ffmpeg", "-y", "-loglevel", "error", "-i", video, "-vf",
f"scale={w}:{h}:force_original_aspect_ratio=increase,crop={w}:{h},fps={FPS}",
"-frames:v", str(MAX_FRAMES), "-crf", "6", "-an", full)
have = nframes(full)
if have < round(MIN_UPLOAD_S * FPS):
raise gr.Error(f"driving video is {have/FPS:.1f}s after retiming to {FPS} fps; "
f"at least {MIN_UPLOAD_S:.1f}s is needed")
# The model renders on the 17k+5 grid and never shorter than 124 frames, so the clip is
# held out to the next legal count with a frozen last frame and the render is cut back to
# `keep` afterwards. That padding is what makes a 4 s upload possible at all.
frames = grid(have)
cond = str(work / "cond.mp4")
pad = ["-vf", "tpad=stop_mode=clone:stop=-1"] if have < frames else []
run("ffmpeg", "-y", "-loglevel", "error", "-i", full, *pad,
"-frames:v", str(frames), "-crf", "6", "-an", cond)
keep = have / FPS
# The upload's own soundtrack, kept for the pin. Both ffmpeg calls above still pass `-an`:
# the driving clip is a *reference*, and its audio must not become conditioning. This is a
# separate extraction for a different destination -- the render's target audio rows.
#
# Cut to `keep`, not to the padded `frames`: the tail the 17k+5 grid forces us to render is a
# frozen frame, and real audio under a frozen frame is exactly the desync the pin exists to
# remove. The rest is zero-padded at encode time. Raw f32 rather than a container so both
# consumers read it without a decoder -- `render` with torch.frombuffer, `finish` with -f f32le.
apath = None
if has_audio(video):
apath = str(work / "audio.f32")
run("ffmpeg", "-y", "-loglevel", "error", "-i", video, "-t", f"{keep:.3f}",
"-vn", "-f", "f32le", "-ac", "2", "-ar", str(ASR), apath)
ref = str(work / "ref.png")
# Already painted: there is nothing to generate, only a canvas to agree on. This is the
# path the bundled examples take -- no OpenAI call, and the render is then deterministic
# in the seed, which the gpt-image-2 path can never be.
if painted:
fit(Image.open(painted).convert("RGB"), w, h).save(ref)
return (ref, cond, keep, frames, apath, ref, None,
f"canvas {w}x{h}{note}, {keep:.1f}s out{', audio pinned' if apath else ''}")
# The counter is taken here, not at the top: it exists for the OpenAI spend below, so the
# painted path is uncounted and a rejected upload never costs one of the day's five.
left = gate(request)
# `OpenAI()` picks up OPENAI_API_KEY *and* OPENAI_BASE_URL from the environment, so which
# provider serves the repaint is a Space-secret decision, not a code one. The two have to
# agree: a relay key against the default endpoint is a 401 on every request.
if not os.environ.get("OPENAI_API_KEY"):
raise gr.Error("OPENAI_API_KEY is not set on this Space")
frame0 = str(work / "frame0.png")
run("ffmpeg", "-y", "-loglevel", "error", "-i", cond, "-frames:v", "1", frame0)
# gpt-image-2 accepts exactly three sizes -- anything else is a 400 whose generic message
# happens to contain "size", which made every failure here look like an aspect problem
# (production, 2026-09-05). So: canonical orientation straight away, cover-crop back onto
# the canvas afterwards.
size = "1024x1536" if h > w else "1536x1024" if w > h else "1024x1024"
t0 = time.time()
b64 = None
for attempt in range(3):
try:
with open(frame0, "rb") as a, open(character, "rb") as b:
r = OpenAI(timeout=300).images.edit(
model="gpt-image-2", image=[a, b], prompt=REPAINT, size=size,
quality=QUALITY, output_format="png", n=1)
b64 = r.data[0].b64_json
break
except Exception as e:
print(f"[paint] attempt {attempt}: {e}", flush=True)
if "moderation_blocked" in str(e):
# Deterministic: the same images meet the same safety system, so retrying is
# three refusals for the price of three. It still costs one of the day's five;
# that counter is the only brake on hammering the filter with variations.
raise gr.Error("OpenAI's safety system refused this repaint. Photoreal people "
"trip it most often; stylized or costumed characters usually "
"pass.")
if attempt == 2:
low = str(e).lower()
if "server_error" in low or "connection" in low or "timed out" in low:
# The visitor did nothing wrong; during the 09-03 relay outage every 504
# still burned quota. Give it back.
refund(request)
raise gr.Error(f"gpt-image-2 failed: {e}")
time.sleep(3)
img = Image.open(io.BytesIO(base64.b64decode(b64))).convert("RGB")
fit(img, w, h).save(ref) # the canonical size is never the canvas aspect; cover-crop
return (ref, cond, keep, frames, apath, ref, None,
f"repainted in {time.time()-t0:.0f}s, canvas {w}x{h}{note}, {keep:.1f}s out"
f"{', audio pinned' if apath else ''}, {left} repaints left today")
_WIRED = False
def wire_aoti():
"""Swap each repeated block's forward for its compiled package -- in the GPU worker, *after*
`PIPE.to("cuda")`. `aoti_patch` snapshots `state_dict()` and holds whatever storages the
params have at that moment, so patching before the copy would feed the compiled kernels the
CPU weights the copy abandons. Latched per worker: the parent process never wires (it has no
GPU and `render` never runs there), each forked worker wires on its first render and stays
wired while warm. One caveat rides along: the compiled graph baked the attention backend
that was active at *build* time (cudnn), so `set_attention_backend` no longer reaches these
blocks -- the cuDNN retry in `render` has to unwire before it means anything.
"""
global _WIRED
if _WIRED or not AOTI_BLOCKS:
return
n = 0
for name, lazy in AOTI_BLOCKS.items():
for m in PIPE.transformer_ref.modules():
if type(m).__name__ == name:
aoti_patch(m, lazy)
n += 1
_WIRED = True
print(f"[gpu] aoti wired {n} blocks", flush=True)
def unwire_aoti():
"""Back to eager, permanently for this worker: `aoti_patch` writes an instance attribute
that shadows the class `forward`, so popping it is a complete undo. Reached from `render`'s
retry, which catches *any* first-attempt failure -- an OOM at 243 frames as much as a cuDNN
refusal -- and staying eager for good is the conservative answer to every one of those
causes."""
global AOTI_BLOCKS, _WIRED
for m in PIPE.transformer_ref.modules():
if type(m).__name__ in AOTI_BLOCKS:
m.__dict__.pop("forward", None)
AOTI_BLOCKS = {}
_WIRED = False
# The geometry the packages are built at, and the different one they are validated at. Every
# bundled example is 480x832, so the second geometry -- the one that proves the sequence length
# stayed a dynamic dim instead of silently specializing -- has to be synthesized (`build_aoti`
# cover-crops an example drive to it). 544x736 is a real ladder entry, so it is also a shape
# production will actually serve.
BUILD_CANVASES = [(480, 832), (544, 736)]
# Off by default: max_autotune benchmarks kernel variants per compile and can add tens of
# minutes, and the build has to fit one PRO day's xlarge admission (see build_aoti).
AUTOTUNE = os.environ.get("AOTI_AUTOTUNE", "0") == "1"
def _capture_kwargs(cond, ref, w, h):
# Exactly what `render` passes -- the captured tensors must have the shapes production sees.
# `render` sets PIN on every call, so this cannot inherit a stale True from a render that
# raised; cleared here anyway because this path calls the pipeline without going through it.
global PIN
PIN = False
PIPE.register_to_config(canvas_short_edge=min(w, h),
canvas_max_pixels=min(w, h) * max(w, h),
reference_image_short_edge=min(w, h))
video = MiniMaxH3VideoReference.from_file(cond)
video.audio, video.sample_rate = None, None
return dict(
prompt=FIXED["presentation"],
references=[video, MiniMaxH3ImageReference.from_file(ref)],
num_frames=MIN_FRAMES, height=h, width=w, num_inference_steps=4,
generator=torch.Generator().manual_seed(42),
output=["videos", "audio", "sampling_rate"],
)
@spaces.GPU(size="xlarge", duration=int(os.environ.get("AOTI_BUILD_BUDGET", "1100")))
def build_aoti():
"""Compile the two repeated block classes on this card and hand the packages back as bytes.
One export covers all 52 block instances and every shape bucket: the sequence dims are left
to Dim.AUTO, and the artifact is then run on a geometry the export never saw -- a dim that
silently specialized fails that call loudly instead of failing some visitor's render later.
Inputs come from `aoti_capture`, which aborts a real pipeline call at the block's first
forward -- diffusers logs each abort as a big ERROR traceback; in this build that is the
plan working, not a failure.
Billed like any render, from the clicker's quota -- and admission doubles the request on
xlarge, so duration=1100 asks for 2200 s of a PRO day's 2400: the largest round request
that still clears a fresh day. Only actual elapsed is charged; without autotune the whole
build (4 captures, 2 exports, 2 compiles, a 243-frame probe) should run well under the
ceiling. If it ever hits it, raise AOTI_BUILD_BUDGET -- but past 1200 a PRO account can no
longer be admitted at all.
"""
from torch.export import Dim, export
from torch.utils._pytree import tree_leaves, tree_map
logs = []
def say(s):
logs.append(s)
print(f"[build] {s}", flush=True)
PIPE.to("cuda")
if ATTN:
PIPE.transformer_ref.set_attention_backend(ATTN)
names = MiniMaxH3Transformer3DModel._repeated_blocks
# An earlier build click on this same worker may have left blocks instance-patched (the
# wired probe below, or a validation that died between patch and unpatch). The export must
# trace eager, and `aoti_capture`'s mock would restore that stale forward instead of the
# class one -- so pop before anything else. Scoped to the block classes: a blanket pop
# would strip forwards diffusers hooks may have installed on other modules.
for m in PIPE.transformer_ref.modules():
if type(m).__name__ in names:
m.__dict__.pop("forward", None)
firsts = {}
for name in names:
blocks = [m for m in PIPE.transformer_ref.modules() if type(m).__name__ == name]
# Block 0 stands in for the whole class, so every sibling must have the same structure.
# "Same class" stopped guaranteeing that when the LoRA was injected: peft rewrites the
# Linears of the layers it targets, and an adapter that skipped some blocks would make
# their state_dicts (and so the compiled graph's constants) disagree.
assert all(set(b.state_dict()) == set(blocks[0].state_dict()) for b in blocks[1:]), \
f"{name}: blocks differ in structure; one export cannot cover them"
firsts[name] = blocks[0]
say(f"{name}: {len(blocks)} instances, structurally uniform")
work = Path(tempfile.mkdtemp())
drive, still = str(EX / "prop_drive.mp4"), str(EX / "prop_ref.png")
cases = []
for w, h in BUILD_CANVASES:
cond, ref = str(work / f"cond{w}.mp4"), str(work / f"ref{w}.png")
run("ffmpeg", "-y", "-loglevel", "error", "-i", drive, "-vf",
f"scale={w}:{h}:force_original_aspect_ratio=increase,crop={w}:{h}",
"-frames:v", str(MIN_FRAMES), "-crf", "6", "-an", cond)
fit(Image.open(still).convert("RGB"), w, h).save(ref)
cases.append((cond, ref, w, h))
calls = {name: [] for name in names}
for cond, ref, w, h in cases:
kw = _capture_kwargs(cond, ref, w, h)
for name in names:
t0 = time.time()
with aoti_capture(firsts[name]) as call:
PIPE(**kw)
assert call.args or call.kwargs, f"{name} was never called at {w}x{h}"
calls[name].append(call)
say(f"captured {name} at {w}x{h} in {time.time() - t0:.0f}s")
packages = {}
for name in names:
block, (call_a, call_b) = firsts[name], calls[name]
# dynamic_shapes is keyed by the forward's parameter names; bind() recovers them no
# matter how the transformer spells the call. No apply_defaults: a key for a parameter
# the call never passed is an export error, not a wildcard.
bound = inspect.signature(block.forward).bind(*call_a.args, **call_a.kwargs)
dyn = {k: tree_map(lambda v: [Dim.AUTO] * v.ndim if isinstance(v, torch.Tensor) else None, x)
for k, x in bound.arguments.items()}
t0 = time.time()
ep = export(block, call_a.args, call_a.kwargs, dynamic_shapes=dyn)
say(f"exported {name} in {time.time() - t0:.0f}s")
t0 = time.time()
aoti_compile_and_save(work / name, ep, {"max_autotune": AUTOTUNE})
pkg = work / name / "root" / PACKAGE_FILENAME
say(f"compiled {name} in {time.time() - t0:.0f}s -> {pkg.stat().st_size / 2**20:.0f} MB")
# Run the artifact on the geometry the export never saw, against eager. This is the
# specialization check, and -- because it goes through the same aoti_patch/load path the
# render uses, with check_full_update -- the constants-fqn check for the peft-wrapped
# structure too. The refiner's inputs are the same shape in both cases (its tokens are
# the frozen text, not the video), so for it this is only the fqn half -- and also why
# its specializing to that shape is harmless.
with torch.no_grad():
want = block(*call_b.args, **call_b.kwargs)
aoti_patch(block, LazyAOTIModel(str(pkg)))
try:
got = block(*call_b.args, **call_b.kwargs)
finally:
del block.forward # even on failure -- the worker survives this fn
want, got = tree_leaves(want), tree_leaves(got)
assert len(want) == len(got), f"{name}: output arity {len(got)} vs {len(want)}"
rel = max((a - b).abs().max().item() / max(a.abs().max().item(), 1e-6)
for a, b in zip(want, got))
assert rel < 0.05, f"{name}: compiled vs eager rel diff {rel:.3f} -- wrong weights?"
say(f"validated {name} at {cases[1][2]}x{cases[1][3]}: rel diff {rel:.4f}")
packages[name] = pkg.read_bytes()
# The worst case production can serve -- 243 frames at the largest-area canvas -- has only
# ever been measured eager, where it peaks ~86.8 of the card's 95 GiB. Run it once with the
# fresh packages wired exactly as `render` will wire them, so an out-of-memory (dlopen'd
# kernels + their workspaces on top of that peak) fails here, on the owner's click, and not
# on a visitor's longest upload. Also the third geometry: any frame-count-dependent dim
# that both 124-frame build cases shared, and the dual-geometry check above therefore
# could not see, breaks this call instead.
cond, ref = str(work / "probe.mp4"), str(work / "probe.png")
run("ffmpeg", "-y", "-loglevel", "error", "-i", drive, "-vf",
"scale=640:640:force_original_aspect_ratio=increase,crop=640:640,"
"tpad=stop_mode=clone:stop=-1",
"-frames:v", str(MAX_FRAMES), "-crf", "6", "-an", cond)
fit(Image.open(still).convert("RGB"), 640, 640).save(ref)
lazies = {name: LazyAOTIModel(str(work / name / "root" / PACKAGE_FILENAME))
for name in names}
try:
for m in PIPE.transformer_ref.modules():
if type(m).__name__ in lazies:
aoti_patch(m, lazies[type(m).__name__])
# 2 steps, not 1: the scheduler refuses num_inference_steps < 2 without an explicit
# sigmas schedule (this killed the first two builds, after everything had compiled).
# Peak memory is the same either way.
kw = _capture_kwargs(cond, ref, 640, 640) | dict(num_frames=MAX_FRAMES,
num_inference_steps=2)
torch.cuda.reset_peak_memory_stats()
t0 = time.time()
PIPE(**kw)
say(f"wired probe: {MAX_FRAMES} frames at 640x640 in {time.time() - t0:.0f}s, "
f"peak {torch.cuda.max_memory_allocated() / 2**30:.1f} GiB")
finally:
for m in PIPE.transformer_ref.modules():
if type(m).__name__ in lazies:
m.__dict__.pop("forward", None)
return packages, "\n".join(logs)
def build_ui(profile: gr.OAuthProfile | None = None):
# The build burns most of the clicker's daily xlarge quota and writes to the weights repo;
# nobody but the owner should be able to press it even if AOTI_BUILD is left on by mistake.
if profile is None or profile.username.lower() not in OWNERS:
raise gr.Error("owner-only maintenance button")
packages, log = build_aoti()
for name, blob in packages.items():
# HF_TOKEN comes from the Space secrets via the environment; HfApi picks it up itself.
HfApi().upload_file(path_or_fileobj=io.BytesIO(blob),
path_in_repo=f"{name}/{PACKAGE_FILENAME}",
repo_id=AOTI_REPO, repo_type="model")
return log + "\nuploaded -- restart the Space to pick the packages up"
def budget(ref, cond, keep, frames, apath, seed):
"""Seconds of GPU to ask ZeroGPU for. Three things ride on it, none of them the bill: quota
is charged on the *actual* elapsed window (a request that never touches the GPU bills zero),
so padding costs nothing. It is an admission check -- a visitor whose remaining Hub quota is
under 2x this number is rejected before anything runs, because `xlarge` doubles server-side
-- a queue-priority hint (shorter first), and a soft ceiling: when the truly-cold worker
blew ~90 s past the old 135 s request, the render finished and the log got a warning, not a
kill. Do not lean on that leniency; keep it an over-estimate.
The slope is measured on one B200: 26 s at 124 frames on 480x832, 72 s at 243 on 480x864 --
~0.39 s per extra frame, and that 480x864 slightly exceeds LADDER's largest area, so the
slope over-covers every canvas. SLOWDOWN is this card's measured ratio to that B200 (42/26 s
at 124 frames). H2D covers the 73.5 GiB host-to-device copy, billed inside the window but
invisible to `render`'s timer because it runs in worker init: measured 22 s from warm page
cache, 75 s typical, 154 s cold -- and one 261 s outlier (2026-09-05) that overran the
whole request by ~40 s and, again, drew only the warning.
The constants are set high ON PURPOSE. The admission check is also the
visitor filter: at these values the shortest render asks for 247 s -- 494 s at admission --
which is more than a free ZeroGPU day and small change against a PRO day (2400 s). That,
not a sign-in, is what keeps anonymous and free-tier GPU use out; lower the H2D/SLOWDOWN
Space variables and the free tier comes back.
"""
if frames is None:
# gr.State does not survive a Space restart; a page opened before one sends None for
# every State. This runs at admission, before `render`, so it is the only place the
# visitor can get a message instead of a bare TypeError (production, 2026-09-05).
raise gr.Error("this session predates a Space restart -- run Prepare again")
return 35 + H2D + round(SLOWDOWN * (26 + 0.39 * (frames - MIN_FRAMES)))
@spaces.GPU(size="xlarge", duration=budget)
def render(ref, cond, keep, frames, apath, seed):
global ATTN
# The real 73.5 GiB pageable copy on a cold worker. A reused warm worker pays nothing --
# but do not count on reuse: it requires the scheduler to return `idle`, a server-side
# decision, and a worker that raised is dropped from the cache rather than re-registered.
# `budget`'s H2D term assumes the pessimistic case.
t_h2d = time.time()
PIPE.to("cuda")
h2d = time.time() - t_h2d
# Workers are reused, so without this the reported peak is the high-water mark of every
# render that worker has ever served, not this one's.
torch.cuda.reset_peak_memory_stats()
# cuDNN's attention kernel is a measured 2.05x on the denoise loop and does not move the
# sample -- same weights, same seed, same take -- so it can be switched on without
# re-rendering the bundled examples. It is selected here and not at import because there is
# no GPU at import under ZeroGPU. `set_attention_backend` validates nothing for this backend
# (`_check_attention_backend_requirements` has no branch for it), so an unserviceable shape
# raises at the first attention call, which is why the pipeline call below can retry. That
# 2.05x was measured on sm_100 and this card is sm_120. `ATTN=""` in the Space variables
# backs it out without a code change.
if ATTN:
PIPE.transformer_ref.set_attention_backend(ATTN)
wire_aoti()
# `frames` arrives from `prepare` through State -- it cut `cond` to exactly that count, so
# re-counting here would be a full ffprobe decode inside the billed window for a number the
# parent already knows.
frames = int(frames)
w, h = probe(cond)
PIPE.register_to_config(canvas_short_edge=min(w, h),
canvas_max_pixels=min(w, h) * max(w, h),
reference_image_short_edge=min(w, h))
# The reference order is frozen -- `<Video 1>` then `<Picture 1>` -- because that order
# advances the shared rotary clock. The clip's soundtrack is still cleared off the reference,
# as in training -- it is never conditioning -- but it is no longer discarded: when there is
# one it goes into the *target* audio rows instead, pinned clean, below.
#
# An older comment here claimed "the audio you hear back is generated". That was wrong. This
# finetune's audio head emits digital silence (RMS ~1e-5), which is why every render has
# needed a manual remux; nothing is lost by overwriting those rows.
video = MiniMaxH3VideoReference.from_file(cond)
video.audio, video.sample_rate = None, None
# Encode the kept soundtrack into the target audio rows. `PIPE.audio_vae` is already on the
# card, and bf16 is free here: measured against an fp32 CPU encode the rows differ by 5.4%
# relative L2, where speech and silence differ by 127% -- and the decoded waveforms are
# indistinguishable (|amp| r 0.9188 vs 0.9191, the VAE round-trip dominating both). A second
# fp32 copy would cost 578 MB of host RAM to buy nothing.
#
# `attention_backend()` rather than trusting statement order: the audio *encoder* dispatches
# `is_causal=True`, which cuDNN has no kernel for, and `set_attention_backend` above is
# process-wide despite looking per-model (its last line sets a classvar on
# `_AttentionBackendRegistry`). Without this the encode dies in `_native_cudnn_attention` with
# "No available kernel". The manager restores in `finally`, so denoise keeps the 2.05x kernel.
# It works here only because the audio VAE's own `_attention_backend` is still None -- setting
# a backend on the VAE itself would pass `backend=` to `dispatch_attention_fn` and bypass this.
global PIN
PIN = apath is not None
audio_latents = None
if PIN:
wav = torch.frombuffer(bytearray(Path(apath).read_bytes()),
dtype=torch.float32).view(-1, 2).T
n_samp = round((frames + 1) / FPS * ASR) # one frame over, so the encoder has enough
wav = torch.nn.functional.pad(wav, (0, max(0, n_samp - wav.shape[1])))[:, :n_samp]
with attention_backend(), torch.no_grad():
posterior = PIPE.audio_vae.encode(
wav[:, None].to("cuda", torch.bfloat16), return_dict=False)[0]
mean = torch.tensor(PIPE.audio_vae.config.latents_mean).view(1, 1, -1)
std = torch.tensor(PIPE.audio_vae.config.latents_std).view(1, 1, -1)
rows = (posterior.mode().float().cpu().transpose(1, 2)[:, :audio_latent_num_frames(frames)]
- mean) / std
audio_latents = rows.permute(0, 2, 1).contiguous() # (2, 32, n_lat)
print(f"[gpu] pinned {wav.shape[1]/ASR:.2f}s of audio -> {tuple(audio_latents.shape)}",
flush=True)
kwargs = dict(
prompt=FIXED["presentation"],
references=[video, MiniMaxH3ImageReference.from_file(ref)],
num_frames=frames, height=h, width=w, num_inference_steps=4,
audio_latents=audio_latents,
generator=torch.Generator().manual_seed(int(42 if seed is None else seed)),
output=["videos", "audio", "sampling_rate"],
)
t0 = time.time()
try:
result = PIPE(**kwargs)
except Exception as e: # noqa: BLE001 -- one retry, only for the backend
if not ATTN:
raise
# cuDNN rejects a shape at the *first* attention call, seconds into a budget of minutes,
# so there is room to start over on the backend that is always correct. Latching ATTN
# off means this worker retries at most once, and every later render skips cuDNN outright.
print(f"[gpu] {ATTN} failed ({type(e).__name__}: {e}); retrying on the default backend",
flush=True)
ATTN = ""
PIPE.transformer_ref.set_attention_backend("native")
# The compiled blocks baked cuDNN at build time and ignore the line above -- eager is
# the only backend change that can actually happen, so the retry drops them too.
unwire_aoti()
result = PIPE(**kwargs)
took = time.time() - t0
work = Path(tempfile.mkdtemp())
raw = str(work / "raw.mp4")
encode_video(result["videos"][0], fps=FPS, output_path=raw,
audio=result["audio"][0], audio_sample_rate=result["sampling_rate"])
msg = (f"{took:.0f}s for 4 steps at {frames} frames ({keep:.1f}s out), peak "
f"{torch.cuda.max_memory_allocated()/2**30:.1f} GiB")
# Calibrate SLOWDOWN against `took` and H2D against `h2d`.
print(f"[gpu] {msg}, weights onto card in {h2d:.0f}s "
f"(budget assumed {budget(ref, cond, keep, frames, apath, seed)}s)", flush=True)
# `keep` rides along with the path instead of being read back from State in `finish`: State
# is read when each chain link *starts*, so a second Generate click during this render would
# overwrite keep_s and the finished clip would be trimmed to the wrong length.
return (raw, keep, apath), msg
def finish(job):
"""Trim and watermark, on the parent's CPU. This used to run inside `render`, where every
second is billed GPU time; x264 and drawtext need no card. `-t keep` drops the frozen tail
the 17k+5 grid forced us to render, so the clip that comes back is exactly as long as the
one that went in. drawtext needs an explicit fontfile: the container has no fontconfig
default, and without one ffmpeg fails here, after the render -- the worst place to fail
(the boot-time assert on FONT exists to keep it unreachable).
"""
raw, keep, apath = job
out = str(Path(raw).parent / "viggle-animate.mp4")
# With a pinned track the render's own audio rows do decode back to the upload's soundtrack,
# but only through the audio VAE, at |amp| r 0.92. The original is on disk and exact, so it is
# remuxed in instead. The timelines agree by construction: `prepare`'s fps filter retimes
# without time-stretching, and it cut the audio to the same `keep` the video is trimmed to here.
src = ["-f", "f32le", "-ar", str(ASR), "-ac", "2", "-i", apath] if apath else []
amap = ["-map", "0:v:0", "-map", "1:a:0", "-c:a", "aac"] if apath else ["-c:a", "copy"]
run("ffmpeg", "-y", "-loglevel", "error", "-i", raw, *src, "-t", f"{keep:.3f}", "-vf",
f"drawtext=fontfile={FONT}:text='viggle-animate':x=w-tw-14:y=h-th-14:fontsize=22:"
"fontcolor=white@0.85:shadowcolor=black@0.6:shadowx=1:shadowy=1",
*amap, "-crf", "20", "-pix_fmt", "yuv420p", out)
return out
with gr.Blocks(title="Viggle-Animate") as demo:
gr.Markdown(
"# Viggle-Animate\n"
"Put the person from a still into a driving video. No pose estimation, no "
"segmentation, no masks — the only preprocessing is repainting the clip's first "
f"frame. 4 sampling steps. Takes {MIN_UPLOAD_S:.0f}–{MAX_FRAMES/FPS:.0f} s of driving "
f"video; anything longer is cut to {MAX_FRAMES/FPS:.1f} s. If the clip carries a "
"soundtrack it is pinned into the render and the mouth tracks it; upload a silent clip "
"and the mouth moves without saying anything.\n\n"
f"[Model card and weights]({MODEL_CARD}) · [viggle.ai]({SITE})"
)
with gr.Row():
with gr.Column():
v = gr.Video(label="driving video — motion, framing and background are kept; "
"its soundtrack, if it has one, is what the mouth follows")
c = gr.Image(label="character — who to put in it", type="filepath")
p = gr.Image(label="…or frame 0, already painted (skips gpt-image-2)",
type="filepath")
go = gr.Button("Generate", variant="primary")
seed = gr.Number(value=42, label="seed", precision=0)
gr.Markdown(f"<sub>No sign-in. The first-frame repaint is paid by this Space — "
f"{DAILY} a day per visitor. The render runs on your own ZeroGPU "
"quota and asks for more than a free day holds, so in practice PRO "
"and up.</sub>")
with gr.Column():
shown = gr.Image(label="step 1: repainted first frame", type="filepath")
out = gr.Video(label="step 2: render")
log = gr.Textbox(label="", lines=2)
# The examples come with frame 0 already painted, so clicking one exercises the model alone:
# no OpenAI call, and the same seed gives the same render every time. `out` is listed as an
# input on purpose -- that is what lets a click drop the finished render into the output slot
# next to the two things that produced it, so the pair can be read without spending quota.
gr.Examples(
examples=[[str(EX / f"{d}_drive.mp4"), None, str(EX / f"{n}_ref.png"),
str(EX / f"{n}_out.mp4")] for n, (d, _) in CASES.items()],
inputs=[v, c, p, out], examples_per_page=len(CASES),
label="examples — click one for its driving clip, its painted first frame and the render",
)
# Rendering asks ZeroGPU for more quota than a free account's day holds (see `budget`), so
# for most people arriving here this row is the only output they will see -- the page's
# actual evidence rather than a footnote.
gr.Markdown("### What it produces\n"
"Every example above, rendered at 4 steps with seed 42. Each is a single paint "
"and a single render; the first and the last three swap in a person. Only the "
"first one has audio — it is the only one whose driving clip had any.")
items = list(CASES.items())
# Rows are cut on aspect, not on a fixed width: the landscape lip-sync take on its own, then
# six portrait swaps, the four landscape two-character takes, and the three human cases.
# Mixing the two shapes in one row squeezes the portrait ones to a third of their height.
for row in (items[0:1], items[1:4], items[4:7], items[7:11], items[11:14]):
with gr.Row():
for n, (_, caption) in row:
gr.Video(str(EX / f"{n}_out.mp4"), label=caption, interactive=False)
# Maintenance, visible only when the AOTI_BUILD Space variable is set (and refused for
# anyone but the owner even then): compiles the block packages on this hardware and uploads
# them to the weights repo. Run once per torch/driver generation, then unset the variable.
# The LoginButton is the only OAuth left in the app and exists purely for this gate:
# `build_ui`'s `gr.OAuthProfile` stays None without a sign-in, and stays None for everyone
# unless `hf_oauth: true` is in the README front matter -- keep both or the button refuses
# even the owner.
if os.environ.get("AOTI_BUILD") == "1":
with gr.Accordion("maintenance -- build AOTI packages", open=False):
gr.LoginButton(size="sm")
b_go = gr.Button("compile the repeated blocks on this GPU (<=18 min of "
"xlarge quota), upload to the weights repo")
b_log = gr.Textbox(label="build log", lines=12)
b_go.click(build_ui, [], [b_log])
# Repeated at the bottom because the examples and the "What it produces" rows push the
# header well off screen -- this is where a visitor actually finishes reading.
gr.Markdown(f"<sub>[Model card and weights]({MODEL_CARD}) · [viggle.ai]({SITE})</sub>")
ref_s, cond_s, keep_s, frames_s, audio_s, raw_s = (gr.State(), gr.State(), gr.State(),
gr.State(), gr.State(), gr.State())
# `.success`, not `.then`: `prepare` raises gr.Error for a missing upload, a too-short clip
# or a spent daily quota, and `.then` would run the render anyway -- on stale State from the
# previous click, or on None, which is a several-minute GPU job started by a failed one.
# `finish` runs in the parent, but the raw path crosses fine: worker and parent share /tmp.
# `finish` runs outside the queue: it is seconds of CPU ffmpeg, and inside the queue a
# "Queue is full" rejection would land *after* the GPU time is already spent.
(go.click(prepare, [v, c, p],
[ref_s, cond_s, keep_s, frames_s, audio_s, shown, out, log])
.success(render, [ref_s, cond_s, keep_s, frames_s, audio_s, seed], [raw_s, log])
.success(finish, [raw_s], [out], queue=False))
# The bundled media sits next to app.py rather than in Gradio's temp dir, so it has to be
# named explicitly or the file route refuses to serve it.
# `max_file_size` matters more than it looks: gradio writes the upload to disk before
# `prepare` can refuse it, and that disk also holds 80 GB of weights. The default is
# None, i.e. unlimited. 10 s of 4K is comfortably under 200 MB.
# Guarded so ../space_viggle_wf/workflow_app.py can import this module (the pipeline and the
# boot-time weight load) without starting a second server.
if __name__ == "__main__":
demo.queue(max_size=32).launch(show_error=True, allowed_paths=[str(EX)],
max_file_size="200mb")