File size: 9,063 Bytes
590e6fe
 
 
 
 
 
 
 
 
 
 
 
afa8596
 
 
590e6fe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
afa8596
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
590e6fe
 
 
 
 
 
 
afa8596
 
 
590e6fe
afa8596
 
 
 
 
 
 
590e6fe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
afa8596
590e6fe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
635c454
590e6fe
 
635c454
590e6fe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""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=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()