Spaces:
Running on Zero
Running on Zero
File size: 18,600 Bytes
099efca b8f2c17 099efca b8f2c17 898fc04 b8f2c17 099efca b8f2c17 099efca b8f2c17 099efca 798a242 099efca 1b6c5c1 099efca 1b6c5c1 099efca 1b6c5c1 099efca 1b6c5c1 b8f2c17 b5ae9a7 798a242 b8f2c17 b5ae9a7 798a242 b5ae9a7 b8f2c17 099efca b8f2c17 099efca 798a242 b8f2c17 898fc04 099efca 898fc04 099efca 898fc04 099efca b5ae9a7 898fc04 b5ae9a7 b8f2c17 099efca b8f2c17 099efca b8f2c17 099efca b8f2c17 099efca b8f2c17 099efca b8f2c17 099efca b8f2c17 099efca b8f2c17 099efca b8f2c17 099efca b8f2c17 099efca b8f2c17 099efca b8f2c17 099efca 1b6c5c1 099efca b8f2c17 099efca b8f2c17 099efca b8f2c17 099efca b8f2c17 099efca | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 | """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)],
)
|