Spaces:
Running on Zero
Running on Zero
| """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) | |
| 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=257, | |
| value=121, | |
| step=8, | |
| label="Frames (121 β 5s, up to 257 β 10s)", | |
| ) | |
| 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() | |