romanbwrk Claude Opus 4.8 commited on
Commit
e4e75f3
·
1 Parent(s): 3aee817

Implement LTX-2.3 image-to-video Gradio Space

Browse files

Add app.py (LTX2Pipeline distilled, ZeroGPU worker, optional 2x upscale),
requirements.txt, and README usage/hardware notes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Files changed (3) hide show
  1. README.md +19 -0
  2. app.py +189 -0
  3. requirements.txt +9 -0
README.md CHANGED
@@ -11,3 +11,22 @@ pinned: false
11
  ---
12
 
13
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  ---
12
 
13
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
14
+
15
+ # LTX-2.3 Image → Video
16
+
17
+ Upload an image and a prompt to generate ~5 seconds of video (with audio) using
18
+ the **LTX-2.3 22B distilled** model via `diffusers`. This is a native Gradio
19
+ reimplementation of the model stack used by the WhatDreamsCost "LTX Director 2"
20
+ ComfyUI workflow.
21
+
22
+ - **Model:** `diffusers/LTX-2.3-Distilled-Diffusers` (8 steps, CFG 1)
23
+ - **Output:** 121 frames @ 24 fps, with audio
24
+ - **2× upscale:** optional toggle, off by default (slower; may exceed the
25
+ ZeroGPU per-call time budget)
26
+
27
+ ## Hardware
28
+
29
+ Requires a **ZeroGPU (H200)** Space — set this in the Space's *Settings →
30
+ Hardware*. The 22B model uses `enable_model_cpu_offload()` to fit in 70 GB.
31
+
32
+ Design doc: `docs/superpowers/specs/2026-06-25-ltx-image-to-video-space-design.md`
app.py ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LTX-2.3 image-to-video Gradio Space (ZeroGPU).
2
+
3
+ Upload an image + prompt -> short MP4 (with audio) generated by the LTX-2.3 22B
4
+ distilled model via diffusers. Matches the model stack of the
5
+ WhatDreamsCost "LTX Director 2" ComfyUI workflow, reimplemented natively.
6
+
7
+ See docs/superpowers/specs/2026-06-25-ltx-image-to-video-space-design.md
8
+ """
9
+
10
+ import random
11
+ import tempfile
12
+
13
+ import gradio as gr
14
+ import spaces
15
+ import torch
16
+ from PIL import Image
17
+
18
+ from diffusers import LTX2Pipeline
19
+
20
+ # --- Generation constants (from the reference workflow + distilled recipe) ---
21
+ MODEL_ID = "diffusers/LTX-2.3-Distilled-Diffusers"
22
+ NUM_FRAMES = 121 # must be 8k + 1; ~5s at 24 fps
23
+ FRAME_RATE = 24.0
24
+ NUM_STEPS = 8 # distilled
25
+ GUIDANCE_SCALE = 1.0 # CFG = 1 for the distilled model
26
+ BASE_LONG_SIDE = 704 # base-stage long edge (rounded to /32 per axis)
27
+ GPU_DURATION = 120 # ZeroGPU seconds budget per call
28
+ MAX_SEED = 2**32 - 1
29
+
30
+ # Optional default negative prompt shipped with the pipeline (best-effort).
31
+ try:
32
+ from diffusers.pipelines.ltx2.utils import DEFAULT_NEGATIVE_PROMPT
33
+ except Exception: # pragma: no cover - depends on diffusers version
34
+ DEFAULT_NEGATIVE_PROMPT = (
35
+ "worst quality, inconsistent motion, blurry, jittery, distorted"
36
+ )
37
+
38
+ # Load the pipeline once at import, on CPU. ZeroGPU attaches the GPU only inside
39
+ # the @spaces.GPU worker, so CUDA placement / offload is set up there.
40
+ pipe = LTX2Pipeline.from_pretrained(MODEL_ID, torch_dtype=torch.bfloat16)
41
+ _offload_ready = False
42
+
43
+
44
+ def _target_size(image: Image.Image, long_side: int = BASE_LONG_SIDE):
45
+ """Fit the image aspect ratio into `long_side`, each axis a multiple of 32."""
46
+ w, h = image.size
47
+ ar = w / h if h else 1.0
48
+ if ar >= 1.0:
49
+ tw, th = long_side, long_side / ar
50
+ else:
51
+ tw, th = long_side * ar, long_side
52
+ tw = max(256, round(tw / 32) * 32)
53
+ th = max(256, round(th / 32) * 32)
54
+ return int(tw), int(th)
55
+
56
+
57
+ def _normalize_output(result):
58
+ """Return (frames, audio) regardless of pipeline return shape.
59
+
60
+ The main LTX2Pipeline returns a (video, audio) tuple; the distilled card
61
+ shows a `.frames[0]` object. Handle both.
62
+ """
63
+ if isinstance(result, (tuple, list)) and len(result) == 2:
64
+ video, audio = result
65
+ # `video` may itself be a batch list of frame-lists.
66
+ if video and isinstance(video[0], (list, tuple)):
67
+ video = video[0]
68
+ return video, audio
69
+ frames = result.frames[0]
70
+ audio = getattr(result, "audio", None)
71
+ return frames, audio
72
+
73
+
74
+ def _save_video(frames, audio, path: str):
75
+ """Export frames (+ audio if available) to an MP4 at `path`."""
76
+ # Preferred: LTX-2.3 joint A/V exporter.
77
+ if audio is not None:
78
+ try:
79
+ from diffusers.pipelines.ltx2.export_utils import encode_video
80
+
81
+ encode_video(frames, audio, FRAME_RATE, path)
82
+ return
83
+ except Exception:
84
+ pass # fall through to video-only export
85
+ from diffusers.utils import export_to_video
86
+
87
+ export_to_video(frames, path, fps=int(FRAME_RATE))
88
+
89
+
90
+ def _maybe_upscale(frames):
91
+ """Best-effort 2x spatial upscale stage.
92
+
93
+ The diffusers two-stage upscaler API for LTX-2.3 is not yet stable, so this
94
+ is opt-in (default off) and degrades gracefully: if unavailable, the caller
95
+ keeps the base-resolution frames and warns the user.
96
+ """
97
+ from diffusers import LTXLatentUpsamplePipeline # raises if unavailable
98
+
99
+ upsampler = LTXLatentUpsamplePipeline.from_pretrained(
100
+ "Lightricks/LTX-2.3", subfolder="latent_upsampler", torch_dtype=torch.bfloat16
101
+ )
102
+ upsampler.to("cuda")
103
+ return upsampler(frames).frames[0]
104
+
105
+
106
+ @spaces.GPU(duration=GPU_DURATION)
107
+ def generate(image, prompt, upscale, progress=gr.Progress(track_tqdm=True)):
108
+ global _offload_ready
109
+ if image is None:
110
+ raise gr.Error("Please upload an image first.")
111
+ if not prompt or not prompt.strip():
112
+ raise gr.Error("Please enter a prompt describing the motion.")
113
+
114
+ if not _offload_ready:
115
+ pipe.enable_model_cpu_offload()
116
+ _offload_ready = True
117
+
118
+ if not isinstance(image, Image.Image):
119
+ image = Image.fromarray(image)
120
+ image = image.convert("RGB")
121
+ width, height = _target_size(image)
122
+
123
+ seed = random.randint(0, MAX_SEED)
124
+ generator = torch.Generator(device="cuda").manual_seed(seed)
125
+
126
+ try:
127
+ result = pipe(
128
+ image=image,
129
+ prompt=prompt.strip(),
130
+ negative_prompt=DEFAULT_NEGATIVE_PROMPT,
131
+ width=width,
132
+ height=height,
133
+ num_frames=NUM_FRAMES,
134
+ frame_rate=FRAME_RATE,
135
+ num_inference_steps=NUM_STEPS,
136
+ guidance_scale=GUIDANCE_SCALE,
137
+ generator=generator,
138
+ )
139
+ except torch.cuda.OutOfMemoryError as exc: # pragma: no cover
140
+ torch.cuda.empty_cache()
141
+ raise gr.Error("Ran out of GPU memory. Try a smaller image.") from exc
142
+
143
+ frames, audio = _normalize_output(result)
144
+
145
+ if upscale:
146
+ try:
147
+ frames = _maybe_upscale(frames)
148
+ except Exception:
149
+ gr.Warning(
150
+ "2x upscale stage is unavailable in this build — "
151
+ "returning base-resolution video."
152
+ )
153
+
154
+ out_path = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False).name
155
+ _save_video(frames, audio, out_path)
156
+ return out_path
157
+
158
+
159
+ with gr.Blocks(title="LTX-2.3 Image to Video") as demo:
160
+ gr.Markdown(
161
+ "# LTX-2.3 Image → Video\n"
162
+ "Upload an image and describe the motion. Generates ~5s of video "
163
+ "(with audio) using the LTX-2.3 22B distilled model."
164
+ )
165
+ with gr.Row():
166
+ with gr.Column():
167
+ image_in = gr.Image(label="Input image", type="pil")
168
+ prompt_in = gr.Textbox(
169
+ label="Prompt",
170
+ placeholder="A man plays a red electric guitar, camera slowly zooms in.",
171
+ lines=3,
172
+ )
173
+ upscale_in = gr.Checkbox(
174
+ label="2× high-res upscale (slower, may exceed GPU time limit)",
175
+ value=False,
176
+ )
177
+ run_btn = gr.Button("Generate", variant="primary")
178
+ with gr.Column():
179
+ video_out = gr.Video(label="Result")
180
+
181
+ run_btn.click(
182
+ fn=generate,
183
+ inputs=[image_in, prompt_in, upscale_in],
184
+ outputs=video_out,
185
+ )
186
+
187
+
188
+ if __name__ == "__main__":
189
+ demo.launch()
requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ --extra-index-url https://download.pytorch.org/whl/cu124
2
+ torch
3
+ git+https://github.com/huggingface/diffusers
4
+ transformers
5
+ accelerate
6
+ sentencepiece
7
+ imageio[ffmpeg]
8
+ Pillow
9
+ spaces