| """ |
| LTX 2.3 LoRA Space — UI Template |
| ================================= |
| Dark-themed Gradio UI template for LTX 2.3 LoRA video generation. |
| CPU-only preview — no model loaded, just the UI. |
| |
| Adapt this template: |
| 1. Edit LORA_* constants below |
| 2. Replace generate_video() with your actual pipeline |
| 3. Deploy on HF Space with or without ZeroGPU |
| """ |
| import os |
| import sys |
| import time |
| import uuid |
| import random |
| from pathlib import Path |
|
|
| import gradio as gr |
|
|
| |
| |
| |
| LORA_NAME = "Transition LoRA" |
| LORA_DESCRIPTION = "Suaviza transições entre cenas com cortes cinematográficos fluidos." |
| LORA_REPO = "joyfox/LTX-2.3-Transition-LORA" |
| LORA_FILENAME = "ltx2.3-transition.safetensors" |
| LORA_STRENGTH_DEFAULT = 0.8 |
| LORA_STRENGTH_MIN = 0.0 |
| LORA_STRENGTH_MAX = 2.0 |
|
|
| SPACE_AUTHOR = "artificialguybr" |
| SPACE_NAME = "test-gui-ltx" |
| HF_LINK = f"https://huggingface.co/{LORA_REPO}" |
| GITHUB_LINK = "https://github.com/artificialguybr/ltx23-lora-transition" |
| MODEL_LINK = "https://huggingface.co/Lightricks/LTX-2.3" |
|
|
| MAX_SEED = 2147483647 |
| DEFAULT_PROMPT = "make this image come alive, cinematic motion, smooth camera movement" |
| DEFAULT_DURATION = 6.0 |
| DEFAULT_SEED = 42 |
|
|
| RESOLUTION_MAP = { |
| "16:9": (768, 512), |
| "1:1": (512, 512), |
| "9:16": (512, 768), |
| } |
|
|
| |
| |
| |
|
|
| class RadioAnimated(gr.HTML): |
| """Animated segmented radio (iOS pill selector).""" |
| def __init__(self, choices, value=None, **kwargs): |
| if not choices or len(choices) < 2: |
| raise ValueError("RadioAnimated needs at least 2 choices.") |
| if value is None: |
| value = choices[0] |
| uid = uuid.uuid4().hex[:8] |
| group_name = f"ra-{uid}" |
| inputs_html = "\n".join( |
| f'<input class="ra-input" type="radio" name="{group_name}" ' |
| f'id="{group_name}-{i}" value="{c}">' |
| f'<label class="ra-label" for="{group_name}-{i}">{c}</label>' |
| for i, c in enumerate(choices) |
| ) |
| html_template = f""" |
| <div class="ra-wrap" data-ra="{uid}"> |
| <div class="ra-inner"> |
| <div class="ra-highlight"></div> |
| {inputs_html} |
| </div> |
| </div>""" |
| js_on_load = r""" |
| (function() { |
| var wrap, inner, highlight, inputs, labels, choices; |
| function init() { |
| wrap = element.querySelector('.ra-wrap'); |
| if (!wrap) return; |
| inner = wrap.querySelector('.ra-inner'); |
| highlight = wrap.querySelector('.ra-highlight'); |
| inputs = Array.from(wrap.querySelectorAll('.ra-input')); |
| labels = Array.from(wrap.querySelectorAll('.ra-label')); |
| choices = inputs.map(function(i) { return i.value; }); |
| if (choices.length === 0) return; |
| var PAD = 6; |
| var currentIdx = choices.indexOf(props.value) >= 0 ? choices.indexOf(props.value) : 0; |
| function setHighlightByIndex(idx) { |
| currentIdx = idx; |
| var lbl = labels[idx]; |
| if (!lbl) return; |
| var innerRect = inner.getBoundingClientRect(); |
| var lblRect = lbl.getBoundingClientRect(); |
| highlight.style.width = lblRect.width + 'px'; |
| highlight.style.transform = 'translateX(' + (lblRect.left - innerRect.left - PAD) + 'px)'; |
| } |
| function setCheckedByValue(val, trigger) { |
| var idx = Math.max(0, choices.indexOf(val)); |
| inputs.forEach(function(inp, i) { inp.checked = (i === idx); }); |
| requestAnimationFrame(function() { setHighlightByIndex(idx); }); |
| props.value = choices[idx]; |
| if (trigger) trigger('change', props.value); |
| } |
| setCheckedByValue(props.value || choices[0], false); |
| inputs.forEach(function(inp) { |
| inp.addEventListener('change', function() { setCheckedByValue(inp.value, true); }); |
| }); |
| window.addEventListener('resize', function() { setHighlightByIndex(currentIdx); }); |
| } |
| if (document.readyState === 'loading') { |
| document.addEventListener('DOMContentLoaded', init); |
| } else { init(); } |
| var last = props.value; |
| (function sync() { |
| if (props.value !== last) { last = props.value; setCheckedByValue(last, false); } |
| requestAnimationFrame(sync); |
| })(); |
| })(); |
| """ |
| super().__init__(value=value, html_template=html_template, js_on_load=js_on_load, **kwargs) |
|
|
|
|
| class CameraDropdown(gr.HTML): |
| """Custom dropdown with icons per option.""" |
| def __init__(self, choices, value=None, title="", **kwargs): |
| if value is None: |
| value = choices[0] if choices else "" |
| uid = uuid.uuid4().hex[:8] |
| items_html = "\n".join( |
| f'<div class="cd-item" data-value="{c}"><span class="cd-label">{c}</span></div>' |
| for c in choices |
| ) |
| html_template = f""" |
| <div class="cd-wrap" data-cd="{uid}"> |
| <button type="button" class="cd-trigger" aria-haspopup="listbox"> |
| <span class="cd-trigger-text">{value}</span> |
| <span class="cd-caret">▾</span> |
| </button> |
| <div class="cd-menu" role="listbox" aria-hidden="true"> |
| <div class="cd-title">{title}</div> |
| <div class="cd-items">{items_html}</div> |
| </div> |
| </div>""" |
| js_on_load = r""" |
| (function() { |
| function init() { |
| var wrap = element.querySelector('.cd-wrap'); |
| if (!wrap) return; |
| var trigger = wrap.querySelector('.cd-trigger'); |
| var menu = wrap.querySelector('.cd-menu'); |
| var items = Array.from(wrap.querySelectorAll('.cd-item')); |
| var triggerText = wrap.querySelector('.cd-trigger-text'); |
| trigger.addEventListener('click', function(e) { |
| e.stopPropagation(); |
| var isOpen = menu.getAttribute('aria-hidden') === 'false'; |
| document.querySelectorAll('.cd-menu[aria-hidden="false"]').forEach(function(m) { |
| m.setAttribute('aria-hidden', 'true'); |
| }); |
| if (!isOpen) menu.setAttribute('aria-hidden', 'false'); |
| }); |
| items.forEach(function(item) { |
| item.addEventListener('click', function() { |
| var val = item.getAttribute('data-value'); |
| triggerText.textContent = val; |
| props.value = val; |
| trigger('change', val); |
| menu.setAttribute('aria-hidden', 'true'); |
| }); |
| }); |
| } |
| if (document.readyState === 'loading') { |
| document.addEventListener('DOMContentLoaded', init); |
| } else { init(); } |
| document.addEventListener('click', function(e) { |
| document.querySelectorAll('.cd-menu[aria-hidden="false"]').forEach(function(menu) { |
| if (!menu.closest('.cd-wrap').contains(e.target)) { |
| menu.setAttribute('aria-hidden', 'true'); |
| } |
| }); |
| }); |
| var last = props.value; |
| (function sync() { |
| var triggerText = element.querySelector('.cd-trigger-text'); |
| if (triggerText && props.value !== last) { |
| last = props.value; |
| triggerText.textContent = last; |
| } |
| requestAnimationFrame(sync); |
| })(); |
| })(); |
| """ |
| super().__init__(value=value, html_template=html_template, js_on_load=js_on_load, **kwargs) |
|
|
|
|
| |
| |
| |
| CSS = """ |
| #col-container { margin: 0 auto; max-width: 1400px; padding: 0 16px; } |
| #controls-col, #output-col { |
| background: rgba(255,255,255,0.03); |
| border-radius: 12px; |
| padding: 20px; |
| border: 1px solid rgba(255,255,255,0.06); |
| } |
| .space-header { text-align: center; padding: 16px 0 8px; } |
| .space-header h1 { |
| font-size: 26px; font-weight: 800; |
| background: linear-gradient(135deg, #ff6b6b, #ffd93d, #6bcb77, #4d96ff); |
| -webkit-background-clip: text; -webkit-text-fill-color: transparent; |
| background-clip: text; margin: 0 0 6px; |
| } |
| .space-header .subtitle { color: rgba(255,255,255,0.5); font-size: 14px; margin: 0; } |
| .header-links { display: flex; gap: 10px; justify-content: center; margin-top: 10px; flex-wrap: wrap; } |
| .header-links a { text-decoration: none; } |
| .ra-wrap { |
| display: inline-flex; background: rgba(255,255,255,0.05); |
| border-radius: 10px; padding: 4px; margin: 8px 0; |
| border: 1px solid rgba(255,255,255,0.08); |
| } |
| .ra-inner { position: relative; display: flex; gap: 2px; } |
| .ra-highlight { |
| position: absolute; top: 0; height: 100%; |
| background: linear-gradient(135deg, #667eea, #764ba2); |
| border-radius: 7px; transition: transform 0.25s cubic-bezier(0.4,0,0.2,1), width 0.25s ease; |
| box-shadow: 0 2px 8px rgba(102,126,234,0.4); |
| } |
| .ra-input { display: none; } |
| .ra-label { |
| position: relative; z-index: 1; padding: 8px 20px; cursor: pointer; |
| font-size: 14px; font-weight: 500; color: rgba(255,255,255,0.5); |
| transition: color 0.2s; user-select: none; white-space: nowrap; |
| } |
| .ra-input:checked + .ra-label { color: #fff; } |
| .cd-wrap { position: relative; display: inline-block; font-family: inherit; } |
| .cd-trigger { |
| display: flex; align-items: center; gap: 8px; padding: 8px 14px; |
| background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.12); |
| border-radius: 8px; cursor: pointer; color: rgba(255,255,255,0.8); |
| font-size: 13px; font-family: inherit; transition: all 0.2s; min-width: 100px; |
| } |
| .cd-trigger:hover { background: rgba(255,255,255,0.09); border-color: rgba(255,255,255,0.2); } |
| .cd-caret { margin-left: auto; font-size: 11px; opacity: 0.6; } |
| .cd-menu { |
| position: absolute; top: calc(100% + 4px); left: 0; min-width: 100%; |
| background: #1a1a2e; border: 1px solid rgba(255,255,255,0.1); |
| border-radius: 8px; padding: 4px; z-index: 100; |
| box-shadow: 0 8px 24px rgba(0,0,0,0.4); display: none; |
| } |
| .cd-menu:not([aria-hidden="true"]) { display: block; } |
| .cd-title { |
| padding: 6px 10px; font-size: 11px; font-weight: 600; |
| color: rgba(255,255,255,0.3); text-transform: uppercase; letter-spacing: 0.5px; |
| } |
| .cd-item { |
| padding: 8px 12px; cursor: pointer; border-radius: 6px; |
| font-size: 13px; color: rgba(255,255,255,0.7); |
| transition: background 0.15s, color 0.15s; |
| } |
| .cd-item:hover { background: rgba(255,255,255,0.08); color: #fff; } |
| .lora-badge { |
| display: inline-flex; align-items: center; gap: 6px; |
| background: linear-gradient(135deg, rgba(102,126,234,0.15), rgba(118,75,162,0.15)); |
| border: 1px solid rgba(102,126,234,0.3); border-radius: 20px; |
| padding: 4px 12px; font-size: 12px; color: rgba(255,255,255,0.7); margin: 6px 0; |
| } |
| .lora-badge .dot { |
| width: 6px; height: 6px; border-radius: 50%; background: #667eea; |
| animation: pulse 2s infinite; |
| } |
| @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.4} } |
| .btn-generate { |
| background: linear-gradient(135deg, #ff416c, #ff4b2b, #ff9f43) !important; |
| background-size: 200% 200% !important; |
| animation: gradientShift 3s ease infinite !important; |
| border: none !important; color: white !important; |
| font-weight: 700 !important; font-size: 16px !important; |
| padding: 14px 28px !important; border-radius: 12px !important; |
| cursor: pointer !important; width: 100%; |
| box-shadow: 0 4px 15px rgba(255,65,108,0.4) !important; |
| transition: transform 0.2s, box-shadow 0.2s !important; |
| } |
| .btn-generate:hover { |
| transform: translateY(-2px); |
| box-shadow: 0 6px 20px rgba(255,65,108,0.5) !important; |
| } |
| @keyframes gradientShift { |
| 0% { background-position: 0% 50%; } |
| 50% { background-position: 100% 50%; } |
| 100% { background-position: 0% 50%; } |
| } |
| .gen-meta-card { |
| margin-top: 12px; padding: 12px 16px; |
| background: rgba(255,255,255,0.03); |
| border: 1px solid rgba(255,255,255,0.06); border-radius: 10px; |
| } |
| .meta-chips { display: flex; flex-wrap: wrap; gap: 8px; } |
| .meta-chip { |
| display: inline-flex; align-items: center; gap: 4px; |
| padding: 4px 10px; background: rgba(255,255,255,0.05); |
| border-radius: 6px; font-size: 12px; color: rgba(255,255,255,0.7); |
| font-family: 'SF Mono', 'Fira Code', monospace; |
| } |
| .meta-chip b { |
| color: rgba(255,255,255,0.35); font-weight: 500; |
| font-family: system-ui, sans-serif; font-size: 10px; |
| text-transform: uppercase; letter-spacing: 0.4px; |
| } |
| .advanced-section { margin-top: 16px; } |
| .space-footer { |
| text-align: center; padding: 20px 0; |
| color: rgba(255,255,255,0.3); font-size: 12px; |
| } |
| .space-footer a { color: rgba(255,255,255,0.4); text-decoration: none; } |
| .space-footer a:hover { color: rgba(255,255,255,0.7); } |
| body, .gradio-container { background: #0d0d14 !important; } |
| """ |
|
|
|
|
| |
| |
| |
| def calc_frames(duration, fps=24.0): |
| raw = int(duration * fps) + 1 |
| raw = max(raw, 9) |
| k = (raw - 1 + 7) // 8 |
| return k * 8 + 1 |
|
|
|
|
| def apply_resolution(resolution): |
| w, h = RESOLUTION_MAP.get(resolution, (768, 512)) |
| return int(w), int(h) |
|
|
|
|
| def format_time(seconds): |
| secs = int(max(0, seconds)) |
| if secs < 60: |
| return f"{secs}s" |
| return f"{secs // 60}m {secs % 60}s" |
|
|
|
|
| def build_metadata_html(seed, width, height, duration, elapsed): |
| meta_parts = [ |
| f'<span class="meta-chip"><b>Seed</b> {seed}</span>', |
| f'<span class="meta-chip"><b>Size</b> {width}×{height}</span>', |
| f'<span class="meta-chip"><b>Dur</b> {duration}s</span>', |
| f'<span class="meta-chip"><b>Time</b> {format_time(elapsed)}</span>', |
| ] |
| return f'<div class="gen-meta-card"><div class="meta-chips">{"".join(meta_parts)}</div></div>' |
|
|
|
|
| |
| |
| |
| def generate_video( |
| first_frame, |
| prompt: str, |
| duration: float, |
| lora_strength: float, |
| seed: int, |
| randomize_seed: bool, |
| width: int, |
| height: int, |
| resolution: str, |
| enhance_prompt: bool, |
| progress=gr.Progress(track_tqdm=True), |
| ): |
| """Mock generation — replace with real LTX 2.3 + LoRA pipeline.""" |
| t0 = time.time() |
|
|
| if not prompt or not prompt.strip(): |
| raise gr.Error("Please enter a prompt.") |
|
|
| current_seed = random.randint(0, MAX_SEED) if randomize_seed else int(seed) |
| num_frames = calc_frames(float(duration)) |
|
|
| progress(0.1, desc="Preparing generation...") |
| time.sleep(0.3) |
| progress(0.3, desc=f"Generating {num_frames} frames...") |
| time.sleep(0.5) |
| progress(0.6, desc="Applying LoRA effect...") |
| time.sleep(0.4) |
| progress(0.9, desc="Finalizing video...") |
| time.sleep(0.3) |
|
|
| elapsed = time.time() - t0 |
| meta_html = build_metadata_html(current_seed, width, height, duration, elapsed) |
|
|
| gr.Info("⚠️ This is a UI preview — no model loaded. Connect your LTX 2.3 pipeline here.") |
| return None, meta_html |
|
|
|
|
| |
| |
| |
| def build_ui(): |
| with gr.Blocks(title=f"LTX 2.3 — {LORA_NAME}") as demo: |
|
|
| |
| gr.HTML(f""" |
| <div class="space-header"> |
| <h1>✨ LTX 2.3 — {LORA_NAME}</h1> |
| <p class="subtitle">{LORA_DESCRIPTION}</p> |
| <div class="header-links"> |
| <a href="{HF_LINK}" target="_blank"> |
| <img src="https://img.shields.io/badge/HF-Model-FFD700?style=flat&logo=huggingface" alt="HF"> |
| </a> |
| <a href="{MODEL_LINK}" target="_blank"> |
| <img src="https://img.shields.io/badge/LTX-2.3%20Base-4D96FF?style=flat" alt="LTX 2.3"> |
| </a> |
| <a href="{GITHUB_LINK}" target="_blank"> |
| <img src="https://img.shields.io/badge/GitHub-Source-181717?style=flat&logo=github" alt="GitHub"> |
| </a> |
| </div> |
| </div> |
| """) |
|
|
| with gr.Row(): |
| with gr.Column(scale=1, elem_id="controls-col"): |
| mode_selector = RadioAnimated( |
| choices=["Image-to-Video", "Interpolate"], |
| value="Image-to-Video", |
| elem_id="mode-selector", |
| ) |
|
|
| first_frame = gr.Image(label="First Frame", type="filepath", height=280) |
| end_frame = gr.Image(label="Last Frame (for Interpolate)", type="filepath", height=140, visible=False) |
|
|
| prompt = gr.Textbox( |
| label="Prompt", value=DEFAULT_PROMPT, lines=3, |
| placeholder="Describe the motion and animation you want...", |
| ) |
|
|
| gr.HTML(f""" |
| <div class="lora-badge"> |
| <span class="dot"></span> |
| <span>🎬 {LORA_NAME}</span> |
| </div> |
| """) |
|
|
| lora_strength = gr.Slider( |
| label="LoRA Strength", |
| minimum=LORA_STRENGTH_MIN, maximum=LORA_STRENGTH_MAX, |
| value=LORA_STRENGTH_DEFAULT, step=0.05, |
| info="Blend weight for the LoRA effect (0 = base, higher = more LoRA)", |
| ) |
|
|
| gr.HTML('<div style="display:flex;gap:10px;flex-wrap:wrap;margin:10px 0;">') |
|
|
| duration_ui = CameraDropdown( |
| choices=["4s", "6s", "8s", "10s"], value="6s", title="Duration", elem_id="duration-ui", |
| ) |
| duration = gr.Number(label="Duration (s)", value=DEFAULT_DURATION, visible=False) |
|
|
| resolution_ui = CameraDropdown( |
| choices=["16:9", "1:1", "9:16"], value="16:9", title="Resolution", elem_id="resolution-ui", |
| ) |
| width = gr.Number(label="Width", value=768, visible=False) |
| height = gr.Number(label="Height", value=512, visible=False) |
|
|
| gr.HTML('</div>') |
|
|
| generate_btn = gr.Button("✨ Generate Video", elem_classes="btn-generate") |
|
|
| gr.HTML('<div class="advanced-section">') |
| with gr.Accordion("⚙️ Advanced Settings", open=False): |
| enhance_prompt = gr.Checkbox(label="Enhance Prompt with AI", value=True) |
| seed = gr.Slider(label="Seed", minimum=0, maximum=MAX_SEED, value=DEFAULT_SEED, step=1) |
| randomize_seed = gr.Checkbox(label="Randomize Seed", value=True) |
| gr.HTML('</div>') |
|
|
| with gr.Column(scale=1, elem_id="output-col"): |
| gr.Markdown("### 🎬 Output") |
| output_video = gr.Video(label="Generated Video", autoplay=True, loop=True, height=480) |
| metadata_display = gr.HTML(value="") |
|
|
| gr.HTML(""" |
| <div style="margin-top:16px;padding:14px;background:rgba(255,255,255,0.03);border-radius:10px;border:1px solid rgba(255,255,255,0.06);"> |
| <div style="font-size:12px;color:rgba(255,255,255,0.4);margin-bottom:8px;text-transform:uppercase;letter-spacing:0.5px;">📖 How to use</div> |
| <ul style="font-size:13px;color:rgba(255,255,255,0.6);margin:0;padding-left:18px;line-height:1.8;"> |
| <li>Upload an image as first frame</li> |
| <li>Write a detailed prompt describing the motion</li> |
| <li>Adjust LoRA strength (higher = stronger effect)</li> |
| <li>Choose duration and resolution</li> |
| <li>Click <strong>Generate Video</strong></li> |
| </ul> |
| </div> |
| """) |
|
|
| gr.HTML(f""" |
| <div class="space-footer"> |
| Powered by <a href="{MODEL_LINK}" target="_blank">LTX 2.3</a> + |
| <a href="{HF_LINK}" target="_blank">{LORA_NAME}</a> |
| · Template by <a href="https://huggingface.co/{SPACE_AUTHOR}" target="_blank">{SPACE_AUTHOR}</a> |
| </div> |
| """) |
|
|
| |
| def on_mode_change(selected): |
| return gr.update(visible=(selected == "Interpolate")) |
|
|
| mode_selector.change(fn=on_mode_change, inputs=mode_selector, outputs=end_frame) |
|
|
| def on_duration_change(val): |
| return float(val.replace("s", "")) |
|
|
| duration_ui.change(fn=on_duration_change, inputs=duration_ui, outputs=duration) |
|
|
| def on_resolution_change(val): |
| w, h = apply_resolution(val) |
| return w, h |
|
|
| resolution_ui.change(fn=on_resolution_change, inputs=resolution_ui, outputs=[width, height]) |
|
|
| generate_btn.click( |
| fn=generate_video, |
| inputs=[first_frame, prompt, duration, lora_strength, seed, randomize_seed, |
| width, height, resolution_ui, enhance_prompt], |
| outputs=[output_video, metadata_display], |
| ) |
|
|
| return demo |
|
|
|
|
| |
| |
| |
| if __name__ == "__main__": |
| print(f"\n{'='*60}") |
| print(f"LTX 2.3 — {LORA_NAME}") |
| print(f"Space: {SPACE_AUTHOR}/{SPACE_NAME}") |
| print(f"{'='*60}\n") |
|
|
| demo = build_ui() |
| demo.launch( |
| server_name="0.0.0.0", |
| server_port=7860, |
| share=False, |
| css=CSS, |
| theme=gr.themes.Default(), |
| ) |
|
|