FastH3-Omni / app.py
multimodalart's picture
multimodalart HF Staff
Tile-64 attention and a persistent kernel cache
798a242 verified
Raw History Blame Contribute Delete
18.6 kB
"""FastH3-Omni 路 reference-to-video (+audio) on ZeroGPU.
Ordered image, video and audio references plus a prompt become a short video
with a matching 32 kHz soundtrack in eight fused forwards of the FastVideo
FastH3 OmniRef student. A plain prompt is wrapped into the model's six-section
H3 format; "Upsample prompt" rewrites it with the MiniMax-H3 prompt rewriter
instead. See fasth3_engine.py for the ZeroGPU + FastVideo integration notes.
"""
import contextvars
import os
import tempfile
from functools import cache
from pathlib import Path
import gradio as gr
from PIL import Image
try:
import spaces
IS_ZERO = True
except ImportError: # local / CI
IS_ZERO = False
class _NoSpaces:
@staticmethod
def GPU(*a, **k):
if a and callable(a[0]):
return a[0]
return lambda f: f
spaces = _NoSpaces() # type: ignore
from fasth3_engine import FastH3OmniRefEngine, MODEL_REPO, prefetch
REWRITER_SPACE = os.getenv("FASTH3_REWRITER", "hugging-apps/minimax-h3-prompt-rewriter")
CANVASES = {
"1344x768 路 16:9 recommended": (768, 1344),
"832x480 路 16:9 fast": (480, 832),
"1280x704 路 16:9": (704, 1280),
"960x544 路 16:9": (544, 960),
"768x1344 路 9:16 recommended": (1344, 768),
"480x832 路 9:16 fast": (832, 480),
"768x768 路 1:1": (768, 768),
"1024x1024 路 1:1": (1024, 1024),
"1024x768 路 4:3": (768, 1024),
"768x1024 路 3:4": (1024, 768),
"1536x672 路 21:9": (672, 1536),
}
DEFAULT_CANVAS = "1344x768 路 16:9 recommended"
PORTRAIT_CANVAS = "768x1344 路 9:16 recommended"
FPS, FRAMES_PER_CHUNK, LATENTS_PER_CHUNK = 24, 17, 5
MIN_FRAMES, MAX_FRAMES = 124, 362
MIN_DURATION, MAX_UI_DURATION = 5, 14
MIN_REFERENCE_VIDEO, MAX_REFERENCE_VIDEO = 2.0, 15.0
MAX_IMAGE_SLOTS, OPEN_IMAGE_SLOTS = 9, 2
REWRITER_IMAGE_SLOTS = 4
DEFAULT_PIXEL_FRAMES = 1344 * 768 * 124
GPU_SECONDS_WARM = int(os.getenv("FASTH3_GPU_SECONDS", "170"))
GPU_SECONDS_COLD = int(os.getenv("FASTH3_GPU_SECONDS_COLD", "360"))
GPU_SECONDS_PER_EXTRA_IMAGE, GPU_SECONDS_PER_VIDEO = 110, 160
APP_DIR = Path(__file__).resolve().parent
EXAMPLES_DIR = APP_DIR / "examples"
LIMITS = {"image": 9, "video": 3, "audio": 3}
REWRITER_LIMITS = {"image": 4, "video": 1, "audio": 1}
EXAMPLES = [
{
"title": "Beach promenade at golden hour",
"image": "woman.jpg",
"desc": "a young woman with a dark curly afro and gold hoop earrings, wearing a white t-shirt",
"scene": "walks along a sunlit beach promenade at golden hour, smiling and glancing toward the camera, "
"palm trees and the ocean behind her.",
"sound": "ocean waves, a gentle breeze and distant seagulls",
"music": "none",
},
{
"title": "Dog bounding through snow",
"image": "dog.jpg",
"desc": "a pale cream, fluffy long-haired dog with floppy ears and a dark nose",
"scene": "bounds across a snowy field in bright winter sunlight, kicking up powder snow, then shakes itself off.",
"sound": "crunching snow, happy panting and a cold wind",
"music": "none",
},
{
"title": "Birthday cake with candles",
"image": "cake.jpg",
"desc": "a whole chocolate layer cake with chocolate frosting, pastel sprinkles and chocolate shards on top",
"scene": "sits at the center of a festive birthday table in a warm, dim room as lit candles flicker and "
"colorful confetti drifts down around it.",
"sound": "soft party chatter and a faint crackle",
"music": "cheerful, upbeat birthday music",
},
]
def composer_examples():
rows = []
for example in EXAMPLES:
path = str(EXAMPLES_DIR / example["image"])
rows.append({
"title": example["title"],
"refs": [{"kind": "image", "path": path, "url": f"/gradio_api/file={path}", "name": example["image"],
"desc": example["desc"]}],
"builder": {"scene": example["scene"], "sound": example["sound"], "music": example["music"],
"style": "realistic photographic"},
})
return rows
def snap_frames(seconds: float) -> int:
frames = max(1, round(float(seconds) * FPS))
while frames % FRAMES_PER_CHUNK != LATENTS_PER_CHUNK:
frames += 1
return frames
def reference_labels(references):
names = {"image": "Picture", "video": "Video", "audio": "Audio"}
counts, labels = {}, []
for kind, _ in references:
counts[kind] = counts.get(kind, 0) + 1
labels.append((kind, f"<{names[kind]} {counts[kind]}>"))
return labels
def to_h3_prompt(prompt: str, references) -> str:
"""Wrap a plain prompt into the six-section H3 format the checkpoint was trained on."""
prompt = (prompt or "").strip()
if "subject_definitions:" in prompt:
return prompt
labels = reference_labels(references)
visual = " and ".join(label for kind, label in labels if kind != "audio") or "the references"
audio = [label for kind, label in labels if kind == "audio"]
sound = (f"the voice and sound of {' and '.join(audio)}, in a natural acoustic space matching the scene"
if audio else "natural ambient sound that matches the scene")
return (
f"subject_definitions:\n<Subject 1> is the subject shown in {visual}.\n\n"
f"summary:\n{prompt}\n\n"
f"retention_analysis:\n<Subject 1>: fully_preserved - the reference subject's identity, "
f"appearance and details are kept.\n\n"
f"detailed_description:\nThe target video is in a realistic photographic style.\n"
f"[Shot 1] {prompt}\n\n"
f"overall_soundscape:\n{sound}.\n\n"
f"non_diegetic_music:\nnone."
)
# --- engine: built once at startup, on ZeroGPU's emulated CUDA ------------
# The weights land on "cuda" in the main process; ZeroGPU packs them and maps
# them onto the real card at the start of every GPU call, so no call rebuilds.
from fasth3_engine import fetch_kernel_cache, zerogpu_main_process_shims
prefetch()
KERNELS_CACHED = fetch_kernel_cache()
if IS_ZERO:
zerogpu_main_process_shims()
ENGINE = FastH3OmniRefEngine()
ENGINE.initialize()
_COMPLETED_GPU_CALLS = 0
def probe(path: str):
import av
def seconds(stream, container):
if stream.duration is not None and stream.time_base is not None:
return float(stream.duration * stream.time_base)
return None if container.duration is None else container.duration / av.time_base
with av.open(path) as container:
video = seconds(container.streams.video[0], container) if container.streams.video else None
audio = seconds(container.streams.audio[0], container) if container.streams.audio else None
return video, audio
def _allowed(path: str) -> bool:
from gradio.utils import get_upload_folder
real = os.path.realpath(path)
roots = [os.path.realpath(get_upload_folder()), os.path.realpath(EXAMPLES_DIR)]
return any(real == root or real.startswith(root + os.sep) for root in roots) and os.path.isfile(real)
def references_from(composer: dict):
refs = (composer or {}).get("refs") or []
if any(ref.get("uploading") for ref in refs):
raise gr.Error("A reference is still uploading. Give it a second and try again.")
for ref in refs:
if ref.get("error"):
raise gr.Error(ref["error"])
references = []
for ref in refs:
kind, path = ref.get("kind"), ref.get("path")
if kind not in LIMITS or not path or not _allowed(path):
raise gr.Error("One of the references could not be read. Remove it and add it again.")
references.append((kind, _as_rgb_png(path, len(references)) if kind == "image" else path))
for kind, limit in LIMITS.items():
if sum(k == kind for k, _ in references) > limit:
raise gr.Error(f"At most {limit} {kind} references.")
return references
def _as_rgb_png(path: str, index: int) -> str:
tmp = tempfile.NamedTemporaryFile(suffix=f"_ref{index + 1}.png", delete=False)
Image.open(path).convert("RGB").save(tmp.name)
return tmp.name
def soundtrack_seconds(references):
carried = []
for kind, path in references:
if kind == "image":
continue
_, audio_seconds = probe(path)
if audio_seconds is not None:
carried.append(audio_seconds)
return carried
def check(prompt: str, references) -> None:
if not references:
raise gr.Error("Add at least one image or video reference.")
if not any(kind != "audio" for kind, _ in references):
raise gr.Error("Audio needs an image or a video alongside it.")
if not prompt or not prompt.strip():
raise gr.Error("Describe the video: fill in the builder or write a prompt.")
if "<removed>" in prompt:
raise gr.Error("The prompt still refers to a removed reference (<removed>). Edit it out first.")
for kind, path in references:
if kind != "video":
continue
video_seconds, _ = probe(path)
if video_seconds is None:
raise gr.Error("A reference video has no video stream. Add it as audio instead.")
if not MIN_REFERENCE_VIDEO <= video_seconds <= MAX_REFERENCE_VIDEO:
raise gr.Error(
f"A reference video is {video_seconds:.1f} s. Use clips between "
f"{MIN_REFERENCE_VIDEO:g} and {MAX_REFERENCE_VIDEO:g} seconds."
)
@cache
def rewriter():
from gradio_client import Client
return Client(REWRITER_SPACE, token=False)
def aspect_label(height: int, width: int) -> str:
ratio = width / height
options = {"21:9": 21 / 9, "16:9": 16 / 9, "4:3": 4 / 3, "1:1": 1.0, "3:4": 3 / 4, "9:16": 9 / 16}
return min(options, key=lambda key: abs(options[key] - ratio))
def resolve_frames(references, match, duration) -> int:
carried = soundtrack_seconds(references)
if match and len(carried) == 1 and MIN_FRAMES <= snap_frames(carried[0]) <= MAX_FRAMES:
return snap_frames(carried[0])
return min(MAX_FRAMES, max(MIN_FRAMES, snap_frames(duration)))
def upsample(composer, canvas, match, duration):
from gradio_client import handle_file
composer = dict(composer or {})
try:
references = references_from(composer)
if not any(kind != "audio" for kind, _ in references):
raise gr.Error("Add an image or a video reference first.")
for kind, limit in REWRITER_LIMITS.items():
if sum(k == kind for k, _ in references) > limit:
raise gr.Error(
f"The rewriter takes at most {REWRITER_LIMITS['image']} images, 1 video and 1 audio clip."
)
prompt = (composer.get("upsample_source") or "").strip()
if not prompt:
raise gr.Error("Describe the video first, then upsample it.")
missing = [label for _, label in reference_labels(references) if label not in prompt]
if missing:
prompt = f"Keep the subject shown in {' and '.join(missing)}. {prompt}"
height, width = CANVASES.get(canvas, CANVASES[DEFAULT_CANVAS])
num_frames = resolve_frames(references, match, duration)
images = [path for kind, path in references if kind == "image"]
images += [None] * (REWRITER_LIMITS["image"] - len(images))
video = next((path for kind, path in references if kind == "video"), None)
audio = next((path for kind, path in references if kind == "audio"), None)
rewritten, status = rewriter().predict(
task="Ref2AV",
prompt=prompt,
duration=round(num_frames / FPS, 2),
resolution=aspect_label(height, width),
image_1=handle_file(images[0]) if images[0] else None,
image_2=handle_file(images[1]) if images[1] else None,
image_3=handle_file(images[2]) if images[2] else None,
image_4=handle_file(images[3]) if images[3] else None,
ref_video=handle_file(video) if video else None,
ref_audio=handle_file(audio) if audio else None,
api_name="/rewrite",
)
rewritten = (rewritten or "").strip()
if not rewritten:
raise gr.Error(f"The rewriter returned nothing: {status}")
except gr.Error as error:
gr.Warning(str(error.message))
rewritten = ""
except Exception as error:
gr.Warning(f"The prompt rewriter failed: {type(error).__name__}: {error}")
rewritten = ""
composer["upsampled"] = rewritten
return composer
def _gpu_duration(prompt, references, height, width, num_frames, seed, *args, **kwargs):
cold = _COMPLETED_GPU_CALLS == 0 and not KERNELS_CACHED
base = GPU_SECONDS_COLD if cold else GPU_SECONDS_WARM
seconds = base * max(1.0, (height * width * num_frames) / DEFAULT_PIXEL_FRAMES)
images = sum(kind == "image" for kind, _ in references)
seconds += GPU_SECONDS_PER_EXTRA_IMAGE * max(0, images - 1)
seconds += GPU_SECONDS_PER_VIDEO * sum(kind == "video" for kind, _ in references)
return int(min(900, seconds))
@spaces.GPU(duration=_gpu_duration, size="xlarge")
def _generate(prompt, references, height, width, num_frames, seed, progress=gr.Progress()):
progress(0.01, desc="Starting on the GPU")
context = contextvars.copy_context()
def report(fraction, desc):
context.copy().run(progress, fraction, desc=desc)
return ENGINE.generate(prompt, references, height, width, num_frames, seed, on_progress=report)
def generate(composer, canvas=DEFAULT_CANVAS, match=True, duration=5, seed=1234, progress=gr.Progress()):
references = references_from(composer)
prompt = ((composer or {}).get("prompt") or "").strip()
check(prompt, references)
height, width = CANVASES.get(canvas, CANVASES[DEFAULT_CANVAS])
num_frames = resolve_frames(references, match, duration)
seed = int(seed) if seed is not None else 0
global _COMPLETED_GPU_CALLS
progress(0, desc="Waiting for a GPU")
path = _generate(to_h3_prompt(prompt, references), references, height, width, num_frames, seed)
_COMPLETED_GPU_CALLS += 1
return path
# --- UI ---------------------------------------------------------------------
HEADER = """
<div class="fh-header">
<h1>FastH3-<span class="fh-accent">Omni</span></h1>
<p>FastH3-Omni is FastVideo's 8-step reference-to-video student that turns reference images, videos and
audio plus a prompt into a short clip with a matching soundtrack. Drop in references, then describe the video
with the builder or write the prompt yourself, tagging each reference by name.</p>
<div class="fh-pills">
<a href="https://huggingface.co/FastVideo/FastVideo-FastH3-Omni-8-Step-V1" target="_blank">Model</a>
<a href="https://huggingface.co/collections/FastVideo/fasth3-omni-6ad501acfccdebd3c3b7445b" target="_blank">Collection</a>
<a href="https://github.com/hao-ai-lab/FastVideo" target="_blank">FastVideo</a>
<a href="https://huggingface.co/MiniMaxAI/MiniMax-H3" target="_blank">MiniMax-H3</a>
</div>
</div>
"""
FASTVIDEO_BLUE = gr.themes.Color(
c50="#eef3ff", c100="#dbe5ff", c200="#bccfff", c300="#8eaeff", c400="#5f8cff",
c500="#346cff", c600="#1f55ec", c700="#1843c4", c800="#17389c", c900="#16317c", c950="#0e1d4a",
name="fastvideo",
)
CSS = """
.main.fillable { max-width: 1250px !important; }
.fh-header { padding: 6px 2px 12px; }
.fh-header h1 { font-size: 1.6rem; margin: 0 0 6px; font-weight: 700; }
.fh-header h1 .fh-accent { color: #346cff; }
.fh-header p { color: var(--body-text-color-subdued); margin: 0 0 12px; max-width: 76ch; }
.fh-pills { display: flex; gap: 8px; flex-wrap: wrap; }
.fh-pills a { font-size: .8rem; padding: 3px 11px; border-radius: 999px;
background: var(--button-secondary-background-fill); color: var(--body-text-color);
text-decoration: none; border: 1px solid var(--border-color-primary); }
.fh-pills a:hover { border-color: #346cff; color: #346cff; }
#gen-btn { background: #346cff; color: #fff; border: none; }
#gen-btn:hover { background: #1f55ec; }
"""
THEME = gr.themes.Base(
primary_hue=FASTVIDEO_BLUE,
secondary_hue=FASTVIDEO_BLUE,
neutral_hue="zinc",
font=[gr.themes.GoogleFont("Inter"), "ui-sans-serif", "system-ui", "sans-serif"],
).set(
body_background_fill_dark="#000000",
block_background_fill_dark="#0b0b0f",
background_fill_secondary_dark="#0b0b0f",
color_accent="#346cff",
slider_color="#346cff",
checkbox_background_color_selected="#346cff",
checkbox_background_color_selected_dark="#346cff",
)
COMPOSER_HTML = (APP_DIR / "composer.html").read_text()
COMPOSER_CSS = (APP_DIR / "composer.css").read_text()
COMPOSER_JS = (APP_DIR / "composer.js").read_text()
with gr.Blocks(title="FastH3-Omni") as demo:
gr.HTML(HEADER)
with gr.Row():
with gr.Column(scale=1):
composer = gr.HTML(
value={"examples": composer_examples()},
html_template=COMPOSER_HTML,
css_template=COMPOSER_CSS,
js_on_load=COMPOSER_JS,
apply_default_css=False,
)
run = gr.Button("Generate", variant="primary", size="lg", elem_id="gen-btn")
with gr.Accordion("Advanced options", open=False):
canvas = gr.Dropdown(label="Canvas", choices=list(CANVASES), value=DEFAULT_CANVAS)
match = gr.Checkbox(
label="Match the reference soundtrack",
info="When exactly one reference carries a soundtrack, the clip takes its length.",
value=True,
)
duration = gr.Slider(
label="Duration (s)", minimum=MIN_DURATION, maximum=MAX_UI_DURATION, step=1, value=5
)
seed = gr.Number(label="Seed", value=1234, precision=0)
gr.Markdown("8 fused steps and guidance 1.0 are fixed by the model's contract.")
with gr.Column(scale=1):
result = gr.Video(label="Video + soundtrack", autoplay=True)
composer.edit(upsample, [composer, canvas, match, duration], [composer], api_name=False, show_progress="hidden")
run.click(generate, [composer, canvas, match, duration, seed], [result], api_name="generate")
if __name__ == "__main__":
demo.queue(max_size=12).launch(
server_name="0.0.0.0",
server_port=7860,
show_error=True,
theme=THEME,
css=CSS,
ssr_mode=False,
allowed_paths=[str(EXAMPLES_DIR)],
)