Spaces:
Sleeping
Sleeping
| import os | |
| import time | |
| import uuid | |
| import spaces | |
| import torch | |
| import gradio as gr | |
| from diffusers import LTXPipeline, LTXImageToVideoPipeline | |
| from diffusers.utils import export_to_video | |
| from huggingface_hub import InferenceClient | |
| # Model used to expand a short idea (in any language) into a detailed, | |
| # English, LTX-style prompt. This runs on HF's free serverless Inference | |
| # API, NOT on your ZeroGPU quota β separate budget entirely. | |
| ENHANCER_MODEL = "Qwen/Qwen2.5-7B-Instruct" | |
| # Requires a Space secret named HF_TOKEN (Settings -> Variables and | |
| # secrets -> New secret). Create the token at | |
| # https://huggingface.co/settings/tokens with "read" access. | |
| _hf_token = os.environ.get("HF_TOKEN") | |
| _hf_client = InferenceClient(token=_hf_token) | |
| ENHANCER_SYSTEM_PROMPT = ( | |
| "You turn a short video idea, in any language, into a single detailed " | |
| "English prompt for the LTX-Video text-to-video AI model. Always " | |
| "translate to English first. Describe: the subject's appearance, the " | |
| "action/motion happening, the environment, camera framing, and " | |
| "lighting/style. Keep it to 2-4 sentences, vivid and concrete, no " | |
| "bullet points, no preamble, no quotation marks β output ONLY the " | |
| "final prompt text." | |
| ) | |
| # Available checkpoints: fast/light vs. higher quality/slower. | |
| # Pipelines are loaded lazily (only when first selected) and cached, | |
| # so startup stays quick and you're not holding multiple models in | |
| # memory unless you actually use them. | |
| MODEL_OPTIONS = { | |
| "Fast (LTX-Video, original) β lower quality, cheapest on quota": "Lightricks/LTX-Video", | |
| "Higher quality (LTX-Video-0.9.5) β slower, costs more quota": "Lightricks/LTX-Video-0.9.5", | |
| } | |
| DEFAULT_MODEL_LABEL = "Fast (LTX-Video, original) β lower quality, cheapest on quota" | |
| # Presets: (width, height, num_frames, steps, guidance) | |
| PRESETS = { | |
| "π’ Draft (cheap & fast)": (512, 320, 49, 20, 3.0), | |
| "π΅ Quality (slower, better look)": (704, 480, 65, 32, 3.0), | |
| "π£ Long (more frames, same res as Draft)": (512, 320, 97, 20, 3.0), | |
| } | |
| HISTORY_DIR = "/tmp/history" | |
| os.makedirs(HISTORY_DIR, exist_ok=True) | |
| MAX_HISTORY = 8 | |
| # Global, in-memory history β fine for a single-user personal Space. | |
| # Resets if the Space restarts (ephemeral storage), but persists across | |
| # generations within a running session. | |
| _history = [] | |
| _t2v_cache = {} | |
| _i2v_cache = {} | |
| def get_t2v_pipeline(model_label): | |
| model_id = MODEL_OPTIONS[model_label] | |
| if model_id not in _t2v_cache: | |
| pipe = LTXPipeline.from_pretrained(model_id, torch_dtype=torch.bfloat16) | |
| if torch.cuda.is_available(): | |
| pipe.to("cuda") | |
| _t2v_cache[model_id] = pipe | |
| return _t2v_cache[model_id] | |
| def get_i2v_pipeline(model_label): | |
| model_id = MODEL_OPTIONS[model_label] | |
| if model_id not in _i2v_cache: | |
| pipe = LTXImageToVideoPipeline.from_pretrained(model_id, torch_dtype=torch.bfloat16) | |
| if torch.cuda.is_available(): | |
| pipe.to("cuda") | |
| _i2v_cache[model_id] = pipe | |
| return _i2v_cache[model_id] | |
| # Warm up the default (fast) text-to-video model at startup so the first | |
| # request doesn't also pay for a cold model load. Image-to-video and the | |
| # quality model load lazily on first use instead, to keep startup light. | |
| get_t2v_pipeline(DEFAULT_MODEL_LABEL) | |
| DEFAULT_NEGATIVE = ( | |
| "worst quality, inconsistent motion, blurry, jittery, distorted, " | |
| "low resolution, deformed" | |
| ) | |
| def _save_to_history(prompt, out_path): | |
| """Copy a generated clip into the history folder and record it.""" | |
| ext = os.path.splitext(out_path)[1] or ".mp4" | |
| dest = os.path.join(HISTORY_DIR, f"{uuid.uuid4().hex}{ext}") | |
| with open(out_path, "rb") as src, open(dest, "wb") as dst: | |
| dst.write(src.read()) | |
| _history.insert(0, {"path": dest, "prompt": prompt, "time": time.time()}) | |
| del _history[MAX_HISTORY:] | |
| def _history_gallery_items(): | |
| return [(h["path"], h["prompt"]) for h in _history] | |
| def apply_preset(preset_name): | |
| w, h, f, s, g = PRESETS[preset_name] | |
| return w, h, f, s, g | |
| def enhance_prompt(short_text): | |
| if not short_text or not short_text.strip(): | |
| raise gr.Error("Type a short idea first, then click Enhance.") | |
| try: | |
| completion = _hf_client.chat.completions.create( | |
| model=ENHANCER_MODEL, | |
| messages=[ | |
| {"role": "system", "content": ENHANCER_SYSTEM_PROMPT}, | |
| {"role": "user", "content": short_text.strip()}, | |
| ], | |
| max_tokens=200, | |
| temperature=0.7, | |
| ) | |
| return completion.choices[0].message.content.strip() | |
| except Exception as e: | |
| raise gr.Error( | |
| f"Prompt enhancer failed ({e}). You can still type your own " | |
| "detailed English prompt directly." | |
| ) | |
| # ignored/no-op on non-ZeroGPU hardware; keep this | |
| # as low as your typical generation allows β ZeroGPU reserves this many | |
| # seconds from your daily quota on every call, whether or not you use it all. | |
| def generate_t2v(model_label, prompt, negative_prompt, width, height, num_frames, steps, guidance, seed): | |
| if not prompt or not prompt.strip(): | |
| raise gr.Error("Please enter a prompt describing the video you want.") | |
| pipe = get_t2v_pipeline(model_label) | |
| generator = None | |
| if seed is not None and int(seed) >= 0: | |
| generator = torch.Generator(device="cuda" if torch.cuda.is_available() else "cpu") | |
| generator.manual_seed(int(seed)) | |
| video = pipe( | |
| prompt=prompt, | |
| negative_prompt=negative_prompt or DEFAULT_NEGATIVE, | |
| width=int(width), | |
| height=int(height), | |
| num_frames=int(num_frames), | |
| num_inference_steps=int(steps), | |
| guidance_scale=float(guidance), | |
| generator=generator, | |
| ).frames[0] | |
| out_path = f"/tmp/t2v_{uuid.uuid4().hex}.mp4" | |
| export_to_video(video, out_path, fps=24) | |
| _save_to_history(prompt, out_path) | |
| return out_path, gr.update(value=_history_gallery_items()) | |
| # same quota note as generate_t2v above | |
| def generate_i2v(model_label, image, prompt, negative_prompt, width, height, num_frames, steps, guidance, seed): | |
| if image is None: | |
| raise gr.Error("Please upload an image to animate.") | |
| if not prompt or not prompt.strip(): | |
| raise gr.Error("Please enter a prompt describing the motion/scene.") | |
| pipe = get_i2v_pipeline(model_label) | |
| generator = None | |
| if seed is not None and int(seed) >= 0: | |
| generator = torch.Generator(device="cuda" if torch.cuda.is_available() else "cpu") | |
| generator.manual_seed(int(seed)) | |
| video = pipe( | |
| image=image, | |
| prompt=prompt, | |
| negative_prompt=negative_prompt or DEFAULT_NEGATIVE, | |
| width=int(width), | |
| height=int(height), | |
| num_frames=int(num_frames), | |
| num_inference_steps=int(steps), | |
| guidance_scale=float(guidance), | |
| generator=generator, | |
| ).frames[0] | |
| out_path = f"/tmp/i2v_{uuid.uuid4().hex}.mp4" | |
| export_to_video(video, out_path, fps=24) | |
| _save_to_history(f"[image-to-video] {prompt}", out_path) | |
| return out_path, gr.update(value=_history_gallery_items()) | |
| def _settings_block(): | |
| """Shared preset + slider block, used by both tabs.""" | |
| with gr.Row(): | |
| preset = gr.Radio( | |
| choices=list(PRESETS.keys()), | |
| value="π’ Draft (cheap & fast)", | |
| label="Preset (click to apply, then tweak below if you want)", | |
| ) | |
| with gr.Row(): | |
| width = gr.Slider(256, 768, value=512, step=32, label="Width") | |
| height = gr.Slider(256, 768, value=320, step=32, label="Height") | |
| with gr.Row(): | |
| num_frames = gr.Slider(9, 97, value=49, step=8, label="Number of frames") | |
| steps = gr.Slider(8, 40, value=20, step=1, label="Inference steps") | |
| with gr.Row(): | |
| guidance = gr.Slider(1.0, 10.0, value=3.0, step=0.1, label="Guidance scale") | |
| seed = gr.Number(value=-1, label="Seed (-1 = random)") | |
| preset.change( | |
| fn=apply_preset, | |
| inputs=preset, | |
| outputs=[width, height, num_frames, steps, guidance], | |
| ) | |
| return width, height, num_frames, steps, guidance, seed | |
| CUSTOM_CSS = """ | |
| @import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Inter:wght@400;500;600&display=swap'); | |
| :root { | |
| --bg: #14131a; | |
| --panel: #1f1d27; | |
| --panel-border: #322f3d; | |
| --text: #f3efe7; | |
| --text-muted: #a29cb0; | |
| --gold: #e4b44b; | |
| --teal: #4fd1c5; | |
| } | |
| .gradio-container { | |
| background: var(--bg) !important; | |
| font-family: 'Inter', sans-serif !important; | |
| color: var(--text) !important; | |
| } | |
| #app-header { | |
| padding: 8px 4px 4px 4px; | |
| border-bottom: 1px solid var(--panel-border); | |
| margin-bottom: 12px; | |
| background: | |
| radial-gradient(circle, var(--panel-border) 1.5px, transparent 1.5px) 0 0 / 14px 14px, | |
| radial-gradient(circle, var(--panel-border) 1.5px, transparent 1.5px) 0 100% / 14px 14px; | |
| background-repeat: repeat-x; | |
| background-position: top left, bottom left; | |
| padding-top: 14px; | |
| padding-bottom: 14px; | |
| } | |
| #app-header h1 { | |
| font-family: 'Space Grotesk', sans-serif !important; | |
| font-weight: 700 !important; | |
| letter-spacing: -0.01em; | |
| background: linear-gradient(90deg, var(--gold), var(--teal)); | |
| -webkit-background-clip: text; | |
| background-clip: text; | |
| color: transparent !important; | |
| display: inline-block; | |
| } | |
| #quota-tip { | |
| background: var(--panel) !important; | |
| border: 1px solid var(--panel-border) !important; | |
| border-radius: 12px !important; | |
| padding: 10px 14px !important; | |
| color: var(--text-muted) !important; | |
| font-size: 0.9em; | |
| } | |
| .tab-panel { | |
| background: var(--panel) !important; | |
| border: 1px solid var(--panel-border) !important; | |
| border-radius: 16px !important; | |
| padding: 18px !important; | |
| } | |
| button.primary { | |
| background: linear-gradient(90deg, var(--gold), #c98f2e) !important; | |
| border: none !important; | |
| color: #1a1408 !important; | |
| font-weight: 600 !important; | |
| } | |
| #enhance-btn-t2v, #enhance-btn-i2v { | |
| background: transparent !important; | |
| border: 1px solid var(--teal) !important; | |
| color: var(--teal) !important; | |
| font-weight: 500 !important; | |
| } | |
| #enhance-btn-t2v:hover, #enhance-btn-i2v:hover { | |
| background: rgba(79, 209, 197, 0.12) !important; | |
| } | |
| #history-panel { | |
| background: var(--panel) !important; | |
| border: 1px solid var(--panel-border) !important; | |
| border-radius: 16px !important; | |
| padding: 18px !important; | |
| margin-top: 8px; | |
| } | |
| #history-panel h2 { | |
| font-family: 'Space Grotesk', sans-serif !important; | |
| font-size: 1.1em !important; | |
| color: var(--text) !important; | |
| margin: 0 0 12px 0 !important; | |
| } | |
| #history-gallery { | |
| background: transparent !important; | |
| border: none !important; | |
| } | |
| #history-gallery .thumbnail-item, | |
| #history-gallery .grid-wrap, | |
| #history-gallery [data-testid="thumbnail item"] { | |
| border-radius: 12px !important; | |
| border: 1px solid var(--panel-border) !important; | |
| overflow: hidden; | |
| transition: transform 0.15s ease, border-color 0.15s ease; | |
| } | |
| #history-gallery .thumbnail-item:hover { | |
| transform: translateY(-3px); | |
| border-color: var(--gold) !important; | |
| } | |
| #history-gallery .caption, | |
| #history-gallery .caption-label { | |
| background: rgba(20, 19, 26, 0.85) !important; | |
| color: var(--text) !important; | |
| font-size: 0.78em !important; | |
| white-space: nowrap; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| } | |
| #footer-tip { | |
| color: var(--text-muted) !important; | |
| font-size: 0.85em; | |
| text-align: center; | |
| margin-top: 6px; | |
| } | |
| """ | |
| with gr.Blocks(title="Free LTX Video Generator", css=CUSTOM_CSS) as demo: | |
| with gr.Column(elem_id="app-header"): | |
| gr.Markdown( | |
| """ | |
| # π¬ LTX Video Generator | |
| Personal video generator powered by [LTX-Video](https://huggingface.co/Lightricks/LTX-Video). | |
| """ | |
| ) | |
| gr.Markdown( | |
| "π‘ **Quota tip:** ZeroGPU gives you a small daily budget of GPU " | |
| "seconds. Lower resolution/frames/steps use less of it per " | |
| "generation β the **Draft** preset is the cheapest, **Quality** and " | |
| "**Long** cost more.", | |
| elem_id="quota-tip", | |
| ) | |
| with gr.Tabs(): | |
| with gr.Tab("Text β Video"): | |
| with gr.Row(): | |
| with gr.Column(scale=1, elem_classes="tab-panel"): | |
| model_choice_t2v = gr.Dropdown( | |
| choices=list(MODEL_OPTIONS.keys()), | |
| value=DEFAULT_MODEL_LABEL, | |
| label="Model", | |
| ) | |
| prompt_t2v = gr.Textbox( | |
| label="Prompt (Hebrew or English β short idea is fine)", | |
| placeholder="A golden retriever running through a field of sunflowers at sunset, cinematic lighting, realistic style", | |
| lines=4, | |
| ) | |
| enhance_btn_t2v = gr.Button("β¨ Enhance & Translate Prompt", elem_id="enhance-btn-t2v") | |
| negative_prompt_t2v = gr.Textbox( | |
| label="Negative prompt (optional)", | |
| value=DEFAULT_NEGATIVE, | |
| lines=2, | |
| ) | |
| (width_t2v, height_t2v, frames_t2v, steps_t2v, | |
| guidance_t2v, seed_t2v) = _settings_block() | |
| run_btn_t2v = gr.Button("Generate Video", variant="primary") | |
| with gr.Column(scale=1, elem_classes="tab-panel"): | |
| output_video_t2v = gr.Video(label="Result") | |
| with gr.Tab("Image β Video"): | |
| with gr.Row(): | |
| with gr.Column(scale=1, elem_classes="tab-panel"): | |
| model_choice_i2v = gr.Dropdown( | |
| choices=list(MODEL_OPTIONS.keys()), | |
| value=DEFAULT_MODEL_LABEL, | |
| label="Model", | |
| ) | |
| input_image = gr.Image(label="Starting image", type="pil") | |
| prompt_i2v = gr.Textbox( | |
| label="Prompt (Hebrew or English β short idea is fine)", | |
| placeholder="The cat slowly turns its head and blinks, gentle breeze moving its fur, cinematic lighting", | |
| lines=4, | |
| ) | |
| enhance_btn_i2v = gr.Button("β¨ Enhance & Translate Prompt", elem_id="enhance-btn-i2v") | |
| negative_prompt_i2v = gr.Textbox( | |
| label="Negative prompt (optional)", | |
| value=DEFAULT_NEGATIVE, | |
| lines=2, | |
| ) | |
| (width_i2v, height_i2v, frames_i2v, steps_i2v, | |
| guidance_i2v, seed_i2v) = _settings_block() | |
| run_btn_i2v = gr.Button("Animate Image", variant="primary") | |
| with gr.Column(scale=1, elem_classes="tab-panel"): | |
| output_video_i2v = gr.Video(label="Result") | |
| with gr.Column(elem_id="history-panel"): | |
| gr.Markdown("## π History β last 8 generations") | |
| history_gallery = gr.Gallery( | |
| label="History", | |
| show_label=False, | |
| value=_history_gallery_items(), | |
| columns=4, | |
| rows=2, | |
| object_fit="cover", | |
| height=340, | |
| elem_id="history-gallery", | |
| ) | |
| gr.Markdown( | |
| "Tip: keep width/height multiples of 32 and frames as `8n+1` " | |
| "(e.g. 49, 65, 97) β these match LTX-Video's training constraints " | |
| "and avoid shape errors.", | |
| elem_id="footer-tip", | |
| ) | |
| enhance_btn_t2v.click( | |
| fn=enhance_prompt, | |
| inputs=prompt_t2v, | |
| outputs=prompt_t2v, | |
| ) | |
| enhance_btn_i2v.click( | |
| fn=enhance_prompt, | |
| inputs=prompt_i2v, | |
| outputs=prompt_i2v, | |
| ) | |
| run_btn_t2v.click( | |
| fn=generate_t2v, | |
| inputs=[model_choice_t2v, prompt_t2v, negative_prompt_t2v, width_t2v, | |
| height_t2v, frames_t2v, steps_t2v, guidance_t2v, seed_t2v], | |
| outputs=[output_video_t2v, history_gallery], | |
| ) | |
| run_btn_i2v.click( | |
| fn=generate_i2v, | |
| inputs=[model_choice_i2v, input_image, prompt_i2v, negative_prompt_i2v, | |
| width_i2v, height_i2v, frames_i2v, steps_i2v, guidance_i2v, seed_i2v], | |
| outputs=[output_video_i2v, history_gallery], | |
| ) | |
| if __name__ == "__main__": | |
| demo.queue(max_size=10).launch() |