ChopperBlu commited on
Commit
93c6e9f
·
verified ·
1 Parent(s): e4b5a3e

Add image-to-video and automatic 15-second duration

Browse files

Adds native LTX-2.5 image conditioning, model-selected duration capped at 15 seconds, resilient optional pipelines, updated controls, and validation documentation.

Files changed (3) hide show
  1. README.md +9 -7
  2. app.py +155 -31
  3. requirements.txt +1 -0
README.md CHANGED
@@ -15,7 +15,7 @@ models:
15
 
16
  # LTX-2.5 Video + Audio
17
 
18
- An interactive **text-to-video with synchronized audio** demo for
19
  [Lightricks LTX-2.5](https://huggingface.co/Lightricks/LTX-2.5), using the
20
  official [Diffusers-compatible checkpoint](https://huggingface.co/Lightricks/LTX-2.5-Diffusers).
21
 
@@ -25,12 +25,12 @@ It runs the model's published distilled two-stage recipe:
25
  2. ×2 spatial latent upsampling.
26
  3. 3 steps at final resolution, including audio-latent refinement.
27
 
28
- The app is intended for a **ZeroGPU xlarge** worker. It offers known-good
29
- landscape, portrait, square, and cinema-resolution presets up to 1536 pixels per
30
- side; manual clips up to 8 seconds; an optional remote LTX prompt enhancer; and
31
- both the tiled convolutional decoder and LTX-2.5's detail-focused diffusion
32
- decoder. High-resolution and long-duration jobs consume more shared ZeroGPU
33
- quota and may wait longer in the queue.
34
 
35
  ## Required Space secret
36
 
@@ -44,6 +44,8 @@ repository.
44
 
45
  - Width and height must each be divisible by 64 for the two-stage pipeline.
46
  - Clip length is aligned to the required `8k + 1` frame grid.
 
 
47
  - The public API intentionally separates `/prepare_prompt` (CPU/remote prompt
48
  enhancement) from `/generate_video` (GPU rendering), so prompt enhancement
49
  never wastes a GPU reservation.
 
15
 
16
  # LTX-2.5 Video + Audio
17
 
18
+ An interactive **text/image-to-video with synchronized audio** demo for
19
  [Lightricks LTX-2.5](https://huggingface.co/Lightricks/LTX-2.5), using the
20
  official [Diffusers-compatible checkpoint](https://huggingface.co/Lightricks/LTX-2.5-Diffusers).
21
 
 
25
  2. ×2 spatial latent upsampling.
26
  3. 3 steps at final resolution, including audio-latent refinement.
27
 
28
+ The app is intended for a **ZeroGPU xlarge** worker. It offers text-to-video and
29
+ image-to-video; landscape, portrait, square, and cinema-resolution presets up to
30
+ 1536 pixels per side; manual clips up to 15 seconds; LTX-2.5 automatic duration;
31
+ an optional remote LTX prompt enhancer; and both the tiled convolutional decoder
32
+ and LTX-2.5's detail-focused diffusion decoder. High-resolution and long-duration
33
+ jobs consume more shared ZeroGPU quota and may wait longer in the queue.
34
 
35
  ## Required Space secret
36
 
 
44
 
45
  - Width and height must each be divisible by 64 for the two-stage pipeline.
46
  - Clip length is aligned to the required `8k + 1` frame grid.
47
+ - Automatic duration reserves ZeroGPU for the 15-second ceiling even when the
48
+ model chooses a shorter clip.
49
  - The public API intentionally separates `/prepare_prompt` (CPU/remote prompt
50
  enhancement) from `/generate_video` (GPU rendering), so prompt enhancement
51
  never wastes a GPU reservation.
app.py CHANGED
@@ -7,7 +7,6 @@ latent x2 spatial upsampling, then 3 final-resolution denoising steps.
7
 
8
  import os
9
  import tempfile
10
- from functools import lru_cache
11
 
12
  # Configure the allocator before Torch initializes. This avoids a known
13
  # allocator assertion on the ZeroGPU MIG runtime.
@@ -17,9 +16,11 @@ os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "backend:cudaMallocAsync")
17
  # `spaces` must load before Torch so ZeroGPU can intercept CUDA placement.
18
  import spaces
19
  import gradio as gr
 
20
  import torch
21
- from gradio_client import Client
22
  from diffusers import (
 
23
  LTX2LatentUpsamplePipeline,
24
  LTX2Pipeline,
25
  LTX2VideoDiffusionDecodePipeline,
@@ -42,7 +43,8 @@ MODEL_ID = "Lightricks/LTX-2.5-Diffusers"
42
  FRAME_RATE = 24.0
43
  MAX_SEED = 2**31 - 1
44
  MAX_SIDE = 1536
45
- MAX_DURATION_SECONDS = 8.0
 
46
  ENHANCER_SPACE = "diffusers-internal-dev/LTX-2.4-Prompt-Enhancer"
47
 
48
  RESOLUTION_PRESETS = {
@@ -61,11 +63,21 @@ def frames_for_duration(seconds: float) -> int:
61
 
62
 
63
  def requested_gpu_seconds(
64
- prompt, width, height, duration_seconds, seed, randomize_seed, decoder, *args, **kwargs
 
 
 
 
 
 
 
 
 
 
65
  ):
66
  """Reserve a realistic ZeroGPU duration based on output pixels and frames."""
67
- del prompt, seed, randomize_seed, args, kwargs
68
- frames = frames_for_duration(float(duration_seconds))
69
  pixels = int(width) * int(height) * frames
70
  # Calibrated against this Space's live calls: 1024x576 at 2s/4s took
71
  # 15.1s/18.8s and 1472x832 at 2s took 21.1s, including public API overhead.
@@ -77,10 +89,12 @@ def requested_gpu_seconds(
77
 
78
  HF_TOKEN = os.environ.get("HF_TOKEN")
79
  pipeline = None
 
80
  upsample_pipeline = None
81
  diffusion_decode_pipeline = None
82
  AUDIO_SAMPLE_RATE = None
83
  MODEL_LOAD_ERROR = None
 
84
  DIFFUSION_DECODER_ERROR = None
85
 
86
  # A new Space can safely boot before its owner adds the gated-model secret. It
@@ -102,6 +116,7 @@ else:
102
  "model_index.json", "audio_vae/*", "connectors/*", "scheduler/*",
103
  "text_encoder/*", "tokenizer/*", "transformer/*", "vae/*",
104
  "vocoder/*", "latent_upsampler/*", "diffusion_decoder/*",
 
105
  ],
106
  ignore_patterns=[
107
  "transformer/*-of-00008.safetensors",
@@ -116,7 +131,7 @@ else:
116
  # the GPU worker when a decorated handler runs.
117
  pipeline = LTX2Pipeline.from_pretrained(
118
  model_dir, dtype=torch.bfloat16,
119
- prompt_enhancer=None, processor=None, duration_head=None,
120
  )
121
  latent_upsampler = LTX2LatentUpsamplerModel.from_pretrained(
122
  model_dir,
@@ -130,6 +145,27 @@ else:
130
  vae=pipeline.vae,
131
  latent_upsampler=latent_upsampler,
132
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
  try:
134
  diffusion_decoder = LTX2VideoDiffusionDecoderModel.from_pretrained(
135
  model_dir,
@@ -153,7 +189,13 @@ else:
153
  flush=True,
154
  )
155
  AUDIO_SAMPLE_RATE = pipeline.vocoder.config.output_sampling_rate
156
- print("[ltx-2-5] Model is ready for ZeroGPU requests.", flush=True)
 
 
 
 
 
 
157
  except Exception as error: # noqa: BLE001 - preserve a bootable setup screen.
158
  import traceback
159
  import transformers
@@ -163,14 +205,14 @@ else:
163
  print(f"[ltx-2-5] {MODEL_LOAD_ERROR}", flush=True)
164
 
165
 
166
- @lru_cache(maxsize=64)
167
- def enhance_prompt_cached(prompt: str) -> str:
168
- """Expand a short prompt using the official remote LTX prompt enhancer."""
169
  client = Client(ENHANCER_SPACE, token=HF_TOKEN, httpx_kwargs={"timeout": 300.0})
170
- return client.predict(prompt, None, api_name="/enhance")
 
171
 
172
 
173
- def prepare_prompt(prompt: str, enhance: bool) -> str:
174
  """Validate and optionally enrich a prompt before reserving the GPU worker."""
175
  cleaned = (prompt or "").strip()
176
  if not cleaned:
@@ -178,7 +220,7 @@ def prepare_prompt(prompt: str, enhance: bool) -> str:
178
  if not enhance:
179
  return cleaned
180
  try:
181
- return enhance_prompt_cached(cleaned) or cleaned
182
  except Exception as error: # noqa: BLE001 - generation can safely use the raw prompt.
183
  print(f"[ltx-2-5] Prompt enhancement failed ({error!r}).", flush=True)
184
  gr.Warning(
@@ -188,7 +230,7 @@ def prepare_prompt(prompt: str, enhance: bool) -> str:
188
  return cleaned
189
 
190
 
191
- def apply_resolution_preset(preset: str) -> tuple[gr.Slider, gr.Slider]:
192
  """Apply a known-good LTX-2.5 resolution while keeping manual controls available."""
193
  dimensions = RESOLUTION_PRESETS.get(preset)
194
  if dimensions is None:
@@ -196,17 +238,57 @@ def apply_resolution_preset(preset: str) -> tuple[gr.Slider, gr.Slider]:
196
  return dimensions
197
 
198
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
199
  @spaces.GPU(duration=requested_gpu_seconds, size="xlarge")
200
  def generate(
201
  prompt: str,
 
202
  width: int,
203
  height: int,
204
  duration_seconds: float,
 
205
  seed: int,
206
  randomize_seed: bool,
207
  decoder: str,
208
  ) -> tuple[str, int, str]:
209
- """Generate an LTX-2.5 video with synchronized audio from a text prompt."""
210
  if pipeline is None or upsample_pipeline is None or AUDIO_SAMPLE_RATE is None:
211
  access_help = (
212
  "This Space needs an HF_TOKEN Secret for the gated LTX-2.5 checkpoint. "
@@ -219,14 +301,23 @@ def generate(
219
  )
220
  if not prompt or not prompt.strip():
221
  raise gr.Error("Enter a prompt before generating.")
 
 
 
 
 
222
 
223
  width, height = int(width), int(height)
224
  if width % 64 or height % 64:
225
  raise gr.Error("Width and height must both be divisible by 64 for two-stage LTX-2.5.")
226
  if not (512 <= width <= MAX_SIDE and 512 <= height <= MAX_SIDE):
227
  raise gr.Error(f"Width and height must be between 512 and {MAX_SIDE} pixels.")
228
- if not (1.0 <= float(duration_seconds) <= MAX_DURATION_SECONDS):
 
 
229
  raise gr.Error(f"Duration must be between 1 and {MAX_DURATION_SECONDS:g} seconds.")
 
 
230
  if decoder == "diffusion" and diffusion_decode_pipeline is None:
231
  detail = f" ({DIFFUSION_DECODER_ERROR})" if DIFFUSION_DECODER_ERROR else ""
232
  raise gr.Error(f"The diffusion decoder is unavailable{detail}; choose conv and retry.")
@@ -234,8 +325,9 @@ def generate(
234
  if randomize_seed:
235
  seed = torch.randint(0, MAX_SEED, (1,), device="cuda").item()
236
  seed = int(seed)
237
- num_frames = frames_for_duration(float(duration_seconds))
238
  generator = torch.Generator("cuda").manual_seed(seed)
 
239
 
240
  shared = {
241
  "prompt": prompt.strip(),
@@ -255,15 +347,25 @@ def generate(
255
  "generator": generator,
256
  "return_dict": False,
257
  }
 
 
258
 
259
  # Stage 1: half-resolution latent generation with the 8-step schedule.
260
- stage_1_latents, stage_1_audio_latents = pipeline(
261
- height=height // 2,
262
- width=width // 2,
263
- num_frames=num_frames,
264
- sigmas=DISTILLED_SIGMA_VALUES,
265
- output_type="latent",
266
  **shared,
 
 
 
 
 
 
 
 
267
  )
268
 
269
  # The official recipe uses an x2 spatial latent upsampler between stages.
@@ -276,7 +378,7 @@ def generate(
276
  # Stage 2: continue the same random-noise stream at full resolution. The
277
  # audio latents are refined too, preserving synchronized audio output.
278
  use_diffusion_decoder = decoder == "diffusion"
279
- stage_2_output = pipeline(
280
  num_frames=num_frames,
281
  sigmas=STAGE_2_DISTILLED_SIGMA_VALUES,
282
  latents=upsampled_latents,
@@ -329,11 +431,13 @@ Diffusers-compatible weights. It renders a video and synchronized audio in two
329
  stages: 8 denoising steps at half resolution, latent upsampling, then 3 final
330
  steps at full resolution.
331
 
332
- The default cinema preset renders 1472×832. Clips can run up to 8 seconds, and
333
- the prompt enhancer turns short ideas into shot-level descriptions before GPU
334
- time starts. Use the convolutional decoder for high resolution and the optional
335
- diffusion decoder when you want a slower detail-focused finish. Outputs are
336
- probabilistic; reuse a seed to reproduce a composition.
 
 
337
 
338
  [Model card](https://huggingface.co/Lightricks/LTX-2.5) ·
339
  [Diffusers checkpoint](https://huggingface.co/Lightricks/LTX-2.5-Diffusers) ·
@@ -377,6 +481,10 @@ with gr.Blocks(title="LTX-2.5 Video + Audio", fill_width=True) as demo:
377
  gr.Markdown(SETUP_NOTICE)
378
  with gr.Row():
379
  with gr.Column(scale=1):
 
 
 
 
380
  prompt_input = gr.Textbox(
381
  label="Prompt",
382
  lines=5,
@@ -399,6 +507,14 @@ with gr.Blocks(title="LTX-2.5 Video + Audio", fill_width=True) as demo:
399
  label="Duration (seconds at 24 fps)",
400
  info="Snapped to LTX's required 8k + 1 frame grid.",
401
  )
 
 
 
 
 
 
 
 
402
  decoder_input = gr.Radio(
403
  choices=["conv", "diffusion"],
404
  value="conv",
@@ -433,18 +549,26 @@ with gr.Blocks(title="LTX-2.5 Video + Audio", fill_width=True) as demo:
433
  outputs=[width_input, height_input],
434
  api_name=False,
435
  )
 
 
 
 
 
 
436
  generation_inputs = [
437
  prompt_used,
 
438
  width_input,
439
  height_input,
440
  duration_input,
 
441
  seed_input,
442
  randomize_input,
443
  decoder_input,
444
  ]
445
  prepare_event = generate_button.click(
446
  fn=prepare_prompt,
447
- inputs=[prompt_input, enhance_input],
448
  outputs=prompt_used,
449
  api_name="prepare_prompt",
450
  )
 
7
 
8
  import os
9
  import tempfile
 
10
 
11
  # Configure the allocator before Torch initializes. This avoids a known
12
  # allocator assertion on the ZeroGPU MIG runtime.
 
16
  # `spaces` must load before Torch so ZeroGPU can intercept CUDA placement.
17
  import spaces
18
  import gradio as gr
19
+ import PIL.Image
20
  import torch
21
+ from gradio_client import Client, handle_file
22
  from diffusers import (
23
+ LTX2ImageToVideoPipeline,
24
  LTX2LatentUpsamplePipeline,
25
  LTX2Pipeline,
26
  LTX2VideoDiffusionDecodePipeline,
 
43
  FRAME_RATE = 24.0
44
  MAX_SEED = 2**31 - 1
45
  MAX_SIDE = 1536
46
+ MAX_DURATION_SECONDS = 15.0
47
+ AUTO_MAX_FRAMES = int(MAX_DURATION_SECONDS * FRAME_RATE) // 8 * 8 + 1
48
  ENHANCER_SPACE = "diffusers-internal-dev/LTX-2.4-Prompt-Enhancer"
49
 
50
  RESOLUTION_PRESETS = {
 
63
 
64
 
65
  def requested_gpu_seconds(
66
+ prompt,
67
+ image_path,
68
+ width,
69
+ height,
70
+ duration_seconds,
71
+ auto_length,
72
+ seed,
73
+ randomize_seed,
74
+ decoder,
75
+ *args,
76
+ **kwargs,
77
  ):
78
  """Reserve a realistic ZeroGPU duration based on output pixels and frames."""
79
+ del prompt, image_path, seed, randomize_seed, args, kwargs
80
+ frames = AUTO_MAX_FRAMES if auto_length else frames_for_duration(float(duration_seconds))
81
  pixels = int(width) * int(height) * frames
82
  # Calibrated against this Space's live calls: 1024x576 at 2s/4s took
83
  # 15.1s/18.8s and 1472x832 at 2s took 21.1s, including public API overhead.
 
89
 
90
  HF_TOKEN = os.environ.get("HF_TOKEN")
91
  pipeline = None
92
+ image_pipeline = None
93
  upsample_pipeline = None
94
  diffusion_decode_pipeline = None
95
  AUDIO_SAMPLE_RATE = None
96
  MODEL_LOAD_ERROR = None
97
+ IMAGE_PIPELINE_ERROR = None
98
  DIFFUSION_DECODER_ERROR = None
99
 
100
  # A new Space can safely boot before its owner adds the gated-model secret. It
 
116
  "model_index.json", "audio_vae/*", "connectors/*", "scheduler/*",
117
  "text_encoder/*", "tokenizer/*", "transformer/*", "vae/*",
118
  "vocoder/*", "latent_upsampler/*", "diffusion_decoder/*",
119
+ "duration_head/*",
120
  ],
121
  ignore_patterns=[
122
  "transformer/*-of-00008.safetensors",
 
131
  # the GPU worker when a decorated handler runs.
132
  pipeline = LTX2Pipeline.from_pretrained(
133
  model_dir, dtype=torch.bfloat16,
134
+ prompt_enhancer=None, processor=None,
135
  )
136
  latent_upsampler = LTX2LatentUpsamplerModel.from_pretrained(
137
  model_dir,
 
145
  vae=pipeline.vae,
146
  latent_upsampler=latent_upsampler,
147
  )
148
+ try:
149
+ image_pipeline = LTX2ImageToVideoPipeline(
150
+ scheduler=pipeline.scheduler,
151
+ vae=pipeline.vae,
152
+ audio_vae=pipeline.audio_vae,
153
+ text_encoder=pipeline.text_encoder,
154
+ tokenizer=pipeline.tokenizer,
155
+ connectors=pipeline.connectors,
156
+ transformer=pipeline.transformer,
157
+ vocoder=pipeline.vocoder,
158
+ processor=getattr(pipeline, "processor", None),
159
+ prompt_enhancer=getattr(pipeline, "prompt_enhancer", None),
160
+ duration_head=getattr(pipeline, "duration_head", None),
161
+ )
162
+ except Exception as image_error: # noqa: BLE001 - text generation remains usable.
163
+ IMAGE_PIPELINE_ERROR = type(image_error).__name__
164
+ print(
165
+ f"[ltx-2-5] Image-to-video unavailable ({image_error!r}); "
166
+ "text-to-video remains enabled.",
167
+ flush=True,
168
+ )
169
  try:
170
  diffusion_decoder = LTX2VideoDiffusionDecoderModel.from_pretrained(
171
  model_dir,
 
189
  flush=True,
190
  )
191
  AUDIO_SAMPLE_RATE = pipeline.vocoder.config.output_sampling_rate
192
+ duration_status = "enabled" if getattr(pipeline, "duration_head", None) else "missing"
193
+ image_status = "enabled" if image_pipeline is not None else "unavailable"
194
+ print(
195
+ "[ltx-2-5] Model is ready for requests; "
196
+ f"image-to-video is {image_status}; automatic duration is {duration_status}.",
197
+ flush=True,
198
+ )
199
  except Exception as error: # noqa: BLE001 - preserve a bootable setup screen.
200
  import traceback
201
  import transformers
 
205
  print(f"[ltx-2-5] {MODEL_LOAD_ERROR}", flush=True)
206
 
207
 
208
+ def enhance_prompt_remote(prompt: str, image_path: str | None) -> str:
209
+ """Expand text/image intent using the official remote LTX prompt enhancer."""
 
210
  client = Client(ENHANCER_SPACE, token=HF_TOKEN, httpx_kwargs={"timeout": 300.0})
211
+ image = handle_file(image_path) if image_path else None
212
+ return client.predict(prompt, image, api_name="/enhance")
213
 
214
 
215
+ def prepare_prompt(prompt: str, image_path: str | None, enhance: bool) -> str:
216
  """Validate and optionally enrich a prompt before reserving the GPU worker."""
217
  cleaned = (prompt or "").strip()
218
  if not cleaned:
 
220
  if not enhance:
221
  return cleaned
222
  try:
223
+ return enhance_prompt_remote(cleaned, image_path) or cleaned
224
  except Exception as error: # noqa: BLE001 - generation can safely use the raw prompt.
225
  print(f"[ltx-2-5] Prompt enhancement failed ({error!r}).", flush=True)
226
  gr.Warning(
 
230
  return cleaned
231
 
232
 
233
+ def apply_resolution_preset(preset: str) -> tuple[object, object]:
234
  """Apply a known-good LTX-2.5 resolution while keeping manual controls available."""
235
  dimensions = RESOLUTION_PRESETS.get(preset)
236
  if dimensions is None:
 
238
  return dimensions
239
 
240
 
241
+ def load_conditioning_image(
242
+ path: str, width: int, height: int
243
+ ) -> PIL.Image.Image:
244
+ """Cover-resize and center-crop an image without distorting its aspect ratio."""
245
+ with PIL.Image.open(path) as source:
246
+ image = source.convert("RGB")
247
+ scale = max(width / image.width, height / image.height)
248
+ image = image.resize(
249
+ (round(image.width * scale), round(image.height * scale)),
250
+ PIL.Image.Resampling.LANCZOS,
251
+ )
252
+ left = (image.width - width) // 2
253
+ top = (image.height - height) // 2
254
+ return image.crop((left, top, left + width, top + height))
255
+
256
+
257
+ def dimensions_for_image(path: str | None) -> tuple[object, object]:
258
+ """Choose a /64 cinema shape that preserves an uploaded image's orientation."""
259
+ if not path:
260
+ return gr.update(), gr.update()
261
+ with PIL.Image.open(path) as image:
262
+ image_width, image_height = image.size
263
+ short_side = 832
264
+ if image_width >= image_height:
265
+ height = short_side
266
+ width = min(
267
+ MAX_SIDE,
268
+ max(short_side, round(short_side * image_width / image_height / 64) * 64),
269
+ )
270
+ else:
271
+ width = short_side
272
+ height = min(
273
+ MAX_SIDE,
274
+ max(short_side, round(short_side * image_height / image_width / 64) * 64),
275
+ )
276
+ return width, height
277
+
278
+
279
  @spaces.GPU(duration=requested_gpu_seconds, size="xlarge")
280
  def generate(
281
  prompt: str,
282
+ image_path: str | None,
283
  width: int,
284
  height: int,
285
  duration_seconds: float,
286
+ auto_length: bool,
287
  seed: int,
288
  randomize_seed: bool,
289
  decoder: str,
290
  ) -> tuple[str, int, str]:
291
+ """Generate an LTX-2.5 video with synchronized audio from text or an image."""
292
  if pipeline is None or upsample_pipeline is None or AUDIO_SAMPLE_RATE is None:
293
  access_help = (
294
  "This Space needs an HF_TOKEN Secret for the gated LTX-2.5 checkpoint. "
 
301
  )
302
  if not prompt or not prompt.strip():
303
  raise gr.Error("Enter a prompt before generating.")
304
+ if image_path and image_pipeline is None:
305
+ detail = f" ({IMAGE_PIPELINE_ERROR})" if IMAGE_PIPELINE_ERROR else ""
306
+ raise gr.Error(
307
+ f"Image-to-video is unavailable{detail}; remove the image and retry."
308
+ )
309
 
310
  width, height = int(width), int(height)
311
  if width % 64 or height % 64:
312
  raise gr.Error("Width and height must both be divisible by 64 for two-stage LTX-2.5.")
313
  if not (512 <= width <= MAX_SIDE and 512 <= height <= MAX_SIDE):
314
  raise gr.Error(f"Width and height must be between 512 and {MAX_SIDE} pixels.")
315
+ if not auto_length and not (
316
+ 1.0 <= float(duration_seconds) <= MAX_DURATION_SECONDS
317
+ ):
318
  raise gr.Error(f"Duration must be between 1 and {MAX_DURATION_SECONDS:g} seconds.")
319
+ if decoder not in {"conv", "diffusion"}:
320
+ raise gr.Error("Decoder must be either conv or diffusion.")
321
  if decoder == "diffusion" and diffusion_decode_pipeline is None:
322
  detail = f" ({DIFFUSION_DECODER_ERROR})" if DIFFUSION_DECODER_ERROR else ""
323
  raise gr.Error(f"The diffusion decoder is unavailable{detail}; choose conv and retry.")
 
325
  if randomize_seed:
326
  seed = torch.randint(0, MAX_SEED, (1,), device="cuda").item()
327
  seed = int(seed)
328
+ num_frames = None if auto_length else frames_for_duration(float(duration_seconds))
329
  generator = torch.Generator("cuda").manual_seed(seed)
330
+ active_pipeline = image_pipeline if image_path else pipeline
331
 
332
  shared = {
333
  "prompt": prompt.strip(),
 
347
  "generator": generator,
348
  "return_dict": False,
349
  }
350
+ if image_path:
351
+ shared["image"] = load_conditioning_image(image_path, width, height)
352
 
353
  # Stage 1: half-resolution latent generation with the 8-step schedule.
354
+ stage_1_args = {
355
+ "height": height // 2,
356
+ "width": width // 2,
357
+ "num_frames": num_frames,
358
+ "sigmas": DISTILLED_SIGMA_VALUES,
359
+ "output_type": "latent",
360
  **shared,
361
+ }
362
+ if auto_length:
363
+ stage_1_args["max_seconds"] = MAX_DURATION_SECONDS
364
+ stage_1_latents, stage_1_audio_latents = active_pipeline(
365
+ **stage_1_args,
366
+ )
367
+ num_frames = (
368
+ (stage_1_latents.shape[2] - 1) * pipeline.vae_temporal_compression_ratio + 1
369
  )
370
 
371
  # The official recipe uses an x2 spatial latent upsampler between stages.
 
378
  # Stage 2: continue the same random-noise stream at full resolution. The
379
  # audio latents are refined too, preserving synchronized audio output.
380
  use_diffusion_decoder = decoder == "diffusion"
381
+ stage_2_output = active_pipeline(
382
  num_frames=num_frames,
383
  sigmas=STAGE_2_DISTILLED_SIGMA_VALUES,
384
  latents=upsampled_latents,
 
431
  stages: 8 denoising steps at half resolution, latent upsampling, then 3 final
432
  steps at full resolution.
433
 
434
+ The default cinema preset renders 1472×832. Text-to-video and image-to-video
435
+ clips can run up to 15 seconds, or LTX-2.5 can choose the duration from the
436
+ prompt. The prompt enhancer turns short ideas or an uploaded reference image
437
+ into shot-level descriptions before GPU time starts. Use the convolutional
438
+ decoder for high resolution and the optional diffusion decoder when you want a
439
+ slower detail-focused finish. Outputs are probabilistic; reuse a seed to
440
+ reproduce a composition.
441
 
442
  [Model card](https://huggingface.co/Lightricks/LTX-2.5) ·
443
  [Diffusers checkpoint](https://huggingface.co/Lightricks/LTX-2.5-Diffusers) ·
 
481
  gr.Markdown(SETUP_NOTICE)
482
  with gr.Row():
483
  with gr.Column(scale=1):
484
+ image_input = gr.Image(
485
+ label="Starting image (optional · image-to-video)",
486
+ type="filepath",
487
+ )
488
  prompt_input = gr.Textbox(
489
  label="Prompt",
490
  lines=5,
 
507
  label="Duration (seconds at 24 fps)",
508
  info="Snapped to LTX's required 8k + 1 frame grid.",
509
  )
510
+ auto_length_input = gr.Checkbox(
511
+ value=False,
512
+ label="Automatic duration (up to 15 seconds)",
513
+ info=(
514
+ "Lets LTX-2.5 choose the clip length from the prompt. "
515
+ "This reserves for the 15-second worst case on ZeroGPU."
516
+ ),
517
+ )
518
  decoder_input = gr.Radio(
519
  choices=["conv", "diffusion"],
520
  value="conv",
 
549
  outputs=[width_input, height_input],
550
  api_name=False,
551
  )
552
+ image_input.upload(
553
+ fn=dimensions_for_image,
554
+ inputs=image_input,
555
+ outputs=[width_input, height_input],
556
+ api_name=False,
557
+ )
558
  generation_inputs = [
559
  prompt_used,
560
+ image_input,
561
  width_input,
562
  height_input,
563
  duration_input,
564
+ auto_length_input,
565
  seed_input,
566
  randomize_input,
567
  decoder_input,
568
  ]
569
  prepare_event = generate_button.click(
570
  fn=prepare_prompt,
571
+ inputs=[prompt_input, image_input, enhance_input],
572
  outputs=prompt_used,
573
  api_name="prepare_prompt",
574
  )
requirements.txt CHANGED
@@ -14,6 +14,7 @@ protobuf
14
  av
15
  imageio
16
  imageio-ffmpeg
 
17
  numpy
18
  scipy
19
  kernels
 
14
  av
15
  imageio
16
  imageio-ffmpeg
17
+ Pillow
18
  numpy
19
  scipy
20
  kernels