smartdigitalnetworks commited on
Commit
ab41ff5
·
verified ·
1 Parent(s): 290a873

Upload 5 files

Browse files
Files changed (5) hide show
  1. appdistilled.py +318 -0
  2. appfirstlastframe.py +542 -0
  3. appoutpaint.py +1246 -0
  4. appsync.py +1317 -0
  5. ltx2_two_stage.py +1 -1
appdistilled.py ADDED
@@ -0,0 +1,318 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import subprocess
3
+ import sys
4
+
5
+ # Disable torch.compile / dynamo before any torch import
6
+ os.environ["TORCH_COMPILE_DISABLE"] = "1"
7
+ os.environ["TORCHDYNAMO_DISABLE"] = "1"
8
+
9
+ # Install xformers for memory-efficient attention
10
+ subprocess.run([sys.executable, "-m", "pip", "install", "xformers==0.0.32.post2", "--no-build-isolation"], check=False)
11
+
12
+ # Clone LTX-2 repo and install packages
13
+ LTX_REPO_URL = "https://github.com/Lightricks/LTX-2.git"
14
+ LTX_REPO_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "LTX-2")
15
+ LTX_COMMIT_SHA = "ae855f8538843825f9015a419cf4ba5edaf5eec2"
16
+
17
+ if not os.path.exists(LTX_REPO_DIR):
18
+ print(f"Cloning {LTX_REPO_URL}...")
19
+ os.makedirs(LTX_REPO_DIR)
20
+ subprocess.run(["git", "init", LTX_REPO_DIR], check=True)
21
+ subprocess.run(["git", "remote", "add", "origin", LTX_REPO_URL], cwd=LTX_REPO_DIR, check=True)
22
+ subprocess.run(["git", "fetch", "--depth", "1", "origin", LTX_COMMIT_SHA], cwd=LTX_REPO_DIR, check=True)
23
+ subprocess.run(["git", "checkout", LTX_COMMIT_SHA], cwd=LTX_REPO_DIR, check=True)
24
+
25
+
26
+ print("Installing ltx-core and ltx-pipelines from cloned repo...")
27
+ subprocess.run(
28
+ [sys.executable, "-m", "pip", "install", "--force-reinstall", "--no-deps", "-e",
29
+ os.path.join(LTX_REPO_DIR, "packages", "ltx-core"),
30
+ "-e", os.path.join(LTX_REPO_DIR, "packages", "ltx-pipelines")],
31
+ check=True,
32
+ )
33
+
34
+ sys.path.insert(0, os.path.join(LTX_REPO_DIR, "packages", "ltx-pipelines", "src"))
35
+ sys.path.insert(0, os.path.join(LTX_REPO_DIR, "packages", "ltx-core", "src"))
36
+
37
+ import logging
38
+ import random
39
+ import tempfile
40
+ from pathlib import Path
41
+
42
+ import torch
43
+ torch._dynamo.config.suppress_errors = True
44
+ torch._dynamo.config.disable = True
45
+
46
+ import spaces
47
+ import gradio as gr
48
+ import numpy as np
49
+ from huggingface_hub import hf_hub_download, snapshot_download
50
+
51
+ from ltx_core.model.video_vae import TilingConfig, get_video_chunks_number
52
+ from ltx_core.quantization import QuantizationPolicy
53
+ from ltx_pipelines.distilled import DistilledPipeline
54
+ from ltx_pipelines.utils.args import ImageConditioningInput
55
+ from ltx_pipelines.utils.media_io import encode_video
56
+
57
+ # Force-patch xformers attention into the LTX attention module.
58
+ from ltx_core.model.transformer import attention as _attn_mod
59
+ print(f"[ATTN] Before patch: memory_efficient_attention={_attn_mod.memory_efficient_attention}")
60
+ try:
61
+ from xformers.ops import memory_efficient_attention as _mea
62
+ _attn_mod.memory_efficient_attention = _mea
63
+ print(f"[ATTN] After patch: memory_efficient_attention={_attn_mod.memory_efficient_attention}")
64
+ except Exception as e:
65
+ print(f"[ATTN] xformers patch FAILED: {type(e).__name__}: {e}")
66
+
67
+ logging.getLogger().setLevel(logging.INFO)
68
+
69
+ MAX_SEED = np.iinfo(np.int32).max
70
+ DEFAULT_PROMPT = (
71
+ "An astronaut hatches from a fragile egg on the surface of the Moon, "
72
+ "the shell cracking and peeling apart in gentle low-gravity motion. "
73
+ "Fine lunar dust lifts and drifts outward with each movement, floating "
74
+ "in slow arcs before settling back onto the ground."
75
+ )
76
+ DEFAULT_FRAME_RATE = 24.0
77
+
78
+ # Resolution presets: (width, height)
79
+ RESOLUTIONS = {
80
+ "high": {"16:9": (1536, 1024), "9:16": (1024, 1536), "1:1": (1024, 1024)},
81
+ "low": {"16:9": (768, 512), "9:16": (512, 768), "1:1": (768, 768)},
82
+ }
83
+
84
+ # Model repos
85
+ LTX_MODEL_REPO = "Lightricks/LTX-2.3"
86
+ GEMMA_REPO = "unsloth/gemma-3-12b-it-qat-bnb-4bit"
87
+
88
+ # Download model checkpoints
89
+ print("=" * 80)
90
+ print("Downloading LTX-2.3 distilled model + Gemma...")
91
+ print("=" * 80)
92
+
93
+ checkpoint_path = hf_hub_download(repo_id=LTX_MODEL_REPO, filename="ltx-2.3-22b-distilled-1.1.safetensors")
94
+ spatial_upsampler_path = hf_hub_download(repo_id=LTX_MODEL_REPO, filename="ltx-2.3-spatial-upscaler-x2-1.1.safetensors")
95
+ gemma_root = snapshot_download(repo_id=GEMMA_REPO)
96
+
97
+ print(f"Checkpoint: {checkpoint_path}")
98
+ print(f"Spatial upsampler: {spatial_upsampler_path}")
99
+ print(f"Gemma root: {gemma_root}")
100
+
101
+ # Initialize pipeline WITH text encoder
102
+ pipeline = DistilledPipeline(
103
+ distilled_checkpoint_path=checkpoint_path,
104
+ spatial_upsampler_path=spatial_upsampler_path,
105
+ gemma_root=gemma_root,
106
+ loras=[],
107
+ quantization=QuantizationPolicy.fp8_cast(),
108
+ )
109
+
110
+ # Preload all models for ZeroGPU tensor packing.
111
+ print("Preloading all models (including Gemma)...")
112
+ ledger = pipeline.model_ledger
113
+ _transformer = ledger.transformer()
114
+ _video_encoder = ledger.video_encoder()
115
+ _video_decoder = ledger.video_decoder()
116
+ _audio_decoder = ledger.audio_decoder()
117
+ _vocoder = ledger.vocoder()
118
+ _spatial_upsampler = ledger.spatial_upsampler()
119
+ _text_encoder = ledger.text_encoder()
120
+ _embeddings_processor = ledger.gemma_embeddings_processor()
121
+
122
+ ledger.transformer = lambda: _transformer
123
+ ledger.video_encoder = lambda: _video_encoder
124
+ ledger.video_decoder = lambda: _video_decoder
125
+ ledger.audio_decoder = lambda: _audio_decoder
126
+ ledger.vocoder = lambda: _vocoder
127
+ ledger.spatial_upsampler = lambda: _spatial_upsampler
128
+ ledger.text_encoder = lambda: _text_encoder
129
+ ledger.gemma_embeddings_processor = lambda: _embeddings_processor
130
+ print("All models preloaded (including Gemma text encoder)!")
131
+
132
+ print("=" * 80)
133
+ print("Pipeline ready!")
134
+ print("=" * 80)
135
+
136
+
137
+ def log_memory(tag: str):
138
+ if torch.cuda.is_available():
139
+ allocated = torch.cuda.memory_allocated() / 1024**3
140
+ peak = torch.cuda.max_memory_allocated() / 1024**3
141
+ free, total = torch.cuda.mem_get_info()
142
+ print(f"[VRAM {tag}] allocated={allocated:.2f}GB peak={peak:.2f}GB free={free / 1024**3:.2f}GB total={total / 1024**3:.2f}GB")
143
+
144
+
145
+ def detect_aspect_ratio(image) -> str:
146
+ """Detect the closest aspect ratio (16:9, 9:16, or 1:1) from an image."""
147
+ if image is None:
148
+ return "16:9"
149
+ if hasattr(image, "size"):
150
+ w, h = image.size
151
+ elif hasattr(image, "shape"):
152
+ h, w = image.shape[:2]
153
+ else:
154
+ return "16:9"
155
+ ratio = w / h
156
+ candidates = {"16:9": 16 / 9, "9:16": 9 / 16, "1:1": 1.0}
157
+ return min(candidates, key=lambda k: abs(ratio - candidates[k]))
158
+
159
+
160
+ def on_image_upload(image, high_res):
161
+ """Auto-set resolution when image is uploaded."""
162
+ aspect = detect_aspect_ratio(image)
163
+ tier = "high" if high_res else "low"
164
+ w, h = RESOLUTIONS[tier][aspect]
165
+ return gr.update(value=w), gr.update(value=h)
166
+
167
+
168
+ def on_highres_toggle(image, high_res):
169
+ """Update resolution when high-res toggle changes."""
170
+ aspect = detect_aspect_ratio(image)
171
+ tier = "high" if high_res else "low"
172
+ w, h = RESOLUTIONS[tier][aspect]
173
+ return gr.update(value=w), gr.update(value=h)
174
+
175
+
176
+ @spaces.GPU(duration=75)
177
+ @torch.inference_mode()
178
+ def generate_video(
179
+ input_image,
180
+ prompt: str,
181
+ duration: float,
182
+ enhance_prompt: bool = True,
183
+ seed: int = 42,
184
+ randomize_seed: bool = True,
185
+ height: int = 1024,
186
+ width: int = 1536,
187
+ progress=gr.Progress(track_tqdm=True),
188
+ ):
189
+ try:
190
+ torch.cuda.reset_peak_memory_stats()
191
+ log_memory("start")
192
+
193
+ current_seed = random.randint(0, MAX_SEED) if randomize_seed else int(seed)
194
+
195
+ frame_rate = DEFAULT_FRAME_RATE
196
+ num_frames = int(duration * frame_rate) + 1
197
+ num_frames = ((num_frames - 1 + 7) // 8) * 8 + 1
198
+
199
+ print(f"Generating: {height}x{width}, {num_frames} frames ({duration}s), seed={current_seed}")
200
+
201
+ images = []
202
+ if input_image is not None:
203
+ output_dir = Path("outputs")
204
+ output_dir.mkdir(exist_ok=True)
205
+ temp_image_path = output_dir / f"temp_input_{current_seed}.jpg"
206
+ if hasattr(input_image, "save"):
207
+ input_image.save(temp_image_path)
208
+ else:
209
+ temp_image_path = Path(input_image)
210
+ images = [ImageConditioningInput(path=str(temp_image_path), frame_idx=0, strength=1.0)]
211
+
212
+ tiling_config = TilingConfig.default()
213
+ video_chunks_number = get_video_chunks_number(num_frames, tiling_config)
214
+
215
+ log_memory("before pipeline call")
216
+
217
+ video, audio = pipeline(
218
+ prompt=prompt,
219
+ seed=current_seed,
220
+ height=int(height),
221
+ width=int(width),
222
+ num_frames=num_frames,
223
+ frame_rate=frame_rate,
224
+ images=images,
225
+ tiling_config=tiling_config,
226
+ enhance_prompt=enhance_prompt,
227
+ )
228
+
229
+ log_memory("after pipeline call")
230
+
231
+ output_path = tempfile.mktemp(suffix=".mp4")
232
+ encode_video(
233
+ video=video,
234
+ fps=frame_rate,
235
+ audio=audio,
236
+ output_path=output_path,
237
+ video_chunks_number=video_chunks_number,
238
+ )
239
+
240
+ log_memory("after encode_video")
241
+ return str(output_path), current_seed
242
+
243
+ except Exception as e:
244
+ import traceback
245
+ log_memory("on error")
246
+ print(f"Error: {str(e)}\n{traceback.format_exc()}")
247
+ return None, current_seed
248
+
249
+
250
+ with gr.Blocks(title="LTX-2.3 Distilled") as demo:
251
+ gr.Markdown("# LTX-2.3 Distilled (22B): Fast Audio-Video Generation")
252
+ gr.Markdown(
253
+ "Fast and high quality video + audio generation "
254
+ "[[model]](https://huggingface.co/Lightricks/LTX-2.3) "
255
+ "[[code]](https://github.com/Lightricks/LTX-2)"
256
+ )
257
+
258
+ with gr.Row():
259
+ with gr.Column():
260
+ input_image = gr.Image(label="Input Image (Optional)", type="pil")
261
+ prompt = gr.Textbox(
262
+ label="Prompt",
263
+ info="for best results - make it as elaborate as possible",
264
+ value="Make this image come alive with cinematic motion, smooth animation",
265
+ lines=3,
266
+ placeholder="Describe the motion and animation you want...",
267
+ )
268
+
269
+ with gr.Row():
270
+ duration = gr.Slider(label="Duration (seconds)", minimum=1.0, maximum=10.0, value=3.0, step=0.1)
271
+ with gr.Column():
272
+ enhance_prompt = gr.Checkbox(label="Enhance Prompt", value=False)
273
+ high_res = gr.Checkbox(label="High Resolution", value=True)
274
+
275
+ generate_btn = gr.Button("Generate Video", variant="primary", size="lg")
276
+
277
+ with gr.Accordion("Advanced Settings", open=False):
278
+ seed = gr.Slider(label="Seed", minimum=0, maximum=MAX_SEED, value=10, step=1)
279
+ randomize_seed = gr.Checkbox(label="Randomize Seed", value=True)
280
+ with gr.Row():
281
+ width = gr.Number(label="Width", value=1536, precision=0)
282
+ height = gr.Number(label="Height", value=1024, precision=0)
283
+
284
+ with gr.Column():
285
+ output_video = gr.Video(label="Generated Video", autoplay=True)
286
+
287
+ # Auto-detect aspect ratio from uploaded image and set resolution
288
+ input_image.change(
289
+ fn=on_image_upload,
290
+ inputs=[input_image, high_res],
291
+ outputs=[width, height],
292
+ )
293
+
294
+ # Update resolution when high-res toggle changes
295
+ high_res.change(
296
+ fn=on_highres_toggle,
297
+ inputs=[input_image, high_res],
298
+ outputs=[width, height],
299
+ )
300
+
301
+ generate_btn.click(
302
+ fn=generate_video,
303
+ inputs=[
304
+ input_image, prompt, duration, enhance_prompt,
305
+ seed, randomize_seed, height, width,
306
+ ],
307
+ outputs=[output_video, seed],
308
+ )
309
+
310
+
311
+ css = """
312
+ .fillable{max-width: 1200px !important}
313
+ .progress-text {color: white}
314
+ """
315
+
316
+ if __name__ == "__main__":
317
+ demo.launch(theme=gr.themes.Citrus(), css=css)
318
+
appfirstlastframe.py ADDED
@@ -0,0 +1,542 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import subprocess
3
+ import sys
4
+
5
+ # Disable torch.compile / dynamo before any torch import
6
+ os.environ["TORCH_COMPILE_DISABLE"] = "1"
7
+ os.environ["TORCHDYNAMO_DISABLE"] = "1"
8
+
9
+ # Install xformers for memory-efficient attention
10
+ subprocess.run([sys.executable, "-m", "pip", "install", "xformers==0.0.32.post2", "--no-build-isolation"], check=False)
11
+
12
+ # Clone LTX-2 repo at a pinned compatible commit and install packages
13
+ LTX_REPO_URL = "https://github.com/Lightricks/LTX-2.git"
14
+ LTX_REPO_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "LTX-2")
15
+ LTX_COMMIT = "ae855f8538843825f9015a419cf4ba5edaf5eec2"
16
+
17
+ if os.path.exists(LTX_REPO_DIR):
18
+ print(f"Removing existing repo at {LTX_REPO_DIR}...")
19
+ subprocess.run(["rm", "-rf", LTX_REPO_DIR], check=True)
20
+
21
+ print(f"Cloning {LTX_REPO_URL}...")
22
+ subprocess.run(["git", "clone", LTX_REPO_URL, LTX_REPO_DIR], check=True)
23
+
24
+ print(f"Checking out commit {LTX_COMMIT}...")
25
+ subprocess.run(["git", "-C", LTX_REPO_DIR, "checkout", LTX_COMMIT], check=True)
26
+
27
+ print("Installing ltx-core and ltx-pipelines from pinned repo commit...")
28
+ subprocess.run(
29
+ [
30
+ sys.executable, "-m", "pip", "install",
31
+ "--force-reinstall", "--no-deps",
32
+ "-e", os.path.join(LTX_REPO_DIR, "packages", "ltx-core"),
33
+ "-e", os.path.join(LTX_REPO_DIR, "packages", "ltx-pipelines"),
34
+ ],
35
+ check=True,
36
+ )
37
+
38
+ sys.path.insert(0, os.path.join(LTX_REPO_DIR, "packages", "ltx-pipelines", "src"))
39
+ sys.path.insert(0, os.path.join(LTX_REPO_DIR, "packages", "ltx-core", "src"))
40
+
41
+ import logging
42
+ import random
43
+ import tempfile
44
+ from pathlib import Path
45
+
46
+ import torch
47
+ torch._dynamo.config.suppress_errors = True
48
+ torch._dynamo.config.disable = True
49
+
50
+ import spaces
51
+ import gradio as gr
52
+ import numpy as np
53
+ from huggingface_hub import hf_hub_download, snapshot_download
54
+
55
+ from ltx_core.components.diffusion_steps import EulerDiffusionStep
56
+ from ltx_core.components.noisers import GaussianNoiser
57
+ from ltx_core.model.audio_vae import encode_audio as vae_encode_audio
58
+ from ltx_core.model.upsampler import upsample_video
59
+ from ltx_core.model.video_vae import TilingConfig, get_video_chunks_number, decode_video as vae_decode_video
60
+ from ltx_core.quantization import QuantizationPolicy
61
+ from ltx_core.types import Audio, AudioLatentShape, VideoPixelShape
62
+ from ltx_pipelines.distilled import DistilledPipeline
63
+ from ltx_pipelines.utils import euler_denoising_loop
64
+ from ltx_pipelines.utils.args import ImageConditioningInput
65
+ from ltx_pipelines.utils.constants import DISTILLED_SIGMA_VALUES, STAGE_2_DISTILLED_SIGMA_VALUES
66
+ from ltx_pipelines.utils.helpers import (
67
+ cleanup_memory,
68
+ combined_image_conditionings,
69
+ denoise_video_only,
70
+ encode_prompts,
71
+ simple_denoising_func,
72
+ )
73
+ from ltx_pipelines.utils.media_io import decode_audio_from_file, encode_video
74
+
75
+ # Force-patch xformers attention into the LTX attention module.
76
+ from ltx_core.model.transformer import attention as _attn_mod
77
+ print(f"[ATTN] Before patch: memory_efficient_attention={_attn_mod.memory_efficient_attention}")
78
+ try:
79
+ from xformers.ops import memory_efficient_attention as _mea
80
+ _attn_mod.memory_efficient_attention = _mea
81
+ print(f"[ATTN] After patch: memory_efficient_attention={_attn_mod.memory_efficient_attention}")
82
+ except Exception as e:
83
+ print(f"[ATTN] xformers patch FAILED: {type(e).__name__}: {e}")
84
+
85
+ logging.getLogger().setLevel(logging.INFO)
86
+
87
+ MAX_SEED = np.iinfo(np.int32).max
88
+ DEFAULT_PROMPT = (
89
+ "An astronaut hatches from a fragile egg on the surface of the Moon, "
90
+ "the shell cracking and peeling apart in gentle low-gravity motion. "
91
+ "Fine lunar dust lifts and drifts outward with each movement, floating "
92
+ "in slow arcs before settling back onto the ground."
93
+ )
94
+ DEFAULT_FRAME_RATE = 24.0
95
+
96
+ # Resolution presets: (width, height)
97
+ RESOLUTIONS = {
98
+ "high": {"16:9": (1536, 1024), "9:16": (1024, 1536), "1:1": (1024, 1024)},
99
+ "low": {"16:9": (768, 512), "9:16": (512, 768), "1:1": (768, 768)},
100
+ }
101
+
102
+
103
+ class LTX23DistilledA2VPipeline(DistilledPipeline):
104
+ """DistilledPipeline with optional audio conditioning."""
105
+
106
+ def __call__(
107
+ self,
108
+ prompt: str,
109
+ seed: int,
110
+ height: int,
111
+ width: int,
112
+ num_frames: int,
113
+ frame_rate: float,
114
+ images: list[ImageConditioningInput],
115
+ audio_path: str | None = None,
116
+ tiling_config: TilingConfig | None = None,
117
+ enhance_prompt: bool = False,
118
+ ):
119
+ # Standard path when no audio input is provided.
120
+ if audio_path is None:
121
+ return super().__call__(
122
+ prompt=prompt,
123
+ seed=seed,
124
+ height=height,
125
+ width=width,
126
+ num_frames=num_frames,
127
+ frame_rate=frame_rate,
128
+ images=images,
129
+ tiling_config=tiling_config,
130
+ enhance_prompt=enhance_prompt,
131
+ )
132
+
133
+ generator = torch.Generator(device=self.device).manual_seed(seed)
134
+ noiser = GaussianNoiser(generator=generator)
135
+ stepper = EulerDiffusionStep()
136
+ dtype = torch.bfloat16
137
+
138
+ (ctx_p,) = encode_prompts(
139
+ [prompt],
140
+ self.model_ledger,
141
+ enhance_first_prompt=enhance_prompt,
142
+ enhance_prompt_image=images[0].path if len(images) > 0 else None,
143
+ )
144
+ video_context, audio_context = ctx_p.video_encoding, ctx_p.audio_encoding
145
+
146
+ video_duration = num_frames / frame_rate
147
+ decoded_audio = decode_audio_from_file(audio_path, self.device, 0.0, video_duration)
148
+ if decoded_audio is None:
149
+ raise ValueError(f"Could not extract audio stream from {audio_path}")
150
+
151
+ encoded_audio_latent = vae_encode_audio(decoded_audio, self.model_ledger.audio_encoder())
152
+ audio_shape = AudioLatentShape.from_duration(batch=1, duration=video_duration, channels=8, mel_bins=16)
153
+ expected_frames = audio_shape.frames
154
+ actual_frames = encoded_audio_latent.shape[2]
155
+
156
+ if actual_frames > expected_frames:
157
+ encoded_audio_latent = encoded_audio_latent[:, :, :expected_frames, :]
158
+ elif actual_frames < expected_frames:
159
+ pad = torch.zeros(
160
+ encoded_audio_latent.shape[0],
161
+ encoded_audio_latent.shape[1],
162
+ expected_frames - actual_frames,
163
+ encoded_audio_latent.shape[3],
164
+ device=encoded_audio_latent.device,
165
+ dtype=encoded_audio_latent.dtype,
166
+ )
167
+ encoded_audio_latent = torch.cat([encoded_audio_latent, pad], dim=2)
168
+
169
+ video_encoder = self.model_ledger.video_encoder()
170
+ transformer = self.model_ledger.transformer()
171
+ stage_1_sigmas = torch.tensor(DISTILLED_SIGMA_VALUES, device=self.device)
172
+
173
+ def denoising_loop(sigmas, video_state, audio_state, stepper):
174
+ return euler_denoising_loop(
175
+ sigmas=sigmas,
176
+ video_state=video_state,
177
+ audio_state=audio_state,
178
+ stepper=stepper,
179
+ denoise_fn=simple_denoising_func(
180
+ video_context=video_context,
181
+ audio_context=audio_context,
182
+ transformer=transformer,
183
+ ),
184
+ )
185
+
186
+ stage_1_output_shape = VideoPixelShape(
187
+ batch=1,
188
+ frames=num_frames,
189
+ width=width // 2,
190
+ height=height // 2,
191
+ fps=frame_rate,
192
+ )
193
+ stage_1_conditionings = combined_image_conditionings(
194
+ images=images,
195
+ height=stage_1_output_shape.height,
196
+ width=stage_1_output_shape.width,
197
+ video_encoder=video_encoder,
198
+ dtype=dtype,
199
+ device=self.device,
200
+ )
201
+ video_state = denoise_video_only(
202
+ output_shape=stage_1_output_shape,
203
+ conditionings=stage_1_conditionings,
204
+ noiser=noiser,
205
+ sigmas=stage_1_sigmas,
206
+ stepper=stepper,
207
+ denoising_loop_fn=denoising_loop,
208
+ components=self.pipeline_components,
209
+ dtype=dtype,
210
+ device=self.device,
211
+ initial_audio_latent=encoded_audio_latent,
212
+ )
213
+
214
+ torch.cuda.synchronize()
215
+ cleanup_memory()
216
+
217
+ upscaled_video_latent = upsample_video(
218
+ latent=video_state.latent[:1],
219
+ video_encoder=video_encoder,
220
+ upsampler=self.model_ledger.spatial_upsampler(),
221
+ )
222
+ stage_2_sigmas = torch.tensor(STAGE_2_DISTILLED_SIGMA_VALUES, device=self.device)
223
+ stage_2_output_shape = VideoPixelShape(batch=1, frames=num_frames, width=width, height=height, fps=frame_rate)
224
+ stage_2_conditionings = combined_image_conditionings(
225
+ images=images,
226
+ height=stage_2_output_shape.height,
227
+ width=stage_2_output_shape.width,
228
+ video_encoder=video_encoder,
229
+ dtype=dtype,
230
+ device=self.device,
231
+ )
232
+ video_state = denoise_video_only(
233
+ output_shape=stage_2_output_shape,
234
+ conditionings=stage_2_conditionings,
235
+ noiser=noiser,
236
+ sigmas=stage_2_sigmas,
237
+ stepper=stepper,
238
+ denoising_loop_fn=denoising_loop,
239
+ components=self.pipeline_components,
240
+ dtype=dtype,
241
+ device=self.device,
242
+ noise_scale=stage_2_sigmas[0],
243
+ initial_video_latent=upscaled_video_latent,
244
+ initial_audio_latent=encoded_audio_latent,
245
+ )
246
+
247
+ torch.cuda.synchronize()
248
+ del transformer
249
+ del video_encoder
250
+ cleanup_memory()
251
+
252
+ decoded_video = vae_decode_video(
253
+ video_state.latent,
254
+ self.model_ledger.video_decoder(),
255
+ tiling_config,
256
+ generator,
257
+ )
258
+ original_audio = Audio(
259
+ waveform=decoded_audio.waveform.squeeze(0),
260
+ sampling_rate=decoded_audio.sampling_rate,
261
+ )
262
+ return decoded_video, original_audio
263
+
264
+
265
+ # Model repos
266
+ LTX_MODEL_REPO = "Lightricks/LTX-2.3"
267
+ GEMMA_REPO = "google/gemma-3-12b-it-qat-q4_0-unquantized"
268
+
269
+ # Download model checkpoints
270
+ print("=" * 80)
271
+ print("Downloading LTX-2.3 distilled model + Gemma...")
272
+ print("=" * 80)
273
+
274
+ checkpoint_path = hf_hub_download(repo_id=LTX_MODEL_REPO, filename="ltx-2.3-22b-distilled-1.1.safetensors")
275
+ spatial_upsampler_path = hf_hub_download(repo_id=LTX_MODEL_REPO, filename="ltx-2.3-spatial-upscaler-x2-1.1.safetensors")
276
+ gemma_root = snapshot_download(repo_id=GEMMA_REPO)
277
+
278
+ print(f"Checkpoint: {checkpoint_path}")
279
+ print(f"Spatial upsampler: {spatial_upsampler_path}")
280
+ print(f"Gemma root: {gemma_root}")
281
+
282
+ # Initialize pipeline WITH text encoder and optional audio support
283
+ pipeline = LTX23DistilledA2VPipeline(
284
+ distilled_checkpoint_path=checkpoint_path,
285
+ spatial_upsampler_path=spatial_upsampler_path,
286
+ gemma_root=gemma_root,
287
+ loras=[],
288
+ quantization=QuantizationPolicy.fp8_cast(),
289
+ )
290
+
291
+ # Preload all models for ZeroGPU tensor packing.
292
+ print("Preloading all models (including Gemma and audio components)...")
293
+ ledger = pipeline.model_ledger
294
+ _transformer = ledger.transformer()
295
+ _video_encoder = ledger.video_encoder()
296
+ _video_decoder = ledger.video_decoder()
297
+ _audio_encoder = ledger.audio_encoder()
298
+ _audio_decoder = ledger.audio_decoder()
299
+ _vocoder = ledger.vocoder()
300
+ _spatial_upsampler = ledger.spatial_upsampler()
301
+ _text_encoder = ledger.text_encoder()
302
+ _embeddings_processor = ledger.gemma_embeddings_processor()
303
+
304
+ ledger.transformer = lambda: _transformer
305
+ ledger.video_encoder = lambda: _video_encoder
306
+ ledger.video_decoder = lambda: _video_decoder
307
+ ledger.audio_encoder = lambda: _audio_encoder
308
+ ledger.audio_decoder = lambda: _audio_decoder
309
+ ledger.vocoder = lambda: _vocoder
310
+ ledger.spatial_upsampler = lambda: _spatial_upsampler
311
+ ledger.text_encoder = lambda: _text_encoder
312
+ ledger.gemma_embeddings_processor = lambda: _embeddings_processor
313
+ print("All models preloaded (including Gemma text encoder and audio encoder)!")
314
+
315
+ print("=" * 80)
316
+ print("Pipeline ready!")
317
+ print("=" * 80)
318
+
319
+
320
+ def log_memory(tag: str):
321
+ if torch.cuda.is_available():
322
+ allocated = torch.cuda.memory_allocated() / 1024**3
323
+ peak = torch.cuda.max_memory_allocated() / 1024**3
324
+ free, total = torch.cuda.mem_get_info()
325
+ print(f"[VRAM {tag}] allocated={allocated:.2f}GB peak={peak:.2f}GB free={free / 1024**3:.2f}GB total={total / 1024**3:.2f}GB")
326
+
327
+
328
+ def detect_aspect_ratio(image) -> str:
329
+ if image is None:
330
+ return "16:9"
331
+ if hasattr(image, "size"):
332
+ w, h = image.size
333
+ elif hasattr(image, "shape"):
334
+ h, w = image.shape[:2]
335
+ else:
336
+ return "16:9"
337
+ ratio = w / h
338
+ candidates = {"16:9": 16 / 9, "9:16": 9 / 16, "1:1": 1.0}
339
+ return min(candidates, key=lambda k: abs(ratio - candidates[k]))
340
+
341
+
342
+ def on_image_upload(first_image, last_image, high_res):
343
+ ref_image = first_image if first_image is not None else last_image
344
+ aspect = detect_aspect_ratio(ref_image)
345
+ tier = "high" if high_res else "low"
346
+ w, h = RESOLUTIONS[tier][aspect]
347
+ return gr.update(value=w), gr.update(value=h)
348
+
349
+
350
+ def on_highres_toggle(first_image, last_image, high_res):
351
+ ref_image = first_image if first_image is not None else last_image
352
+ aspect = detect_aspect_ratio(ref_image)
353
+ tier = "high" if high_res else "low"
354
+ w, h = RESOLUTIONS[tier][aspect]
355
+ return gr.update(value=w), gr.update(value=h)
356
+
357
+
358
+ @spaces.GPU(duration=75)
359
+ @torch.inference_mode()
360
+ def generate_video(
361
+ first_image,
362
+ last_image,
363
+ input_audio,
364
+ prompt: str,
365
+ duration: float,
366
+ enhance_prompt: bool = True,
367
+ seed: int = 42,
368
+ randomize_seed: bool = True,
369
+ height: int = 1024,
370
+ width: int = 1536,
371
+ progress=gr.Progress(track_tqdm=True),
372
+ ):
373
+ try:
374
+ torch.cuda.reset_peak_memory_stats()
375
+ log_memory("start")
376
+
377
+ current_seed = random.randint(0, MAX_SEED) if randomize_seed else int(seed)
378
+
379
+ frame_rate = DEFAULT_FRAME_RATE
380
+ num_frames = int(duration * frame_rate) + 1
381
+ num_frames = ((num_frames - 1 + 7) // 8) * 8 + 1
382
+
383
+ print(f"Generating: {height}x{width}, {num_frames} frames ({duration}s), seed={current_seed}")
384
+
385
+ images = []
386
+ output_dir = Path("outputs")
387
+ output_dir.mkdir(exist_ok=True)
388
+
389
+ if first_image is not None:
390
+ temp_first_path = output_dir / f"temp_first_{current_seed}.jpg"
391
+ if hasattr(first_image, "save"):
392
+ first_image.save(temp_first_path)
393
+ else:
394
+ temp_first_path = Path(first_image)
395
+ images.append(ImageConditioningInput(path=str(temp_first_path), frame_idx=0, strength=1.0))
396
+
397
+ if last_image is not None:
398
+ temp_last_path = output_dir / f"temp_last_{current_seed}.jpg"
399
+ if hasattr(last_image, "save"):
400
+ last_image.save(temp_last_path)
401
+ else:
402
+ temp_last_path = Path(last_image)
403
+ images.append(ImageConditioningInput(path=str(temp_last_path), frame_idx=num_frames - 1, strength=1.0))
404
+
405
+ tiling_config = TilingConfig.default()
406
+ video_chunks_number = get_video_chunks_number(num_frames, tiling_config)
407
+
408
+ log_memory("before pipeline call")
409
+
410
+ video, audio = pipeline(
411
+ prompt=prompt,
412
+ seed=current_seed,
413
+ height=int(height),
414
+ width=int(width),
415
+ num_frames=num_frames,
416
+ frame_rate=frame_rate,
417
+ images=images,
418
+ audio_path=input_audio,
419
+ tiling_config=tiling_config,
420
+ enhance_prompt=enhance_prompt,
421
+ )
422
+
423
+ log_memory("after pipeline call")
424
+
425
+ output_path = tempfile.mktemp(suffix=".mp4")
426
+ encode_video(
427
+ video=video,
428
+ fps=frame_rate,
429
+ audio=audio,
430
+ output_path=output_path,
431
+ video_chunks_number=video_chunks_number,
432
+ )
433
+
434
+ log_memory("after encode_video")
435
+ return str(output_path), current_seed
436
+
437
+ except Exception as e:
438
+ import traceback
439
+ log_memory("on error")
440
+ print(f"Error: {str(e)}\n{traceback.format_exc()}")
441
+ return None, current_seed
442
+
443
+
444
+ with gr.Blocks(title="LTX-2.3 Distilled") as demo:
445
+ gr.Markdown("# LTX-2.3 F2LF: Fast Audio-Video Generation with Frame Conditioning")
446
+ gr.Markdown(
447
+ "Fast and high quality video + audio generation with first and last frame conditioning and optional audio input "
448
+ "[[model]](https://huggingface.co/Lightricks/LTX-2.3) "
449
+ "[[code]](https://github.com/Lightricks/LTX-2)"
450
+ )
451
+
452
+ with gr.Row():
453
+ with gr.Column():
454
+ with gr.Row():
455
+ first_image = gr.Image(label="First Frame (Optional)", type="pil")
456
+ last_image = gr.Image(label="Last Frame (Optional)", type="pil")
457
+ input_audio = gr.Audio(label="Audio Input (Optional)", type="filepath")
458
+ prompt = gr.Textbox(
459
+ label="Prompt",
460
+ info="for best results - make it as elaborate as possible",
461
+ value="Make this image come alive with cinematic motion, smooth animation",
462
+ lines=3,
463
+ placeholder="Describe the motion and animation you want...",
464
+ )
465
+ duration = gr.Slider(label="Duration (seconds)", minimum=1.0, maximum=10.0, value=3.0, step=0.1)
466
+
467
+
468
+ generate_btn = gr.Button("Generate Video", variant="primary", size="lg")
469
+
470
+ with gr.Accordion("Advanced Settings", open=False):
471
+ seed = gr.Slider(label="Seed", minimum=0, maximum=MAX_SEED, value=10, step=1)
472
+ randomize_seed = gr.Checkbox(label="Randomize Seed", value=True)
473
+ with gr.Row():
474
+ width = gr.Number(label="Width", value=1536, precision=0)
475
+ height = gr.Number(label="Height", value=1024, precision=0)
476
+ with gr.Row():
477
+ enhance_prompt = gr.Checkbox(label="Enhance Prompt", value=False)
478
+ high_res = gr.Checkbox(label="High Resolution", value=True)
479
+
480
+ with gr.Column():
481
+ output_video = gr.Video(label="Generated Video", autoplay=True)
482
+
483
+ gr.Examples(
484
+ examples=[
485
+ [
486
+ None,
487
+ "pinkknit.jpg",
488
+ None,
489
+ "The camera falls downward through darkness as if dropped into a tunnel. "
490
+ "As it slows, five friends wearing pink knitted hats and sunglasses lean "
491
+ "over and look down toward the camera with curious expressions. The lens "
492
+ "has a strong fisheye effect, creating a circular frame around them. They "
493
+ "crowd together closely, forming a symmetrical cluster while staring "
494
+ "directly into the lens.",
495
+ 3.0,
496
+ False,
497
+ 42,
498
+ True,
499
+ 1024,
500
+ 1024,
501
+ ],
502
+ ],
503
+ inputs=[
504
+ first_image, last_image, input_audio, prompt, duration,
505
+ enhance_prompt, seed, randomize_seed, height, width,
506
+ ],
507
+ )
508
+
509
+ first_image.change(
510
+ fn=on_image_upload,
511
+ inputs=[first_image, last_image, high_res],
512
+ outputs=[width, height],
513
+ )
514
+
515
+ last_image.change(
516
+ fn=on_image_upload,
517
+ inputs=[first_image, last_image, high_res],
518
+ outputs=[width, height],
519
+ )
520
+
521
+ high_res.change(
522
+ fn=on_highres_toggle,
523
+ inputs=[first_image, last_image, high_res],
524
+ outputs=[width, height],
525
+ )
526
+
527
+ generate_btn.click(
528
+ fn=generate_video,
529
+ inputs=[
530
+ first_image, last_image, input_audio, prompt, duration, enhance_prompt,
531
+ seed, randomize_seed, height, width,
532
+ ],
533
+ outputs=[output_video, seed],
534
+ )
535
+
536
+
537
+ css = """
538
+ .fillable{max-width: 1200px !important}
539
+ """
540
+
541
+ if __name__ == "__main__":
542
+ demo.launch(theme=gr.themes.Citrus(), css=css)
appoutpaint.py ADDED
@@ -0,0 +1,1246 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import subprocess
3
+ import sys
4
+
5
+ # Disable torch.compile / dynamo before any torch import
6
+ os.environ["TORCH_COMPILE_DISABLE"] = "1"
7
+ os.environ["TORCHDYNAMO_DISABLE"] = "1"
8
+
9
+ # Install xformers for memory-efficient attention
10
+ subprocess.run([sys.executable, "-m", "pip", "install", "xformers==0.0.32.post2", "--no-build-isolation"], check=False)
11
+
12
+ # Install video preprocessing dependencies
13
+ subprocess.run([sys.executable, "-m", "pip", "install",
14
+ "imageio[ffmpeg]", "scikit-image",
15
+ "opencv-python-headless", "decord", "num2words"], check=False)
16
+
17
+ # Ensure num2words is installed (required by SmolVLMProcessor)
18
+ subprocess.run([sys.executable, "-m", "pip", "install", "num2words"], check=True)
19
+
20
+ # Reinstall torchaudio to match the torch CUDA version on this space.
21
+ _tv = subprocess.run([sys.executable, "-c", "import torch; print(torch.__version__)"],
22
+ capture_output=True, text=True)
23
+ if _tv.returncode == 0:
24
+ _full_ver = _tv.stdout.strip()
25
+ _cuda_suffix = _full_ver.split("+")[-1] if "+" in _full_ver else "cu124"
26
+ _base_ver = _full_ver.split("+")[0]
27
+ print(f"Detected torch {_full_ver}, reinstalling matching torchaudio...")
28
+ subprocess.run([
29
+ sys.executable, "-m", "pip", "install", "--force-reinstall", "--no-deps",
30
+ f"torchaudio=={_base_ver}",
31
+ "--index-url", f"https://download.pytorch.org/whl/{_cuda_suffix}",
32
+ ], check=False)
33
+
34
+ # Clone LTX-2 repo at a pinned commit and install packages
35
+ LTX_REPO_URL = "https://github.com/Lightricks/LTX-2.git"
36
+ LTX_REPO_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "LTX-2")
37
+ LTX_COMMIT = "ae855f8538843825f9015a419cf4ba5edaf5eec2"
38
+
39
+ if os.path.exists(LTX_REPO_DIR):
40
+ print(f"Removing existing repo at {LTX_REPO_DIR}...")
41
+ subprocess.run(["rm", "-rf", LTX_REPO_DIR], check=True)
42
+
43
+ print(f"Cloning {LTX_REPO_URL}...")
44
+ subprocess.run(["git", "clone", LTX_REPO_URL, LTX_REPO_DIR], check=True)
45
+
46
+ print(f"Checking out commit {LTX_COMMIT}...")
47
+ subprocess.run(["git", "-C", LTX_REPO_DIR, "checkout", LTX_COMMIT], check=True)
48
+
49
+ print("Installing ltx-core and ltx-pipelines from pinned repo commit...")
50
+ subprocess.run(
51
+ [
52
+ sys.executable, "-m", "pip", "install",
53
+ "--force-reinstall", "--no-deps",
54
+ "-e", os.path.join(LTX_REPO_DIR, "packages", "ltx-core"),
55
+ "-e", os.path.join(LTX_REPO_DIR, "packages", "ltx-pipelines"),
56
+ ],
57
+ check=True,
58
+ )
59
+ sys.path.insert(0, os.path.join(LTX_REPO_DIR, "packages", "ltx-pipelines", "src"))
60
+ sys.path.insert(0, os.path.join(LTX_REPO_DIR, "packages", "ltx-core", "src"))
61
+
62
+ import logging
63
+ import random
64
+ import tempfile
65
+ from pathlib import Path
66
+
67
+ import torch
68
+ torch._dynamo.config.suppress_errors = True
69
+ torch._dynamo.config.disable = True
70
+
71
+ import spaces
72
+ import gradio as gr
73
+ import numpy as np
74
+ from huggingface_hub import hf_hub_download, snapshot_download
75
+ from safetensors import safe_open
76
+
77
+ from ltx_core.components.diffusion_steps import EulerDiffusionStep
78
+ from ltx_core.components.noisers import GaussianNoiser
79
+ from ltx_core.conditioning import (
80
+ ConditioningItem,
81
+ ConditioningItemAttentionStrengthWrapper,
82
+ VideoConditionByReferenceLatent,
83
+ )
84
+ from ltx_core.loader import LoraPathStrengthAndSDOps
85
+ from ltx_core.model.audio_vae import decode_audio as vae_decode_audio
86
+ from ltx_core.model.audio_vae import encode_audio as vae_encode_audio
87
+
88
+ from ltx_core.model.upsampler import upsample_video
89
+ from ltx_core.model.video_vae import TilingConfig, VideoEncoder, get_video_chunks_number
90
+ from ltx_core.model.video_vae import decode_video as vae_decode_video
91
+
92
+ from ltx_core.quantization import QuantizationPolicy
93
+ from ltx_core.types import Audio, AudioLatentShape, LatentState, VideoLatentShape, VideoPixelShape
94
+ from ltx_pipelines.utils import ModelLedger, euler_denoising_loop
95
+ from ltx_pipelines.utils.args import ImageConditioningInput
96
+ from ltx_pipelines.utils.constants import DISTILLED_SIGMA_VALUES, STAGE_2_DISTILLED_SIGMA_VALUES
97
+ from ltx_pipelines.utils.helpers import (
98
+ assert_resolution,
99
+ cleanup_memory,
100
+ combined_image_conditionings,
101
+ denoise_audio_video,
102
+ denoise_video_only,
103
+ encode_prompts,
104
+ get_device,
105
+ simple_denoising_func,
106
+ )
107
+ from ltx_pipelines.utils.media_io import (
108
+ decode_audio_from_file,
109
+ encode_video,
110
+ load_video_conditioning,
111
+ )
112
+ from ltx_pipelines.utils.types import PipelineComponents
113
+
114
+ # Force-patch xformers attention into the LTX attention module.
115
+ from ltx_core.model.transformer import attention as _attn_mod
116
+ print(f"[ATTN] Before patch: memory_efficient_attention={_attn_mod.memory_efficient_attention}")
117
+ try:
118
+ from xformers.ops import memory_efficient_attention as _mea
119
+ _attn_mod.memory_efficient_attention = _mea
120
+ print(f"[ATTN] After patch: memory_efficient_attention={_attn_mod.memory_efficient_attention}")
121
+ except Exception as e:
122
+ print(f"[ATTN] xformers patch FAILED: {type(e).__name__}: {e}")
123
+
124
+ logging.getLogger().setLevel(logging.INFO)
125
+
126
+
127
+ # ──────────────���──────────────────────────────────────────────────────────────
128
+ # Video Preprocessing: Letterboxing / Outpainting preparation
129
+ # ─────────────────────────────────────────────────────────────────────────────
130
+ import imageio
131
+ import cv2
132
+ from PIL import Image
133
+
134
+
135
+ def load_video_frames(video_path: str) -> list[np.ndarray]:
136
+ """Load video frames as list of HWC uint8 numpy arrays."""
137
+ frames = []
138
+ with imageio.get_reader(video_path) as reader:
139
+ for frame in reader:
140
+ frames.append(frame)
141
+ return frames
142
+
143
+
144
+ def write_video_mp4(frames: list[np.ndarray], fps: float, out_path: str) -> str:
145
+ """Write uint8 HWC frames to mp4."""
146
+ with imageio.get_writer(out_path, fps=fps, macro_block_size=1) as writer:
147
+ for fr in frames:
148
+ writer.append_data(fr)
149
+ return out_path
150
+
151
+
152
+ def get_video_fps(video_path: str) -> float:
153
+ """Get video FPS via ffprobe."""
154
+ try:
155
+ result = subprocess.run(
156
+ ["ffprobe", "-v", "error", "-select_streams", "v:0",
157
+ "-show_entries", "stream=r_frame_rate", "-of", "default=nw=1:nk=1",
158
+ str(video_path)],
159
+ capture_output=True, text=True,
160
+ )
161
+ num, den = result.stdout.strip().split("/")
162
+ return float(num) / float(den)
163
+ except Exception:
164
+ return 24.0
165
+
166
+
167
+ def get_video_dimensions(video_path: str) -> tuple[int, int]:
168
+ """Return (width, height) of video."""
169
+ try:
170
+ result = subprocess.run(
171
+ ["ffprobe", "-v", "error", "-select_streams", "v:0",
172
+ "-show_entries", "stream=width,height", "-of", "csv=s=x:p=0",
173
+ str(video_path)],
174
+ capture_output=True, text=True,
175
+ )
176
+ parts = result.stdout.strip().split("x")
177
+ return int(parts[0]), int(parts[1])
178
+ except Exception:
179
+ return 768, 512
180
+
181
+
182
+ def apply_gamma(frame: np.ndarray, gamma: float) -> np.ndarray:
183
+ """Apply gamma correction to a uint8 frame. Returns uint8."""
184
+ # Normalize to [0,1], apply gamma, back to uint8
185
+ f = frame.astype(np.float32) / 255.0
186
+ f = np.power(f, 1.0 / gamma) # gamma 2.0 => exponent 0.5 => brightens
187
+ return (np.clip(f, 0.0, 1.0) * 255).astype(np.uint8)
188
+
189
+
190
+ def apply_inverse_gamma(frame: np.ndarray, gamma: float) -> np.ndarray:
191
+ """Apply inverse gamma (darken back). gamma=2.0 forward => gamma=0.5 inverse => exponent 2.0"""
192
+ f = frame.astype(np.float32) / 255.0
193
+ f = np.power(f, gamma) # gamma 2.0 => exponent 2.0 => darkens
194
+ return (np.clip(f, 0.0, 1.0) * 255).astype(np.uint8)
195
+
196
+
197
+ def compute_letterbox_params(
198
+ src_w: int, src_h: int, target_w: int, target_h: int
199
+ ) -> tuple[int, int, int, int]:
200
+ """
201
+ Compute padding to place src in the center of target canvas.
202
+ Returns (pad_top, pad_bottom, pad_left, pad_right).
203
+ Source is scaled to fit inside target while maintaining aspect ratio,
204
+ then centered with black bars.
205
+ """
206
+ src_aspect = src_w / src_h
207
+ target_aspect = target_w / target_h
208
+
209
+ if src_aspect > target_aspect:
210
+ # Source is wider — fit to width, pad top/bottom
211
+ new_w = target_w
212
+ new_h = int(round(target_w / src_aspect))
213
+ else:
214
+ # Source is taller — fit to height, pad left/right
215
+ new_h = target_h
216
+ new_w = int(round(target_h * src_aspect))
217
+
218
+ pad_top = (target_h - new_h) // 2
219
+ pad_bottom = target_h - new_h - pad_top
220
+ pad_left = (target_w - new_w) // 2
221
+ pad_right = target_w - new_w - pad_left
222
+
223
+ return pad_top, pad_bottom, pad_left, pad_right, new_w, new_h
224
+
225
+
226
+ def letterbox_frame(frame: np.ndarray, target_w: int, target_h: int) -> np.ndarray:
227
+ """Resize frame to fit inside target dimensions, pad with black (0,0,0)."""
228
+ src_h, src_w = frame.shape[:2]
229
+ pad_top, pad_bottom, pad_left, pad_right, new_w, new_h = compute_letterbox_params(
230
+ src_w, src_h, target_w, target_h
231
+ )
232
+
233
+ # Resize source to fit
234
+ resized = cv2.resize(frame, (new_w, new_h), interpolation=cv2.INTER_AREA)
235
+
236
+ # Create black canvas and paste
237
+ canvas = np.zeros((target_h, target_w, 3), dtype=np.uint8)
238
+ canvas[pad_top:pad_top + new_h, pad_left:pad_left + new_w] = resized
239
+ return canvas
240
+
241
+
242
+ def letterbox_video(
243
+ video_path: str,
244
+ target_w: int,
245
+ target_h: int,
246
+ use_gamma: bool = False,
247
+ num_frames: int | None = None,
248
+ burnin_frames: int = 0,
249
+ ) -> tuple[str, str]:
250
+ """
251
+ Letterbox a video to target dimensions with black bars.
252
+ Optionally applies gamma 2.0 brightening for dark scenes.
253
+
254
+ burnin_frames: extra copies of the first frame prepended to give the
255
+ model time to fill the black regions before actual content starts.
256
+
257
+ Returns: (letterboxed_video_path, first_frame_preview_path)
258
+ """
259
+ frames = load_video_frames(video_path)
260
+ if not frames:
261
+ raise ValueError("No frames decoded from video")
262
+
263
+ fps = get_video_fps(video_path)
264
+
265
+ if num_frames is not None:
266
+ # Reserve space: we need num_frames of actual content + burn-in
267
+ frames = frames[:num_frames]
268
+
269
+ # Prepend burn-in copies of the first frame
270
+ if burnin_frames > 0:
271
+ frames = [frames[0]] * burnin_frames + frames
272
+
273
+ processed = []
274
+ for frame in frames:
275
+ lb = letterbox_frame(frame, target_w, target_h)
276
+ if use_gamma:
277
+ lb = apply_gamma(lb, gamma=2.0)
278
+ processed.append(lb)
279
+
280
+ # Save letterboxed video
281
+ out_path = tempfile.mktemp(suffix=".mp4")
282
+ write_video_mp4(processed, fps=fps, out_path=out_path)
283
+
284
+ # Preview is the first real content frame (after burn-in)
285
+ preview_path = tempfile.mktemp(suffix=".png")
286
+ Image.fromarray(processed[min(burnin_frames, len(processed) - 1)]).save(preview_path)
287
+
288
+ return out_path, preview_path
289
+
290
+
291
+ def apply_inverse_gamma_to_video(video_path: str) -> str:
292
+ """Apply inverse gamma 0.5 to all frames of a video (undo the gamma 2.0 brightening)."""
293
+ frames = load_video_frames(video_path)
294
+ fps = get_video_fps(video_path)
295
+
296
+ corrected = []
297
+ for frame in frames:
298
+ corrected.append(apply_inverse_gamma(frame, gamma=2.0))
299
+
300
+ out_path = tempfile.mktemp(suffix=".mp4")
301
+ write_video_mp4(corrected, fps=fps, out_path=out_path)
302
+ return out_path
303
+
304
+
305
+ def trim_video_start(video_path: str, trim_frames: int, frame_rate: float) -> str:
306
+ """
307
+ Trim the first N frames (and matching audio) from the output.
308
+
309
+ Since we prepended silence to the audio matching the burn-in duration,
310
+ trimming both video and audio by the same amount removes the burn-in
311
+ video frames AND the silence, leaving everything in sync.
312
+ """
313
+ if trim_frames <= 0:
314
+ return video_path
315
+ trim_seconds = trim_frames / frame_rate
316
+ out_path = tempfile.mktemp(suffix=".mp4")
317
+ subprocess.run(
318
+ ["ffmpeg", "-y", "-v", "error",
319
+ "-ss", f"{trim_seconds:.4f}",
320
+ "-i", video_path,
321
+ "-c:v", "libx264", "-crf", "18", "-preset", "fast",
322
+ "-c:a", "aac",
323
+ out_path],
324
+ check=True,
325
+ )
326
+ return out_path
327
+
328
+
329
+ # ─────────────────────────────────────────────────────────────────────────────
330
+ # Helper: read reference downscale factor from IC-LoRA metadata
331
+ # ─────────────────────────────────────────────────────────────────────────────
332
+ def _read_lora_reference_downscale_factor(lora_path: str) -> int:
333
+ try:
334
+ with safe_open(lora_path, framework="pt") as f:
335
+ metadata = f.metadata() or {}
336
+ return int(metadata.get("reference_downscale_factor", 1))
337
+ except Exception as e:
338
+ logging.warning(f"Failed to read metadata from LoRA file '{lora_path}': {e}")
339
+ return 1
340
+
341
+
342
+ # ─────────────────────────────────────────────────────────────────────────────
343
+ # Unified Pipeline: Distilled + Audio + IC-LoRA Video-to-Video
344
+ # ─────────────────────────────────────────────────────────────────────────────
345
+ class LTX23OutpaintPipeline:
346
+ """
347
+ LTX-2.3 pipeline for outpainting using IC-LoRA.
348
+ The outpaint LoRA is loaded separately (not fused), so:
349
+ - stage_1_model_ledger: base transformer + outpaint LoRA (Stage 1)
350
+ - stage_2_model_ledger: base transformer WITHOUT LoRA (Stage 2 upsampling)
351
+ """
352
+
353
+ def __init__(
354
+ self,
355
+ distilled_checkpoint_path: str,
356
+ spatial_upsampler_path: str,
357
+ gemma_root: str,
358
+ ic_loras: list[LoraPathStrengthAndSDOps] | None = None,
359
+ device: torch.device | None = None,
360
+ quantization: QuantizationPolicy | None = None,
361
+ stage_1_quantization: QuantizationPolicy | None = None,
362
+ reference_downscale_factor: int | None = None,
363
+ ):
364
+ self.device = device or get_device()
365
+ self.dtype = torch.bfloat16
366
+
367
+ ic_loras = ic_loras or []
368
+ self.has_ic_lora = len(ic_loras) > 0
369
+
370
+ # Stage 1 quantization: use stage_1_quantization if provided,
371
+ # otherwise fall back to the shared quantization policy.
372
+ # On ZeroGPU, fp8_cast LoRA fusion requires CUDA at init time,
373
+ # so we typically pass None for Stage 1 (with LoRA) to avoid the issue.
374
+ s1_quant = stage_1_quantization if stage_1_quantization is not None else quantization
375
+
376
+ # Stage 1: transformer with IC-LoRA (outpaint) — no fp8 quant to
377
+ # avoid Triton CUDA kernel during LoRA fusion at startup
378
+ self.stage_1_model_ledger = ModelLedger(
379
+ dtype=self.dtype,
380
+ device=self.device,
381
+ checkpoint_path=distilled_checkpoint_path,
382
+ spatial_upsampler_path=spatial_upsampler_path,
383
+ gemma_root_path=gemma_root,
384
+ loras=ic_loras,
385
+ quantization=s1_quant,
386
+ )
387
+
388
+ if self.has_ic_lora:
389
+ # Stage 2 needs a separate transformer WITHOUT IC-LoRA
390
+ # Can safely use fp8_cast here since no LoRA fusion is involved
391
+ self.stage_2_model_ledger = ModelLedger(
392
+ dtype=self.dtype,
393
+ device=self.device,
394
+ checkpoint_path=distilled_checkpoint_path,
395
+ spatial_upsampler_path=spatial_upsampler_path,
396
+ gemma_root_path=gemma_root,
397
+ loras=[],
398
+ quantization=quantization,
399
+ )
400
+ else:
401
+ self.stage_2_model_ledger = self.stage_1_model_ledger
402
+
403
+ self.pipeline_components = PipelineComponents(
404
+ dtype=self.dtype,
405
+ device=self.device,
406
+ )
407
+
408
+ # Reference downscale factor
409
+ if reference_downscale_factor is not None:
410
+ self.reference_downscale_factor = reference_downscale_factor
411
+ else:
412
+ self.reference_downscale_factor = 1
413
+ for lora in ic_loras:
414
+ scale = _read_lora_reference_downscale_factor(lora.path)
415
+ if scale != 1:
416
+ if self.reference_downscale_factor not in (1, scale):
417
+ raise ValueError(
418
+ f"Conflicting reference_downscale_factor: "
419
+ f"already {self.reference_downscale_factor}, got {scale}"
420
+ )
421
+ self.reference_downscale_factor = scale
422
+
423
+ logging.info(f"[Pipeline] reference_downscale_factor={self.reference_downscale_factor}")
424
+
425
+ # ── Video reference conditioning (IC-LoRA) ─────────────────────────────
426
+ def _create_ic_conditionings(
427
+ self,
428
+ video_conditioning: list[tuple[str, float]],
429
+ height: int,
430
+ width: int,
431
+ num_frames: int,
432
+ video_encoder: VideoEncoder,
433
+ conditioning_strength: float = 1.0,
434
+ ) -> list[ConditioningItem]:
435
+ """Create IC-LoRA video reference conditioning items."""
436
+ conditionings: list[ConditioningItem] = []
437
+ scale = self.reference_downscale_factor
438
+
439
+ ref_height = height // scale
440
+ ref_width = width // scale
441
+
442
+ for video_path, strength in video_conditioning:
443
+ video = load_video_conditioning(
444
+ video_path=video_path,
445
+ height=ref_height,
446
+ width=ref_width,
447
+ frame_cap=num_frames,
448
+ dtype=self.dtype,
449
+ device=self.device,
450
+ )
451
+ encoded_video = video_encoder(video)
452
+
453
+ cond = VideoConditionByReferenceLatent(
454
+ latent=encoded_video,
455
+ downscale_factor=scale,
456
+ strength=strength,
457
+ )
458
+ if conditioning_strength < 1.0:
459
+ cond = ConditioningItemAttentionStrengthWrapper(
460
+ cond, attention_mask=conditioning_strength
461
+ )
462
+ conditionings.append(cond)
463
+
464
+ if conditionings:
465
+ logging.info(f"[IC-LoRA] Added {len(conditionings)} video conditioning(s)")
466
+ return conditionings
467
+
468
+ # ── Main generation entry point ──────────────────────────────────────
469
+ def __call__(
470
+ self,
471
+ prompt: str,
472
+ seed: int,
473
+ height: int,
474
+ width: int,
475
+ num_frames: int,
476
+ frame_rate: float,
477
+ images: list[ImageConditioningInput],
478
+ audio_path: str | None = None,
479
+ video_conditioning: list[tuple[str, float]] | None = None,
480
+ tiling_config: TilingConfig | None = None,
481
+ enhance_prompt: bool = False,
482
+ conditioning_strength: float = 1.0,
483
+ ):
484
+ """
485
+ Generate outpainted video.
486
+ The video_conditioning should contain the letterboxed video (with black bars).
487
+ """
488
+ assert_resolution(height=height, width=width, is_two_stage=True)
489
+
490
+ has_audio = audio_path is not None
491
+ has_video_cond = bool(video_conditioning)
492
+
493
+ generator = torch.Generator(device=self.device).manual_seed(seed)
494
+ noiser = GaussianNoiser(generator=generator)
495
+ stepper = EulerDiffusionStep()
496
+ dtype = torch.bfloat16
497
+
498
+ # ── Encode text prompt ───────────────────────────────────────────
499
+ (ctx_p,) = encode_prompts(
500
+ [prompt],
501
+ self.stage_1_model_ledger,
502
+ enhance_first_prompt=enhance_prompt,
503
+ enhance_prompt_image=images[0].path if len(images) > 0 else None,
504
+ )
505
+ video_context, audio_context = ctx_p.video_encoding, ctx_p.audio_encoding
506
+
507
+ # ── Encode external audio (if provided) ─────────────────────────
508
+ encoded_audio_latent = None
509
+ decoded_audio_for_output = None
510
+ if has_audio:
511
+ video_duration = num_frames / frame_rate
512
+ decoded_audio = decode_audio_from_file(audio_path, self.device, 0.0, video_duration)
513
+ if decoded_audio is None:
514
+ raise ValueError(f"Could not extract audio stream from {audio_path}")
515
+
516
+ encoded_audio_latent = vae_encode_audio(
517
+ decoded_audio, self.stage_1_model_ledger.audio_encoder()
518
+ )
519
+ audio_shape = AudioLatentShape.from_duration(
520
+ batch=1, duration=video_duration, channels=8, mel_bins=16
521
+ )
522
+ expected_frames = audio_shape.frames
523
+ actual_frames = encoded_audio_latent.shape[2]
524
+
525
+ if actual_frames > expected_frames:
526
+ encoded_audio_latent = encoded_audio_latent[:, :, :expected_frames, :]
527
+ elif actual_frames < expected_frames:
528
+ pad = torch.zeros(
529
+ encoded_audio_latent.shape[0], encoded_audio_latent.shape[1],
530
+ expected_frames - actual_frames, encoded_audio_latent.shape[3],
531
+ device=encoded_audio_latent.device, dtype=encoded_audio_latent.dtype,
532
+ )
533
+ encoded_audio_latent = torch.cat([encoded_audio_latent, pad], dim=2)
534
+
535
+ decoded_audio_for_output = Audio(
536
+ waveform=decoded_audio.waveform.squeeze(0),
537
+ sampling_rate=decoded_audio.sampling_rate,
538
+ )
539
+
540
+ # ── Build conditionings for Stage 1 ──────────────────────────────
541
+ video_encoder = self.stage_1_model_ledger.video_encoder()
542
+
543
+ stage_1_output_shape = VideoPixelShape(
544
+ batch=1, frames=num_frames,
545
+ width=width // 2, height=height // 2, fps=frame_rate,
546
+ )
547
+
548
+ # Image conditionings (first frame of letterboxed video)
549
+ stage_1_conditionings = combined_image_conditionings(
550
+ images=images,
551
+ height=stage_1_output_shape.height,
552
+ width=stage_1_output_shape.width,
553
+ video_encoder=video_encoder,
554
+ dtype=dtype,
555
+ device=self.device,
556
+ )
557
+
558
+ # IC-LoRA video reference conditionings (the letterboxed video)
559
+ if has_video_cond:
560
+ ic_conds = self._create_ic_conditionings(
561
+ video_conditioning=video_conditioning,
562
+ height=stage_1_output_shape.height,
563
+ width=stage_1_output_shape.width,
564
+ num_frames=num_frames,
565
+ video_encoder=video_encoder,
566
+ conditioning_strength=conditioning_strength,
567
+ )
568
+ stage_1_conditionings.extend(ic_conds)
569
+
570
+ # ── Stage 1: Low-res generation ──────────────────────────────────
571
+ transformer = self.stage_1_model_ledger.transformer()
572
+ stage_1_sigmas = torch.Tensor(DISTILLED_SIGMA_VALUES).to(self.device)
573
+
574
+ def denoising_loop(sigmas, video_state, audio_state, stepper):
575
+ return euler_denoising_loop(
576
+ sigmas=sigmas,
577
+ video_state=video_state,
578
+ audio_state=audio_state,
579
+ stepper=stepper,
580
+ denoise_fn=simple_denoising_func(
581
+ video_context=video_context,
582
+ audio_context=audio_context,
583
+ transformer=transformer,
584
+ ),
585
+ )
586
+
587
+ if has_audio:
588
+ video_state = denoise_video_only(
589
+ output_shape=stage_1_output_shape,
590
+ conditionings=stage_1_conditionings,
591
+ noiser=noiser,
592
+ sigmas=stage_1_sigmas,
593
+ stepper=stepper,
594
+ denoising_loop_fn=denoising_loop,
595
+ components=self.pipeline_components,
596
+ dtype=dtype,
597
+ device=self.device,
598
+ initial_audio_latent=encoded_audio_latent,
599
+ )
600
+ audio_state = None
601
+ else:
602
+ video_state, audio_state = denoise_audio_video(
603
+ output_shape=stage_1_output_shape,
604
+ conditionings=stage_1_conditionings,
605
+ noiser=noiser,
606
+ sigmas=stage_1_sigmas,
607
+ stepper=stepper,
608
+ denoising_loop_fn=denoising_loop,
609
+ components=self.pipeline_components,
610
+ dtype=dtype,
611
+ device=self.device,
612
+ )
613
+
614
+ torch.cuda.synchronize()
615
+ cleanup_memory()
616
+
617
+ # ── Stage 2: Upsample + Refine ────���─────────────────────────────
618
+ upscaled_video_latent = upsample_video(
619
+ latent=video_state.latent[:1],
620
+ video_encoder=video_encoder,
621
+ upsampler=self.stage_2_model_ledger.spatial_upsampler(),
622
+ )
623
+
624
+ torch.cuda.synchronize()
625
+ cleanup_memory()
626
+
627
+ # Stage 2 uses the transformer WITHOUT IC-LoRA
628
+ transformer_s2 = self.stage_2_model_ledger.transformer()
629
+ stage_2_sigmas = torch.Tensor(STAGE_2_DISTILLED_SIGMA_VALUES).to(self.device)
630
+
631
+ def denoising_loop_s2(sigmas, video_state, audio_state, stepper):
632
+ return euler_denoising_loop(
633
+ sigmas=sigmas,
634
+ video_state=video_state,
635
+ audio_state=audio_state,
636
+ stepper=stepper,
637
+ denoise_fn=simple_denoising_func(
638
+ video_context=video_context,
639
+ audio_context=audio_context,
640
+ transformer=transformer_s2,
641
+ ),
642
+ )
643
+
644
+ stage_2_output_shape = VideoPixelShape(
645
+ batch=1, frames=num_frames,
646
+ width=width, height=height, fps=frame_rate,
647
+ )
648
+ stage_2_conditionings = combined_image_conditionings(
649
+ images=images,
650
+ height=stage_2_output_shape.height,
651
+ width=stage_2_output_shape.width,
652
+ video_encoder=video_encoder,
653
+ dtype=dtype,
654
+ device=self.device,
655
+ )
656
+
657
+ if has_audio:
658
+ video_state = denoise_video_only(
659
+ output_shape=stage_2_output_shape,
660
+ conditionings=stage_2_conditionings,
661
+ noiser=noiser,
662
+ sigmas=stage_2_sigmas,
663
+ stepper=stepper,
664
+ denoising_loop_fn=denoising_loop_s2,
665
+ components=self.pipeline_components,
666
+ dtype=dtype,
667
+ device=self.device,
668
+ noise_scale=stage_2_sigmas[0],
669
+ initial_video_latent=upscaled_video_latent,
670
+ initial_audio_latent=encoded_audio_latent,
671
+ )
672
+ audio_state = None
673
+ else:
674
+ video_state, audio_state = denoise_audio_video(
675
+ output_shape=stage_2_output_shape,
676
+ conditionings=stage_2_conditionings,
677
+ noiser=noiser,
678
+ sigmas=stage_2_sigmas,
679
+ stepper=stepper,
680
+ denoising_loop_fn=denoising_loop_s2,
681
+ components=self.pipeline_components,
682
+ dtype=dtype,
683
+ device=self.device,
684
+ noise_scale=stage_2_sigmas[0],
685
+ initial_video_latent=upscaled_video_latent,
686
+ initial_audio_latent=audio_state.latent,
687
+ )
688
+
689
+ torch.cuda.synchronize()
690
+ del transformer, transformer_s2, video_encoder
691
+ cleanup_memory()
692
+
693
+ # ── Decode ───────────────────────────────────────────────────────
694
+ decoded_video = vae_decode_video(
695
+ video_state.latent,
696
+ self.stage_2_model_ledger.video_decoder(),
697
+ tiling_config,
698
+ generator,
699
+ )
700
+
701
+ if has_audio:
702
+ output_audio = decoded_audio_for_output
703
+ else:
704
+ output_audio = vae_decode_audio(
705
+ audio_state.latent,
706
+ self.stage_2_model_ledger.audio_decoder(),
707
+ self.stage_2_model_ledger.vocoder(),
708
+ )
709
+
710
+ return decoded_video, output_audio
711
+
712
+
713
+ # ─────────────────────────────────────────────────────────────────────────────
714
+ # Constants
715
+ # ─────────────────────────────────────────────────────────────────────────────
716
+ MAX_SEED = np.iinfo(np.int32).max
717
+ DEFAULT_FRAME_RATE = 24.0
718
+
719
+ # Output resolutions for outpainting (the expanded canvas)
720
+ RESOLUTIONS = {
721
+ "high": {"16:9": (1536, 1024), "9:16": (1024, 1536), "1:1": (1024, 1024),
722
+ "4:3": (1536, 1152), "3:4": (1152, 1536), "21:9": (1536, 768)},
723
+ "low": {"16:9": (768, 512), "9:16": (512, 768), "1:1": (768, 768),
724
+ "4:3": (768, 576), "3:4": (576, 768), "21:9": (768, 384)},
725
+ }
726
+
727
+ # Outpaint fused checkpoint (base + LoRA pre-merged)
728
+ FUSED_CHECKPOINT_REPO = "linoyts/ltx-2.3-22b-fused-outpaint"
729
+ FUSED_CHECKPOINT_FILENAME = "ltx-2.3-22b-fused-outpaint.safetensors"
730
+
731
+ # ─────────────────────────────────────────────────────────────────────────────
732
+ # Download Models
733
+ # ──────��──────────────────────────────────────────────────────────────────────
734
+ LTX_MODEL_REPO = "Lightricks/LTX-2.3"
735
+ GEMMA_REPO = "google/gemma-3-12b-it-qat-q4_0-unquantized"
736
+
737
+ print("=" * 80)
738
+ print("Downloading LTX-2.3 fused outpaint model + Gemma...")
739
+ print("=" * 80)
740
+
741
+ # Fused checkpoint: base distilled + outpaint LoRA already merged
742
+ checkpoint_path = hf_hub_download(
743
+ repo_id=FUSED_CHECKPOINT_REPO, filename=FUSED_CHECKPOINT_FILENAME
744
+ )
745
+ spatial_upsampler_path = hf_hub_download(
746
+ repo_id=LTX_MODEL_REPO, filename="ltx-2.3-spatial-upscaler-x2-1.1.safetensors"
747
+ )
748
+ gemma_root = snapshot_download(repo_id=GEMMA_REPO)
749
+
750
+ print(f"Checkpoint (fused): {checkpoint_path}")
751
+ print(f"Spatial upsampler: {spatial_upsampler_path}")
752
+ print(f"Gemma root: {gemma_root}")
753
+
754
+
755
+ # ─────────────────────────────────────────────────────────────────────────────
756
+ # Initialize Pipeline
757
+ # ─────────────────────────────────────────────────────────────────────────────
758
+ pipeline = LTX23OutpaintPipeline(
759
+ distilled_checkpoint_path=checkpoint_path,
760
+ spatial_upsampler_path=spatial_upsampler_path,
761
+ gemma_root=gemma_root,
762
+ # ic_loras=[] — LoRA already fused into checkpoint
763
+ quantization=QuantizationPolicy.fp8_cast(),
764
+ # Outpaint IC-LoRA reference_downscale_factor: read from the LoRA metadata
765
+ # it was 1 for outpaint, but set explicitly in case
766
+ reference_downscale_factor=1,
767
+ )
768
+
769
+ # Preload all models for ZeroGPU tensor packing.
770
+ print("Preloading all models...")
771
+
772
+ _ledger_1 = pipeline.stage_1_model_ledger
773
+ _ledger_2 = pipeline.stage_2_model_ledger
774
+ _shared = _ledger_1 is _ledger_2
775
+
776
+ # Stage 1 models (with outpaint LoRA)
777
+ _s1_transformer = _ledger_1.transformer()
778
+ _s1_video_encoder = _ledger_1.video_encoder()
779
+ _s1_text_encoder = _ledger_1.text_encoder()
780
+ _s1_embeddings = _ledger_1.gemma_embeddings_processor()
781
+ _s1_audio_encoder = _ledger_1.audio_encoder()
782
+
783
+ _ledger_1.transformer = lambda: _s1_transformer
784
+ _ledger_1.video_encoder = lambda: _s1_video_encoder
785
+ _ledger_1.text_encoder = lambda: _s1_text_encoder
786
+ _ledger_1.gemma_embeddings_processor = lambda: _s1_embeddings
787
+ _ledger_1.audio_encoder = lambda: _s1_audio_encoder
788
+
789
+ if _shared:
790
+ _video_decoder = _ledger_1.video_decoder()
791
+ _audio_decoder = _ledger_1.audio_decoder()
792
+ _vocoder = _ledger_1.vocoder()
793
+ _spatial_upsampler = _ledger_1.spatial_upsampler()
794
+
795
+ _ledger_1.video_decoder = lambda: _video_decoder
796
+ _ledger_1.audio_decoder = lambda: _audio_decoder
797
+ _ledger_1.vocoder = lambda: _vocoder
798
+ _ledger_1.spatial_upsampler = lambda: _spatial_upsampler
799
+ print(" (single shared ledger — no IC-LoRA)")
800
+ else:
801
+ # Stage 2 models (separate transformer without IC-LoRA)
802
+ _s2_transformer = _ledger_2.transformer()
803
+ _s2_video_encoder = _ledger_2.video_encoder()
804
+ _s2_video_decoder = _ledger_2.video_decoder()
805
+ _s2_audio_decoder = _ledger_2.audio_decoder()
806
+ _s2_vocoder = _ledger_2.vocoder()
807
+ _s2_spatial_upsampler = _ledger_2.spatial_upsampler()
808
+ _s2_text_encoder = _ledger_2.text_encoder()
809
+ _s2_embeddings = _ledger_2.gemma_embeddings_processor()
810
+ _s2_audio_encoder = _ledger_2.audio_encoder()
811
+
812
+ _ledger_2.transformer = lambda: _s2_transformer
813
+ _ledger_2.video_encoder = lambda: _s2_video_encoder
814
+ _ledger_2.video_decoder = lambda: _s2_video_decoder
815
+ _ledger_2.audio_decoder = lambda: _s2_audio_decoder
816
+ _ledger_2.vocoder = lambda: _s2_vocoder
817
+ _ledger_2.spatial_upsampler = lambda: _s2_spatial_upsampler
818
+ _ledger_2.text_encoder = lambda: _s2_text_encoder
819
+ _ledger_2.gemma_embeddings_processor = lambda: _s2_embeddings
820
+ _ledger_2.audio_encoder = lambda: _s2_audio_encoder
821
+ print(" (two separate ledgers — IC-LoRA active)")
822
+
823
+ print("All models preloaded!")
824
+ print("=" * 80)
825
+
826
+
827
+ # ─────────────────────────────────────────────────────────────────────────────
828
+ # UI Helpers
829
+ # ─────────────────────────────────────────────────────────────────────────────
830
+ def detect_aspect_ratio(media_path) -> str:
831
+ """Detect the closest aspect ratio from a video."""
832
+ if media_path is None:
833
+ return "16:9"
834
+
835
+ try:
836
+ w, h = get_video_dimensions(str(media_path))
837
+ except Exception:
838
+ return "16:9"
839
+
840
+ ratio = w / h
841
+ candidates = {
842
+ "16:9": 16 / 9, "9:16": 9 / 16, "1:1": 1.0,
843
+ "4:3": 4 / 3, "3:4": 3 / 4, "21:9": 21 / 9,
844
+ }
845
+ return min(candidates, key=lambda k: abs(ratio - candidates[k]))
846
+
847
+
848
+ def _get_video_duration(video_path) -> float | None:
849
+ """Get video duration in seconds via ffprobe."""
850
+ if video_path is None:
851
+ return None
852
+ try:
853
+ result = subprocess.run(
854
+ ["ffprobe", "-v", "error", "-select_streams", "v:0",
855
+ "-show_entries", "format=duration", "-of", "default=nw=1:nk=1",
856
+ str(video_path)],
857
+ capture_output=True, text=True,
858
+ )
859
+ return float(result.stdout.strip())
860
+ except Exception:
861
+ return None
862
+
863
+
864
+ def on_video_upload(video, high_res):
865
+ """Auto-set duration when video is uploaded."""
866
+ vid_dur = _get_video_duration(video)
867
+ if vid_dur is not None:
868
+ dur = round(min(vid_dur, 15.0), 1)
869
+ else:
870
+ dur = 3.0
871
+ return gr.update(value=dur)
872
+
873
+
874
+ def get_target_resolution(target_aspect: str, high_res: bool) -> tuple[int, int]:
875
+ """Get the target output resolution for the selected aspect ratio."""
876
+ tier = "high" if high_res else "low"
877
+ return RESOLUTIONS[tier].get(target_aspect, RESOLUTIONS[tier]["16:9"])
878
+
879
+
880
+ def preview_letterbox(video, target_aspect, high_res, use_gamma):
881
+ """Generate a preview of the letterboxed first frame."""
882
+ if video is None:
883
+ return None, gr.update(), gr.update()
884
+
885
+ target_w, target_h = get_target_resolution(target_aspect, high_res)
886
+
887
+ # Load first frame only for preview
888
+ frames = load_video_frames(str(video))
889
+ if not frames:
890
+ return None, gr.update(value=target_w), gr.update(value=target_h)
891
+
892
+ frame = letterbox_frame(frames[0], target_w, target_h)
893
+ if use_gamma:
894
+ frame = apply_gamma(frame, gamma=2.0)
895
+
896
+ preview_path = tempfile.mktemp(suffix=".png")
897
+ Image.fromarray(frame).save(preview_path)
898
+
899
+ return preview_path, gr.update(value=target_w), gr.update(value=target_h)
900
+
901
+
902
+ # ─────────────────────────────────────────────────────────────────────────────
903
+ # Audio extraction
904
+ # ─────────────────────────────────────────────────────────────────────────────
905
+ def _extract_audio_from_video(video_path: str) -> str | None:
906
+ """Extract audio from video as a temp WAV file. Returns None if no audio."""
907
+ out_path = tempfile.mktemp(suffix=".wav")
908
+ try:
909
+ probe = subprocess.run(
910
+ ["ffprobe", "-v", "error", "-select_streams", "a:0",
911
+ "-show_entries", "stream=codec_type", "-of", "default=nw=1:nk=1",
912
+ video_path],
913
+ capture_output=True, text=True,
914
+ )
915
+ if not probe.stdout.strip():
916
+ return None
917
+
918
+ subprocess.run(
919
+ ["ffmpeg", "-y", "-v", "error", "-i", video_path,
920
+ "-vn", "-ac", "2", "-ar", "48000", "-c:a", "pcm_s16le", out_path],
921
+ check=True,
922
+ )
923
+ return out_path
924
+ except (subprocess.CalledProcessError, FileNotFoundError):
925
+ return None
926
+
927
+
928
+ def _prepend_silence_to_audio(audio_path: str, silence_duration: float) -> str:
929
+ """Prepend silence to an audio file so it starts later in the timeline.
930
+ This aligns audio with the real content when burn-in frames are prepended to video."""
931
+ if silence_duration <= 0:
932
+ return audio_path
933
+ out_path = tempfile.mktemp(suffix=".wav")
934
+ # Generate silence then concatenate with original audio
935
+ subprocess.run(
936
+ ["ffmpeg", "-y", "-v", "error",
937
+ "-f", "lavfi", "-i", f"anullsrc=r=48000:cl=stereo:d={silence_duration:.4f}",
938
+ "-i", audio_path,
939
+ "-filter_complex", "[0:a][1:a]concat=n=2:v=0:a=1[out]",
940
+ "-map", "[out]",
941
+ "-ac", "2", "-ar", "48000", "-c:a", "pcm_s16le",
942
+ out_path],
943
+ check=True,
944
+ )
945
+ return out_path
946
+
947
+
948
+ def _mux_audio_to_video(video_path: str, audio_path: str) -> str:
949
+ """Mux an external audio track into a video, trimming to the shorter of the two."""
950
+ out_path = tempfile.mktemp(suffix=".mp4")
951
+ subprocess.run(
952
+ ["ffmpeg", "-y", "-v", "error",
953
+ "-i", video_path,
954
+ "-i", audio_path,
955
+ "-c:v", "copy",
956
+ "-c:a", "aac",
957
+ "-map", "0:v:0", "-map", "1:a:0",
958
+ "-shortest",
959
+ out_path],
960
+ check=True,
961
+ )
962
+ return out_path
963
+
964
+
965
+ # ─────────────────────────────────────────────────────────────────────────────
966
+ # Generation
967
+ # ─────────────────────────────────────────────────────────────────────────────
968
+ @spaces.GPU(duration=120)
969
+ @torch.inference_mode()
970
+ def generate_video(
971
+ input_video,
972
+ prompt: str = "",
973
+ duration: float = 3,
974
+ target_aspect: str = "16:9",
975
+ conditioning_strength: float = 1.0,
976
+ enhance_prompt: bool = True,
977
+ use_gamma: bool = False,
978
+ use_video_audio: bool = True,
979
+ seed: int = 42,
980
+ randomize_seed: bool = True,
981
+ high_res: bool = False,
982
+ input_audio=None,
983
+ progress=gr.Progress(track_tqdm=True),
984
+ ):
985
+ try:
986
+ torch.cuda.reset_peak_memory_stats()
987
+ current_seed = random.randint(0, MAX_SEED) if randomize_seed else int(seed)
988
+
989
+ if input_video is None:
990
+ raise ValueError("Please upload a source video to outpaint.")
991
+
992
+ video_path = str(input_video)
993
+ frame_rate = DEFAULT_FRAME_RATE
994
+
995
+ # Burn-in: prepend extra frames of the first frame so the model
996
+ # has time to fill the black regions before actual content starts.
997
+ # These will be trimmed from the final output.
998
+ BURNIN_FRAMES = 24 # ~1 second at 24fps
999
+
1000
+ # Total frames to generate includes burn-in
1001
+ content_frames = int(duration * frame_rate) + 1
1002
+ content_frames = ((content_frames - 1 + 7) // 8) * 8 + 1
1003
+ total_frames = content_frames + BURNIN_FRAMES
1004
+ # Re-align to multiple of 8 + 1
1005
+ total_frames = ((total_frames - 1 + 7) // 8) * 8 + 1
1006
+ # Actual burn-in count after alignment (may differ slightly)
1007
+ actual_burnin = total_frames - content_frames
1008
+
1009
+ # Get target resolution
1010
+ target_w, target_h = get_target_resolution(target_aspect, high_res)
1011
+
1012
+ print(f"[Outpaint] Generating: {target_h}x{target_w}, {total_frames} frames "
1013
+ f"(content={content_frames}, burnin={actual_burnin}), "
1014
+ f"seed={current_seed}, gamma={use_gamma}, "
1015
+ f"target_aspect={target_aspect}")
1016
+
1017
+ # Step 1: Letterbox the input video with black bars + burn-in frames
1018
+ letterboxed_path, first_frame_path = letterbox_video(
1019
+ video_path=video_path,
1020
+ target_w=target_w,
1021
+ target_h=target_h,
1022
+ use_gamma=use_gamma,
1023
+ num_frames=content_frames,
1024
+ burnin_frames=actual_burnin,
1025
+ )
1026
+ print(f"[Outpaint] Letterboxed video saved to {letterboxed_path}")
1027
+
1028
+ # Build image conditioning from letterboxed first frame
1029
+ images = [ImageConditioningInput(path=first_frame_path, frame_idx=0, strength=1.0)]
1030
+
1031
+ # Build video conditioning — the letterboxed video IS the conditioning
1032
+ video_conditioning = [(letterboxed_path, 1.0)]
1033
+
1034
+ # Extract original audio — we'll mux it back at the end untouched,
1035
+ # NOT through the pipeline's audio VAE which would introduce artifacts.
1036
+ original_audio_path = None
1037
+ if input_audio is not None:
1038
+ original_audio_path = str(input_audio)
1039
+ elif use_video_audio:
1040
+ original_audio_path = _extract_audio_from_video(video_path)
1041
+ if original_audio_path:
1042
+ print(f"[Outpaint] Extracted audio from input video (will mux at end)")
1043
+
1044
+ tiling_config = TilingConfig.default()
1045
+ video_chunks_number = get_video_chunks_number(total_frames, tiling_config)
1046
+
1047
+ # Generate video WITHOUT audio — audio will be muxed in post
1048
+ video, audio = pipeline(
1049
+ prompt=prompt,
1050
+ seed=current_seed,
1051
+ height=int(target_h),
1052
+ width=int(target_w),
1053
+ num_frames=total_frames,
1054
+ frame_rate=frame_rate,
1055
+ images=images,
1056
+ audio_path=None, # no audio through pipeline
1057
+ video_conditioning=video_conditioning,
1058
+ tiling_config=tiling_config,
1059
+ enhance_prompt=enhance_prompt,
1060
+ conditioning_strength=conditioning_strength,
1061
+ )
1062
+
1063
+ output_path = tempfile.mktemp(suffix=".mp4")
1064
+ encode_video(
1065
+ video=video,
1066
+ fps=frame_rate,
1067
+ audio=audio,
1068
+ output_path=output_path,
1069
+ video_chunks_number=video_chunks_number,
1070
+ )
1071
+
1072
+ # Step 2: If gamma was used, apply inverse gamma to the final output
1073
+ if use_gamma:
1074
+ print("[Outpaint] Applying inverse gamma correction to output...")
1075
+ output_path = apply_inverse_gamma_to_video(output_path)
1076
+
1077
+ # Step 3: Trim burn-in frames from the start (video-only at this point)
1078
+ if actual_burnin > 0:
1079
+ print(f"[Outpaint] Trimming {actual_burnin} burn-in frames from output...")
1080
+ output_path = trim_video_start(output_path, actual_burnin, frame_rate)
1081
+
1082
+ # Step 4: Mux the original untouched audio back in
1083
+ if original_audio_path is not None:
1084
+ print("[Outpaint] Muxing original audio into output...")
1085
+ output_path = _mux_audio_to_video(output_path, original_audio_path)
1086
+
1087
+ return str(output_path), current_seed
1088
+
1089
+ except Exception as e:
1090
+ import traceback
1091
+ print(f"Error: {str(e)}\n{traceback.format_exc()}")
1092
+ return None, current_seed
1093
+
1094
+
1095
+ # ─────────────────────────────────────────────────────────────────────────────
1096
+ # Gradio UI — LTX 2.3 Outpaint
1097
+ # ─────────────────────────────────────────────────────────────────────────────
1098
+ css = """
1099
+ .main-title { text-align: center; margin-bottom: 0.5em; }
1100
+ .generate-btn { min-height: 52px !important; font-size: 1.1em !important; }
1101
+ footer { display: none !important; }
1102
+ video { object-fit: contain !important; }
1103
+ .preview-frame img { max-height: 300px !important; object-fit: contain !important; }
1104
+ """
1105
+
1106
+ purple_citrus = gr.themes.Citrus(
1107
+ primary_hue=gr.themes.colors.purple,
1108
+ secondary_hue=gr.themes.colors.purple,
1109
+ neutral_hue=gr.themes.colors.gray,
1110
+ )
1111
+
1112
+ with gr.Blocks(title="LTX 2.3 Outpaint", css=css, theme=purple_citrus) as demo:
1113
+ gr.Markdown("""
1114
+ # LTX 2.3 Outpaint: Extend Your Video to Any Aspect Ratio 🖼️
1115
+ Expand video beyond its original frame with visually and temporally consistent content using [LTX-2.3](https://huggingface.co/Lightricks/LTX-2.3) + [Outpaint IC-LoRA](https://huggingface.co/oumoumad/LTX-2.3-22b-IC-LoRA-Outpaint) by [@oumoumad](https://huggingface.co/oumoumad) ✨
1116
+
1117
+ **Tip:** For dark/night scenes, enable **Gamma Correction** (Advanced Settings) so the model can distinguish dark content from the black sentinel bars.
1118
+ """)
1119
+
1120
+ with gr.Row():
1121
+ # ── Left column: inputs ──────────────────────────────────────
1122
+ with gr.Column(scale=1):
1123
+ input_video = gr.Video(label="Source Video")
1124
+
1125
+ with gr.Row():
1126
+ target_aspect = gr.Dropdown(
1127
+ label="Expand to Aspect Ratio",
1128
+ choices=["16:9", "9:16", "1:1", "4:3", "3:4", "21:9"],
1129
+ value="16:9",
1130
+ info="The target canvas shape — black bars will fill the new area",
1131
+ )
1132
+ duration = gr.Slider(
1133
+ label="Duration (s)", minimum=1.0, maximum=15.0, value=3.0, step=0.5,
1134
+ )
1135
+
1136
+ prompt = gr.Textbox(
1137
+ label="Prompt (optional)",
1138
+ info="Describe the video + what should appear in the expanded regions",
1139
+ lines=2,
1140
+ placeholder="a wide landscape with mountains and a clear sky",
1141
+ )
1142
+
1143
+ with gr.Row():
1144
+ preview_btn = gr.Button("Preview Letterbox", variant="secondary")
1145
+ generate_btn = gr.Button(
1146
+ "Generate Outpaint", variant="primary", size="lg",
1147
+ elem_classes=["generate-btn"],
1148
+ )
1149
+
1150
+ with gr.Accordion("Letterbox Preview", open=True):
1151
+ preview_image = gr.Image(
1152
+ label="Letterboxed first frame (black = regions to generate)",
1153
+ type="filepath",
1154
+ elem_classes=["preview-frame"],
1155
+ interactive=False,
1156
+ )
1157
+
1158
+ with gr.Accordion("Advanced Settings", open=False):
1159
+ enhance_prompt = gr.Checkbox(label="Enhance Prompt", value=True)
1160
+ conditioning_strength = gr.Slider(
1161
+ label="Conditioning Strength",
1162
+ info="How strongly the original video content influences generation",
1163
+ minimum=0.0, maximum=1.0, value=1.0, step=0.05,
1164
+ )
1165
+ use_gamma = gr.Checkbox(
1166
+ label="Gamma Correction (for dark scenes)",
1167
+ value=False,
1168
+ info="Apply gamma 2.0 brightening before generation and inverse after — "
1169
+ "recommended for dark/night footage where black bars may be confused "
1170
+ "with dark scene content",
1171
+ )
1172
+ high_res = gr.Checkbox(label="High Resolution (2×)", value=False)
1173
+ use_video_audio = gr.Checkbox(
1174
+ label="Preserve Audio from Source Video", value=True,
1175
+ info="Extract and keep the audio track from the source video",
1176
+ )
1177
+ input_audio = gr.Audio(
1178
+ label="Override Audio (optional — replaces video audio)",
1179
+ type="filepath",
1180
+ )
1181
+ seed = gr.Slider(
1182
+ label="Seed", minimum=0, maximum=MAX_SEED, value=42, step=1,
1183
+ )
1184
+ randomize_seed = gr.Checkbox(label="Randomize Seed", value=True)
1185
+ with gr.Row():
1186
+ width_display = gr.Number(label="Output Width", interactive=False)
1187
+ height_display = gr.Number(label="Output Height", interactive=False)
1188
+
1189
+ # ── Right column: output ─────────────────────────────────────
1190
+ with gr.Column(scale=1):
1191
+ output_video = gr.Video(label="Outpainted Result", autoplay=True, height=480)
1192
+
1193
+
1194
+ # ── Event handlers ───────────────────────────────────────────────────
1195
+ input_video.change(
1196
+ fn=on_video_upload,
1197
+ inputs=[input_video, high_res],
1198
+ outputs=[duration],
1199
+ )
1200
+
1201
+ # Auto-preview when video or settings change
1202
+ preview_btn.click(
1203
+ fn=preview_letterbox,
1204
+ inputs=[input_video, target_aspect, high_res, use_gamma],
1205
+ outputs=[preview_image, width_display, height_display],
1206
+ )
1207
+
1208
+ # Also auto-preview when aspect ratio or gamma changes
1209
+ target_aspect.change(
1210
+ fn=preview_letterbox,
1211
+ inputs=[input_video, target_aspect, high_res, use_gamma],
1212
+ outputs=[preview_image, width_display, height_display],
1213
+ )
1214
+
1215
+ use_gamma.change(
1216
+ fn=preview_letterbox,
1217
+ inputs=[input_video, target_aspect, high_res, use_gamma],
1218
+ outputs=[preview_image, width_display, height_display],
1219
+ )
1220
+
1221
+ high_res.change(
1222
+ fn=preview_letterbox,
1223
+ inputs=[input_video, target_aspect, high_res, use_gamma],
1224
+ outputs=[preview_image, width_display, height_display],
1225
+ )
1226
+
1227
+ # Auto-preview on video upload too
1228
+ input_video.change(
1229
+ fn=preview_letterbox,
1230
+ inputs=[input_video, target_aspect, high_res, use_gamma],
1231
+ outputs=[preview_image, width_display, height_display],
1232
+ )
1233
+
1234
+ generate_btn.click(
1235
+ fn=generate_video,
1236
+ inputs=[
1237
+ input_video, prompt, duration, target_aspect,
1238
+ conditioning_strength, enhance_prompt, use_gamma,
1239
+ use_video_audio, seed, randomize_seed, high_res, input_audio,
1240
+ ],
1241
+ outputs=[output_video, seed],
1242
+ )
1243
+
1244
+
1245
+ if __name__ == "__main__":
1246
+ demo.launch()
appsync.py ADDED
@@ -0,0 +1,1317 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import subprocess
3
+ import sys
4
+
5
+ # Disable torch.compile / dynamo before any torch import
6
+ os.environ["TORCH_COMPILE_DISABLE"] = "1"
7
+ os.environ["TORCHDYNAMO_DISABLE"] = "1"
8
+
9
+ # Install xformers for memory-efficient attention
10
+ subprocess.run([sys.executable, "-m", "pip", "install", "xformers==0.0.32.post2", "--no-build-isolation"], check=False)
11
+
12
+ # Install video preprocessing dependencies
13
+ subprocess.run([sys.executable, "-m", "pip", "install",
14
+ "dwpose", "onnxruntime-gpu", "imageio[ffmpeg]", "scikit-image",
15
+ "opencv-python-headless", "decord", "num2words"], check=False)
16
+
17
+ # Ensure num2words is installed (required by SmolVLMProcessor)
18
+ subprocess.run([sys.executable, "-m", "pip", "install", "num2words"], check=True)
19
+
20
+ # Reinstall torchaudio to match the torch CUDA version on this space.
21
+ # controlnet_aux or other deps can pull in a CPU-only torchaudio that conflicts
22
+ # with the pre-installed CUDA torch, causing "undefined symbol" errors.
23
+ _tv = subprocess.run([sys.executable, "-c", "import torch; print(torch.__version__)"],
24
+ capture_output=True, text=True)
25
+ if _tv.returncode == 0:
26
+ _full_ver = _tv.stdout.strip()
27
+ # Extract CUDA suffix if present (e.g. "2.7.0+cu124" -> "cu124")
28
+ _cuda_suffix = _full_ver.split("+")[-1] if "+" in _full_ver else "cu124"
29
+ _base_ver = _full_ver.split("+")[0]
30
+ print(f"Detected torch {_full_ver}, reinstalling matching torchaudio...")
31
+ subprocess.run([
32
+ sys.executable, "-m", "pip", "install", "--force-reinstall", "--no-deps",
33
+ f"torchaudio=={_base_ver}",
34
+ "--index-url", f"https://download.pytorch.org/whl/{_cuda_suffix}",
35
+ ], check=False)
36
+
37
+ # Clone LTX-2 repo at a pinned commit and install packages
38
+ LTX_REPO_URL = "https://github.com/Lightricks/LTX-2.git"
39
+ LTX_REPO_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "LTX-2")
40
+ LTX_COMMIT = "ae855f8538843825f9015a419cf4ba5edaf5eec2"
41
+
42
+ # Re-clone cleanly to avoid keeping an incompatible previous checkout
43
+ if os.path.exists(LTX_REPO_DIR):
44
+ print(f"Removing existing repo at {LTX_REPO_DIR}...")
45
+ subprocess.run(["rm", "-rf", LTX_REPO_DIR], check=True)
46
+
47
+ print(f"Cloning {LTX_REPO_URL}...")
48
+ subprocess.run(["git", "clone", LTX_REPO_URL, LTX_REPO_DIR], check=True)
49
+
50
+ print(f"Checking out commit {LTX_COMMIT}...")
51
+ subprocess.run(["git", "-C", LTX_REPO_DIR, "checkout", LTX_COMMIT], check=True)
52
+
53
+ print("Installing ltx-core and ltx-pipelines from pinned repo commit...")
54
+ subprocess.run(
55
+ [
56
+ sys.executable, "-m", "pip", "install",
57
+ "--force-reinstall", "--no-deps",
58
+ "-e", os.path.join(LTX_REPO_DIR, "packages", "ltx-core"),
59
+ "-e", os.path.join(LTX_REPO_DIR, "packages", "ltx-pipelines"),
60
+ ],
61
+ check=True,
62
+ )
63
+ sys.path.insert(0, os.path.join(LTX_REPO_DIR, "packages", "ltx-pipelines", "src"))
64
+ sys.path.insert(0, os.path.join(LTX_REPO_DIR, "packages", "ltx-core", "src"))
65
+
66
+ import logging
67
+ import random
68
+ import tempfile
69
+ from pathlib import Path
70
+
71
+ import torch
72
+ torch._dynamo.config.suppress_errors = True
73
+ torch._dynamo.config.disable = True
74
+
75
+ import spaces
76
+ import gradio as gr
77
+ import numpy as np
78
+ from huggingface_hub import hf_hub_download, snapshot_download
79
+ from safetensors import safe_open
80
+
81
+ from ltx_core.components.diffusion_steps import EulerDiffusionStep
82
+ from ltx_core.components.noisers import GaussianNoiser
83
+ from ltx_core.conditioning import (
84
+ ConditioningItem,
85
+ ConditioningItemAttentionStrengthWrapper,
86
+ VideoConditionByReferenceLatent,
87
+ )
88
+ from ltx_core.loader import LoraPathStrengthAndSDOps, LTXV_LORA_COMFY_RENAMING_MAP
89
+ from ltx_core.model.audio_vae import decode_audio as vae_decode_audio
90
+ from ltx_core.model.audio_vae import encode_audio as vae_encode_audio
91
+
92
+ from ltx_core.model.upsampler import upsample_video
93
+ from ltx_core.model.video_vae import TilingConfig, VideoEncoder, get_video_chunks_number
94
+ from ltx_core.model.video_vae import decode_video as vae_decode_video
95
+
96
+ from ltx_core.quantization import QuantizationPolicy
97
+ from ltx_core.types import Audio, AudioLatentShape, LatentState, VideoLatentShape, VideoPixelShape
98
+ from ltx_pipelines.utils import ModelLedger, euler_denoising_loop
99
+ from ltx_pipelines.utils.args import ImageConditioningInput
100
+ from ltx_pipelines.utils.constants import DISTILLED_SIGMA_VALUES, STAGE_2_DISTILLED_SIGMA_VALUES
101
+ from ltx_pipelines.utils.helpers import (
102
+ assert_resolution,
103
+ cleanup_memory,
104
+ combined_image_conditionings,
105
+ denoise_audio_video,
106
+ denoise_video_only,
107
+ encode_prompts,
108
+ get_device,
109
+ simple_denoising_func,
110
+ )
111
+ from ltx_pipelines.utils.media_io import (
112
+ decode_audio_from_file,
113
+ encode_video,
114
+ load_video_conditioning,
115
+ )
116
+ from ltx_pipelines.utils.types import PipelineComponents
117
+
118
+ # Force-patch xformers attention into the LTX attention module.
119
+ from ltx_core.model.transformer import attention as _attn_mod
120
+ print(f"[ATTN] Before patch: memory_efficient_attention={_attn_mod.memory_efficient_attention}")
121
+ try:
122
+ from xformers.ops import memory_efficient_attention as _mea
123
+ _attn_mod.memory_efficient_attention = _mea
124
+ print(f"[ATTN] After patch: memory_efficient_attention={_attn_mod.memory_efficient_attention}")
125
+ except Exception as e:
126
+ print(f"[ATTN] xformers patch FAILED: {type(e).__name__}: {e}")
127
+
128
+ logging.getLogger().setLevel(logging.INFO)
129
+
130
+
131
+ # ─────────────────────────────────────────────────────────────────────────────
132
+ # Video Preprocessing: Strip appearance, keep structure
133
+ # ─────────────────────────────────────────────────────────────────────────────
134
+ import imageio
135
+ import cv2
136
+ from PIL import Image
137
+
138
+ from dwpose import DwposeDetector
139
+
140
+ _pose_processor = None
141
+ _depth_processor = None
142
+
143
+
144
+ def _get_pose_processor():
145
+ global _pose_processor
146
+ if _pose_processor is None:
147
+ _pose_processor = DwposeDetector.from_pretrained_default()
148
+ print("[Preprocess] DWPose processor loaded")
149
+ return _pose_processor
150
+
151
+
152
+ def _get_depth_processor():
153
+ """Placeholder — uses simple Laplacian edge-based depth approximation via OpenCV."""
154
+ global _depth_processor
155
+ if _depth_processor is None:
156
+ _depth_processor = "cv2" # sentinel — we use cv2 directly
157
+ print("[Preprocess] CV2-based depth processor loaded")
158
+ return _depth_processor
159
+
160
+
161
+ def load_video_frames(video_path: str) -> list[np.ndarray]:
162
+ """Load video frames as list of HWC uint8 numpy arrays."""
163
+ frames = []
164
+ with imageio.get_reader(video_path) as reader:
165
+ for frame in reader:
166
+ frames.append(frame)
167
+ return frames
168
+
169
+
170
+ def write_video_mp4(frames_float_01: list[np.ndarray], fps: float, out_path: str) -> str:
171
+ """Write float [0,1] frames to mp4."""
172
+ frames_uint8 = [(f * 255).astype(np.uint8) for f in frames_float_01]
173
+ with imageio.get_writer(out_path, fps=fps, macro_block_size=1) as writer:
174
+ for fr in frames_uint8:
175
+ writer.append_data(fr)
176
+ return out_path
177
+
178
+
179
+ def extract_first_frame(video_path: str) -> str:
180
+ """Extract first frame as a temp PNG file, return path."""
181
+ frames = load_video_frames(video_path)
182
+ if not frames:
183
+ raise ValueError("No frames in video")
184
+ out_path = tempfile.mktemp(suffix=".png")
185
+ Image.fromarray(frames[0]).save(out_path)
186
+ return out_path
187
+
188
+
189
+ def preprocess_video_pose(frames: list[np.ndarray], width: int, height: int) -> list[np.ndarray]:
190
+ """Extract DWPose skeletons from each frame. Returns float [0,1] frames."""
191
+ processor = _get_pose_processor()
192
+ result = []
193
+ for frame in frames:
194
+ pil = Image.fromarray(frame.astype(np.uint8)).convert("RGB")
195
+ pose_img = processor(pil, include_body=True, include_hand=True, include_face=True)
196
+ if not isinstance(pose_img, Image.Image):
197
+ pose_img = Image.fromarray(np.array(pose_img).astype(np.uint8))
198
+ pose_img = pose_img.convert("RGB").resize((width, height), Image.BILINEAR)
199
+ result.append(np.array(pose_img).astype(np.float32) / 255.0)
200
+ return result
201
+
202
+
203
+ def preprocess_video_canny(frames: list[np.ndarray], width: int, height: int,
204
+ low_threshold: int = 50, high_threshold: int = 100) -> list[np.ndarray]:
205
+ """Extract Canny edges from each frame. Returns float [0,1] frames."""
206
+ result = []
207
+ for frame in frames:
208
+ # Resize first
209
+ resized = cv2.resize(frame, (width, height), interpolation=cv2.INTER_AREA)
210
+ gray = cv2.cvtColor(resized, cv2.COLOR_RGB2GRAY)
211
+ edges = cv2.Canny(gray, low_threshold, high_threshold)
212
+ # Convert single-channel to 3-channel
213
+ edges_3ch = np.stack([edges, edges, edges], axis=-1)
214
+ result.append(edges_3ch.astype(np.float32) / 255.0)
215
+ return result
216
+
217
+
218
+ def preprocess_video_depth(frames: list[np.ndarray], width: int, height: int) -> list[np.ndarray]:
219
+ """Estimate depth-like maps from each frame using Laplacian gradient magnitude.
220
+ This is a fast approximation — for true depth, use MiDaS externally."""
221
+ result = []
222
+ for frame in frames:
223
+ resized = cv2.resize(frame, (width, height), interpolation=cv2.INTER_AREA)
224
+ gray = cv2.cvtColor(resized, cv2.COLOR_RGB2GRAY).astype(np.float32)
225
+ # Laplacian gives edge/gradient info that approximates depth discontinuities
226
+ lap = np.abs(cv2.Laplacian(gray, cv2.CV_32F, ksize=5))
227
+ # Normalize to [0, 1]
228
+ lap = lap / (lap.max() + 1e-8)
229
+ depth_3ch = np.stack([lap, lap, lap], axis=-1)
230
+ result.append(depth_3ch)
231
+ return result
232
+
233
+
234
+ def preprocess_conditioning_video(
235
+ video_path: str,
236
+ mode: str,
237
+ width: int,
238
+ height: int,
239
+ num_frames: int,
240
+ fps: float,
241
+ ) -> tuple[str, str]:
242
+ """
243
+ Preprocess a video for conditioning. Strips appearance, keeps structure.
244
+ Returns:
245
+ (conditioning_mp4_path, first_frame_png_path)
246
+ """
247
+ frames = load_video_frames(video_path)
248
+ if not frames:
249
+ raise ValueError("No frames decoded from video")
250
+
251
+ # Trim to num_frames
252
+ frames = frames[:num_frames]
253
+
254
+ # Save first frame (original appearance) for image conditioning
255
+ first_png = tempfile.mktemp(suffix=".png")
256
+ Image.fromarray(frames[0]).save(first_png)
257
+
258
+ # Process based on mode
259
+ if mode == "Pose (DWPose)":
260
+ processed = preprocess_video_pose(frames, width, height)
261
+ elif mode == "Canny Edge":
262
+ processed = preprocess_video_canny(frames, width, height)
263
+ elif mode == "Depth (Laplacian)":
264
+ processed = preprocess_video_depth(frames, width, height)
265
+ else:
266
+ # "Raw" mode — no preprocessing
267
+ processed = [f.astype(np.float32) / 255.0 for f in frames]
268
+
269
+ cond_mp4 = tempfile.mktemp(suffix=".mp4")
270
+ write_video_mp4(processed, fps=fps, out_path=cond_mp4)
271
+
272
+ return cond_mp4, first_png
273
+
274
+
275
+ # ─────────────────────────────────────────────────────────────────────────────
276
+ # Helper: read reference downscale factor from IC-LoRA metadata
277
+ # ─────────────────────────────────────────────────────────────────────────────
278
+ def _read_lora_reference_downscale_factor(lora_path: str) -> int:
279
+ try:
280
+ with safe_open(lora_path, framework="pt") as f:
281
+ metadata = f.metadata() or {}
282
+ return int(metadata.get("reference_downscale_factor", 1))
283
+ except Exception as e:
284
+ logging.warning(f"Failed to read metadata from LoRA file '{lora_path}': {e}")
285
+ return 1
286
+
287
+
288
+ # ─────────────────────────────────────────────────────────────────────────────
289
+ # Unified Pipeline: Distilled + Audio + IC-LoRA Video-to-Video
290
+ # ─────────────────────────────────────────────────────────────────────────────
291
+ class LTX23UnifiedPipeline:
292
+ """
293
+ Unified LTX-2.3 pipeline supporting all generation modes:
294
+ • Text-to-Video
295
+ • Image-to-Video (first-frame conditioning)
296
+ • Audio-to-Video (lip-sync / BGM conditioning with external audio)
297
+ • Video-to-Video (IC-LoRA reference video conditioning)
298
+ • Any combination of the above
299
+ Architecture:
300
+ - stage_1_model_ledger: transformer WITH IC-LoRA fused (used for Stage 1)
301
+ - stage_2_model_ledger: transformer WITHOUT IC-LoRA (used for Stage 2 upsampling)
302
+ - When no IC-LoRA is provided, both stages use the same base model.
303
+ """
304
+
305
+ def __init__(
306
+ self,
307
+ distilled_checkpoint_path: str,
308
+ spatial_upsampler_path: str,
309
+ gemma_root: str,
310
+ ic_loras: list[LoraPathStrengthAndSDOps] | None = None,
311
+ device: torch.device | None = None,
312
+ quantization: QuantizationPolicy | None = None,
313
+ reference_downscale_factor: int | None = None,
314
+ ):
315
+ self.device = device or get_device()
316
+ self.dtype = torch.bfloat16
317
+
318
+ ic_loras = ic_loras or []
319
+ self.has_ic_lora = len(ic_loras) > 0
320
+
321
+ # Stage 1: transformer with IC-LoRA (if provided)
322
+ self.stage_1_model_ledger = ModelLedger(
323
+ dtype=self.dtype,
324
+ device=self.device,
325
+ checkpoint_path=distilled_checkpoint_path,
326
+ spatial_upsampler_path=spatial_upsampler_path,
327
+ gemma_root_path=gemma_root,
328
+ loras=ic_loras,
329
+ quantization=quantization,
330
+ )
331
+
332
+ if self.has_ic_lora:
333
+ # Stage 2 needs a separate transformer WITHOUT IC-LoRA
334
+ self.stage_2_model_ledger = ModelLedger(
335
+ dtype=self.dtype,
336
+ device=self.device,
337
+ checkpoint_path=distilled_checkpoint_path,
338
+ spatial_upsampler_path=spatial_upsampler_path,
339
+ gemma_root_path=gemma_root,
340
+ loras=[],
341
+ quantization=quantization,
342
+ )
343
+ else:
344
+ # No IC-LoRA: share a single ledger for both stages (saves ~half VRAM)
345
+ self.stage_2_model_ledger = self.stage_1_model_ledger
346
+
347
+ self.pipeline_components = PipelineComponents(
348
+ dtype=self.dtype,
349
+ device=self.device,
350
+ )
351
+
352
+ # Reference downscale factor: explicit value takes priority,
353
+ # otherwise read from IC-LoRA metadata, otherwise default to 1.
354
+ if reference_downscale_factor is not None:
355
+ self.reference_downscale_factor = reference_downscale_factor
356
+ else:
357
+ self.reference_downscale_factor = 1
358
+ for lora in ic_loras:
359
+ scale = _read_lora_reference_downscale_factor(lora.path)
360
+ if scale != 1:
361
+ if self.reference_downscale_factor not in (1, scale):
362
+ raise ValueError(
363
+ f"Conflicting reference_downscale_factor: "
364
+ f"already {self.reference_downscale_factor}, got {scale}"
365
+ )
366
+ self.reference_downscale_factor = scale
367
+
368
+ logging.info(f"[Pipeline] reference_downscale_factor={self.reference_downscale_factor}")
369
+
370
+ # ── Video reference conditioning (from ICLoraPipeline) ───────────────
371
+ def _create_ic_conditionings(
372
+ self,
373
+ video_conditioning: list[tuple[str, float]],
374
+ height: int,
375
+ width: int,
376
+ num_frames: int,
377
+ video_encoder: VideoEncoder,
378
+ conditioning_strength: float = 1.0,
379
+ ) -> list[ConditioningItem]:
380
+ """Create IC-LoRA video reference conditioning items."""
381
+ conditionings: list[ConditioningItem] = []
382
+ scale = self.reference_downscale_factor
383
+ ref_height = height // scale
384
+ ref_width = width // scale
385
+
386
+ for video_path, strength in video_conditioning:
387
+ video = load_video_conditioning(
388
+ video_path=video_path,
389
+ height=ref_height,
390
+ width=ref_width,
391
+ frame_cap=num_frames,
392
+ dtype=self.dtype,
393
+ device=self.device,
394
+ )
395
+ encoded_video = video_encoder(video)
396
+
397
+ cond = VideoConditionByReferenceLatent(
398
+ latent=encoded_video,
399
+ downscale_factor=scale,
400
+ strength=strength,
401
+ )
402
+ if conditioning_strength < 1.0:
403
+ cond = ConditioningItemAttentionStrengthWrapper(
404
+ cond, attention_mask=conditioning_strength
405
+ )
406
+ conditionings.append(cond)
407
+
408
+ if conditionings:
409
+ logging.info(f"[IC-LoRA] Added {len(conditionings)} video conditioning(s)")
410
+ return conditionings
411
+
412
+ # ── Main generation entry point ──────────────────────────────────────
413
+ def __call__(
414
+ self,
415
+ prompt: str,
416
+ seed: int,
417
+ height: int,
418
+ width: int,
419
+ num_frames: int,
420
+ frame_rate: float,
421
+ images: list[ImageConditioningInput],
422
+ audio_path: str | None = None,
423
+ video_conditioning: list[tuple[str, float]] | None = None,
424
+ tiling_config: TilingConfig | None = None,
425
+ enhance_prompt: bool = False,
426
+ conditioning_strength: float = 1.0,
427
+ ):
428
+ """
429
+ Generate video with any combination of conditioning.
430
+ Args:
431
+ audio_path: Path to external audio file for lipsync/BGM conditioning.
432
+ video_conditioning: List of (path, strength) tuples for IC-LoRA V2V.
433
+ conditioning_strength: Scale for IC-LoRA attention influence [0, 1].
434
+ Returns:
435
+ Tuple of (decoded_video_iterator, Audio).
436
+ """
437
+ assert_resolution(height=height, width=width, is_two_stage=True)
438
+
439
+ prompt += " synchronized lipsync"
440
+
441
+ has_audio = audio_path is not None
442
+ has_video_cond = bool(video_conditioning)
443
+
444
+ generator = torch.Generator(device=self.device).manual_seed(seed)
445
+ noiser = GaussianNoiser(generator=generator)
446
+ stepper = EulerDiffusionStep()
447
+ dtype = torch.bfloat16
448
+
449
+ # ── Encode text prompt ───────────────────────────────────────────
450
+ # Use stage_1 ledger for prompt encoding (has text encoder)
451
+ (ctx_p,) = encode_prompts(
452
+ [prompt],
453
+ self.stage_1_model_ledger,
454
+ enhance_first_prompt=enhance_prompt,
455
+ enhance_prompt_image=images[0].path if len(images) > 0 else None,
456
+ )
457
+ video_context, audio_context = ctx_p.video_encoding, ctx_p.audio_encoding
458
+
459
+ # ── Encode external audio (if provided) ─────────────────────────
460
+ encoded_audio_latent = None
461
+ decoded_audio_for_output = None
462
+ if has_audio:
463
+ video_duration = num_frames / frame_rate
464
+ decoded_audio = decode_audio_from_file(audio_path, self.device, 0.0, video_duration)
465
+ if decoded_audio is None:
466
+ raise ValueError(f"Could not extract audio stream from {audio_path}")
467
+
468
+ encoded_audio_latent = vae_encode_audio(
469
+ decoded_audio, self.stage_1_model_ledger.audio_encoder()
470
+ )
471
+ audio_shape = AudioLatentShape.from_duration(
472
+ batch=1, duration=video_duration, channels=8, mel_bins=16
473
+ )
474
+ expected_frames = audio_shape.frames
475
+ actual_frames = encoded_audio_latent.shape[2]
476
+
477
+ if actual_frames > expected_frames:
478
+ encoded_audio_latent = encoded_audio_latent[:, :, :expected_frames, :]
479
+ elif actual_frames < expected_frames:
480
+ pad = torch.zeros(
481
+ encoded_audio_latent.shape[0], encoded_audio_latent.shape[1],
482
+ expected_frames - actual_frames, encoded_audio_latent.shape[3],
483
+ device=encoded_audio_latent.device, dtype=encoded_audio_latent.dtype,
484
+ )
485
+ encoded_audio_latent = torch.cat([encoded_audio_latent, pad], dim=2)
486
+
487
+ decoded_audio_for_output = Audio(
488
+ waveform=decoded_audio.waveform.squeeze(0),
489
+ sampling_rate=decoded_audio.sampling_rate,
490
+ )
491
+
492
+ # ── Build conditionings for Stage 1 ──────────────────────────────
493
+ # Use stage_1 video encoder (has IC-LoRA context)
494
+ video_encoder = self.stage_1_model_ledger.video_encoder()
495
+
496
+ stage_1_output_shape = VideoPixelShape(
497
+ batch=1, frames=num_frames,
498
+ width=width // 2, height=height // 2, fps=frame_rate,
499
+ )
500
+
501
+ # Image conditionings
502
+ stage_1_conditionings = combined_image_conditionings(
503
+ images=images,
504
+ height=stage_1_output_shape.height,
505
+ width=stage_1_output_shape.width,
506
+ video_encoder=video_encoder,
507
+ dtype=dtype,
508
+ device=self.device,
509
+ )
510
+
511
+ # IC-LoRA video reference conditionings
512
+ if has_video_cond:
513
+ ic_conds = self._create_ic_conditionings(
514
+ video_conditioning=video_conditioning,
515
+ height=stage_1_output_shape.height,
516
+ width=stage_1_output_shape.width,
517
+ num_frames=num_frames,
518
+ video_encoder=video_encoder,
519
+ conditioning_strength=conditioning_strength,
520
+ )
521
+ stage_1_conditionings.extend(ic_conds)
522
+
523
+ # ── Stage 1: Low-res generation ──────────────────────────────────
524
+ transformer = self.stage_1_model_ledger.transformer()
525
+ stage_1_sigmas = torch.Tensor(DISTILLED_SIGMA_VALUES).to(self.device)
526
+
527
+ def denoising_loop(sigmas, video_state, audio_state, stepper):
528
+ return euler_denoising_loop(
529
+ sigmas=sigmas,
530
+ video_state=video_state,
531
+ audio_state=audio_state,
532
+ stepper=stepper,
533
+ denoise_fn=simple_denoising_func(
534
+ video_context=video_context,
535
+ audio_context=audio_context,
536
+ transformer=transformer,
537
+ ),
538
+ )
539
+
540
+ if has_audio:
541
+ # Audio mode: denoise video only, use external audio latent
542
+ video_state = denoise_video_only(
543
+ output_shape=stage_1_output_shape,
544
+ conditionings=stage_1_conditionings,
545
+ noiser=noiser,
546
+ sigmas=stage_1_sigmas,
547
+ stepper=stepper,
548
+ denoising_loop_fn=denoising_loop,
549
+ components=self.pipeline_components,
550
+ dtype=dtype,
551
+ device=self.device,
552
+ initial_audio_latent=encoded_audio_latent,
553
+ )
554
+ audio_state = None # we'll use the original audio for output
555
+ else:
556
+ # Standard / IC-only mode: denoise both audio and video
557
+ video_state, audio_state = denoise_audio_video(
558
+ output_shape=stage_1_output_shape,
559
+ conditionings=stage_1_conditionings,
560
+ noiser=noiser,
561
+ sigmas=stage_1_sigmas,
562
+ stepper=stepper,
563
+ denoising_loop_fn=denoising_loop,
564
+ components=self.pipeline_components,
565
+ dtype=dtype,
566
+ device=self.device,
567
+ )
568
+
569
+ torch.cuda.synchronize()
570
+ cleanup_memory()
571
+
572
+ # ── Stage 2: Upsample + Refine ──────────────────────────────────
573
+ upscaled_video_latent = upsample_video(
574
+ latent=video_state.latent[:1],
575
+ video_encoder=video_encoder,
576
+ upsampler=self.stage_2_model_ledger.spatial_upsampler(),
577
+ )
578
+
579
+ torch.cuda.synchronize()
580
+ cleanup_memory()
581
+
582
+ # Stage 2 uses the transformer WITHOUT IC-LoRA
583
+ transformer_s2 = self.stage_2_model_ledger.transformer()
584
+ stage_2_sigmas = torch.Tensor(STAGE_2_DISTILLED_SIGMA_VALUES).to(self.device)
585
+
586
+ def denoising_loop_s2(sigmas, video_state, audio_state, stepper):
587
+ return euler_denoising_loop(
588
+ sigmas=sigmas,
589
+ video_state=video_state,
590
+ audio_state=audio_state,
591
+ stepper=stepper,
592
+ denoise_fn=simple_denoising_func(
593
+ video_context=video_context,
594
+ audio_context=audio_context,
595
+ transformer=transformer_s2,
596
+ ),
597
+ )
598
+
599
+ stage_2_output_shape = VideoPixelShape(
600
+ batch=1, frames=num_frames,
601
+ width=width, height=height, fps=frame_rate,
602
+ )
603
+ stage_2_conditionings = combined_image_conditionings(
604
+ images=images,
605
+ height=stage_2_output_shape.height,
606
+ width=stage_2_output_shape.width,
607
+ video_encoder=video_encoder,
608
+ dtype=dtype,
609
+ device=self.device,
610
+ )
611
+
612
+ if has_audio:
613
+ video_state = denoise_video_only(
614
+ output_shape=stage_2_output_shape,
615
+ conditionings=stage_2_conditionings,
616
+ noiser=noiser,
617
+ sigmas=stage_2_sigmas,
618
+ stepper=stepper,
619
+ denoising_loop_fn=denoising_loop_s2,
620
+ components=self.pipeline_components,
621
+ dtype=dtype,
622
+ device=self.device,
623
+ noise_scale=stage_2_sigmas[0],
624
+ initial_video_latent=upscaled_video_latent,
625
+ initial_audio_latent=encoded_audio_latent,
626
+ )
627
+ audio_state = None
628
+ else:
629
+ video_state, audio_state = denoise_audio_video(
630
+ output_shape=stage_2_output_shape,
631
+ conditionings=stage_2_conditionings,
632
+ noiser=noiser,
633
+ sigmas=stage_2_sigmas,
634
+ stepper=stepper,
635
+ denoising_loop_fn=denoising_loop_s2,
636
+ components=self.pipeline_components,
637
+ dtype=dtype,
638
+ device=self.device,
639
+ noise_scale=stage_2_sigmas[0],
640
+ initial_video_latent=upscaled_video_latent,
641
+ initial_audio_latent=audio_state.latent,
642
+ )
643
+
644
+ torch.cuda.synchronize()
645
+ del transformer, transformer_s2, video_encoder
646
+ cleanup_memory()
647
+
648
+ # ── Decode ───────────────────────────────────────────────────────
649
+ decoded_video = vae_decode_video(
650
+ video_state.latent,
651
+ self.stage_2_model_ledger.video_decoder(),
652
+ tiling_config,
653
+ generator,
654
+ )
655
+
656
+ if has_audio:
657
+ output_audio = decoded_audio_for_output
658
+ else:
659
+ output_audio = vae_decode_audio(
660
+ audio_state.latent,
661
+ self.stage_2_model_ledger.audio_decoder(),
662
+ self.stage_2_model_ledger.vocoder(),
663
+ )
664
+
665
+ return decoded_video, output_audio
666
+
667
+
668
+ # ─────────────────────────────────────────────────────────────────────────────
669
+ # Constants
670
+ # ─────────────────────────────────────────────────────────────────────────────
671
+ MAX_SEED = np.iinfo(np.int32).max
672
+ DEFAULT_PROMPT = (
673
+ "An astronaut hatches from a fragile egg on the surface of the Moon, "
674
+ "the shell cracking and peeling apart in gentle low-gravity motion."
675
+ )
676
+ DEFAULT_FRAME_RATE = 24.0
677
+
678
+ RESOLUTIONS = {
679
+ "high": {"16:9": (1536, 1024), "9:16": (1024, 1536), "1:1": (1024, 1024)},
680
+ "low": {"16:9": (768, 512), "9:16": (512, 768), "1:1": (768, 768)},
681
+ }
682
+
683
+ # Available IC-LoRA models
684
+ IC_LORA_OPTIONS = {
685
+ "Union Control (Depth + Edge)": {
686
+ "repo": "Lightricks/LTX-2.3-22b-IC-LoRA-Union-Control",
687
+ "filename": "ltx-2.3-22b-ic-lora-union-control-ref0.5.safetensors",
688
+ },
689
+ "Motion Track Control": {
690
+ "repo": "Lightricks/LTX-2.3-22b-IC-LoRA-Motion-Track-Control",
691
+ "filename": "ltx-2.3-22b-ic-lora-motion-track-control-ref0.5.safetensors",
692
+ },
693
+ }
694
+ DEFAULT_IC_LORA = "Union Control (Depth + Edge)"
695
+
696
+
697
+ # ─────────────────────────────────────────────────────────────────────────────
698
+ # Download Models
699
+ # ─────────────────────────────────────────────────────────────────────────────
700
+ LTX_MODEL_REPO = "Lightricks/LTX-2.3"
701
+ CHECKPOINT_PATH = "linoyts/ltx-2.3-22b-distilled-1.1-fused-union-control" #ltx 2.3 with fused union control lora because it breaks on quantization otherwise
702
+ GEMMA_REPO = "google/gemma-3-12b-it-qat-q4_0-unquantized"
703
+
704
+ print("=" * 80)
705
+ print("Downloading LTX-2.3 distilled model + Gemma + IC-LoRA...")
706
+ print("=" * 80)
707
+
708
+ checkpoint_path = hf_hub_download(
709
+ # repo_id=LTX_MODEL_REPO, filename="ltx-2.3-22b-distilled.safetensors"
710
+ repo_id=CHECKPOINT_PATH, filename="ltx-2.3-22b-distilled-1.1-fused-union-control.safetensors"
711
+ )
712
+ spatial_upsampler_path = hf_hub_download(
713
+ repo_id=LTX_MODEL_REPO, filename="ltx-2.3-spatial-upscaler-x2-1.1.safetensors"
714
+ )
715
+ gemma_root = snapshot_download(repo_id=GEMMA_REPO)
716
+
717
+ # Download default IC-LoRA
718
+ default_lora_info = IC_LORA_OPTIONS[DEFAULT_IC_LORA]
719
+ default_ic_lora_path = hf_hub_download(
720
+ repo_id=default_lora_info["repo"], filename=default_lora_info["filename"]
721
+ )
722
+
723
+ print(f"Checkpoint: {checkpoint_path}")
724
+ print(f"Spatial upsampler: {spatial_upsampler_path}")
725
+ print(f"Gemma root: {gemma_root}")
726
+ print(f"IC-LoRA: {default_ic_lora_path}")
727
+
728
+
729
+ # ─────────────────────────────────────────────────────────────────────────────
730
+ # Initialize Pipeline
731
+ # ─────────────────────────────────────────────────────────────────────────────
732
+ ic_loras = [
733
+ LoraPathStrengthAndSDOps(default_ic_lora_path, 1.0, LTXV_LORA_COMFY_RENAMING_MAP)
734
+ ]
735
+
736
+ pipeline = LTX23UnifiedPipeline(
737
+ distilled_checkpoint_path=checkpoint_path,
738
+ spatial_upsampler_path=spatial_upsampler_path,
739
+ gemma_root=gemma_root,
740
+ # ic_loras=ic_loras, # LoRA already fused into checkpoint
741
+ quantization=QuantizationPolicy.fp8_cast(),
742
+ # Union Control IC-LoRA was trained with reference videos at half resolution.
743
+ # Set explicitly so it works both with separate LoRA and fused checkpoints.
744
+ reference_downscale_factor=2,
745
+ )
746
+
747
+ # Preload all models for ZeroGPU tensor packing.
748
+ print("Preloading all models (including Gemma, Audio encoders)...")
749
+
750
+ # Shared ledger: preload once. Separate ledgers (IC-LoRA): preload both.
751
+ _ledger_1 = pipeline.stage_1_model_ledger
752
+ _ledger_2 = pipeline.stage_2_model_ledger
753
+ _shared = _ledger_1 is _ledger_2
754
+
755
+ # Stage 1 models (with IC-LoRA if loaded)
756
+ _s1_transformer = _ledger_1.transformer()
757
+ _s1_video_encoder = _ledger_1.video_encoder()
758
+ _s1_text_encoder = _ledger_1.text_encoder()
759
+ _s1_embeddings = _ledger_1.gemma_embeddings_processor()
760
+ _s1_audio_encoder = _ledger_1.audio_encoder()
761
+
762
+ _ledger_1.transformer = lambda: _s1_transformer
763
+ _ledger_1.video_encoder = lambda: _s1_video_encoder
764
+ _ledger_1.text_encoder = lambda: _s1_text_encoder
765
+ _ledger_1.gemma_embeddings_processor = lambda: _s1_embeddings
766
+ _ledger_1.audio_encoder = lambda: _s1_audio_encoder
767
+
768
+ if _shared:
769
+ # Single ledger — also preload decoder/upsampler/vocoder on the same object
770
+ _video_decoder = _ledger_1.video_decoder()
771
+ _audio_decoder = _ledger_1.audio_decoder()
772
+ _vocoder = _ledger_1.vocoder()
773
+ _spatial_upsampler = _ledger_1.spatial_upsampler()
774
+
775
+ _ledger_1.video_decoder = lambda: _video_decoder
776
+ _ledger_1.audio_decoder = lambda: _audio_decoder
777
+ _ledger_1.vocoder = lambda: _vocoder
778
+ _ledger_1.spatial_upsampler = lambda: _spatial_upsampler
779
+ print(" (single shared ledger — no IC-LoRA)")
780
+ else:
781
+ # Stage 2 models (separate transformer without IC-LoRA)
782
+ _s2_transformer = _ledger_2.transformer()
783
+ _s2_video_encoder = _ledger_2.video_encoder()
784
+ _s2_video_decoder = _ledger_2.video_decoder()
785
+ _s2_audio_decoder = _ledger_2.audio_decoder()
786
+ _s2_vocoder = _ledger_2.vocoder()
787
+ _s2_spatial_upsampler = _ledger_2.spatial_upsampler()
788
+ _s2_text_encoder = _ledger_2.text_encoder()
789
+ _s2_embeddings = _ledger_2.gemma_embeddings_processor()
790
+ _s2_audio_encoder = _ledger_2.audio_encoder()
791
+
792
+ _ledger_2.transformer = lambda: _s2_transformer
793
+ _ledger_2.video_encoder = lambda: _s2_video_encoder
794
+ _ledger_2.video_decoder = lambda: _s2_video_decoder
795
+ _ledger_2.audio_decoder = lambda: _s2_audio_decoder
796
+ _ledger_2.vocoder = lambda: _s2_vocoder
797
+ _ledger_2.spatial_upsampler = lambda: _s2_spatial_upsampler
798
+ _ledger_2.text_encoder = lambda: _s2_text_encoder
799
+ _ledger_2.gemma_embeddings_processor = lambda: _s2_embeddings
800
+ _ledger_2.audio_encoder = lambda: _s2_audio_encoder
801
+ print(" (two separate ledgers — IC-LoRA active)")
802
+
803
+ print("All models preloaded!")
804
+ print("=" * 80)
805
+
806
+
807
+ # ─────────────────────────────────────────────────────────────────────────────
808
+ # UI Helpers
809
+ # ─────────────────────────────────────────────────────────────────────────────
810
+ def detect_aspect_ratio(media_path) -> str:
811
+ """Detect the closest aspect ratio from an image or video."""
812
+ if media_path is None:
813
+ return "16:9"
814
+
815
+ ext = str(media_path).lower().rsplit(".", 1)[-1] if "." in str(media_path) else ""
816
+
817
+ # Try as image first
818
+ if ext in ("jpg", "jpeg", "png", "bmp", "webp", "gif", "tiff"):
819
+ import PIL.Image
820
+ try:
821
+ with PIL.Image.open(media_path) as img:
822
+ w, h = img.size
823
+ except Exception:
824
+ return "16:9"
825
+ else:
826
+ # Try as video
827
+ try:
828
+ import av
829
+ with av.open(str(media_path)) as container:
830
+ stream = container.streams.video[0]
831
+ w, h = stream.codec_context.width, stream.codec_context.height
832
+ except Exception:
833
+ # Fallback: try as image anyway
834
+ import PIL.Image
835
+ try:
836
+ with PIL.Image.open(media_path) as img:
837
+ w, h = img.size
838
+ except Exception:
839
+ return "16:9"
840
+
841
+ ratio = w / h
842
+ candidates = {"16:9": 16 / 9, "9:16": 9 / 16, "1:1": 1.0}
843
+ return min(candidates, key=lambda k: abs(ratio - candidates[k]))
844
+
845
+
846
+ def on_image_upload(image, video, high_res):
847
+ """Auto-set resolution when image is uploaded."""
848
+ media = image if image is not None else video
849
+ aspect = detect_aspect_ratio(media)
850
+ tier = "high" if high_res else "low"
851
+ w, h = RESOLUTIONS[tier][aspect]
852
+ return gr.update(value=w), gr.update(value=h)
853
+
854
+
855
+ def _get_video_duration(video_path) -> float | None:
856
+ """Get video duration in seconds via ffprobe."""
857
+ if video_path is None:
858
+ return None
859
+ try:
860
+ result = subprocess.run(
861
+ ["ffprobe", "-v", "error", "-select_streams", "v:0",
862
+ "-show_entries", "format=duration", "-of", "default=nw=1:nk=1",
863
+ str(video_path)],
864
+ capture_output=True, text=True,
865
+ )
866
+ return float(result.stdout.strip())
867
+ except Exception:
868
+ return None
869
+
870
+
871
+ def on_video_upload(video, image, high_res):
872
+ """Auto-set resolution and duration when video is uploaded."""
873
+ media = video if video is not None else image
874
+ aspect = detect_aspect_ratio(media)
875
+ tier = "high" if high_res else "low"
876
+ w, h = RESOLUTIONS[tier][aspect]
877
+
878
+ # Auto-adjust duration to min(video_length, 10)
879
+ vid_dur = _get_video_duration(video)
880
+ if vid_dur is not None:
881
+ dur = round(min(vid_dur, 15.0), 1)
882
+ else:
883
+ dur = 3.0
884
+
885
+ return gr.update(value=w), gr.update(value=h), gr.update(value=dur)
886
+
887
+
888
+ def on_highres_toggle(image, video, high_res):
889
+ """Update resolution when high-res toggle changes."""
890
+ media = image if image is not None else video
891
+ aspect = detect_aspect_ratio(media)
892
+ tier = "high" if high_res else "low"
893
+ w, h = RESOLUTIONS[tier][aspect]
894
+ return gr.update(value=w), gr.update(value=h)
895
+
896
+
897
+ # ─────────────────────────────────────────────────────────────────────────────
898
+ # Generation
899
+ # ─────────────────────────────────────────────────────────────────────────────
900
+ def _extract_audio_from_video(video_path: str) -> str | None:
901
+ """Extract audio from video as a temp WAV file. Returns None if no audio."""
902
+ out_path = tempfile.mktemp(suffix=".wav")
903
+ try:
904
+ # Check if video has an audio stream
905
+ probe = subprocess.run(
906
+ ["ffprobe", "-v", "error", "-select_streams", "a:0",
907
+ "-show_entries", "stream=codec_type", "-of", "default=nw=1:nk=1",
908
+ video_path],
909
+ capture_output=True, text=True,
910
+ )
911
+ if not probe.stdout.strip():
912
+ return None
913
+
914
+ # Extract audio
915
+ subprocess.run(
916
+ ["ffmpeg", "-y", "-v", "error", "-i", video_path,
917
+ "-vn", "-ac", "2", "-ar", "48000", "-c:a", "pcm_s16le", out_path],
918
+ check=True,
919
+ )
920
+ return out_path
921
+ except (subprocess.CalledProcessError, FileNotFoundError):
922
+ return None
923
+
924
+
925
+ @spaces.GPU(duration=100)
926
+ @torch.inference_mode()
927
+ def generate_video(
928
+ input_image,
929
+ input_video,
930
+ prompt: str = "",
931
+ duration: float = 3,
932
+ conditioning_strength: float = 0.85,
933
+ enhance_prompt: bool = True,
934
+ use_video_audio: bool = True,
935
+ seed: int = 42,
936
+ randomize_seed: bool = True,
937
+ height: int = 512,
938
+ width: int = 768,
939
+ input_audio = None,
940
+ progress=gr.Progress(track_tqdm=True),
941
+ ):
942
+ video_preprocess="Pose (DWPose)"
943
+ try:
944
+ torch.cuda.reset_peak_memory_stats()
945
+ current_seed = random.randint(0, MAX_SEED) if randomize_seed else int(seed)
946
+
947
+ frame_rate = DEFAULT_FRAME_RATE
948
+ num_frames = int(duration * frame_rate) + 1
949
+ num_frames = ((num_frames - 1 + 7) // 8) * 8 + 1
950
+
951
+ mode_parts = []
952
+ if input_image is not None:
953
+ mode_parts.append("Image")
954
+ if input_video is not None:
955
+ mode_parts.append(f"Video({video_preprocess})")
956
+ if input_audio is not None:
957
+ mode_parts.append("Audio")
958
+ if not mode_parts:
959
+ mode_parts.append("Text")
960
+ mode_str = " + ".join(mode_parts)
961
+
962
+ print(f"[{mode_str}] Generating: {height}x{width}, {num_frames} frames "
963
+ f"({duration}s), seed={current_seed}")
964
+
965
+ # Build image conditionings
966
+ images = []
967
+ if input_image is not None:
968
+ images = [ImageConditioningInput(path=str(input_image), frame_idx=0, strength=1.0)]
969
+
970
+ # Build video conditionings — preprocess to strip appearance
971
+ video_conditioning = None
972
+ if input_video is not None:
973
+ video_path = str(input_video)
974
+
975
+ if video_preprocess != "Raw (no preprocessing)":
976
+ print(f"[Preprocess] Running {video_preprocess} on input video...")
977
+ cond_mp4, first_frame_png = preprocess_conditioning_video(
978
+ video_path=video_path,
979
+ mode=video_preprocess,
980
+ width=int(width) // 2, # Stage 1 operates at half res
981
+ height=int(height) // 2,
982
+ num_frames=num_frames,
983
+ fps=frame_rate,
984
+ )
985
+ video_conditioning = [(cond_mp4, 1.0)]
986
+
987
+ # If no image was provided, use the video's first frame
988
+ # (original appearance) as the image conditioning
989
+ if input_image is None:
990
+ images = [ImageConditioningInput(
991
+ path=first_frame_png, frame_idx=0, strength=1.0,
992
+ )]
993
+ print(f"[Preprocess] Using video first frame as image conditioning")
994
+ else:
995
+ # Raw mode — pass video as-is
996
+ video_conditioning = [(video_path, 1.0)]
997
+
998
+ # If no audio was provided, optionally extract audio from the video
999
+ if input_audio is None and use_video_audio:
1000
+ extracted_audio = _extract_audio_from_video(video_path)
1001
+ if extracted_audio is not None:
1002
+ input_audio = extracted_audio
1003
+ print(f"[Preprocess] Extracted audio from input video")
1004
+
1005
+ tiling_config = TilingConfig.default()
1006
+ video_chunks_number = get_video_chunks_number(num_frames, tiling_config)
1007
+
1008
+ video, audio = pipeline(
1009
+ prompt=prompt,
1010
+ seed=current_seed,
1011
+ height=int(height),
1012
+ width=int(width),
1013
+ num_frames=num_frames,
1014
+ frame_rate=frame_rate,
1015
+ images=images,
1016
+ audio_path=input_audio,
1017
+ video_conditioning=video_conditioning,
1018
+ tiling_config=tiling_config,
1019
+ enhance_prompt=enhance_prompt,
1020
+ conditioning_strength=conditioning_strength,
1021
+ )
1022
+
1023
+ output_path = tempfile.mktemp(suffix=".mp4")
1024
+ encode_video(
1025
+ video=video,
1026
+ fps=frame_rate,
1027
+ audio=audio,
1028
+ output_path=output_path,
1029
+ video_chunks_number=video_chunks_number,
1030
+ )
1031
+
1032
+ return str(output_path), current_seed
1033
+
1034
+ except Exception as e:
1035
+ import traceback
1036
+ print(f"Error: {str(e)}\n{traceback.format_exc()}")
1037
+ return None, current_seed
1038
+
1039
+
1040
+ # ─────────────────────────────────────────────────────────────────────────────
1041
+ # SmolVLM2 — Auto-describe motion from reference video
1042
+ # ─────────────────────────────────────────────────────────────────────────────
1043
+ SMOLVLM_MODEL_ID = "HuggingFaceTB/SmolVLM2-500M-Video-Instruct"
1044
+ _vlm_model = None
1045
+ _vlm_processor = None
1046
+
1047
+ MOTION_PROMPT = """\
1048
+ Watch this video carefully. Describe ONLY the following:
1049
+ 1. The body movements and gestures (walking, dancing, waving, turning, etc.)
1050
+ 2. Facial expressions and head movements (smiling, nodding, looking around, etc.)
1051
+ 3. The rhythm, speed, and energy of the motion (slow, fast, smooth, jerky, etc.)
1052
+ 4. The overall mood and tone conveyed by the movement
1053
+ Do NOT describe:
1054
+ - What the person/subject looks like (clothing, hair, skin, age, gender)
1055
+ - The background, setting, or environment
1056
+ - Colors, lighting, or visual style
1057
+ - Any objects or props
1058
+ Write a concise, single-paragraph description focused purely on motion and expression.\
1059
+ """
1060
+
1061
+
1062
+ def _load_vlm():
1063
+ global _vlm_model, _vlm_processor
1064
+ if _vlm_model is None:
1065
+ from transformers import AutoProcessor, AutoModelForImageTextToText
1066
+
1067
+ print(f"[SmolVLM] Loading {SMOLVLM_MODEL_ID}...")
1068
+ _vlm_processor = AutoProcessor.from_pretrained(
1069
+ SMOLVLM_MODEL_ID, trust_remote_code=True
1070
+ )
1071
+ try:
1072
+ _vlm_model = AutoModelForImageTextToText.from_pretrained(
1073
+ SMOLVLM_MODEL_ID,
1074
+ torch_dtype=torch.bfloat16,
1075
+ trust_remote_code=True,
1076
+ _attn_implementation="flash_attention_2",
1077
+ ).to("cuda")
1078
+ except Exception:
1079
+ _vlm_model = AutoModelForImageTextToText.from_pretrained(
1080
+ SMOLVLM_MODEL_ID,
1081
+ torch_dtype=torch.bfloat16,
1082
+ trust_remote_code=True,
1083
+ ).to("cuda")
1084
+ print("[SmolVLM] Model loaded!")
1085
+ return _vlm_model, _vlm_processor
1086
+
1087
+
1088
+ @spaces.GPU(duration=60)
1089
+ @torch.inference_mode()
1090
+ def describe_video_motion(video_path, auto_describe=True):
1091
+ """Use SmolVLM2 to generate a motion-only description of a video."""
1092
+ if video_path is None or not auto_describe:
1093
+ return gr.update()
1094
+
1095
+ try:
1096
+ model, processor = _load_vlm()
1097
+
1098
+ messages = [
1099
+ {
1100
+ "role": "user",
1101
+ "content": [
1102
+ {"type": "video", "path": str(video_path)},
1103
+ {"type": "text", "text": MOTION_PROMPT},
1104
+ ],
1105
+ },
1106
+ ]
1107
+
1108
+ inputs = processor.apply_chat_template(
1109
+ messages,
1110
+ add_generation_prompt=True,
1111
+ tokenize=True,
1112
+ return_dict=True,
1113
+ return_tensors="pt",
1114
+ ).to(model.device, dtype=torch.bfloat16)
1115
+
1116
+ generated_ids = model.generate(**inputs, do_sample=False, max_new_tokens=200)
1117
+ generated_text = processor.batch_decode(
1118
+ generated_ids, skip_special_tokens=True
1119
+ )[0]
1120
+
1121
+ # Extract only the assistant's response (after the prompt)
1122
+ if "Assistant:" in generated_text:
1123
+ motion_desc = generated_text.split("Assistant:")[-1].strip()
1124
+ else:
1125
+ motion_desc = generated_text.strip()
1126
+
1127
+ # Clean up any leftover prompt fragments
1128
+ for marker in [MOTION_PROMPT[:40], "Watch this video", "Do NOT describe"]:
1129
+ if marker in motion_desc:
1130
+ motion_desc = motion_desc.split(marker)[0].strip()
1131
+
1132
+ if motion_desc:
1133
+ print(f"[SmolVLM] Motion description: {motion_desc[:100]}...")
1134
+ return gr.update(value=motion_desc)
1135
+ else:
1136
+ return gr.update()
1137
+
1138
+ except Exception as e:
1139
+ print(f"[SmolVLM] Error: {e}")
1140
+ return gr.update()
1141
+
1142
+
1143
+ # ─────────────────────────────────────────────────────────────────────────────
1144
+ # Gradio UI — LTX 2.3 Sync
1145
+ # ─────────────────────────────────────────────────────────────────────────────
1146
+ css = """
1147
+ .main-title { text-align: center; margin-bottom: 0.5em; }
1148
+ .generate-btn { min-height: 52px !important; font-size: 1.1em !important; }
1149
+ footer { display: none !important; }
1150
+ video { object-fit: contain !important; }
1151
+ """
1152
+
1153
+ purple_citrus = gr.themes.Citrus(
1154
+ primary_hue=gr.themes.colors.purple,
1155
+ secondary_hue=gr.themes.colors.purple,
1156
+ neutral_hue=gr.themes.colors.gray,
1157
+ )
1158
+
1159
+ with gr.Blocks(title="LTX 2.3 Sync", css=css, theme=purple_citrus) as demo:
1160
+ gr.Markdown("""
1161
+ # LTX 2.3 Sync: Fast Character Animation🕺
1162
+ **Fast Character Animation with LTX 2.3 Distilled**, using [Lightricks/LTX-2.3-22b-IC-LoRA-Union-Control](https://huggingface.co/Lightricks/LTX-2.3-22b-IC-LoRA-Union-Control) with pose estimation & custom audio inputs for precise lipsync and body movement replication ✨
1163
+ """)
1164
+
1165
+ # Hidden state — preprocessing is always Pose
1166
+ video_preprocess = gr.State("Pose (DWPose)")
1167
+
1168
+ with gr.Row():
1169
+ # ── Left column: inputs ──────────────────────────────────────
1170
+ with gr.Column(scale=1):
1171
+ with gr.Row():
1172
+
1173
+ input_image = gr.Image(
1174
+ label="Character reference",
1175
+ type="filepath",
1176
+ )
1177
+ input_video = gr.Video(
1178
+ label="Motion & audio reference",
1179
+ )
1180
+ with gr.Row():
1181
+ with gr.Column(min_width=160):
1182
+ prompt = gr.Textbox(
1183
+ label="Prompt (optional)",
1184
+ info="tip: describe the motion, body posture, facial expressions of the ref video",
1185
+ lines=2,
1186
+ placeholder="the person talks to the camera, making hand gestures",
1187
+ )
1188
+ duration = gr.Slider(
1189
+ label="Duration (s)", minimum=1.0, maximum=15.0, value=3.0, step=0.5,
1190
+ )
1191
+ auto_describe = gr.Checkbox(
1192
+ label="Auto-describe motion", value=False, visible=False,
1193
+ info="Use AI to describe the video's motion as a prompt",
1194
+ )
1195
+
1196
+ generate_btn = gr.Button(
1197
+ "Generate", variant="primary", size="lg", elem_classes=["generate-btn"],
1198
+ )
1199
+
1200
+ with gr.Accordion("Advanced Settings", open=False):
1201
+ enhance_prompt = gr.Checkbox(label="Enhance Prompt", value=True)
1202
+ conditioning_strength = gr.Slider(
1203
+ label="V2V Conditioning Strength",
1204
+ info="How closely to follow the reference video's structure",
1205
+ minimum=0.0, maximum=1.0, value=0.85, step=0.05,
1206
+ )
1207
+ high_res = gr.Checkbox(label="High Resolution (2×)", value=False)
1208
+ use_video_audio = gr.Checkbox(
1209
+ label="Use Audio from Video", value=True,
1210
+ info="Extract the audio track from the motion source video",
1211
+ )
1212
+ input_audio = gr.Audio(
1213
+ label="Override Audio (optional — replaces video audio)",
1214
+ type="filepath",
1215
+ )
1216
+ seed = gr.Slider(
1217
+ label="Seed", minimum=0, maximum=MAX_SEED, value=42, step=1,
1218
+ )
1219
+ randomize_seed = gr.Checkbox(label="Randomize Seed", value=True)
1220
+ with gr.Row():
1221
+ width = gr.Number(label="Width", value=768, precision=0)
1222
+ height = gr.Number(label="Height", value=512, precision=0)
1223
+
1224
+ # ── Right column: output ─────────────────────────────────────
1225
+ with gr.Column(scale=1):
1226
+ output_video = gr.Video(label="Result", autoplay=True, height=480)
1227
+
1228
+ gr.Examples(
1229
+ examples=[
1230
+ [
1231
+ "britney-spears-toxic-2004.jpg",
1232
+ "example_2.mp4",
1233
+ "",
1234
+ 3.4,
1235
+ 0.85,
1236
+ False,
1237
+ True,
1238
+ 1824535108,
1239
+ False,
1240
+ 512,
1241
+ 768,
1242
+ ],
1243
+ [
1244
+ "1 1.jpeg",
1245
+ "1 (2).mp4",
1246
+ "a man speaking while making hand gestures",
1247
+ 3.5,
1248
+ 0.9,
1249
+ False,
1250
+ True,
1251
+ 1723325627,
1252
+ False,
1253
+ 512,
1254
+ 768,
1255
+ ],
1256
+ [
1257
+ "2 (1).jpeg",
1258
+ "video-5.mp4",
1259
+ "",
1260
+ 6.8,
1261
+ 0.9,
1262
+ False,
1263
+ True,
1264
+ 42,
1265
+ True,
1266
+ 512,
1267
+ 768,
1268
+ ],
1269
+ ],
1270
+ inputs=[
1271
+ input_image,
1272
+ input_video,
1273
+ prompt,
1274
+ duration,
1275
+ conditioning_strength,
1276
+ enhance_prompt,
1277
+ use_video_audio,
1278
+ seed,
1279
+ randomize_seed,
1280
+ height,
1281
+ width,
1282
+ ],
1283
+ fn = generate_video,
1284
+ cache_examples=True,
1285
+ cache_mode="lazy",
1286
+ outputs=[output_video, seed],
1287
+ )
1288
+
1289
+ # ── Event handlers ───────────────────────────────────────────────────
1290
+ input_image.change(
1291
+ fn=on_image_upload,
1292
+ inputs=[input_image, input_video, high_res],
1293
+ outputs=[width, height],
1294
+ )
1295
+ input_video.change(
1296
+ fn=on_video_upload,
1297
+ inputs=[input_video, input_image, high_res],
1298
+ outputs=[width, height, duration],
1299
+ )
1300
+ high_res.change(
1301
+ fn=on_highres_toggle,
1302
+ inputs=[input_image, input_video, high_res],
1303
+ outputs=[width, height],
1304
+ )
1305
+ generate_btn.click(
1306
+ fn=generate_video,
1307
+ inputs=[
1308
+ input_image, input_video, prompt, duration,
1309
+ conditioning_strength, enhance_prompt,
1310
+ use_video_audio, seed, randomize_seed, height, width,input_audio
1311
+ ],
1312
+ outputs=[output_video, seed],
1313
+ )
1314
+
1315
+
1316
+ if __name__ == "__main__":
1317
+ demo.launch()
ltx2_two_stage.py CHANGED
@@ -3,7 +3,7 @@ python ltx2_two_stage.py \
3
  --image "astronaut.jpg" 0 1.0 \
4
  --prompt="An astronaut hatches from a fragile egg on the surface of the Moon, the shell cracking and peeling apart in gentle low-gravity motion. Fine lunar dust lifts and drifts outward with each movement, floating in slow arcs before settling back onto the ground. The astronaut pushes free in a deliberate, weightless motion, small fragments of the egg tumbling and spinning through the air. In the background, the deep darkness of space subtly shifts as stars glide with the camera's movement, emphasizing vast depth and scale. The camera performs a smooth, cinematic slow push-in, with natural parallax between the foreground dust, the astronaut, and the distant starfield. Ultra-realistic detail, physically accurate low-gravity motion, cinematic lighting, and a breath-taking, movie-like shot." \
5
  --output_path="t2v_2.mp4" \
6
- --gemma_root="unsloth/gemma-3-12b-it-qat-bnb-4bit" \
7
  --checkpoint_path="rc1/ltx-2-19b-dev-rc1.safetensors" \
8
  --distilled_lora_path "rc1/ltx-2-19b-distilled-lora-384-rc1.safetensors" \
9
  --spatial_upsampler_path "rc1/ltx-2-spatial-upscaler-x2-1.0-rc1.safetensors"
 
3
  --image "astronaut.jpg" 0 1.0 \
4
  --prompt="An astronaut hatches from a fragile egg on the surface of the Moon, the shell cracking and peeling apart in gentle low-gravity motion. Fine lunar dust lifts and drifts outward with each movement, floating in slow arcs before settling back onto the ground. The astronaut pushes free in a deliberate, weightless motion, small fragments of the egg tumbling and spinning through the air. In the background, the deep darkness of space subtly shifts as stars glide with the camera's movement, emphasizing vast depth and scale. The camera performs a smooth, cinematic slow push-in, with natural parallax between the foreground dust, the astronaut, and the distant starfield. Ultra-realistic detail, physically accurate low-gravity motion, cinematic lighting, and a breath-taking, movie-like shot." \
5
  --output_path="t2v_2.mp4" \
6
+ --gemma_root="google/gemma-3-12b-it-qat-q4_0-unquantized" \
7
  --checkpoint_path="rc1/ltx-2-19b-dev-rc1.safetensors" \
8
  --distilled_lora_path "rc1/ltx-2-19b-distilled-lora-384-rc1.safetensors" \
9
  --spatial_upsampler_path "rc1/ltx-2-spatial-upscaler-x2-1.0-rc1.safetensors"