Spaces:
Running on Zero
Running on Zero
Upload 5 files
Browse files- appdistilled.py +318 -0
- appfirstlastframe.py +542 -0
- appoutpaint.py +1246 -0
- appsync.py +1317 -0
- 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="
|
| 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"
|