Exosfeer commited on
Commit
b086208
·
1 Parent(s): 7a407b5

Full UI rewrite: fancy alexnasa-style components, audio input, interpolation, social links

Browse files

- Rewrite app.py with custom UI components (RadioAnimated, PromptBox, CameraDropdown, AudioDropUpload)
- Port ~700 lines of dark theme CSS from alexnasa/ltx-2-TURBO
- Add Image-to-Video and Interpolate modes with first/last frame support
- Add audio input support with waveform conditioning (neutral audio context pattern)
- Add duration presets (2s/3s/5s) and resolution presets (16:9/1:1/9:16) with SVG icons
- Modify distilled.py: add AudioConditionByLatent, _build_audio_conditionings_from_waveform, _create_conditionings
- Modify helpers.py: denoise_audio_video now accepts audio_conditionings parameter
- Add ZeroCollabs/ZeroHackz follow buttons in header and footer

app.py CHANGED
@@ -3,6 +3,13 @@ LTX-2.3 Turbo — ZeroGPU Edition
3
  Generates synchronized audio-video content using Lightricks/LTX-2.3 on
4
  free ZeroGPU hardware via Hugging Face Spaces.
5
 
 
 
 
 
 
 
 
6
  Architecture (following alexnasa/ltx-2-TURBO's proven ZeroGPU pattern):
7
  1. Vendored ltx-core and ltx-pipelines added to sys.path before any imports.
8
  2. Model files downloaded at module startup (CPU, no GPU lease).
@@ -32,17 +39,23 @@ sys.path.insert(0, str(_here / "packages" / "ltx-core" / "src"))
32
  # ───────────────────────────────────────────────────────────────────────────
33
  # Standard library & third-party imports
34
  # ───────────────────────────────────────────────────────────────────────────
 
35
  import logging
36
  import os
37
  import random
 
38
  import tempfile
39
  import time
40
  import traceback
 
 
41
 
42
  import gradio as gr
43
  import numpy as np
44
  import spaces
45
  import torch
 
 
46
  from huggingface_hub import hf_hub_download, snapshot_download
47
 
48
  logging.basicConfig(level=logging.INFO)
@@ -67,12 +80,68 @@ GEMMA_REPO = "google/gemma-3-12b-it-qat-q4_0-unquantized"
67
  CKPT_DISTILLED = "ltx-2.3-22b-distilled.safetensors"
68
  CKPT_UPSCALER = "ltx-2.3-spatial-upscaler-x2-1.0.safetensors"
69
 
70
- RESOLUTION_PRESETS = {
71
- "16:9 (768x512)": (768, 512),
72
- "1:1 (512x512)": (512, 512),
73
- "9:16 (512x768)": (512, 768),
74
  }
75
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  # ───────────────────────────────────────────────────────────────────────────
77
  # 1) Download model files at module startup (CPU, no GPU lease)
78
  # ───────────────────────────────────────────────────────────────────────────
@@ -91,7 +160,6 @@ logger.info("All model files ready on disk.")
91
 
92
  # ──────────────────────────���────────────────────────────────────────────────
93
  # 2) Construct ModelLedger (CPU — no model weights loaded to GPU)
94
- # We construct a separate ledger WITH gemma_root for text encoding.
95
  # ───────────────────────────────────────────────────────────────────────────
96
  logger.info("Constructing ModelLedger (with Gemma for text encoding)...")
97
 
@@ -118,7 +186,6 @@ logger.info("Text encoder loaded and ready!")
118
 
119
  # ───────────────────────────────────────────────────────────────────────────
120
  # 4) Construct DistilledPipeline WITHOUT text encoder (gemma_root=None)
121
- # Text encoding is handled externally via encode_prompt().
122
  # ───────────────────────────────────────────────────────────────────────────
123
  logger.info("Constructing DistilledPipeline (gemma_root=None)...")
124
 
@@ -154,25 +221,56 @@ def calc_frames(duration: float, fps: float) -> int:
154
  return k * 8 + 1
155
 
156
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
157
  def get_duration(
158
  first_frame,
 
159
  prompt,
160
  duration,
 
161
  enhance_prompt,
162
  seed,
163
  randomize_seed,
164
- resolution,
 
 
165
  *args,
166
  **kwargs,
167
  ):
168
  """Estimate GPU lease duration for @spaces.GPU(duration=...)."""
 
 
 
 
169
  dur = float(duration)
170
  if dur <= 2:
171
- return 120
172
- elif dur <= 4:
173
- return 180
174
  else:
175
- return 240
176
 
177
 
178
  # ───────────────────────────────────────────────────────────────────────────
@@ -202,18 +300,7 @@ def encode_prompt(
202
  logger.info(f"[encode_prompt] Enhanced prompt: '{final_prompt[:120]}...'")
203
 
204
  with torch.inference_mode():
205
- # Step 1: Get raw hidden states from text encoder
206
- hidden_states, attention_mask = text_encoder.encode(final_prompt)
207
-
208
- # Step 2: Process through embeddings processor to get video/audio contexts
209
- embeddings_processor = model_ledger.gemma_embeddings_processor()
210
- result = embeddings_processor.process_hidden_states(
211
- hidden_states, attention_mask
212
- )
213
- del embeddings_processor
214
-
215
- video_context = result.video_encoding
216
- audio_context = result.audio_encoding
217
 
218
  embedding_data = {
219
  "video_context": video_context.detach().cpu(),
@@ -231,47 +318,72 @@ def encode_prompt(
231
  @spaces.GPU(duration=get_duration)
232
  def generate_video(
233
  first_frame,
 
234
  prompt: str,
235
  duration: float,
236
- enhance_prompt: bool,
237
- seed: int,
238
- randomize_seed: bool,
239
- resolution: str,
 
 
 
240
  progress=gr.Progress(track_tqdm=True),
241
  ):
242
  """
243
- Full generation: encode prompt (nested GPU call) then run pipeline with
244
- pre-encoded contexts.
245
  """
246
  if not prompt or not prompt.strip():
247
  raise gr.Error("Please enter a prompt.")
248
 
249
  current_seed = random.randint(0, MAX_SEED) if randomize_seed else int(seed)
250
- width, height = RESOLUTION_PRESETS.get(resolution, (768, 512))
251
  num_frames = calc_frames(duration, 24.0)
252
  frame_rate = 24.0
253
 
254
  logger.info(
255
- f"[generate_video] seed={current_seed}, {width}x{height}, "
256
- f"frames={num_frames}, duration={duration}s, enhance={enhance_prompt}"
 
257
  )
258
 
259
- # --- Handle input image ---
260
  images = []
261
  image_path_for_enhance = None
 
262
  if first_frame is not None:
263
- tmp_dir = tempfile.mkdtemp()
264
- temp_image_path = os.path.join(tmp_dir, f"input_{int(time.time())}.png")
265
- if hasattr(first_frame, "save"):
266
- first_frame.save(temp_image_path)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
267
  else:
268
- from PIL import Image as PILImage
 
 
 
 
 
269
 
270
- PILImage.open(first_frame).save(temp_image_path)
271
- images = [
272
- ImageConditioningInput(path=temp_image_path, frame_idx=0, strength=1.0)
273
- ]
274
- image_path_for_enhance = temp_image_path
275
 
276
  t0 = time.time()
277
  try:
@@ -288,6 +400,29 @@ def generate_video(
288
  del embeddings
289
  torch.cuda.empty_cache()
290
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
291
  # Phase 2: Run pipeline with pre-encoded contexts
292
  with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as tmpfile:
293
  output_path = tmpfile.name
@@ -305,9 +440,13 @@ def generate_video(
305
  tiling_config=TilingConfig.default(),
306
  video_context=video_context,
307
  audio_context=audio_context,
 
 
308
  )
309
 
310
  del video_context, audio_context
 
 
311
  torch.cuda.empty_cache()
312
 
313
  elapsed = time.time() - t0
@@ -323,164 +462,1116 @@ def generate_video(
323
  logger.error(f"Generation failed after {elapsed:.1f}s:\n{tb}")
324
  raise gr.Error(f"Generation failed: {type(e).__name__}: {e}")
325
 
326
- info_text = (
327
- f"Seed: {current_seed}\n"
328
- f"Resolution: {width}x{height} (upscaled from {width // 2}x{height // 2})\n"
329
- f"Frames: {num_frames} @ {int(frame_rate)} fps\n"
330
- f"Duration: {duration}s\n"
331
- f"Pipeline: Distilled 2-stage (8+4 steps, FP8 quantized)\n"
332
- f"Total time: {elapsed:.1f}s\n"
333
- f"Hardware: ZeroGPU"
334
- )
335
-
336
- return output_path, info_text, current_seed
337
 
338
 
339
  # ───────────────────────────────────────────────────────────────────────────
340
- # UI toggle
341
  # ───────────────────────────────────────────────────────────────────────────
342
- def toggle_image(mode: str):
343
- return gr.update(visible=(mode == "Image to Video"))
344
 
345
 
346
- def on_mode_change(mode: str):
347
- """Update image visibility and first_frame value based on mode."""
348
- if mode == "Image to Video":
349
- return gr.update(visible=True)
350
- return gr.update(visible=False, value=None)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
351
 
352
 
353
  # ───────────────────────────────────────────────────────────────────────────
354
- # Gradio UI
355
  # ───────────────────────────────────────────────────────────────────────────
356
  CSS = """
357
- .gradio-container { max-width: 1200px !important; }
358
- .header { text-align: center; margin-bottom: 1rem; }
359
- .generate-btn { min-height: 50px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
360
  """
361
 
362
- with gr.Blocks(css=CSS, title="LTX-2.3 Turbo", theme=gr.themes.Soft()) as demo:
363
- gr.Markdown(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
364
  """
365
- # LTX-2.3 Turbo (ZeroGPU)
366
- Generate synchronized **video + audio** from text or images using
367
- [Lightricks/LTX-2.3](https://huggingface.co/Lightricks/LTX-2.3) —
368
- a 22B parameter DiT-based audio-video foundation model.
369
-
370
- Running on **free ZeroGPU** with FP8 quantization.
371
- Distilled pipeline (8+4 denoising steps, two-stage with 2x spatial upscaling).
372
- """,
373
- elem_classes="header",
374
  )
375
 
376
- with gr.Row():
377
- # --- Left: Controls ---
378
- with gr.Column(scale=1):
379
- mode = gr.Radio(
380
- ["Text to Video", "Image to Video"],
381
- value="Text to Video",
382
- label="Mode",
383
- )
384
- first_frame = gr.Image(
385
- type="pil",
386
- label="Input Image (first frame)",
387
- visible=False,
388
- )
389
- prompt = gr.Textbox(
390
- label="Prompt",
391
- lines=3,
392
- placeholder="Describe the scene, motion, and audio...",
393
- value=(
394
- "A golden retriever puppy plays in fresh snow, "
395
- "tossing it up with its paws, soft winter sunlight, "
396
- "gentle wind sounds and playful barking"
397
- ),
398
  )
399
 
400
- with gr.Row():
401
- resolution = gr.Dropdown(
402
- choices=list(RESOLUTION_PRESETS.keys()),
403
- value="16:9 (768x512)",
404
- label="Resolution",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
405
  )
406
- duration = gr.Slider(1, 5, value=2, step=0.5, label="Duration (sec)")
407
 
408
- with gr.Row():
409
- enhance_prompt = gr.Checkbox(value=True, label="Enhance prompt")
410
- randomize_seed = gr.Checkbox(value=True, label="Random seed")
 
411
 
412
- seed = gr.Slider(0, MAX_SEED, value=42, step=1, label="Seed")
 
 
 
 
 
 
413
 
414
- generate_btn = gr.Button(
415
- "Generate Video",
416
- variant="primary",
417
- size="lg",
418
- elem_classes="generate-btn",
419
- )
420
 
421
- # --- Right: Output ---
422
- with gr.Column(scale=1):
423
- output_video = gr.Video(label="Generated Video", autoplay=True)
424
- run_info = gr.Textbox(label="Generation Info", lines=7, interactive=False)
425
-
426
- # --- Events ---
427
- mode.change(fn=on_mode_change, inputs=mode, outputs=[first_frame])
428
-
429
- _inputs = [
430
- first_frame,
431
- prompt,
432
- duration,
433
- enhance_prompt,
434
- seed,
435
- randomize_seed,
436
- resolution,
437
- ]
438
- _outputs = [output_video, run_info, seed]
439
-
440
- generate_btn.click(fn=generate_video, inputs=_inputs, outputs=_outputs)
441
-
442
- # --- Examples ---
443
- gr.Examples(
444
- examples=[
445
- [
446
- None,
447
- "Aerial drone shot of a coastal city at sunset, golden light "
448
- "reflecting off glass buildings, gentle ocean waves, seagulls "
449
- "calling, cinematic ambient soundtrack",
450
- 3.0,
451
- True,
452
- 42,
453
- True,
454
- "16:9 (768x512)",
455
- ],
456
- [
457
- None,
458
- "Close-up of a barista pouring latte art in slow motion, "
459
- "steam rising from the cup, coffee shop ambience with soft jazz",
460
- 2.0,
461
- True,
462
- 123,
463
- True,
464
- "1:1 (512x512)",
465
- ],
466
- [
467
- None,
468
- "A cat sits on a windowsill watching rain fall outside, "
469
- "soft indoor lighting, raindrops on glass, gentle rain sounds",
470
- 3.0,
471
- True,
472
- 7,
473
- True,
474
- "16:9 (768x512)",
475
- ],
476
- ],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
477
  fn=generate_video,
478
- inputs=_inputs,
479
- outputs=_outputs,
480
- cache_examples=False,
481
- label="Example Prompts",
 
 
 
 
 
 
 
 
 
 
482
  )
483
 
 
484
  gr.Markdown(
485
  """
486
  ---
@@ -494,6 +1585,8 @@ with gr.Blocks(css=CSS, title="LTX-2.3 Turbo", theme=gr.themes.Soft()) as demo:
494
 
495
  Built with [Lightricks/LTX-2.3](https://huggingface.co/Lightricks/LTX-2.3)
496
  | [GitHub](https://github.com/Lightricks/LTX-2)
 
 
497
  """
498
  )
499
 
 
3
  Generates synchronized audio-video content using Lightricks/LTX-2.3 on
4
  free ZeroGPU hardware via Hugging Face Spaces.
5
 
6
+ UI inspired by alexnasa/ltx-2-TURBO with full feature parity for LTX-2.3:
7
+ - Image-to-Video mode (first frame conditioning)
8
+ - Interpolate mode (first + last frame)
9
+ - Audio input (user provides audio for lip-sync/soundtrack)
10
+ - Custom UI components (RadioAnimated, PromptBox, CameraDropdown, AudioDropUpload)
11
+ - Duration presets (2s, 3s, 5s) and resolution selector with SVG icons
12
+
13
  Architecture (following alexnasa/ltx-2-TURBO's proven ZeroGPU pattern):
14
  1. Vendored ltx-core and ltx-pipelines added to sys.path before any imports.
15
  2. Model files downloaded at module startup (CPU, no GPU lease).
 
39
  # ───────────────────────────────────────────────────────────────────────────
40
  # Standard library & third-party imports
41
  # ───────────────────────────────────────────────────────────────────────────
42
+ import json
43
  import logging
44
  import os
45
  import random
46
+ import subprocess
47
  import tempfile
48
  import time
49
  import traceback
50
+ import uuid
51
+ from typing import Any
52
 
53
  import gradio as gr
54
  import numpy as np
55
  import spaces
56
  import torch
57
+ import torch.nn.functional as F
58
+ import torchaudio
59
  from huggingface_hub import hf_hub_download, snapshot_download
60
 
61
  logging.basicConfig(level=logging.INFO)
 
80
  CKPT_DISTILLED = "ltx-2.3-22b-distilled.safetensors"
81
  CKPT_UPSCALER = "ltx-2.3-spatial-upscaler-x2-1.0.safetensors"
82
 
83
+ RESOLUTION_MAP = {
84
+ "16:9": (768, 512),
85
+ "1:1": (512, 512),
86
+ "9:16": (512, 768),
87
  }
88
 
89
+
90
+ # ───────────────────────────────────────────────────────────────────────────
91
+ # Audio helper functions (ported from alexnasa/ltx-2-TURBO)
92
+ # ───────────────────────────────────────────────────────────────────────────
93
+ def _coerce_audio_path(audio_path: Any) -> str:
94
+ """Handle Gradio's various audio path formats (tuple, dict, string)."""
95
+ if isinstance(audio_path, tuple) and len(audio_path) > 0:
96
+ audio_path = audio_path[0]
97
+ if isinstance(audio_path, dict):
98
+ audio_path = audio_path.get("name") or audio_path.get("path")
99
+ if not isinstance(audio_path, (str, bytes, os.PathLike)):
100
+ raise TypeError(
101
+ f"audio_path must be a path-like, got {type(audio_path)}: {audio_path}"
102
+ )
103
+ return os.fspath(audio_path)
104
+
105
+
106
+ def match_audio_to_duration(
107
+ audio_path: str,
108
+ target_seconds: float,
109
+ target_sr: int = 48000,
110
+ to_mono: bool = True,
111
+ pad_mode: str = "silence",
112
+ device: str = "cuda",
113
+ ):
114
+ """
115
+ Load audio, resample, (optionally) mono, then trim/pad to exactly target_seconds.
116
+ Returns: (waveform tensor, sample_rate)
117
+ """
118
+ audio_path = _coerce_audio_path(audio_path)
119
+ wav, sr = torchaudio.load(audio_path) # [C, T] float32 CPU
120
+
121
+ if sr != target_sr:
122
+ wav = torchaudio.functional.resample(wav, sr, target_sr)
123
+ sr = target_sr
124
+
125
+ if to_mono and wav.shape[0] > 1:
126
+ wav = wav.mean(dim=0, keepdim=True)
127
+
128
+ target_len = int(round(target_seconds * sr))
129
+ cur_len = wav.shape[-1]
130
+
131
+ if cur_len > target_len:
132
+ wav = wav[..., :target_len]
133
+ elif cur_len < target_len:
134
+ pad_len = target_len - cur_len
135
+ if pad_mode == "repeat" and cur_len > 0:
136
+ reps = (target_len + cur_len - 1) // cur_len
137
+ wav = wav.repeat(1, reps)[..., :target_len]
138
+ else:
139
+ wav = F.pad(wav, (0, pad_len))
140
+
141
+ wav = wav.to(device, non_blocking=True)
142
+ return wav, sr
143
+
144
+
145
  # ───────────────────────────────────────────────────────────────────────────
146
  # 1) Download model files at module startup (CPU, no GPU lease)
147
  # ───────────────────────────────────────────────────────────────────────────
 
160
 
161
  # ──────────────────────────���────────────────────────────────────────────────
162
  # 2) Construct ModelLedger (CPU — no model weights loaded to GPU)
 
163
  # ───────────────────────────────────────────────────────────────────────────
164
  logger.info("Constructing ModelLedger (with Gemma for text encoding)...")
165
 
 
186
 
187
  # ───────────────────────────────────────────────────────────────────────────
188
  # 4) Construct DistilledPipeline WITHOUT text encoder (gemma_root=None)
 
189
  # ───────────────────────────────────────────────────────────────────────────
190
  logger.info("Constructing DistilledPipeline (gemma_root=None)...")
191
 
 
221
  return k * 8 + 1
222
 
223
 
224
+ def encode_text_simple(te, prompt: str):
225
+ """Simple text encoding without using pipeline_utils."""
226
+ hidden_states, attention_mask = te.encode(prompt)
227
+ embeddings_processor = model_ledger.gemma_embeddings_processor()
228
+ result = embeddings_processor.process_hidden_states(hidden_states, attention_mask)
229
+ del embeddings_processor
230
+ return result.video_encoding, result.audio_encoding
231
+
232
+
233
+ def apply_resolution(resolution: str):
234
+ w, h = RESOLUTION_MAP.get(resolution, (768, 512))
235
+ return int(w), int(h)
236
+
237
+
238
+ def apply_duration(duration_str: str):
239
+ return int(duration_str[:-1])
240
+
241
+
242
+ def on_mode_change(selected: str):
243
+ is_interpolate = selected == "Interpolate"
244
+ return gr.update(visible=is_interpolate)
245
+
246
+
247
  def get_duration(
248
  first_frame,
249
+ end_frame,
250
  prompt,
251
  duration,
252
+ generation_mode,
253
  enhance_prompt,
254
  seed,
255
  randomize_seed,
256
+ height,
257
+ width,
258
+ audio_path,
259
  *args,
260
  **kwargs,
261
  ):
262
  """Estimate GPU lease duration for @spaces.GPU(duration=...)."""
263
+ extra_time = 0
264
+ if audio_path is not None:
265
+ extra_time += 10
266
+
267
  dur = float(duration)
268
  if dur <= 2:
269
+ return 120 + extra_time
270
+ elif dur <= 3:
271
+ return 150 + extra_time
272
  else:
273
+ return 200 + extra_time
274
 
275
 
276
  # ───────────────────────────────────────────────────────────────────────────
 
300
  logger.info(f"[encode_prompt] Enhanced prompt: '{final_prompt[:120]}...'")
301
 
302
  with torch.inference_mode():
303
+ video_context, audio_context = encode_text_simple(text_encoder, final_prompt)
 
 
 
 
 
 
 
 
 
 
 
304
 
305
  embedding_data = {
306
  "video_context": video_context.detach().cpu(),
 
318
  @spaces.GPU(duration=get_duration)
319
  def generate_video(
320
  first_frame,
321
+ end_frame,
322
  prompt: str,
323
  duration: float,
324
+ generation_mode: str = "Image-to-Video",
325
+ enhance_prompt: bool = True,
326
+ seed: int = 42,
327
+ randomize_seed: bool = True,
328
+ height: int = 512,
329
+ width: int = 768,
330
+ audio_path=None,
331
  progress=gr.Progress(track_tqdm=True),
332
  ):
333
  """
334
+ Full generation: encode prompt then run pipeline with pre-encoded contexts.
335
+ Supports Image-to-Video, Interpolate, and audio input modes.
336
  """
337
  if not prompt or not prompt.strip():
338
  raise gr.Error("Please enter a prompt.")
339
 
340
  current_seed = random.randint(0, MAX_SEED) if randomize_seed else int(seed)
 
341
  num_frames = calc_frames(duration, 24.0)
342
  frame_rate = 24.0
343
 
344
  logger.info(
345
+ f"[generate_video] mode={generation_mode}, seed={current_seed}, {width}x{height}, "
346
+ f"frames={num_frames}, duration={duration}s, enhance={enhance_prompt}, "
347
+ f"audio={'yes' if audio_path else 'no'}"
348
  )
349
 
350
+ # --- Handle input images ---
351
  images = []
352
  image_path_for_enhance = None
353
+
354
  if first_frame is not None:
355
+ # first_frame is filepath from gr.Image(type="filepath")
356
+ if isinstance(first_frame, str):
357
+ img_path = first_frame
358
+ else:
359
+ tmp_dir = tempfile.mkdtemp()
360
+ img_path = os.path.join(tmp_dir, f"input_{int(time.time())}.png")
361
+ if hasattr(first_frame, "save"):
362
+ first_frame.save(img_path)
363
+ else:
364
+ from PIL import Image as PILImage
365
+
366
+ PILImage.open(first_frame).save(img_path)
367
+
368
+ images.append((img_path, 0, 1.0))
369
+ image_path_for_enhance = img_path
370
+
371
+ # Interpolation: add end frame as guiding latent
372
+ if generation_mode == "Interpolate" and end_frame is not None:
373
+ if isinstance(end_frame, str):
374
+ end_path = end_frame
375
  else:
376
+ tmp_dir = tempfile.mkdtemp()
377
+ end_path = os.path.join(tmp_dir, f"end_{int(time.time())}.png")
378
+ if hasattr(end_frame, "save"):
379
+ end_frame.save(end_path)
380
+ else:
381
+ from PIL import Image as PILImage
382
 
383
+ PILImage.open(end_frame).save(end_path)
384
+
385
+ end_idx = max(0, num_frames - 1)
386
+ images.append((end_path, end_idx, 0.5))
 
387
 
388
  t0 = time.time()
389
  try:
 
400
  del embeddings
401
  torch.cuda.empty_cache()
402
 
403
+ # If user provided audio, use a neutral audio_context (encode empty prompt)
404
+ if audio_path is not None:
405
+ with torch.inference_mode():
406
+ _, neutral_audio_context = encode_text_simple(text_encoder, "")
407
+ del audio_context
408
+ audio_context = neutral_audio_context
409
+
410
+ # Prepare audio waveform if provided
411
+ input_waveform = None
412
+ input_waveform_sample_rate = None
413
+ if audio_path is not None:
414
+ video_seconds = (num_frames - 1) / frame_rate
415
+ input_waveform, input_waveform_sample_rate = match_audio_to_duration(
416
+ audio_path=audio_path,
417
+ target_seconds=video_seconds,
418
+ target_sr=48000,
419
+ to_mono=True,
420
+ pad_mode="silence",
421
+ device="cuda",
422
+ )
423
+
424
+ torch.cuda.empty_cache()
425
+
426
  # Phase 2: Run pipeline with pre-encoded contexts
427
  with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as tmpfile:
428
  output_path = tmpfile.name
 
440
  tiling_config=TilingConfig.default(),
441
  video_context=video_context,
442
  audio_context=audio_context,
443
+ input_waveform=input_waveform,
444
+ input_waveform_sample_rate=input_waveform_sample_rate,
445
  )
446
 
447
  del video_context, audio_context
448
+ if input_waveform is not None:
449
+ del input_waveform
450
  torch.cuda.empty_cache()
451
 
452
  elapsed = time.time() - t0
 
462
  logger.error(f"Generation failed after {elapsed:.1f}s:\n{tb}")
463
  raise gr.Error(f"Generation failed: {type(e).__name__}: {e}")
464
 
465
+ return str(output_path)
 
 
 
 
 
 
 
 
 
 
466
 
467
 
468
  # ───────────────────────────────────────────────────────────────────────────
469
+ # Custom UI Components (ported from alexnasa/ltx-2-TURBO)
470
  # ───────────────────────────────────────────────────────────────────────────
 
 
471
 
472
 
473
+ class RadioAnimated(gr.HTML):
474
+ """Animated segmented radio (like iOS pill selector)."""
475
+
476
+ def __init__(self, choices, value=None, **kwargs):
477
+ if not choices or len(choices) < 2:
478
+ raise ValueError("RadioAnimated requires at least 2 choices.")
479
+ if value is None:
480
+ value = choices[0]
481
+
482
+ uid = uuid.uuid4().hex[:8]
483
+ group_name = f"ra-{uid}"
484
+
485
+ inputs_html = "\n".join(
486
+ f'<input class="ra-input" type="radio" name="{group_name}" '
487
+ f'id="{group_name}-{i}" value="{c}">'
488
+ f'<label class="ra-label" for="{group_name}-{i}">{c}</label>'
489
+ for i, c in enumerate(choices)
490
+ )
491
+
492
+ html_template = f"""
493
+ <div class="ra-wrap" data-ra="{uid}">
494
+ <div class="ra-inner">
495
+ <div class="ra-highlight"></div>
496
+ {inputs_html}
497
+ </div>
498
+ </div>
499
+ """
500
+
501
+ js_on_load = r"""
502
+ (() => {
503
+ const wrap = element.querySelector('.ra-wrap');
504
+ const inner = element.querySelector('.ra-inner');
505
+ const highlight = element.querySelector('.ra-highlight');
506
+ const inputs = Array.from(element.querySelectorAll('.ra-input'));
507
+ const labels = Array.from(element.querySelectorAll('.ra-label'));
508
+ if (!inputs.length || !labels.length) return;
509
+ const choices = inputs.map(i => i.value);
510
+ const PAD = 6;
511
+ let currentIdx = 0;
512
+ function setHighlightByIndex(idx) {
513
+ currentIdx = idx;
514
+ const lbl = labels[idx];
515
+ if (!lbl) return;
516
+ const innerRect = inner.getBoundingClientRect();
517
+ const lblRect = lbl.getBoundingClientRect();
518
+ highlight.style.width = `${lblRect.width}px`;
519
+ const x = (lblRect.left - innerRect.left - PAD);
520
+ highlight.style.transform = `translateX(${x}px)`;
521
+ }
522
+ function setCheckedByValue(val, shouldTrigger=false) {
523
+ const idx = Math.max(0, choices.indexOf(val));
524
+ inputs.forEach((inp, i) => { inp.checked = (i === idx); });
525
+ requestAnimationFrame(() => setHighlightByIndex(idx));
526
+ props.value = choices[idx];
527
+ if (shouldTrigger) trigger('change', props.value);
528
+ }
529
+ setCheckedByValue(props.value ?? choices[0], false);
530
+ inputs.forEach((inp) => {
531
+ inp.addEventListener('change', () => setCheckedByValue(inp.value, true));
532
+ });
533
+ window.addEventListener('resize', () => setHighlightByIndex(currentIdx));
534
+ let last = props.value;
535
+ const syncFromProps = () => {
536
+ if (props.value !== last) {
537
+ last = props.value;
538
+ setCheckedByValue(last, false);
539
+ }
540
+ requestAnimationFrame(syncFromProps);
541
+ };
542
+ requestAnimationFrame(syncFromProps);
543
+ })();
544
+ """
545
+
546
+ super().__init__(
547
+ value=value,
548
+ html_template=html_template,
549
+ js_on_load=js_on_load,
550
+ **kwargs,
551
+ )
552
+
553
+
554
+ class PromptBox(gr.HTML):
555
+ """Prompt textarea with an internal footer slot for embedding dropdowns."""
556
+
557
+ def __init__(self, value="", placeholder="Describe what you want...", **kwargs):
558
+ uid = uuid.uuid4().hex[:8]
559
+
560
+ html_template = f"""
561
+ <div class="ds-card" data-ds="{uid}">
562
+ <div class="ds-top">
563
+ <textarea class="ds-textarea" rows="3" placeholder="{placeholder}"></textarea>
564
+ <div class="ds-footer" aria-label="prompt-footer"></div>
565
+ </div>
566
+ </div>
567
+ """
568
+
569
+ js_on_load = r"""
570
+ (() => {
571
+ const textarea = element.querySelector(".ds-textarea");
572
+ if (!textarea) return;
573
+ const autosize = () => {
574
+ textarea.style.height = "0px";
575
+ textarea.style.height = Math.min(textarea.scrollHeight, 240) + "px";
576
+ };
577
+ const setValue = (v, triggerChange=false) => {
578
+ const val = (v ?? "");
579
+ if (textarea.value !== val) textarea.value = val;
580
+ autosize();
581
+ props.value = textarea.value;
582
+ if (triggerChange) trigger("change", props.value);
583
+ };
584
+ setValue(props.value, false);
585
+ textarea.addEventListener("input", () => {
586
+ autosize();
587
+ props.value = textarea.value;
588
+ trigger("change", props.value);
589
+ });
590
+ const shouldAutoFocus = () => {
591
+ const ae = document.activeElement;
592
+ if (ae && ae !== document.body && ae !== document.documentElement) return false;
593
+ if (window.matchMedia && window.matchMedia("(max-width: 768px)").matches) return false;
594
+ return true;
595
+ };
596
+ const focusWithRetry = (tries = 30) => {
597
+ if (!shouldAutoFocus()) return;
598
+ if (document.activeElement !== textarea) textarea.focus({ preventScroll: true });
599
+ if (document.activeElement === textarea) return;
600
+ if (tries > 0) requestAnimationFrame(() => focusWithRetry(tries - 1));
601
+ };
602
+ requestAnimationFrame(() => focusWithRetry());
603
+ let last = props.value;
604
+ const syncFromProps = () => {
605
+ if (props.value !== last) {
606
+ last = props.value;
607
+ setValue(last, false);
608
+ }
609
+ requestAnimationFrame(syncFromProps);
610
+ };
611
+ requestAnimationFrame(syncFromProps);
612
+ })();
613
+ """
614
+
615
+ super().__init__(
616
+ value=value,
617
+ html_template=html_template,
618
+ js_on_load=js_on_load,
619
+ **kwargs,
620
+ )
621
+
622
+
623
+ class CameraDropdown(gr.HTML):
624
+ """Custom dropdown with optional icons per item."""
625
+
626
+ def __init__(self, choices, value="None", title="Dropdown", **kwargs):
627
+ if not choices:
628
+ raise ValueError("CameraDropdown requires choices.")
629
+
630
+ norm = []
631
+ for c in choices:
632
+ if isinstance(c, dict):
633
+ label = str(c.get("label", c.get("value", "")))
634
+ val = str(c.get("value", label))
635
+ icon = c.get("icon", None)
636
+ norm.append({"label": label, "value": val, "icon": icon})
637
+ else:
638
+ s = str(c)
639
+ norm.append({"label": s, "value": s, "icon": None})
640
+
641
+ uid = uuid.uuid4().hex[:8]
642
+
643
+ def render_item(item):
644
+ icon_html = ""
645
+ if item["icon"]:
646
+ icon_html = f'<span class="cd-icn">{item["icon"]}</span>'
647
+ return (
648
+ f'<button type="button" class="cd-item" '
649
+ f'data-value="{item["value"]}">'
650
+ f'{icon_html}<span class="cd-label">{item["label"]}</span>'
651
+ f"</button>"
652
+ )
653
+
654
+ items_html = "\n".join(render_item(item) for item in norm)
655
+
656
+ html_template = f"""
657
+ <div class="cd-wrap" data-cd="{uid}">
658
+ <button type="button" class="cd-trigger" aria-haspopup="menu" aria-expanded="false">
659
+ <span class="cd-trigger-icon"></span>
660
+ <span class="cd-trigger-text"></span>
661
+ <span class="cd-caret">&#x25BE;</span>
662
+ </button>
663
+ <div class="cd-menu" role="menu" aria-hidden="true">
664
+ <div class="cd-title">{title}</div>
665
+ <div class="cd-items">
666
+ {items_html}
667
+ </div>
668
+ </div>
669
+ </div>
670
+ """
671
+
672
+ value_to_label = {it["value"]: it["label"] for it in norm}
673
+ value_to_icon = {it["value"]: (it["icon"] or "") for it in norm}
674
+
675
+ js_on_load = r"""
676
+ (() => {
677
+ const wrap = element.querySelector(".cd-wrap");
678
+ const trigger = element.querySelector(".cd-trigger");
679
+ const triggerIcon = element.querySelector(".cd-trigger-icon");
680
+ const triggerText = element.querySelector(".cd-trigger-text");
681
+ const menu = element.querySelector(".cd-menu");
682
+ const items = Array.from(element.querySelectorAll(".cd-item"));
683
+ if (!wrap || !trigger || !menu || !items.length) return;
684
+ const valueToLabel = __VALUE_TO_LABEL__;
685
+ const valueToIcon = __VALUE_TO_ICON__;
686
+ const safeLabel = (v) => (valueToLabel && valueToLabel[v]) ? valueToLabel[v] : (v ?? "None");
687
+ const safeIcon = (v) => (valueToIcon && valueToIcon[v]) ? valueToIcon[v] : "";
688
+ function closeMenu() {
689
+ menu.classList.remove("open");
690
+ trigger.setAttribute("aria-expanded", "false");
691
+ menu.setAttribute("aria-hidden", "true");
692
+ }
693
+ function openMenu() {
694
+ menu.classList.add("open");
695
+ trigger.setAttribute("aria-expanded", "true");
696
+ menu.setAttribute("aria-hidden", "false");
697
+ }
698
+ function setValue(val, shouldTrigger = false) {
699
+ const v = (val ?? "None");
700
+ props.value = v;
701
+ triggerText.textContent = safeLabel(v);
702
+ if (triggerIcon) {
703
+ triggerIcon.innerHTML = safeIcon(v);
704
+ triggerIcon.style.display = safeIcon(v) ? "inline-flex" : "none";
705
+ }
706
+ items.forEach(btn => {
707
+ btn.dataset.selected = (btn.dataset.value === v) ? "true" : "false";
708
+ });
709
+ if (shouldTrigger) trigger("change", props.value);
710
+ }
711
+ trigger.addEventListener("pointerdown", (e) => {
712
+ e.preventDefault();
713
+ e.stopPropagation();
714
+ if (menu.classList.contains("open")) closeMenu();
715
+ else openMenu();
716
+ });
717
+ document.addEventListener("pointerdown", (e) => {
718
+ if (!wrap.contains(e.target)) closeMenu();
719
+ }, true);
720
+ document.addEventListener("keydown", (e) => {
721
+ if (e.key === "Escape") closeMenu();
722
+ });
723
+ wrap.addEventListener("focusout", (e) => {
724
+ if (!wrap.contains(e.relatedTarget)) closeMenu();
725
+ });
726
+ items.forEach((btn) => {
727
+ btn.addEventListener("pointerdown", (e) => {
728
+ e.preventDefault();
729
+ e.stopPropagation();
730
+ closeMenu();
731
+ setValue(btn.dataset.value, true);
732
+ });
733
+ });
734
+ setValue((props.value ?? "None"), false);
735
+ let last = props.value;
736
+ const syncFromProps = () => {
737
+ if (props.value !== last) {
738
+ last = props.value;
739
+ setValue(last, false);
740
+ }
741
+ requestAnimationFrame(syncFromProps);
742
+ };
743
+ requestAnimationFrame(syncFromProps);
744
+ })();
745
+ """
746
+
747
+ js_on_load = js_on_load.replace(
748
+ "__VALUE_TO_LABEL__", json.dumps(value_to_label)
749
+ )
750
+ js_on_load = js_on_load.replace("__VALUE_TO_ICON__", json.dumps(value_to_icon))
751
+
752
+ super().__init__(
753
+ value=value,
754
+ html_template=html_template,
755
+ js_on_load=js_on_load,
756
+ **kwargs,
757
+ )
758
+
759
+
760
+ class AudioDropUpload(gr.HTML):
761
+ """Custom audio drop/click UI that proxies file into a hidden gr.File component."""
762
+
763
+ def __init__(self, target_audio_elem_id: str, value=None, **kwargs):
764
+ uid = uuid.uuid4().hex[:8]
765
+
766
+ html_template = f"""
767
+ <div class="aud-wrap" data-aud="{uid}">
768
+ <div class="aud-drop" role="button" tabindex="0" aria-label="Upload audio">
769
+ <div><strong>(Optional) Drag &amp; drop an audio file here</strong></div>
770
+ <div class="aud-hint">...or click to browse</div>
771
+ </div>
772
+ <div class="aud-row" aria-live="polite">
773
+ <audio class="aud-player" controls></audio>
774
+ <button class="aud-remove" type="button" aria-label="Remove audio">
775
+ <svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
776
+ <path d="M18 6L6 18M6 6l12 12"
777
+ stroke="currentColor" stroke-width="2.25" stroke-linecap="round"/>
778
+ </svg>
779
+ </button>
780
+ </div>
781
+ <div class="aud-filelabel"></div>
782
+ </div>
783
+ """
784
+
785
+ js_on_load = """
786
+ (() => {{
787
+ function grRoot() {{
788
+ const ga = document.querySelector("gradio-app");
789
+ return (ga && ga.shadowRoot) ? ga.shadowRoot : document;
790
+ }}
791
+ const root = grRoot();
792
+ const wrap = element.querySelector(".aud-wrap");
793
+ const drop = element.querySelector(".aud-drop");
794
+ const row = element.querySelector(".aud-row");
795
+ const player = element.querySelector(".aud-player");
796
+ const removeBtn = element.querySelector(".aud-remove");
797
+ const label = element.querySelector(".aud-filelabel");
798
+ const TARGET_ID = "__TARGET_ID__";
799
+ let currentUrl = null;
800
+ function findHiddenAudioFileInput() {{
801
+ const host = root.querySelector("#" + CSS.escape(TARGET_ID));
802
+ if (!host) return null;
803
+ const inp = host.querySelector('input[type="file"]');
804
+ return inp;
805
+ }}
806
+ function showDrop() {{
807
+ drop.style.display = "";
808
+ row.style.display = "none";
809
+ label.style.display = "none";
810
+ label.textContent = "";
811
+ }}
812
+ function showPlayer(filename) {{
813
+ drop.style.display = "none";
814
+ row.style.display = "flex";
815
+ if (filename) {{
816
+ label.textContent = "Loaded: " + filename;
817
+ label.style.display = "block";
818
+ }}
819
+ }}
820
+ function clearPreview() {{
821
+ player.pause();
822
+ player.removeAttribute("src");
823
+ player.load();
824
+ if (currentUrl) {{
825
+ URL.revokeObjectURL(currentUrl);
826
+ currentUrl = null;
827
+ }}
828
+ }}
829
+ function clearHiddenGradioAudio() {{
830
+ const fileInput = findHiddenAudioFileInput();
831
+ if (!fileInput) return;
832
+ fileInput.value = "";
833
+ const dt = new DataTransfer();
834
+ fileInput.files = dt.files;
835
+ fileInput.dispatchEvent(new Event("input", {{ bubbles: true }}));
836
+ fileInput.dispatchEvent(new Event("change", {{ bubbles: true }}));
837
+ }}
838
+ function clearAll() {{
839
+ clearPreview();
840
+ clearHiddenGradioAudio();
841
+ props.value = "__CLEAR__";
842
+ trigger("change", props.value);
843
+ showDrop();
844
+ }}
845
+ function loadFileToPreview(file) {{
846
+ if (!file) return;
847
+ if (!file.type || !file.type.startsWith("audio/")) {{
848
+ alert("Please choose an audio file.");
849
+ return;
850
+ }}
851
+ clearPreview();
852
+ currentUrl = URL.createObjectURL(file);
853
+ player.src = currentUrl;
854
+ showPlayer(file.name);
855
+ }}
856
+ function pushFileIntoHiddenGradioAudio(file) {{
857
+ const fileInput = findHiddenAudioFileInput();
858
+ if (!fileInput) {{
859
+ console.warn("Could not find hidden gr.File input. Check elem_id:", TARGET_ID);
860
+ return;
861
+ }}
862
+ fileInput.value = "";
863
+ const dt = new DataTransfer();
864
+ dt.items.add(file);
865
+ fileInput.files = dt.files;
866
+ fileInput.dispatchEvent(new Event("input", {{ bubbles: true }}));
867
+ fileInput.dispatchEvent(new Event("change", {{ bubbles: true }}));
868
+ }}
869
+ function handleFile(file) {{
870
+ loadFileToPreview(file);
871
+ pushFileIntoHiddenGradioAudio(file);
872
+ }}
873
+ const localPicker = document.createElement("input");
874
+ localPicker.type = "file";
875
+ localPicker.accept = "audio/*";
876
+ localPicker.style.display = "none";
877
+ wrap.appendChild(localPicker);
878
+ localPicker.addEventListener("change", () => {{
879
+ const f = localPicker.files && localPicker.files[0];
880
+ if (f) handleFile(f);
881
+ localPicker.value = "";
882
+ }});
883
+ drop.addEventListener("click", () => localPicker.click());
884
+ drop.addEventListener("keydown", (e) => {{
885
+ if (e.key === "Enter" || e.key === " ") {{
886
+ e.preventDefault();
887
+ localPicker.click();
888
+ }}
889
+ }});
890
+ removeBtn.addEventListener("click", clearAll);
891
+ ["dragenter","dragover","dragleave","drop"].forEach(evt => {{
892
+ drop.addEventListener(evt, (e) => {{
893
+ e.preventDefault();
894
+ e.stopPropagation();
895
+ }});
896
+ }});
897
+ drop.addEventListener("dragover", () => drop.classList.add("dragover"));
898
+ drop.addEventListener("dragleave", () => drop.classList.remove("dragover"));
899
+ drop.addEventListener("drop", (e) => {{
900
+ drop.classList.remove("dragover");
901
+ const f = e.dataTransfer.files && e.dataTransfer.files[0];
902
+ if (f) handleFile(f);
903
+ }});
904
+ showDrop();
905
+ function setPreviewFromPath(path) {{
906
+ if (path === "__CLEAR__") path = null;
907
+ if (!path) {{
908
+ clearPreview();
909
+ showDrop();
910
+ return;
911
+ }}
912
+ let url = path;
913
+ if (!/^https?:\\/\\//.test(path) && !path.startsWith("gradio_api/file=") && !path.startsWith("/file=")) {{
914
+ url = "gradio_api/file=" + path;
915
+ }}
916
+ clearPreview();
917
+ player.src = url;
918
+ showPlayer(path.split("/").pop());
919
+ }}
920
+ let last = props.value;
921
+ const syncFromProps = () => {{
922
+ const v = props.value;
923
+ if (v !== last) {{
924
+ last = v;
925
+ if (!v || v === "__CLEAR__") setPreviewFromPath(null);
926
+ else setPreviewFromPath(String(v));
927
+ }}
928
+ requestAnimationFrame(syncFromProps);
929
+ }};
930
+ requestAnimationFrame(syncFromProps);
931
+ }})();
932
+ """
933
+ js_on_load = js_on_load.replace("__TARGET_ID__", target_audio_elem_id)
934
+
935
+ super().__init__(
936
+ value=value,
937
+ html_template=html_template,
938
+ js_on_load=js_on_load,
939
+ **kwargs,
940
+ )
941
 
942
 
943
  # ───────────────────────────────────────────────────────────────────────────
944
+ # CSS (dark theme, ported from alexnasa/ltx-2-TURBO)
945
  # ───────────────────────────────────────────────────────────────────────────
946
  CSS = """
947
+ /* ---- layout ---- */
948
+ #controls-row {
949
+ display: none !important;
950
+ align-items: center;
951
+ gap: 12px;
952
+ flex-wrap: nowrap;
953
+ }
954
+ #controls-row > * {
955
+ flex: 0 0 auto !important;
956
+ width: auto !important;
957
+ min-width: 0 !important;
958
+ }
959
+ #col-container {
960
+ margin: 0 auto;
961
+ max-width: 1600px;
962
+ }
963
+ #step-column {
964
+ padding: 10px;
965
+ border-radius: 8px;
966
+ box-shadow: var(--card-shadow);
967
+ margin: 10px;
968
+ }
969
+
970
+ /* ---- generate button ---- */
971
+ .button-gradient {
972
+ background: linear-gradient(45deg, rgb(255, 65, 108), rgb(255, 75, 43), rgb(255, 155, 0), rgb(255, 65, 108)) 0% 0% / 400% 400%;
973
+ border: none;
974
+ padding: 14px 28px;
975
+ font-size: 16px;
976
+ font-weight: bold;
977
+ color: white;
978
+ border-radius: 10px;
979
+ cursor: pointer;
980
+ transition: 0.3s ease-in-out;
981
+ animation: 2s linear 0s infinite normal none running gradientAnimation;
982
+ box-shadow: rgba(255, 65, 108, 0.6) 0px 4px 10px;
983
+ }
984
+ @keyframes gradientAnimation {
985
+ 0% { background-position: 0% 50%; }
986
+ 50% { background-position: 100% 50%; }
987
+ 100% { background-position: 0% 50%; }
988
+ }
989
+
990
+ /* ---- mode row ---- */
991
+ #mode-row {
992
+ display: flex !important;
993
+ justify-content: center !important;
994
+ align-items: center !important;
995
+ width: 100% !important;
996
+ }
997
+ #mode-row > * {
998
+ flex: 0 0 auto !important;
999
+ width: auto !important;
1000
+ min-width: 0 !important;
1001
+ }
1002
+ #mode-row .gr-html,
1003
+ #mode-row .gradio-html,
1004
+ #mode-row .prose,
1005
+ #mode-row .block {
1006
+ width: auto !important;
1007
+ flex: 0 0 auto !important;
1008
+ display: inline-block !important;
1009
+ }
1010
+ #radioanimated_mode {
1011
+ display: inline-flex !important;
1012
+ justify-content: center !important;
1013
+ width: auto !important;
1014
+ }
1015
+
1016
+ /* ---- radioanimated ---- */
1017
+ .ra-wrap { width: fit-content; }
1018
+ .ra-inner {
1019
+ position: relative;
1020
+ display: inline-flex;
1021
+ align-items: center;
1022
+ gap: 0;
1023
+ padding: 6px;
1024
+ background: #0b0b0b;
1025
+ border-radius: 9999px;
1026
+ overflow: hidden;
1027
+ user-select: none;
1028
+ }
1029
+ .ra-input { display: none; }
1030
+ .ra-label {
1031
+ position: relative;
1032
+ z-index: 2;
1033
+ padding: 10px 18px;
1034
+ font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial;
1035
+ font-size: 14px;
1036
+ font-weight: 600;
1037
+ color: rgba(255,255,255,0.7);
1038
+ cursor: pointer;
1039
+ transition: color 180ms ease;
1040
+ white-space: nowrap;
1041
+ }
1042
+ .ra-highlight {
1043
+ position: absolute;
1044
+ z-index: 1;
1045
+ top: 6px;
1046
+ left: 6px;
1047
+ height: calc(100% - 12px);
1048
+ border-radius: 9999px;
1049
+ background: #8bff97;
1050
+ transition: transform 200ms ease, width 200ms ease;
1051
+ }
1052
+ .ra-input:checked + .ra-label { color: rgba(0,0,0,0.75); }
1053
+
1054
+ /* ---- prompt box ---- */
1055
+ .ds-card {
1056
+ width: 100%;
1057
+ max-width: 720px;
1058
+ margin: 0 auto;
1059
+ position: relative;
1060
+ z-index: 50;
1061
+ }
1062
+ .ds-top {
1063
+ position: relative;
1064
+ background: #2b2b2b;
1065
+ border: 1px solid rgba(255,255,255,0.12);
1066
+ border-radius: 14px;
1067
+ overflow: visible !important;
1068
+ }
1069
+ .ds-textarea {
1070
+ width: 100%;
1071
+ box-sizing: border-box;
1072
+ background: transparent !important;
1073
+ border: none !important;
1074
+ border-radius: 0 !important;
1075
+ color: rgba(255,255,255,0.9);
1076
+ padding: 14px 16px;
1077
+ padding-bottom: 72px;
1078
+ outline: none;
1079
+ font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial;
1080
+ font-size: 15px;
1081
+ line-height: 1.35;
1082
+ resize: none;
1083
+ min-height: 210px;
1084
+ max-height: 210px;
1085
+ overflow-y: auto;
1086
+ scrollbar-width: none;
1087
+ position: relative;
1088
+ z-index: 1;
1089
+ }
1090
+ .ds-textarea::-webkit-scrollbar { width: 0; height: 0; }
1091
+ .ds-textarea:focus,
1092
+ .ds-textarea:focus-visible { outline: none !important; box-shadow: none !important; }
1093
+ .ds-textarea { outline: none !important; }
1094
+ .ds-top:focus-within {
1095
+ border-color: rgba(255,255,255,0.22) !important;
1096
+ box-shadow: 0 0 0 3px rgba(255,255,255,0.06) !important;
1097
+ border-radius: 14px !important;
1098
+ }
1099
+ .ds-top { border-radius: 14px !important; }
1100
+ .ds-top::after {
1101
+ content: "";
1102
+ position: absolute;
1103
+ left: 0; right: 0; bottom: 0;
1104
+ height: 56px;
1105
+ background: #2b2b2b;
1106
+ border-bottom-left-radius: 14px !important;
1107
+ border-bottom-right-radius: 14px !important;
1108
+ pointer-events: none;
1109
+ z-index: 2;
1110
+ }
1111
+ .ds-footer {
1112
+ position: absolute;
1113
+ right: 12px;
1114
+ bottom: 10px;
1115
+ display: flex;
1116
+ gap: 8px;
1117
+ align-items: center;
1118
+ justify-content: flex-end;
1119
+ z-index: 20 !important;
1120
+ }
1121
+ .ds-footer .cd-trigger {
1122
+ min-height: 32px;
1123
+ padding: 6px 10px;
1124
+ font-size: 12px;
1125
+ gap: 6px;
1126
+ border-radius: 9999px;
1127
+ }
1128
+ .ds-footer .cd-trigger-icon,
1129
+ .ds-footer .cd-icn { width: 14px; height: 14px; }
1130
+ .ds-footer .cd-trigger-icon svg,
1131
+ .ds-footer .cd-icn svg { width: 14px; height: 14px; }
1132
+ .ds-footer .cd-caret { font-size: 11px; }
1133
+ .ds-footer .cd-menu { z-index: 999999 !important; }
1134
+
1135
+ /* ---- camera dropdown ---- */
1136
+ .cd-wrap { position: relative; display: inline-block; }
1137
+ .cd-trigger {
1138
+ margin-top: 2px;
1139
+ display: inline-flex;
1140
+ align-items: center;
1141
+ justify-content: center;
1142
+ gap: 10px;
1143
+ border: none;
1144
+ box-sizing: border-box;
1145
+ padding: 10px 18px;
1146
+ min-height: 52px;
1147
+ line-height: 1.2;
1148
+ border-radius: 9999px;
1149
+ background: #0b0b0b;
1150
+ font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial;
1151
+ font-size: 14px;
1152
+ color: rgba(255,255,255,0.7) !important;
1153
+ font-weight: 600 !important;
1154
+ cursor: pointer;
1155
+ user-select: none;
1156
+ white-space: nowrap;
1157
+ }
1158
+ .cd-trigger .cd-trigger-text,
1159
+ .cd-trigger .cd-caret { color: rgba(255,255,255,0.7) !important; }
1160
+ .cd-caret { opacity: 0.8; font-weight: 900; }
1161
+ .cd-trigger-icon {
1162
+ color: rgba(255,255,255,0.9);
1163
+ display: inline-flex;
1164
+ align-items: center;
1165
+ justify-content: center;
1166
+ width: 18px; height: 18px;
1167
+ }
1168
+ .cd-trigger-icon svg { width: 18px; height: 18px; display: block; }
1169
+ .cd-menu {
1170
+ position: absolute;
1171
+ top: calc(100% + 4px);
1172
+ left: 0;
1173
+ min-width: 240px;
1174
+ background: #2b2b2b !important;
1175
+ border: 1px solid rgba(255,255,255,0.14) !important;
1176
+ border-radius: 14px;
1177
+ box-shadow: 0 18px 40px rgba(0,0,0,0.35);
1178
+ padding: 10px;
1179
+ opacity: 0;
1180
+ transform: translateY(-6px);
1181
+ pointer-events: none;
1182
+ transition: opacity 160ms ease, transform 160ms ease;
1183
+ z-index: 9999;
1184
+ }
1185
+ .cd-menu.open {
1186
+ opacity: 1;
1187
+ transform: translateY(0);
1188
+ pointer-events: auto;
1189
+ }
1190
+ .cd-title {
1191
+ font-size: 12px;
1192
+ font-weight: 600;
1193
+ text-transform: uppercase;
1194
+ letter-spacing: 0.04em;
1195
+ color: rgba(255,255,255,0.55) !important;
1196
+ margin-bottom: 6px;
1197
+ padding: 0 6px;
1198
+ pointer-events: none;
1199
+ }
1200
+ .cd-items { display: flex; flex-direction: column; gap: 0px; }
1201
+ .cd-item {
1202
+ width: 100%;
1203
+ text-align: left;
1204
+ border: none;
1205
+ background: transparent;
1206
+ color: rgba(255,255,255,0.92) !important;
1207
+ padding: 8px 34px 8px 12px;
1208
+ border-radius: 10px;
1209
+ cursor: pointer;
1210
+ font-size: 14px;
1211
+ font-weight: 700;
1212
+ position: relative;
1213
+ transition: background 120ms ease;
1214
+ display: flex;
1215
+ align-items: center;
1216
+ gap: 10px;
1217
+ }
1218
+ .cd-item * { color: rgba(255,255,255,0.92) !important; }
1219
+ .cd-item:hover { background: rgba(255,255,255,0.10) !important; }
1220
+ .cd-item::after {
1221
+ content: "\\2713";
1222
+ position: absolute;
1223
+ right: 12px;
1224
+ top: 50%;
1225
+ transform: translateY(-50%);
1226
+ opacity: 0;
1227
+ transition: opacity 120ms ease;
1228
+ color: rgba(255,255,255,0.92) !important;
1229
+ font-weight: 900;
1230
+ }
1231
+ .cd-item[data-selected="true"]::after { opacity: 1; }
1232
+ .cd-item.selected {
1233
+ background: transparent !important;
1234
+ border: none !important;
1235
+ }
1236
+ .cd-icn {
1237
+ display: inline-flex;
1238
+ align-items: center;
1239
+ justify-content: center;
1240
+ width: 18px; height: 18px;
1241
+ flex: 0 0 18px;
1242
+ }
1243
+ .cd-icn svg { width: 18px; height: 18px; display: block; }
1244
+ .cd-icn svg * { stroke: rgba(255,255,255,0.9); }
1245
+ .cd-label { flex: 1; }
1246
+ .cd-trigger, .cd-trigger * { color: rgba(255,255,255,0.75) !important; }
1247
+
1248
+ /* ---- AudioDropUpload ---- */
1249
+ .aud-wrap { width: 100%; max-width: 720px; }
1250
+ .aud-drop {
1251
+ border: 2px dashed var(--body-text-color-subdued);
1252
+ border-radius: 16px;
1253
+ padding: 18px;
1254
+ text-align: center;
1255
+ cursor: pointer;
1256
+ user-select: none;
1257
+ color: var(--body-text-color);
1258
+ background: var(--block-background-fill);
1259
+ }
1260
+ .aud-drop.dragover {
1261
+ border-color: rgba(255,255,255,0.35);
1262
+ background: rgba(255,255,255,0.06);
1263
+ }
1264
+ .aud-hint {
1265
+ color: var(--body-text-color-subdued);
1266
+ font-size: 0.95rem;
1267
+ margin-top: 6px;
1268
+ }
1269
+ .aud-row {
1270
+ display: none;
1271
+ align-items: center;
1272
+ gap: 10px;
1273
+ background: #0b0b0b;
1274
+ border-radius: 9999px;
1275
+ padding: 8px 10px;
1276
+ }
1277
+ .aud-player {
1278
+ flex: 1;
1279
+ width: 100%;
1280
+ height: 34px;
1281
+ border-radius: 9999px;
1282
+ }
1283
+ .aud-remove {
1284
+ appearance: none;
1285
+ border: none;
1286
+ background: transparent;
1287
+ color: rgba(255,255,255);
1288
+ cursor: pointer;
1289
+ width: 36px; height: 36px;
1290
+ border-radius: 9999px;
1291
+ display: inline-flex;
1292
+ align-items: center;
1293
+ justify-content: center;
1294
+ padding: 0;
1295
+ transition: background 120ms ease, color 120ms ease, opacity 120ms ease;
1296
+ opacity: 0.9;
1297
+ flex: 0 0 auto;
1298
+ }
1299
+ .aud-remove:hover {
1300
+ background: rgba(255,255,255,0.08);
1301
+ color: rgb(255,255,255);
1302
+ opacity: 1;
1303
+ }
1304
+ .aud-filelabel {
1305
+ margin: 10px 6px 0;
1306
+ color: var(--body-text-color-subdued);
1307
+ font-size: 0.95rem;
1308
+ display: none;
1309
+ }
1310
+ #audio_input_hidden { display: none !important; }
1311
  """
1312
 
1313
+
1314
+ # ───────────────────────────────────────────────────────────────────────────
1315
+ # SVG icons for resolution dropdown
1316
+ # ───────────────────────────────────────────────────────────────────────────
1317
+ ICON_16_9 = """<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
1318
+ <rect x="3" y="7" width="18" height="10" rx="2" stroke="currentColor" stroke-width="2"/>
1319
+ </svg>"""
1320
+
1321
+ ICON_1_1 = """<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
1322
+ <rect x="6" y="6" width="12" height="12" rx="2" stroke="currentColor" stroke-width="2"/>
1323
+ </svg>"""
1324
+
1325
+ ICON_9_16 = """<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
1326
+ <rect x="7" y="3" width="10" height="18" rx="2" stroke="currentColor" stroke-width="2"/>
1327
+ </svg>"""
1328
+
1329
+
1330
+ # ───────────────────────────────────────────────────────────────────────────
1331
+ # Gradio UI
1332
+ # ───────────────────────────────────────────────────────────────────────────
1333
+ with gr.Blocks(title="LTX-2.3 Turbo", css=CSS) as demo:
1334
+ gr.HTML(
1335
+ """
1336
+ <div style="text-align: center;">
1337
+ <p style="font-size:16px; display: inline; margin: 0;">
1338
+ <strong>LTX-2.3 Turbo</strong> &mdash; 22B DiT audio-video model on free ZeroGPU
1339
+ </p>
1340
+ <a href="https://huggingface.co/Lightricks/LTX-2.3"
1341
+ target="_blank" rel="noopener noreferrer"
1342
+ style="display: inline-block; vertical-align: middle; margin-left: 0.5em;">
1343
+ [model]
1344
+ </a>
1345
+ <a href="https://github.com/Lightricks/LTX-2"
1346
+ target="_blank" rel="noopener noreferrer"
1347
+ style="display: inline-block; vertical-align: middle; margin-left: 0.5em;">
1348
+ [github]
1349
+ </a>
1350
+ </div>
1351
+ <div style="text-align: center; margin-top: 4px;">
1352
+ <strong>HF Space by:</strong>
1353
+ <a href="https://huggingface.co/ZeroCollabs" target="_blank" rel="noopener noreferrer"
1354
+ style="display: inline-block; vertical-align: middle; margin-left: 0.5em;">
1355
+ <img src="https://img.shields.io/badge/%F0%9F%A4%97-Follow%20on%20HF-green.svg" alt="Follow on HF">
1356
+ </a>
1357
+ <a href="https://github.com/ZeroHackz" target="_blank" rel="noopener noreferrer"
1358
+ style="display: inline-block; vertical-align: middle; margin-left: 0.5em;">
1359
+ <img src="https://img.shields.io/badge/GitHub-Follow-181717?logo=github" alt="Follow on GitHub">
1360
+ </a>
1361
+ </div>
1362
  """
 
 
 
 
 
 
 
 
 
1363
  )
1364
 
1365
+ with gr.Column(elem_id="col-container"):
1366
+ # ---- Mode selector ----
1367
+ with gr.Row(elem_id="mode-row"):
1368
+ radioanimated_mode = RadioAnimated(
1369
+ choices=["Image-to-Video", "Interpolate"],
1370
+ value="Image-to-Video",
1371
+ elem_id="radioanimated_mode",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1372
  )
1373
 
1374
+ with gr.Row():
1375
+ # ---- Left column: controls ----
1376
+ with gr.Column(elem_id="step-column"):
1377
+ with gr.Row():
1378
+ first_frame = gr.Image(
1379
+ label="First Frame (Optional)",
1380
+ type="filepath",
1381
+ height=256,
1382
+ )
1383
+ end_frame = gr.Image(
1384
+ label="Last Frame (Optional)",
1385
+ type="filepath",
1386
+ height=256,
1387
+ visible=False,
1388
+ )
1389
+
1390
+ # JS relocator: moves duration & resolution dropdowns into prompt footer
1391
+ relocate = gr.HTML(
1392
+ value="",
1393
+ html_template="<div></div>",
1394
+ js_on_load=r"""
1395
+ (() => {
1396
+ function moveIntoFooter() {
1397
+ const promptRoot = document.querySelector("#prompt_ui");
1398
+ if (!promptRoot) return false;
1399
+ const footer = promptRoot.querySelector(".ds-footer");
1400
+ if (!footer) return false;
1401
+ const dur = document.querySelector("#duration_ui .cd-wrap");
1402
+ const res = document.querySelector("#resolution_ui .cd-wrap");
1403
+ if (!dur || !res) return false;
1404
+ footer.appendChild(dur);
1405
+ footer.appendChild(res);
1406
+ return true;
1407
+ }
1408
+ const tick = () => {
1409
+ if (!moveIntoFooter()) requestAnimationFrame(tick);
1410
+ };
1411
+ requestAnimationFrame(tick);
1412
+ })();
1413
+ """,
1414
  )
 
1415
 
1416
+ prompt_ui = PromptBox(
1417
+ value="A golden retriever puppy plays in fresh snow, tossing it up with its paws, soft winter sunlight, gentle wind sounds and playful barking",
1418
+ elem_id="prompt_ui",
1419
+ )
1420
 
1421
+ # Hidden real audio input (backend value)
1422
+ audio_input = gr.File(
1423
+ label="Audio (Optional)",
1424
+ file_types=["audio"],
1425
+ type="filepath",
1426
+ elem_id="audio_input_hidden",
1427
+ )
1428
 
1429
+ # Custom audio UI that feeds the hidden gr.File
1430
+ audio_ui = AudioDropUpload(
1431
+ target_audio_elem_id="audio_input_hidden",
1432
+ elem_id="audio_ui",
1433
+ )
 
1434
 
1435
+ # Hidden prompt textbox (synced from PromptBox)
1436
+ prompt = gr.Textbox(
1437
+ label="Prompt",
1438
+ value="A golden retriever puppy plays in fresh snow, tossing it up with its paws, soft winter sunlight, gentle wind sounds and playful barking",
1439
+ lines=3,
1440
+ max_lines=3,
1441
+ visible=False,
1442
+ )
1443
+
1444
+ enhance_prompt = gr.Checkbox(
1445
+ label="Enhance Prompt",
1446
+ value=True,
1447
+ visible=False,
1448
+ )
1449
+
1450
+ with gr.Accordion("Advanced Settings", open=False, visible=False):
1451
+ seed = gr.Slider(
1452
+ label="Seed",
1453
+ minimum=0,
1454
+ maximum=MAX_SEED,
1455
+ value=42,
1456
+ step=1,
1457
+ )
1458
+ randomize_seed = gr.Checkbox(label="Randomize Seed", value=True)
1459
+
1460
+ # ---- Right column: output + hidden controls ----
1461
+ with gr.Column(elem_id="step-column"):
1462
+ output_video = gr.Video(
1463
+ label="Generated Video", autoplay=True, height=512
1464
+ )
1465
+
1466
+ with gr.Row(elem_id="controls-row"):
1467
+ duration_ui = CameraDropdown(
1468
+ choices=["2s", "3s", "5s"],
1469
+ value="2s",
1470
+ title="Clip Duration",
1471
+ elem_id="duration_ui",
1472
+ )
1473
+ duration = gr.Slider(
1474
+ label="Duration (seconds)",
1475
+ minimum=1.0,
1476
+ maximum=5.0,
1477
+ value=2.0,
1478
+ step=0.1,
1479
+ visible=False,
1480
+ )
1481
+
1482
+ resolution_ui = CameraDropdown(
1483
+ choices=[
1484
+ {"label": "16:9", "value": "16:9", "icon": ICON_16_9},
1485
+ {"label": "1:1", "value": "1:1", "icon": ICON_1_1},
1486
+ {"label": "9:16", "value": "9:16", "icon": ICON_9_16},
1487
+ ],
1488
+ value="16:9",
1489
+ title="Resolution",
1490
+ elem_id="resolution_ui",
1491
+ )
1492
+
1493
+ width = gr.Number(
1494
+ label="Width", value=768, precision=0, visible=False
1495
+ )
1496
+ height = gr.Number(
1497
+ label="Height", value=512, precision=0, visible=False
1498
+ )
1499
+
1500
+ generate_btn = gr.Button(
1501
+ "Generate Video",
1502
+ variant="primary",
1503
+ elem_classes="button-gradient",
1504
+ )
1505
+
1506
+ # ────────────────────────────────────────────────────────────────────
1507
+ # Event wiring
1508
+ # ────────────────────────────────────────────────────────────────────
1509
+
1510
+ # Mode selector -> show/hide end_frame
1511
+ radioanimated_mode.change(
1512
+ fn=on_mode_change,
1513
+ inputs=radioanimated_mode,
1514
+ outputs=[end_frame],
1515
+ api_visibility="private",
1516
+ )
1517
+
1518
+ # Duration dropdown -> hidden slider
1519
+ duration_ui.change(
1520
+ fn=apply_duration,
1521
+ inputs=duration_ui,
1522
+ outputs=[duration],
1523
+ api_visibility="private",
1524
+ )
1525
+
1526
+ # Resolution dropdown -> hidden width/height
1527
+ resolution_ui.change(
1528
+ fn=apply_resolution,
1529
+ inputs=resolution_ui,
1530
+ outputs=[width, height],
1531
+ api_visibility="private",
1532
+ )
1533
+
1534
+ # PromptBox -> hidden textbox
1535
+ prompt_ui.change(
1536
+ fn=lambda x: x,
1537
+ inputs=prompt_ui,
1538
+ outputs=prompt,
1539
+ api_visibility="private",
1540
+ )
1541
+
1542
+ # Audio UI clear handler
1543
+ def on_audio_ui_change(v):
1544
+ if v == "__CLEAR__" or v is None or v == "":
1545
+ return None
1546
+ return gr.update()
1547
+
1548
+ audio_ui.change(
1549
+ fn=on_audio_ui_change,
1550
+ inputs=audio_ui,
1551
+ outputs=audio_input,
1552
+ api_visibility="private",
1553
+ )
1554
+
1555
+ # Generate button
1556
+ generate_btn.click(
1557
  fn=generate_video,
1558
+ inputs=[
1559
+ first_frame,
1560
+ end_frame,
1561
+ prompt,
1562
+ duration,
1563
+ radioanimated_mode,
1564
+ enhance_prompt,
1565
+ seed,
1566
+ randomize_seed,
1567
+ height,
1568
+ width,
1569
+ audio_input,
1570
+ ],
1571
+ outputs=[output_video],
1572
  )
1573
 
1574
+ # ---- Footer ----
1575
  gr.Markdown(
1576
  """
1577
  ---
 
1585
 
1586
  Built with [Lightricks/LTX-2.3](https://huggingface.co/Lightricks/LTX-2.3)
1587
  | [GitHub](https://github.com/Lightricks/LTX-2)
1588
+ | Space by [ZeroCollabs](https://huggingface.co/ZeroCollabs)
1589
+ | [GitHub](https://github.com/ZeroHackz)
1590
  """
1591
  )
1592
 
packages/ltx-pipelines/src/ltx_pipelines/distilled.py CHANGED
@@ -4,22 +4,28 @@
4
  # - Caches _video_encoder and _transformer for reuse across calls
5
  # - Supports gemma_root=None (no text encoder loaded)
6
  # - Accepts output_path to write video directly from __call__
 
 
7
 
8
  import logging
9
  from collections.abc import Iterator
10
 
11
  import torch
 
12
 
13
  from ltx_core.components.diffusion_steps import EulerDiffusionStep
14
  from ltx_core.components.noisers import GaussianNoiser
15
  from ltx_core.components.protocols import DiffusionStepProtocol
 
16
  from ltx_core.loader import LoraPathStrengthAndSDOps
17
  from ltx_core.model.audio_vae import decode_audio as vae_decode_audio
 
18
  from ltx_core.model.upsampler import upsample_video
19
  from ltx_core.model.video_vae import TilingConfig, get_video_chunks_number
20
  from ltx_core.model.video_vae import decode_video as vae_decode_video
21
  from ltx_core.quantization import QuantizationPolicy
22
- from ltx_core.types import Audio, LatentState, VideoPixelShape
 
23
  from ltx_pipelines.utils import ModelLedger, euler_denoising_loop
24
  from ltx_pipelines.utils.args import (
25
  ImageConditioningInput,
@@ -34,10 +40,11 @@ from ltx_pipelines.utils.constants import (
34
  from ltx_pipelines.utils.helpers import (
35
  assert_resolution,
36
  cleanup_memory,
37
- combined_image_conditionings,
38
  denoise_audio_video,
39
  encode_prompts,
40
  get_device,
 
 
41
  simple_denoising_func,
42
  )
43
  from ltx_pipelines.utils.media_io import encode_video
@@ -46,6 +53,51 @@ from ltx_pipelines.utils.types import PipelineComponents
46
  device = get_device()
47
 
48
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  class DistilledPipeline:
50
  """
51
  Two-stage distilled video generation pipeline.
@@ -57,6 +109,8 @@ class DistilledPipeline:
57
  - Caches _video_encoder and _transformer for reuse across calls
58
  - Supports gemma_root=None (no text encoder loaded)
59
  - Accepts output_path to write video directly from __call__
 
 
60
  """
61
 
62
  def __init__(
@@ -90,6 +144,180 @@ class DistilledPipeline:
90
  self._video_encoder = None
91
  self._transformer = None
92
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  @torch.inference_mode()
94
  def __call__(
95
  self,
@@ -105,6 +333,9 @@ class DistilledPipeline:
105
  output_path: str | None = None,
106
  video_context: torch.Tensor | None = None,
107
  audio_context: torch.Tensor | None = None,
 
 
 
108
  ) -> tuple[Iterator[torch.Tensor], Audio] | None:
109
  """
110
  Run the two-stage distilled pipeline.
@@ -112,6 +343,11 @@ class DistilledPipeline:
112
  If video_context and audio_context are provided, text encoding is skipped.
113
  If output_path is provided, the video is written to disk and None is returned.
114
  Otherwise, returns (decoded_video, decoded_audio).
 
 
 
 
 
115
  """
116
  assert_resolution(height=height, width=width, is_two_stage=True)
117
 
@@ -120,6 +356,21 @@ class DistilledPipeline:
120
  stepper = EulerDiffusionStep()
121
  dtype = torch.bfloat16
122
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  # Use pre-encoded contexts if provided, otherwise encode internally
124
  if video_context is not None and audio_context is not None:
125
  video_context = video_context.to(self.device)
@@ -170,18 +421,18 @@ class DistilledPipeline:
170
  height=height // 2,
171
  fps=frame_rate,
172
  )
173
- stage_1_conditionings = combined_image_conditionings(
174
  images=images,
175
  height=stage_1_output_shape.height,
176
  width=stage_1_output_shape.width,
177
  video_encoder=video_encoder,
178
  dtype=dtype,
179
- device=self.device,
180
  )
181
 
182
  video_state, audio_state = denoise_audio_video(
183
  output_shape=stage_1_output_shape,
184
  conditionings=stage_1_conditionings,
 
185
  noiser=noiser,
186
  sigmas=stage_1_sigmas,
187
  stepper=stepper,
@@ -205,17 +456,38 @@ class DistilledPipeline:
205
  stage_2_output_shape = VideoPixelShape(
206
  batch=1, frames=num_frames, width=width, height=height, fps=frame_rate
207
  )
208
- stage_2_conditionings = combined_image_conditionings(
209
- images=images,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
210
  height=stage_2_output_shape.height,
211
  width=stage_2_output_shape.width,
212
  video_encoder=video_encoder,
213
  dtype=dtype,
214
  device=self.device,
215
  )
 
216
  video_state, audio_state = denoise_audio_video(
217
  output_shape=stage_2_output_shape,
218
  conditionings=stage_2_conditionings,
 
219
  noiser=noiser,
220
  sigmas=stage_2_sigmas,
221
  stepper=stepper,
 
4
  # - Caches _video_encoder and _transformer for reuse across calls
5
  # - Supports gemma_root=None (no text encoder loaded)
6
  # - Accepts output_path to write video directly from __call__
7
+ # - Supports input_waveform for audio conditioning (ported from alexnasa/ltx-2-TURBO)
8
+ # - Supports interpolation mode via _create_conditionings (first frame replace, end frame guide)
9
 
10
  import logging
11
  from collections.abc import Iterator
12
 
13
  import torch
14
+ import torchaudio
15
 
16
  from ltx_core.components.diffusion_steps import EulerDiffusionStep
17
  from ltx_core.components.noisers import GaussianNoiser
18
  from ltx_core.components.protocols import DiffusionStepProtocol
19
+ from ltx_core.conditioning import ConditioningItem, ConditioningError
20
  from ltx_core.loader import LoraPathStrengthAndSDOps
21
  from ltx_core.model.audio_vae import decode_audio as vae_decode_audio
22
+ from ltx_core.model.audio_vae.ops import AudioProcessor
23
  from ltx_core.model.upsampler import upsample_video
24
  from ltx_core.model.video_vae import TilingConfig, get_video_chunks_number
25
  from ltx_core.model.video_vae import decode_video as vae_decode_video
26
  from ltx_core.quantization import QuantizationPolicy
27
+ from ltx_core.tools import LatentTools
28
+ from ltx_core.types import Audio, AudioLatentShape, LatentState, VideoPixelShape
29
  from ltx_pipelines.utils import ModelLedger, euler_denoising_loop
30
  from ltx_pipelines.utils.args import (
31
  ImageConditioningInput,
 
40
  from ltx_pipelines.utils.helpers import (
41
  assert_resolution,
42
  cleanup_memory,
 
43
  denoise_audio_video,
44
  encode_prompts,
45
  get_device,
46
+ image_conditionings_by_adding_guiding_latent,
47
+ image_conditionings_by_replacing_latent,
48
  simple_denoising_func,
49
  )
50
  from ltx_pipelines.utils.media_io import encode_video
 
53
  device = get_device()
54
 
55
 
56
+ # ─── Audio conditioning class (ported from alexnasa/ltx-2-TURBO) ────────────
57
+ class AudioConditionByLatent(ConditioningItem):
58
+ """
59
+ Conditions audio generation by injecting a full latent sequence.
60
+ Replaces tokens in the latent state with the provided audio latents,
61
+ and sets denoise strength according to the strength parameter.
62
+ """
63
+
64
+ def __init__(self, latent: torch.Tensor, strength: float):
65
+ self.latent = latent
66
+ self.strength = strength
67
+
68
+ def apply_to(
69
+ self, latent_state: LatentState, latent_tools: LatentTools
70
+ ) -> LatentState:
71
+ if not isinstance(latent_tools.target_shape, AudioLatentShape):
72
+ raise ConditioningError(
73
+ "Audio conditioning requires an audio latent target shape."
74
+ )
75
+
76
+ cond_batch, cond_channels, cond_frames, cond_bins = self.latent.shape
77
+ tgt_batch, tgt_channels, tgt_frames, tgt_bins = (
78
+ latent_tools.target_shape.to_torch_shape()
79
+ )
80
+
81
+ if (cond_batch, cond_channels, cond_frames, cond_bins) != (
82
+ tgt_batch,
83
+ tgt_channels,
84
+ tgt_frames,
85
+ tgt_bins,
86
+ ):
87
+ raise ConditioningError(
88
+ f"Can't apply audio conditioning item to latent with shape {latent_tools.target_shape}, expected "
89
+ f"shape is ({tgt_batch}, {tgt_channels}, {tgt_frames}, {tgt_bins})."
90
+ )
91
+
92
+ tokens = latent_tools.patchifier.patchify(self.latent)
93
+ latent_state = latent_state.clone()
94
+ latent_state.latent[:, : tokens.shape[1]] = tokens
95
+ latent_state.clean_latent[:, : tokens.shape[1]] = tokens
96
+ latent_state.denoise_mask[:, : tokens.shape[1]] = 1.0 - self.strength
97
+
98
+ return latent_state
99
+
100
+
101
  class DistilledPipeline:
102
  """
103
  Two-stage distilled video generation pipeline.
 
109
  - Caches _video_encoder and _transformer for reuse across calls
110
  - Supports gemma_root=None (no text encoder loaded)
111
  - Accepts output_path to write video directly from __call__
112
+ - Supports input_waveform for audio conditioning
113
+ - Supports interpolation mode (first frame replace + end frame guide)
114
  """
115
 
116
  def __init__(
 
144
  self._video_encoder = None
145
  self._transformer = None
146
 
147
+ def _build_audio_conditionings_from_waveform(
148
+ self,
149
+ input_waveform: torch.Tensor,
150
+ input_sample_rate: int,
151
+ num_frames: int,
152
+ fps: float,
153
+ strength: float = 1.0,
154
+ ) -> list[AudioConditionByLatent] | None:
155
+ """
156
+ Convert a raw waveform into audio latent conditioning for the denoiser.
157
+ Ported from alexnasa/ltx-2-TURBO.
158
+ """
159
+ strength = float(strength)
160
+ if strength <= 0.0:
161
+ return None
162
+
163
+ # Normalize waveform shape to (B, C, T)
164
+ waveform = input_waveform
165
+ if waveform.ndim == 1:
166
+ waveform = waveform.unsqueeze(0).unsqueeze(0)
167
+ elif waveform.ndim == 2:
168
+ waveform = waveform.unsqueeze(0)
169
+ elif waveform.ndim != 3:
170
+ raise ValueError(
171
+ f"input_waveform must be 1D/2D/3D, got shape {tuple(waveform.shape)}"
172
+ )
173
+
174
+ # Get audio encoder + its config
175
+ audio_encoder = self.model_ledger.audio_encoder()
176
+ target_sr = int(getattr(audio_encoder, "sample_rate"))
177
+ target_channels = int(getattr(audio_encoder, "in_channels", waveform.shape[1]))
178
+ mel_bins = int(getattr(audio_encoder, "mel_bins"))
179
+ mel_hop = int(getattr(audio_encoder, "mel_hop_length"))
180
+ n_fft = int(getattr(audio_encoder, "n_fft"))
181
+
182
+ # Match channels
183
+ if waveform.shape[1] != target_channels:
184
+ if waveform.shape[1] == 1 and target_channels > 1:
185
+ waveform = waveform.repeat(1, target_channels, 1)
186
+ elif target_channels == 1:
187
+ waveform = waveform.mean(dim=1, keepdim=True)
188
+ else:
189
+ waveform = waveform[:, :target_channels, :]
190
+ if waveform.shape[1] < target_channels:
191
+ pad_ch = target_channels - waveform.shape[1]
192
+ pad = torch.zeros(
193
+ (waveform.shape[0], pad_ch, waveform.shape[2]),
194
+ dtype=waveform.dtype,
195
+ )
196
+ waveform = torch.cat([waveform, pad], dim=1)
197
+
198
+ # Resample if needed (CPU float32 is safest for torchaudio)
199
+ waveform = waveform.to(device="cpu", dtype=torch.float32)
200
+ if int(input_sample_rate) != target_sr:
201
+ waveform = torchaudio.functional.resample(
202
+ waveform, int(input_sample_rate), target_sr
203
+ )
204
+
205
+ # Waveform -> Mel spectrogram
206
+ audio_processor = AudioProcessor(
207
+ sample_rate=target_sr,
208
+ mel_bins=mel_bins,
209
+ mel_hop_length=mel_hop,
210
+ n_fft=n_fft,
211
+ ).to(waveform.device)
212
+
213
+ mel = audio_processor.waveform_to_mel(waveform, target_sr)
214
+
215
+ # Mel -> latent (run encoder on its own device/dtype)
216
+ audio_params = next(audio_encoder.parameters(), None)
217
+ enc_device = audio_params.device if audio_params is not None else self.device
218
+ enc_dtype = audio_params.dtype if audio_params is not None else self.dtype
219
+
220
+ mel = mel.to(device=enc_device, dtype=enc_dtype)
221
+ with torch.inference_mode():
222
+ audio_latent = audio_encoder(mel)
223
+
224
+ # Pad/trim latent to match the target video duration
225
+ audio_downsample = getattr(
226
+ getattr(audio_encoder, "patchifier", None),
227
+ "audio_latent_downsample_factor",
228
+ 4,
229
+ )
230
+ target_shape = AudioLatentShape.from_video_pixel_shape(
231
+ VideoPixelShape(
232
+ batch=audio_latent.shape[0],
233
+ frames=int(num_frames),
234
+ width=1,
235
+ height=1,
236
+ fps=float(fps),
237
+ ),
238
+ channels=audio_latent.shape[1],
239
+ mel_bins=audio_latent.shape[3],
240
+ sample_rate=target_sr,
241
+ hop_length=mel_hop,
242
+ audio_latent_downsample_factor=audio_downsample,
243
+ )
244
+ target_frames = int(target_shape.frames)
245
+
246
+ if audio_latent.shape[2] < target_frames:
247
+ pad_frames = target_frames - audio_latent.shape[2]
248
+ pad = torch.zeros(
249
+ (
250
+ audio_latent.shape[0],
251
+ audio_latent.shape[1],
252
+ pad_frames,
253
+ audio_latent.shape[3],
254
+ ),
255
+ device=audio_latent.device,
256
+ dtype=audio_latent.dtype,
257
+ )
258
+ audio_latent = torch.cat([audio_latent, pad], dim=2)
259
+ elif audio_latent.shape[2] > target_frames:
260
+ audio_latent = audio_latent[:, :, :target_frames, :]
261
+
262
+ # Move latent to pipeline device/dtype for conditioning object
263
+ audio_latent = audio_latent.to(device=self.device, dtype=self.dtype)
264
+
265
+ return [AudioConditionByLatent(audio_latent, strength)]
266
+
267
+ def _create_conditionings(
268
+ self,
269
+ images: list[tuple[str, int, float]],
270
+ height: int,
271
+ width: int,
272
+ video_encoder,
273
+ dtype: torch.dtype,
274
+ ) -> list[ConditioningItem]:
275
+ """
276
+ Create image conditionings supporting both first-frame (replace latent)
277
+ and end-frame (guiding latent for interpolation mode).
278
+
279
+ - frame_idx == 0 -> replace latent (strong anchor)
280
+ - frame_idx > 0 -> guiding latent (softer interpolation target)
281
+ """
282
+ replace_imgs = []
283
+ guide_imgs = []
284
+
285
+ for img in images:
286
+ if isinstance(img, ImageConditioningInput):
287
+ path, frame_idx, strength = img.path, img.frame_idx, img.strength
288
+ else:
289
+ path, frame_idx, strength = img[0], img[1], img[2]
290
+
291
+ entry = ImageConditioningInput(
292
+ path=path, frame_idx=frame_idx, strength=strength
293
+ )
294
+ if frame_idx == 0:
295
+ replace_imgs.append(entry)
296
+ else:
297
+ guide_imgs.append(entry)
298
+
299
+ conditionings = []
300
+ if replace_imgs:
301
+ conditionings += image_conditionings_by_replacing_latent(
302
+ images=replace_imgs,
303
+ height=height,
304
+ width=width,
305
+ video_encoder=video_encoder,
306
+ dtype=dtype,
307
+ device=self.device,
308
+ )
309
+ if guide_imgs:
310
+ conditionings += image_conditionings_by_adding_guiding_latent(
311
+ images=guide_imgs,
312
+ height=height,
313
+ width=width,
314
+ video_encoder=video_encoder,
315
+ dtype=dtype,
316
+ device=self.device,
317
+ )
318
+
319
+ return conditionings
320
+
321
  @torch.inference_mode()
322
  def __call__(
323
  self,
 
333
  output_path: str | None = None,
334
  video_context: torch.Tensor | None = None,
335
  audio_context: torch.Tensor | None = None,
336
+ input_waveform: torch.Tensor | None = None,
337
+ input_waveform_sample_rate: int | None = None,
338
+ audio_strength: float = 1.0,
339
  ) -> tuple[Iterator[torch.Tensor], Audio] | None:
340
  """
341
  Run the two-stage distilled pipeline.
 
343
  If video_context and audio_context are provided, text encoding is skipped.
344
  If output_path is provided, the video is written to disk and None is returned.
345
  Otherwise, returns (decoded_video, decoded_audio).
346
+
347
+ New audio input params:
348
+ input_waveform: raw waveform tensor for audio conditioning
349
+ input_waveform_sample_rate: sample rate of input_waveform
350
+ audio_strength: conditioning strength (0 = ignore, 1 = full replace)
351
  """
352
  assert_resolution(height=height, width=width, is_two_stage=True)
353
 
 
356
  stepper = EulerDiffusionStep()
357
  dtype = torch.bfloat16
358
 
359
+ # Build audio conditionings from waveform if provided
360
+ audio_conditionings = None
361
+ if input_waveform is not None:
362
+ if input_waveform_sample_rate is None:
363
+ raise ValueError(
364
+ "input_waveform_sample_rate must be provided when input_waveform is set."
365
+ )
366
+ audio_conditionings = self._build_audio_conditionings_from_waveform(
367
+ input_waveform=input_waveform,
368
+ input_sample_rate=int(input_waveform_sample_rate),
369
+ num_frames=num_frames,
370
+ fps=frame_rate,
371
+ strength=audio_strength,
372
+ )
373
+
374
  # Use pre-encoded contexts if provided, otherwise encode internally
375
  if video_context is not None and audio_context is not None:
376
  video_context = video_context.to(self.device)
 
421
  height=height // 2,
422
  fps=frame_rate,
423
  )
424
+ stage_1_conditionings = self._create_conditionings(
425
  images=images,
426
  height=stage_1_output_shape.height,
427
  width=stage_1_output_shape.width,
428
  video_encoder=video_encoder,
429
  dtype=dtype,
 
430
  )
431
 
432
  video_state, audio_state = denoise_audio_video(
433
  output_shape=stage_1_output_shape,
434
  conditionings=stage_1_conditionings,
435
+ audio_conditionings=audio_conditionings,
436
  noiser=noiser,
437
  sigmas=stage_1_sigmas,
438
  stepper=stepper,
 
456
  stage_2_output_shape = VideoPixelShape(
457
  batch=1, frames=num_frames, width=width, height=height, fps=frame_rate
458
  )
459
+ # Stage 2 uses guiding (not replacing) for first-frame images only
460
+ stage_2_images = []
461
+ for img in images:
462
+ if isinstance(img, ImageConditioningInput):
463
+ fi = img.frame_idx
464
+ else:
465
+ fi = img[1]
466
+ if fi == 0:
467
+ stage_2_images.append(
468
+ ImageConditioningInput(
469
+ path=img.path
470
+ if isinstance(img, ImageConditioningInput)
471
+ else img[0],
472
+ frame_idx=0,
473
+ strength=img.strength
474
+ if isinstance(img, ImageConditioningInput)
475
+ else img[2],
476
+ )
477
+ )
478
+ stage_2_conditionings = image_conditionings_by_adding_guiding_latent(
479
+ images=stage_2_images,
480
  height=stage_2_output_shape.height,
481
  width=stage_2_output_shape.width,
482
  video_encoder=video_encoder,
483
  dtype=dtype,
484
  device=self.device,
485
  )
486
+
487
  video_state, audio_state = denoise_audio_video(
488
  output_shape=stage_2_output_shape,
489
  conditionings=stage_2_conditionings,
490
+ audio_conditionings=audio_conditionings,
491
  noiser=noiser,
492
  sigmas=stage_2_sigmas,
493
  stepper=stepper,
packages/ltx-pipelines/src/ltx_pipelines/utils/helpers.py CHANGED
@@ -23,9 +23,18 @@ from ltx_core.model.video_vae import VideoEncoder
23
  from ltx_core.text_encoders.gemma import GemmaTextEncoder
24
  from ltx_core.text_encoders.gemma.embeddings_processor import EmbeddingsProcessorOutput
25
  from ltx_core.tools import AudioLatentTools, LatentTools, VideoLatentTools
26
- from ltx_core.types import AudioLatentShape, LatentState, VideoLatentShape, VideoPixelShape
 
 
 
 
 
27
  from ltx_pipelines.utils.args import ImageConditioningInput
28
- from ltx_pipelines.utils.media_io import decode_image, load_image_conditioning, resize_aspect_ratio_preserving
 
 
 
 
29
  from ltx_pipelines.utils.types import (
30
  DenoisingFunc,
31
  DenoisingLoopFunc,
@@ -71,7 +80,9 @@ def encode_prompts(
71
  text_encoder = model_ledger.text_encoder()
72
  if enhance_first_prompt:
73
  prompts = list(prompts)
74
- prompts[0] = generate_enhanced_prompt(text_encoder, prompts[0], enhance_prompt_image, seed=enhance_prompt_seed)
 
 
75
  raw_outputs = [text_encoder.encode(p) for p in prompts]
76
  torch.cuda.synchronize()
77
  del text_encoder
@@ -173,7 +184,9 @@ def image_conditionings_by_adding_guiding_latent(
173
  )
174
  encoded_image = video_encoder(image)
175
  conditionings.append(
176
- VideoConditionByKeyframeIndex(keyframes=encoded_image, frame_idx=img.frame_idx, strength=img.strength)
 
 
177
  )
178
  return conditionings
179
 
@@ -199,7 +212,9 @@ def noise_video_state(
199
  latent_channels=components.video_latent_channels,
200
  scale_factors=components.video_scale_factors,
201
  )
202
- video_tools = VideoLatentTools(components.video_patchifier, video_latent_shape, output_shape.fps)
 
 
203
  video_state = create_noised_state(
204
  tools=video_tools,
205
  conditionings=conditionings,
@@ -265,21 +280,29 @@ def create_noised_state(
265
 
266
 
267
  def state_with_conditionings(
268
- latent_state: LatentState, conditioning_items: list[ConditioningItem], latent_tools: LatentTools
 
 
269
  ) -> LatentState:
270
  """Apply a list of conditionings to a latent state.
271
  Iterates through the conditioning items and applies each one to the latent
272
  state in sequence. Returns the modified state with all conditionings applied.
273
  """
274
  for conditioning in conditioning_items:
275
- latent_state = conditioning.apply_to(latent_state=latent_state, latent_tools=latent_tools)
 
 
276
 
277
  return latent_state
278
 
279
 
280
- def post_process_latent(denoised: torch.Tensor, denoise_mask: torch.Tensor, clean: torch.Tensor) -> torch.Tensor:
 
 
281
  """Blend denoised output with clean state based on mask."""
282
- return (denoised * denoise_mask + clean.float() * (1 - denoise_mask)).to(denoised.dtype)
 
 
283
 
284
 
285
  def modality_from_latent_state(
@@ -304,7 +327,9 @@ def modality_from_latent_state(
304
  )
305
 
306
 
307
- def timesteps_from_mask(denoise_mask: torch.Tensor, sigma: float | torch.Tensor) -> torch.Tensor:
 
 
308
  """Compute timesteps from a denoise mask and sigma value.
309
  Multiplies the denoise mask by sigma to produce timesteps for each position
310
  in the latent state. Areas where the mask is 0 will have zero timesteps.
@@ -316,13 +341,18 @@ def simple_denoising_func(
316
  video_context: torch.Tensor, audio_context: torch.Tensor, transformer: X0Model
317
  ) -> DenoisingFunc:
318
  def simple_denoising_step(
319
- video_state: LatentState, audio_state: LatentState, sigmas: torch.Tensor, step_index: int
 
 
 
320
  ) -> tuple[torch.Tensor, torch.Tensor]:
321
  sigma = sigmas[step_index]
322
  pos_video = modality_from_latent_state(video_state, video_context, sigma)
323
  pos_audio = modality_from_latent_state(audio_state, audio_context, sigma)
324
 
325
- denoised_video, denoised_audio = transformer(video=pos_video, audio=pos_audio, perturbations=None)
 
 
326
  return denoised_video, denoised_audio
327
 
328
  return simple_denoising_step
@@ -337,21 +367,32 @@ def guider_denoising_func(
337
  transformer: X0Model,
338
  ) -> DenoisingFunc:
339
  def guider_denoising_step(
340
- video_state: LatentState, audio_state: LatentState, sigmas: torch.Tensor, step_index: int
 
 
 
341
  ) -> tuple[torch.Tensor, torch.Tensor]:
342
  sigma = sigmas[step_index]
343
  pos_video = modality_from_latent_state(video_state, v_context_p, sigma)
344
  pos_audio = modality_from_latent_state(audio_state, a_context_p, sigma)
345
 
346
- denoised_video, denoised_audio = transformer(video=pos_video, audio=pos_audio, perturbations=None)
 
 
347
  if guider.enabled():
348
  neg_video = modality_from_latent_state(video_state, v_context_n, sigma)
349
  neg_audio = modality_from_latent_state(audio_state, a_context_n, sigma)
350
 
351
- neg_denoised_video, neg_denoised_audio = transformer(video=neg_video, audio=neg_audio, perturbations=None)
 
 
352
 
353
- denoised_video = denoised_video + guider.delta(denoised_video, neg_denoised_video)
354
- denoised_audio = denoised_audio + guider.delta(denoised_audio, neg_denoised_audio)
 
 
 
 
355
 
356
  return denoised_video, denoised_audio
357
 
@@ -369,30 +410,54 @@ def multi_modal_guider_denoising_func(
369
  last_denoised_audio: torch.Tensor | None = None,
370
  ) -> DenoisingFunc:
371
  def guider_denoising_step(
372
- video_state: LatentState, audio_state: LatentState, sigmas: torch.Tensor, step_index: int
 
 
 
373
  ) -> tuple[torch.Tensor, torch.Tensor]:
374
  nonlocal last_denoised_video, last_denoised_audio
375
 
376
- if video_guider.should_skip_step(step_index) and audio_guider.should_skip_step(step_index):
 
 
377
  return last_denoised_video, last_denoised_audio
378
 
379
  sigma = sigmas[step_index]
380
  pos_video_modality = modality_from_latent_state(
381
- video_state, v_context, sigma, enabled=not video_guider.should_skip_step(step_index)
 
 
 
382
  )
383
  pos_audio_modality = modality_from_latent_state(
384
- audio_state, a_context, sigma, enabled=not audio_guider.should_skip_step(step_index)
 
 
 
385
  )
386
 
387
  denoised_video, denoised_audio = transformer(
388
  video=pos_video_modality, audio=pos_audio_modality, perturbations=None
389
  )
390
  neg_denoised_video, neg_denoised_audio = 0.0, 0.0
391
- if video_guider.do_unconditional_generation() or audio_guider.do_unconditional_generation():
392
- if video_guider.do_unconditional_generation() and video_guider.negative_context is None:
393
- raise ValueError("Negative context is required for unconditioned denoising")
394
- if audio_guider.do_unconditional_generation() and audio_guider.negative_context is None:
395
- raise ValueError("Negative context is required for unconditioned denoising")
 
 
 
 
 
 
 
 
 
 
 
 
 
396
  neg_video_modality = modality_from_latent_state(
397
  video_state,
398
  video_guider.negative_context
@@ -413,25 +478,39 @@ def multi_modal_guider_denoising_func(
413
  )
414
 
415
  ptb_denoised_video, ptb_denoised_audio = 0.0, 0.0
416
- if video_guider.do_perturbed_generation() or audio_guider.do_perturbed_generation():
 
 
 
417
  perturbations = []
418
  if video_guider.do_perturbed_generation():
419
  perturbations.append(
420
- Perturbation(type=PerturbationType.SKIP_VIDEO_SELF_ATTN, blocks=video_guider.params.stg_blocks)
 
 
 
421
  )
422
  if audio_guider.do_perturbed_generation():
423
  perturbations.append(
424
- Perturbation(type=PerturbationType.SKIP_AUDIO_SELF_ATTN, blocks=audio_guider.params.stg_blocks)
 
 
 
425
  )
426
  perturbation_config = PerturbationConfig(perturbations=perturbations)
427
  ptb_denoised_video, ptb_denoised_audio = transformer(
428
  video=pos_video_modality,
429
  audio=pos_audio_modality,
430
- perturbations=BatchedPerturbationConfig(perturbations=[perturbation_config]),
 
 
431
  )
432
 
433
  mod_denoised_video, mod_denoised_audio = 0.0, 0.0
434
- if video_guider.do_isolated_modality_generation() or audio_guider.do_isolated_modality_generation():
 
 
 
435
  perturbations = [
436
  Perturbation(type=PerturbationType.SKIP_A2V_CROSS_ATTN, blocks=None),
437
  Perturbation(type=PerturbationType.SKIP_V2A_CROSS_ATTN, blocks=None),
@@ -440,21 +519,29 @@ def multi_modal_guider_denoising_func(
440
  mod_denoised_video, mod_denoised_audio = transformer(
441
  video=pos_video_modality,
442
  audio=pos_audio_modality,
443
- perturbations=BatchedPerturbationConfig(perturbations=[perturbation_config]),
 
 
444
  )
445
 
446
  if video_guider.should_skip_step(step_index):
447
  denoised_video = last_denoised_video
448
  else:
449
  denoised_video = video_guider.calculate(
450
- denoised_video, neg_denoised_video, ptb_denoised_video, mod_denoised_video
 
 
 
451
  )
452
 
453
  if audio_guider.should_skip_step(step_index):
454
  denoised_audio = last_denoised_audio
455
  else:
456
  denoised_audio = audio_guider.calculate(
457
- denoised_audio, neg_denoised_audio, ptb_denoised_audio, mod_denoised_audio
 
 
 
458
  )
459
 
460
  last_denoised_video = denoised_video
@@ -478,14 +565,19 @@ def multi_modal_guider_factory_denoising_func(
478
  sigma_vals_cached: list[float] | None = None
479
 
480
  def guider_denoising_step(
481
- video_state: LatentState, audio_state: LatentState, sigmas: torch.Tensor, step_index: int
 
 
 
482
  ) -> tuple[torch.Tensor, torch.Tensor]:
483
  nonlocal last_denoised_video, last_denoised_audio, sigma_vals_cached
484
  if sigma_vals_cached is None:
485
  sigma_vals_cached = sigmas.detach().cpu().tolist()
486
  sigma_val = sigma_vals_cached[step_index]
487
  video_guider = video_guider_factory.build_from_sigma(sigma_val)
488
- audio_guider = (audio_guider_factory or video_guider_factory).build_from_sigma(sigma_val)
 
 
489
  denoise_fn = multi_modal_guider_denoising_func(
490
  video_guider,
491
  audio_guider,
@@ -495,7 +587,9 @@ def multi_modal_guider_factory_denoising_func(
495
  last_denoised_video=last_denoised_video,
496
  last_denoised_audio=last_denoised_audio,
497
  )
498
- denoised_video, denoised_audio = denoise_fn(video_state, audio_state, sigmas, step_index)
 
 
499
  last_denoised_video, last_denoised_audio = denoised_video, denoised_audio
500
  return denoised_video, denoised_audio
501
 
@@ -515,6 +609,7 @@ def denoise_audio_video( # noqa: PLR0913
515
  noise_scale: float = 1.0,
516
  initial_video_latent: torch.Tensor | None = None,
517
  initial_audio_latent: torch.Tensor | None = None,
 
518
  ) -> tuple[LatentState, LatentState]:
519
  video_state, video_tools = noise_video_state(
520
  output_shape=output_shape,
@@ -529,7 +624,7 @@ def denoise_audio_video( # noqa: PLR0913
529
  audio_state, audio_tools = noise_audio_state(
530
  output_shape=output_shape,
531
  noiser=noiser,
532
- conditionings=[],
533
  components=components,
534
  dtype=dtype,
535
  device=device,
@@ -588,7 +683,9 @@ def denoise_video_only( # noqa: PLR0913
588
  initial_latent=initial_audio_latent,
589
  )
590
 
591
- audio_state = replace(audio_state, denoise_mask=torch.zeros_like(audio_state.denoise_mask))
 
 
592
 
593
  video_state, audio_state = denoising_loop_fn(
594
  sigmas,
@@ -603,7 +700,9 @@ def denoise_video_only( # noqa: PLR0913
603
  return video_state
604
 
605
 
606
- _UNICODE_REPLACEMENTS = str.maketrans("\u2018\u2019\u201c\u201d\u2014\u2013\u00a0\u2032\u2212", "''\"\"-- '-")
 
 
607
 
608
 
609
  def clean_response(text: str) -> str:
 
23
  from ltx_core.text_encoders.gemma import GemmaTextEncoder
24
  from ltx_core.text_encoders.gemma.embeddings_processor import EmbeddingsProcessorOutput
25
  from ltx_core.tools import AudioLatentTools, LatentTools, VideoLatentTools
26
+ from ltx_core.types import (
27
+ AudioLatentShape,
28
+ LatentState,
29
+ VideoLatentShape,
30
+ VideoPixelShape,
31
+ )
32
  from ltx_pipelines.utils.args import ImageConditioningInput
33
+ from ltx_pipelines.utils.media_io import (
34
+ decode_image,
35
+ load_image_conditioning,
36
+ resize_aspect_ratio_preserving,
37
+ )
38
  from ltx_pipelines.utils.types import (
39
  DenoisingFunc,
40
  DenoisingLoopFunc,
 
80
  text_encoder = model_ledger.text_encoder()
81
  if enhance_first_prompt:
82
  prompts = list(prompts)
83
+ prompts[0] = generate_enhanced_prompt(
84
+ text_encoder, prompts[0], enhance_prompt_image, seed=enhance_prompt_seed
85
+ )
86
  raw_outputs = [text_encoder.encode(p) for p in prompts]
87
  torch.cuda.synchronize()
88
  del text_encoder
 
184
  )
185
  encoded_image = video_encoder(image)
186
  conditionings.append(
187
+ VideoConditionByKeyframeIndex(
188
+ keyframes=encoded_image, frame_idx=img.frame_idx, strength=img.strength
189
+ )
190
  )
191
  return conditionings
192
 
 
212
  latent_channels=components.video_latent_channels,
213
  scale_factors=components.video_scale_factors,
214
  )
215
+ video_tools = VideoLatentTools(
216
+ components.video_patchifier, video_latent_shape, output_shape.fps
217
+ )
218
  video_state = create_noised_state(
219
  tools=video_tools,
220
  conditionings=conditionings,
 
280
 
281
 
282
  def state_with_conditionings(
283
+ latent_state: LatentState,
284
+ conditioning_items: list[ConditioningItem],
285
+ latent_tools: LatentTools,
286
  ) -> LatentState:
287
  """Apply a list of conditionings to a latent state.
288
  Iterates through the conditioning items and applies each one to the latent
289
  state in sequence. Returns the modified state with all conditionings applied.
290
  """
291
  for conditioning in conditioning_items:
292
+ latent_state = conditioning.apply_to(
293
+ latent_state=latent_state, latent_tools=latent_tools
294
+ )
295
 
296
  return latent_state
297
 
298
 
299
+ def post_process_latent(
300
+ denoised: torch.Tensor, denoise_mask: torch.Tensor, clean: torch.Tensor
301
+ ) -> torch.Tensor:
302
  """Blend denoised output with clean state based on mask."""
303
+ return (denoised * denoise_mask + clean.float() * (1 - denoise_mask)).to(
304
+ denoised.dtype
305
+ )
306
 
307
 
308
  def modality_from_latent_state(
 
327
  )
328
 
329
 
330
+ def timesteps_from_mask(
331
+ denoise_mask: torch.Tensor, sigma: float | torch.Tensor
332
+ ) -> torch.Tensor:
333
  """Compute timesteps from a denoise mask and sigma value.
334
  Multiplies the denoise mask by sigma to produce timesteps for each position
335
  in the latent state. Areas where the mask is 0 will have zero timesteps.
 
341
  video_context: torch.Tensor, audio_context: torch.Tensor, transformer: X0Model
342
  ) -> DenoisingFunc:
343
  def simple_denoising_step(
344
+ video_state: LatentState,
345
+ audio_state: LatentState,
346
+ sigmas: torch.Tensor,
347
+ step_index: int,
348
  ) -> tuple[torch.Tensor, torch.Tensor]:
349
  sigma = sigmas[step_index]
350
  pos_video = modality_from_latent_state(video_state, video_context, sigma)
351
  pos_audio = modality_from_latent_state(audio_state, audio_context, sigma)
352
 
353
+ denoised_video, denoised_audio = transformer(
354
+ video=pos_video, audio=pos_audio, perturbations=None
355
+ )
356
  return denoised_video, denoised_audio
357
 
358
  return simple_denoising_step
 
367
  transformer: X0Model,
368
  ) -> DenoisingFunc:
369
  def guider_denoising_step(
370
+ video_state: LatentState,
371
+ audio_state: LatentState,
372
+ sigmas: torch.Tensor,
373
+ step_index: int,
374
  ) -> tuple[torch.Tensor, torch.Tensor]:
375
  sigma = sigmas[step_index]
376
  pos_video = modality_from_latent_state(video_state, v_context_p, sigma)
377
  pos_audio = modality_from_latent_state(audio_state, a_context_p, sigma)
378
 
379
+ denoised_video, denoised_audio = transformer(
380
+ video=pos_video, audio=pos_audio, perturbations=None
381
+ )
382
  if guider.enabled():
383
  neg_video = modality_from_latent_state(video_state, v_context_n, sigma)
384
  neg_audio = modality_from_latent_state(audio_state, a_context_n, sigma)
385
 
386
+ neg_denoised_video, neg_denoised_audio = transformer(
387
+ video=neg_video, audio=neg_audio, perturbations=None
388
+ )
389
 
390
+ denoised_video = denoised_video + guider.delta(
391
+ denoised_video, neg_denoised_video
392
+ )
393
+ denoised_audio = denoised_audio + guider.delta(
394
+ denoised_audio, neg_denoised_audio
395
+ )
396
 
397
  return denoised_video, denoised_audio
398
 
 
410
  last_denoised_audio: torch.Tensor | None = None,
411
  ) -> DenoisingFunc:
412
  def guider_denoising_step(
413
+ video_state: LatentState,
414
+ audio_state: LatentState,
415
+ sigmas: torch.Tensor,
416
+ step_index: int,
417
  ) -> tuple[torch.Tensor, torch.Tensor]:
418
  nonlocal last_denoised_video, last_denoised_audio
419
 
420
+ if video_guider.should_skip_step(step_index) and audio_guider.should_skip_step(
421
+ step_index
422
+ ):
423
  return last_denoised_video, last_denoised_audio
424
 
425
  sigma = sigmas[step_index]
426
  pos_video_modality = modality_from_latent_state(
427
+ video_state,
428
+ v_context,
429
+ sigma,
430
+ enabled=not video_guider.should_skip_step(step_index),
431
  )
432
  pos_audio_modality = modality_from_latent_state(
433
+ audio_state,
434
+ a_context,
435
+ sigma,
436
+ enabled=not audio_guider.should_skip_step(step_index),
437
  )
438
 
439
  denoised_video, denoised_audio = transformer(
440
  video=pos_video_modality, audio=pos_audio_modality, perturbations=None
441
  )
442
  neg_denoised_video, neg_denoised_audio = 0.0, 0.0
443
+ if (
444
+ video_guider.do_unconditional_generation()
445
+ or audio_guider.do_unconditional_generation()
446
+ ):
447
+ if (
448
+ video_guider.do_unconditional_generation()
449
+ and video_guider.negative_context is None
450
+ ):
451
+ raise ValueError(
452
+ "Negative context is required for unconditioned denoising"
453
+ )
454
+ if (
455
+ audio_guider.do_unconditional_generation()
456
+ and audio_guider.negative_context is None
457
+ ):
458
+ raise ValueError(
459
+ "Negative context is required for unconditioned denoising"
460
+ )
461
  neg_video_modality = modality_from_latent_state(
462
  video_state,
463
  video_guider.negative_context
 
478
  )
479
 
480
  ptb_denoised_video, ptb_denoised_audio = 0.0, 0.0
481
+ if (
482
+ video_guider.do_perturbed_generation()
483
+ or audio_guider.do_perturbed_generation()
484
+ ):
485
  perturbations = []
486
  if video_guider.do_perturbed_generation():
487
  perturbations.append(
488
+ Perturbation(
489
+ type=PerturbationType.SKIP_VIDEO_SELF_ATTN,
490
+ blocks=video_guider.params.stg_blocks,
491
+ )
492
  )
493
  if audio_guider.do_perturbed_generation():
494
  perturbations.append(
495
+ Perturbation(
496
+ type=PerturbationType.SKIP_AUDIO_SELF_ATTN,
497
+ blocks=audio_guider.params.stg_blocks,
498
+ )
499
  )
500
  perturbation_config = PerturbationConfig(perturbations=perturbations)
501
  ptb_denoised_video, ptb_denoised_audio = transformer(
502
  video=pos_video_modality,
503
  audio=pos_audio_modality,
504
+ perturbations=BatchedPerturbationConfig(
505
+ perturbations=[perturbation_config]
506
+ ),
507
  )
508
 
509
  mod_denoised_video, mod_denoised_audio = 0.0, 0.0
510
+ if (
511
+ video_guider.do_isolated_modality_generation()
512
+ or audio_guider.do_isolated_modality_generation()
513
+ ):
514
  perturbations = [
515
  Perturbation(type=PerturbationType.SKIP_A2V_CROSS_ATTN, blocks=None),
516
  Perturbation(type=PerturbationType.SKIP_V2A_CROSS_ATTN, blocks=None),
 
519
  mod_denoised_video, mod_denoised_audio = transformer(
520
  video=pos_video_modality,
521
  audio=pos_audio_modality,
522
+ perturbations=BatchedPerturbationConfig(
523
+ perturbations=[perturbation_config]
524
+ ),
525
  )
526
 
527
  if video_guider.should_skip_step(step_index):
528
  denoised_video = last_denoised_video
529
  else:
530
  denoised_video = video_guider.calculate(
531
+ denoised_video,
532
+ neg_denoised_video,
533
+ ptb_denoised_video,
534
+ mod_denoised_video,
535
  )
536
 
537
  if audio_guider.should_skip_step(step_index):
538
  denoised_audio = last_denoised_audio
539
  else:
540
  denoised_audio = audio_guider.calculate(
541
+ denoised_audio,
542
+ neg_denoised_audio,
543
+ ptb_denoised_audio,
544
+ mod_denoised_audio,
545
  )
546
 
547
  last_denoised_video = denoised_video
 
565
  sigma_vals_cached: list[float] | None = None
566
 
567
  def guider_denoising_step(
568
+ video_state: LatentState,
569
+ audio_state: LatentState,
570
+ sigmas: torch.Tensor,
571
+ step_index: int,
572
  ) -> tuple[torch.Tensor, torch.Tensor]:
573
  nonlocal last_denoised_video, last_denoised_audio, sigma_vals_cached
574
  if sigma_vals_cached is None:
575
  sigma_vals_cached = sigmas.detach().cpu().tolist()
576
  sigma_val = sigma_vals_cached[step_index]
577
  video_guider = video_guider_factory.build_from_sigma(sigma_val)
578
+ audio_guider = (audio_guider_factory or video_guider_factory).build_from_sigma(
579
+ sigma_val
580
+ )
581
  denoise_fn = multi_modal_guider_denoising_func(
582
  video_guider,
583
  audio_guider,
 
587
  last_denoised_video=last_denoised_video,
588
  last_denoised_audio=last_denoised_audio,
589
  )
590
+ denoised_video, denoised_audio = denoise_fn(
591
+ video_state, audio_state, sigmas, step_index
592
+ )
593
  last_denoised_video, last_denoised_audio = denoised_video, denoised_audio
594
  return denoised_video, denoised_audio
595
 
 
609
  noise_scale: float = 1.0,
610
  initial_video_latent: torch.Tensor | None = None,
611
  initial_audio_latent: torch.Tensor | None = None,
612
+ audio_conditionings: list[ConditioningItem] | None = None,
613
  ) -> tuple[LatentState, LatentState]:
614
  video_state, video_tools = noise_video_state(
615
  output_shape=output_shape,
 
624
  audio_state, audio_tools = noise_audio_state(
625
  output_shape=output_shape,
626
  noiser=noiser,
627
+ conditionings=audio_conditionings if audio_conditionings is not None else [],
628
  components=components,
629
  dtype=dtype,
630
  device=device,
 
683
  initial_latent=initial_audio_latent,
684
  )
685
 
686
+ audio_state = replace(
687
+ audio_state, denoise_mask=torch.zeros_like(audio_state.denoise_mask)
688
+ )
689
 
690
  video_state, audio_state = denoising_loop_fn(
691
  sigmas,
 
700
  return video_state
701
 
702
 
703
+ _UNICODE_REPLACEMENTS = str.maketrans(
704
+ "\u2018\u2019\u201c\u201d\u2014\u2013\u00a0\u2032\u2212", "''\"\"-- '-"
705
+ )
706
 
707
 
708
  def clean_response(text: str) -> str: