cydermotion / app.py
Cydercoder's picture
second commit
afa8596 verified
Raw
History Blame
9.05 kB
"""My Motion Video AI — LTX-Video (text-to-video + image-to-video) on ZeroGPU.
Deploy as a Hugging Face Space with the ZeroGPU hardware option selected.
Everything runs in the cloud: your laptop only needs a browser.
"""
import os
import random
import tempfile
import gradio as gr
import spaces
import torch
from diffusers import LTXImageToVideoPipeline, LTXPipeline
from diffusers.utils import export_to_video
# The 2B LTX-Video checkpoint (0.9.5): much lighter than the 13B main repo
# (~15GB bf16 vs ~38GB), so it fits the 48GB ZeroGPU slice comfortably.
# Un-gated, official diffusers structure, supports text-to-video + image-to-video.
MODEL_ID = "Lightricks/LTX-Video-0.9.5"
DEFAULT_NEGATIVE = (
"worst quality, inconsistent motion, blurry, jittery, distorted, "
"low resolution, watermark, flicker"
)
RESOLUTIONS = {
"Landscape 768x512": (768, 512),
"Portrait 512x768": (512, 768),
"Square 768x768": (768, 768),
"Square 512x512": (512, 512),
}
FPS = 24
# ---------------------------------------------------------------------------
# Load the model ONCE at startup (module level).
#
# ZeroGPU rule: place models on cuda at module level so loading happens OUTSIDE
# the quota-charged generation call. A lazy first load inside @spaces.GPU would
# need a huge reservation (240s -> 360s billed after ZeroGPU's 1.5x factor),
# which exceeds the 300s free daily quota and gets rejected with
# "duration is larger than the maximum allowed". Preloading keeps every call
# small, so the free quota buys ~2-4 videos per day.
# The model files are prefetched at build time via the `models:` key in
# README.md, so this reads from local disk and takes under a minute.
# ---------------------------------------------------------------------------
print("Loading LTX-Video model (once, at startup)...")
_text_pipe = LTXPipeline.from_pretrained(MODEL_ID, torch_dtype=torch.bfloat16)
_text_pipe.to("cuda")
_text_pipe.vae.enable_slicing()
_text_pipe.vae.enable_tiling()
# The image-to-video pipeline reuses the same components, so it adds
# almost no extra memory.
_image_pipe = LTXImageToVideoPipeline.from_pretrained(
MODEL_ID,
transformer=_text_pipe.transformer,
vae=_text_pipe.vae,
text_encoder=_text_pipe.text_encoder,
tokenizer=_text_pipe.tokenizer,
scheduler=_text_pipe.scheduler,
torch_dtype=torch.bfloat16,
)
_image_pipe.to("cuda")
print("Model ready.")
def _get_duration(
prompt, negative_prompt, mode, input_image, num_frames, resolution, seed, num_steps, guidance
):
"""Return the GPU runtime budget for this call (seconds).
The model is already loaded, so this only needs to cover generation.
ZeroGPU bills the reservation (x1.5 on current hardware) against the daily
quota, so keep it tight: shorter durations = more videos per day.
"""
width, height = RESOLUTIONS[resolution]
pixel_scale = (width * height) / (512 * 704)
step_scale = num_steps / 30.0
estimate = num_frames * 0.25 * pixel_scale * step_scale
total = int((estimate + 15) * 1.2) # + VAE decode/export overhead, 20% margin
total = max(45, total)
return min(total, 120)
@spaces.GPU(duration=_get_duration)
def generate_video(
prompt,
negative_prompt,
mode,
input_image,
num_frames,
resolution,
seed,
num_steps,
guidance,
):
"""Generate a video from a text prompt (or an image + prompt)."""
if not prompt or not prompt.strip():
raise gr.Error("Please write a prompt first.")
negative_prompt = (negative_prompt or "").strip() or DEFAULT_NEGATIVE
width, height = RESOLUTIONS[resolution]
seed = int(seed) # gr.Number returns a float
if seed < 0:
seed = random.randint(0, 2**31 - 1)
generator = torch.Generator(device="cuda").manual_seed(seed)
text_pipe, image_pipe = _text_pipe, _image_pipe
# Timestep-aware VAE settings recommended for LTX-Video 0.9.1+.
decode_kwargs = {"decode_timestep": 0.05, "decode_noise_scale": 0.025}
if mode == "Image to Video":
if input_image is None:
raise gr.Error("Upload an image to use Image-to-Video mode.")
result = image_pipe(
prompt=prompt,
negative_prompt=negative_prompt,
image=input_image,
num_frames=num_frames,
height=height,
width=width,
num_inference_steps=num_steps,
guidance_scale=guidance,
image_cond_noise_scale=0.025,
generator=generator,
**decode_kwargs,
)
else:
result = text_pipe(
prompt=prompt,
negative_prompt=negative_prompt,
num_frames=num_frames,
height=height,
width=width,
num_inference_steps=num_steps,
guidance_scale=guidance,
generator=generator,
**decode_kwargs,
)
frames = result.frames[0]
out_path = os.path.join(
tempfile.gettempdir(), f"ltx_{seed}_{random.randint(0, 99999)}.mp4"
)
export_to_video(frames, out_path, fps=FPS)
return out_path
PROMPT_EXAMPLES = [
"Cinematic 3D render, glossy chrome sphere rotating on a dark studio background, volumetric lighting, smooth slow motion, octane render, 8k",
"Seamless abstract loop, flowing liquid metal, iridescent gradient colors, dark background, smooth hypnotic motion",
"Kinetic typography, the word MOTION exploding into view letter by letter, bold neon glowing letters, dark background, energetic dynamic camera",
]
with gr.Blocks(title="My Motion Video AI", theme=gr.themes.Soft()) as demo:
gr.Markdown(
"""# 🎬 My Motion Video AI
Your own video generation AI, running 100% in the cloud on Hugging Face GPUs
(open-source **LTX-Video**). Your laptop never does the work.
> **Free tier limit:** ~5 minutes of GPU per day (resets 24h after first use).
> One short clip ≈ 1–2 minutes of that. Choose your prompts wisely!
"""
)
with gr.Row():
with gr.Column(scale=1):
mode = gr.Radio(
["Text to Video", "Image to Video"],
value="Text to Video",
label="Mode",
info="Image to Video animates an uploaded image — great for kinetic typography and logos.",
)
prompt = gr.Textbox(
lines=3,
label="Prompt",
placeholder="Describe the motion graphics you want...",
info="Describe the scene AND the motion. Short clips work best.",
)
negative_prompt = gr.Textbox(
lines=2,
label="Negative prompt (optional)",
placeholder=DEFAULT_NEGATIVE,
)
input_image = gr.Image(
type="pil",
label="Starting image (Image to Video only)",
visible=False,
)
with gr.Accordion("Settings", open=False):
num_frames = gr.Slider(
minimum=49,
maximum=241,
value=121,
step=8,
label="Frames (121 ≈ 5 seconds)",
)
resolution = gr.Dropdown(
list(RESOLUTIONS.keys()),
value="Landscape 768x512",
label="Resolution",
)
num_steps = gr.Slider(
minimum=10,
maximum=50,
value=30,
step=1,
label="Inference steps (more = slower but higher quality)",
)
guidance = gr.Slider(
minimum=1.0,
maximum=6.0,
value=3.0,
step=0.5,
label="Guidance scale (how strictly it follows the prompt)",
)
seed = gr.Number(
value=-1,
label="Seed (-1 = random, reuse a seed to reproduce a video)",
)
generate_btn = gr.Button("🎬 Generate video", variant="primary")
with gr.Column(scale=1):
output_video = gr.Video(
label="Your video", format="mp4", autoplay=False
)
gr.Examples(
examples=PROMPT_EXAMPLES,
inputs=prompt,
label="Try one of these",
)
def toggle_image_visibility(selected_mode):
return gr.update(visible=(selected_mode == "Image to Video"))
mode.change(toggle_image_visibility, inputs=mode, outputs=input_image)
generate_btn.click(
generate_video,
inputs=[
prompt,
negative_prompt,
mode,
input_image,
num_frames,
resolution,
seed,
num_steps,
guidance,
],
outputs=output_video,
)
if __name__ == "__main__":
demo.queue().launch()