File size: 18,112 Bytes
677b78a
8c86c53
 
677b78a
8c86c53
7df16e2
8c86c53
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
677b78a
 
8c86c53
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
677b78a
 
8c86c53
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125b505
 
 
 
 
 
 
8c86c53
 
 
 
 
125b505
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8c86c53
 
 
 
 
 
 
 
 
 
 
677b78a
8c86c53
 
 
 
 
 
 
 
 
 
 
 
 
 
2c57808
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2537fc3
2c57808
8c86c53
2c57808
 
 
8c86c53
 
 
 
2c57808
 
8c86c53
 
 
 
 
 
 
2c57808
8c86c53
 
 
 
 
 
 
 
 
125b505
8c86c53
 
 
 
 
 
677b78a
8c86c53
2c57808
 
8c86c53
677b78a
 
8c86c53
677b78a
 
8c86c53
 
 
 
 
 
2c57808
 
 
8c86c53
677b78a
8c86c53
 
 
125b505
8c86c53
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2c57808
 
 
 
8c86c53
 
 
677b78a
 
 
 
8c86c53
 
 
 
 
677b78a
8c86c53
677b78a
8c86c53
 
677b78a
8c86c53
677b78a
 
8c86c53
677b78a
8c86c53
 
 
 
677b78a
 
8c86c53
677b78a
 
8c86c53
677b78a
8c86c53
677b78a
8c86c53
677b78a
8c86c53
677b78a
 
 
 
 
 
8c86c53
 
 
 
 
 
677b78a
 
 
8c86c53
 
677b78a
8c86c53
 
 
677b78a
8c86c53
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2c57808
 
8c86c53
 
 
 
677b78a
8c86c53
 
 
 
 
 
 
 
677b78a
 
 
 
8c86c53
 
 
 
 
 
677b78a
 
 
8c86c53
677b78a
8c86c53
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
import os
import subprocess
import sys

# Allocator config for pixel-space video SR memory spikes β€” set before torch import.
os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
# Disable torch.compile / dynamo before any torch import (unsupported on ZeroGPU).
os.environ["TORCH_COMPILE_DISABLE"] = "1"
os.environ["TORCHDYNAMO_DISABLE"] = "1"

# ── Install xformers (memory-efficient attention) and video I/O deps ──────────
subprocess.run(
    [sys.executable, "-m", "pip", "install", "xformers==0.0.32.post2", "--no-build-isolation"],
    check=False,
)
subprocess.run(
    [sys.executable, "-m", "pip", "install", "imageio[ffmpeg]", "av", "num2words"],
    check=False,
)

# Reinstall torchaudio to match the pre-installed CUDA torch (deps can pull a CPU-only build).
_tv = subprocess.run(
    [sys.executable, "-c", "import torch; print(torch.__version__)"],
    capture_output=True, text=True,
)
if _tv.returncode == 0:
    _full_ver = _tv.stdout.strip()
    _cuda_suffix = _full_ver.split("+")[-1] if "+" in _full_ver else "cu124"
    _base_ver = _full_ver.split("+")[0]
    print(f"Detected torch {_full_ver}, reinstalling matching torchaudio...")
    subprocess.run(
        [sys.executable, "-m", "pip", "install", "--force-reinstall", "--no-deps",
         f"torchaudio=={_base_ver}",
         "--index-url", f"https://download.pytorch.org/whl/{_cuda_suffix}"],
        check=False,
    )

# ── Clone LTX-2 repo at a pinned commit and install ltx-core / ltx-pipelines ──
LTX_REPO_URL = "https://github.com/Lightricks/LTX-2.git"
LTX_REPO_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "LTX-2")
LTX_COMMIT = "ae855f8538843825f9015a419cf4ba5edaf5eec2"

if os.path.exists(LTX_REPO_DIR):
    subprocess.run(["rm", "-rf", LTX_REPO_DIR], check=True)

print(f"Cloning {LTX_REPO_URL}...")
subprocess.run(["git", "clone", LTX_REPO_URL, LTX_REPO_DIR], check=True)
print(f"Checking out commit {LTX_COMMIT}...")
subprocess.run(["git", "-C", LTX_REPO_DIR, "checkout", LTX_COMMIT], check=True)

print("Installing ltx-core and ltx-pipelines from pinned repo commit...")
subprocess.run(
    [sys.executable, "-m", "pip", "install", "--force-reinstall", "--no-deps",
     "-e", os.path.join(LTX_REPO_DIR, "packages", "ltx-core"),
     "-e", os.path.join(LTX_REPO_DIR, "packages", "ltx-pipelines")],
    check=True,
)
sys.path.insert(0, os.path.join(LTX_REPO_DIR, "packages", "ltx-pipelines", "src"))
sys.path.insert(0, os.path.join(LTX_REPO_DIR, "packages", "ltx-core", "src"))

import logging
import random
import tempfile

import torch
torch._dynamo.config.suppress_errors = True
torch._dynamo.config.disable = True

import spaces
import gradio as gr
import numpy as np
from huggingface_hub import hf_hub_download, snapshot_download

from ltx_core.loader import LoraPathStrengthAndSDOps, LTXV_LORA_COMFY_RENAMING_MAP
from ltx_core.model.video_vae import TilingConfig, get_video_chunks_number
from ltx_core.quantization import QuantizationPolicy
from ltx_pipelines.ic_lora import ICLoraPipeline
from ltx_pipelines.utils.media_io import encode_video

# ── Disable xformers FA3 dispatch (Blackwell / RTX PRO 6000 is sm_120; FA3 is Hopper-only) ──
try:
    from xformers.ops.fmha import _set_use_fa3
    _set_use_fa3(False)
    print("[ATTN] xformers FA3 dispatch disabled (Blackwell-incompatible)")
except Exception as e:
    print(f"[ATTN] FA3 disable skipped: {type(e).__name__}: {e}")

logging.getLogger().setLevel(logging.INFO)

MAX_SEED = np.iinfo(np.int32).max
DEFAULT_FRAME_RATE = 24.0

# ─────────────────────────────────────────────────────────────────────────────
# Model / checkpoint locations
# ─────────────────────────────────────────────────────────────────────────────
LTX_MODEL_REPO = "Lightricks/LTX-2.3"
DISTILLED_FILENAME = "ltx-2.3-22b-distilled-1.1.safetensors"
SPATIAL_UPSAMPLER_FILENAME = "ltx-2.3-spatial-upscaler-x2-1.1.safetensors"
GEMMA_REPO = "google/gemma-3-12b-it-qat-q4_0-unquantized"

UPSCALER_REPO = "Lightricks/LTX-2.3-22b-IC-LoRA-Pixel-Spatial-Upscaler"
UPSCALER_X4_FILENAME = "ltx-2.3-22b-ic-lora-pixel-spatial-upscaler-x4-0.9.safetensors"

print("=" * 80)
print("Downloading LTX-2.3 distilled base + spatial upsampler + Gemma + upscaler IC-LoRA...")
print("=" * 80)

checkpoint_path = hf_hub_download(repo_id=LTX_MODEL_REPO, filename=DISTILLED_FILENAME)
spatial_upsampler_path = hf_hub_download(repo_id=LTX_MODEL_REPO, filename=SPATIAL_UPSAMPLER_FILENAME)
gemma_root = snapshot_download(repo_id=GEMMA_REPO)
upscaler_lora_path = hf_hub_download(repo_id=UPSCALER_REPO, filename=UPSCALER_X4_FILENAME)

print(f"Checkpoint:        {checkpoint_path}")
print(f"Spatial upsampler: {spatial_upsampler_path}")
print(f"Gemma root:        {gemma_root}")
print(f"Upscaler IC-LoRA:  {upscaler_lora_path}")

# ─────────────────────────────────────────────────────────────────────────────
# IC-LoRA pipeline (4x spatial upscaler) β€” built lazily inside the GPU worker.
#
# The LTX loader opens safetensors with `safe_open(device="cuda")` and streams
# tensors straight onto the GPU, which bypasses the ZeroGPU `.to("cuda")` hijack
# and fails at module scope ("No CUDA GPUs are available"). So we construct the
# pipeline on the first `@spaces.GPU` call, where a real GPU is attached, and
# cache it in a module global for subsequent calls on the same warm worker.
# ─────────────────────────────────────────────────────────────────────────────
ic_loras = [
    LoraPathStrengthAndSDOps(upscaler_lora_path, 1.0, LTXV_LORA_COMFY_RENAMING_MAP)
]

_PIPELINE = None
REFERENCE_DOWNSCALE_FACTOR = 4  # x4 upscaler LoRA (from its safetensors metadata)


def get_pipeline():
    """Build + cache the ICLoraPipeline. Must be called inside a GPU worker."""
    global _PIPELINE
    if _PIPELINE is None:
        print("[Pipeline] Building ICLoraPipeline (first GPU call)...")
        _PIPELINE = ICLoraPipeline(
            distilled_checkpoint_path=checkpoint_path,
            spatial_upsampler_path=spatial_upsampler_path,
            gemma_root=gemma_root,
            loras=ic_loras,
            quantization=QuantizationPolicy.fp8_cast(),
        )
        print(f"[Pipeline] reference_downscale_factor = {_PIPELINE.reference_downscale_factor}")
        print("[Pipeline] Ready.")
    return _PIPELINE


# ─────────────────────────────────────────────────────────────────────────────
# Resolution helpers
# ─────────────────────────────────────────────────────────────────────────────
def _round_to(x: int, base: int) -> int:
    return max(base, int(round(x / base)) * base)


def _probe_video(video_path: str):
    """Return (width, height, fps, duration_seconds) of a video."""
    import av
    with av.open(str(video_path)) as container:
        s = container.streams.video[0]
        w = s.codec_context.width
        h = s.codec_context.height
        fps = float(s.average_rate) if s.average_rate else DEFAULT_FRAME_RATE
        if s.duration is not None and s.time_base is not None:
            dur = float(s.duration * s.time_base)
        elif container.duration is not None:
            dur = float(container.duration) / 1_000_000.0
        else:
            dur = 3.0
    return w, h, fps, dur


# VAE spatial compression, read directly from the checkpoint's embedded VAE
# config (encoder_blocks): patch_size(4) * compress_space_res(2) *
# compress_all_res(2) * compress_all_res(2) = 32. NOT taken from
# ltx_core.types.SpatioTemporalScaleFactors.default() -- that "default" is a
# hardcoded literal on VideoDecoder unrelated to the actual decoder_blocks it's
# built with, so it can't be trusted to reflect any given checkpoint.
VAE_SPATIAL_COMPRESSION = 32

# ICLoraPipeline runs stage 1 at half the requested output resolution
# (ic_lora.py: `VideoPixelShape(width=width // 2, height=height // 2, ...)`),
# and stage 1's IC-LoRA reference-conditioning video is encoded at that
# half-resolution divided again by reference_downscale_factor. So the video
# actually reaching the VAE encoder is:
#   height_full / STAGE_1_RESOLUTION_DIVISOR / reference_downscale_factor
# and for that to be divisible by VAE_SPATIAL_COMPRESSION, height_full (and
# width_full) must be divisible by their product.
STAGE_1_RESOLUTION_DIVISOR = 2


def _reference_resolution_multiple(scale: int) -> int:
    """Full output resolution must be a multiple of this for the IC-LoRA
    reference-conditioning video (see constants above) to survive VAE encode."""
    return STAGE_1_RESOLUTION_DIVISOR * scale * VAE_SPATIAL_COMPRESSION


def compute_output_resolution(video_path: str, scale: int = REFERENCE_DOWNSCALE_FACTOR):
    """Target output = input resolution * scale, snapped to a multiple of
    _reference_resolution_multiple(scale)."""
    w, h, _, _ = _probe_video(video_path)
    res_multiple = _reference_resolution_multiple(scale)
    out_w = _round_to(w * scale, res_multiple)
    out_h = _round_to(h * scale, res_multiple)
    # Cap the longest side to keep runtime + VRAM bounded.
    max_side = 1536
    if max(out_w, out_h) > max_side:
        ratio = max_side / max(out_w, out_h)
        out_w = _round_to(out_w * ratio, res_multiple)
        out_h = _round_to(out_h * ratio, res_multiple)
    return int(out_w), int(out_h)


def on_video_upload(video_path):
    if video_path is None:
        return gr.update(), gr.update()
    try:
        out_w, out_h = compute_output_resolution(video_path, scale=REFERENCE_DOWNSCALE_FACTOR)
        return gr.update(value=out_w), gr.update(value=out_h)
    except Exception as e:
        print(f"[probe] failed: {e}")
        return gr.update(), gr.update()


# ─────────────────────────────────────────────────────────────────────────────
# Generation
# ─────────────────────────────────────────────────────────────────────────────
@spaces.GPU(duration=300)
@torch.inference_mode()
def upscale_video(
    input_video,
    prompt: str = "",
    lora_strength: float = 1.0,
    enhance_prompt: bool = True,
    seed: int = 42,
    randomize_seed: bool = True,
    width: int = 1536,
    height: int = 768,
    duration_seconds: float = 3.0,
    progress=gr.Progress(track_tqdm=True),
):
    """Creatively upscale a low-resolution video 4x with the LTX-2.3 IC-LoRA Pixel Spatial Upscaler.

    Args:
        input_video: Path to the low-resolution source video to upscale.
        prompt: Text describing the scene (helps the model synthesize detail).
        lora_strength: IC-LoRA reference strength. Lower stays closer to the source, higher hallucinates more detail.
        enhance_prompt: Auto-enhance the prompt with the built-in text model.
        seed: RNG seed.
        randomize_seed: Pick a fresh random seed each run.
        width: Target output width in pixels (rounded to a multiple of
            _reference_resolution_multiple(scale), 256 for the default 4x LoRA).
        height: Target output height in pixels (same rounding as width).
        duration_seconds: Length of the output clip in seconds.
    """
    if input_video is None:
        raise gr.Error("Please provide a low-resolution input video to upscale.")

    pipeline = get_pipeline()
    current_seed = random.randint(0, MAX_SEED) if randomize_seed else int(seed)

    frame_rate = DEFAULT_FRAME_RATE
    try:
        _, _, src_fps, src_dur = _probe_video(str(input_video))
        if src_fps and 1.0 <= src_fps <= 60.0:
            frame_rate = float(src_fps)
        duration_seconds = min(float(duration_seconds), float(src_dur) + 0.1)
    except Exception as e:
        print(f"[probe] failed, using defaults: {e}")

    num_frames = int(duration_seconds * frame_rate) + 1
    num_frames = ((num_frames - 1 + 7) // 8) * 8 + 1  # 8k + 1
    num_frames = max(9, min(num_frames, 121))

    scale = pipeline.reference_downscale_factor  # 4, but read live from the pipeline
    res_multiple = _reference_resolution_multiple(scale)
    width = _round_to(int(width), res_multiple)
    height = _round_to(int(height), res_multiple)

    print(f"[Upscale x{scale}] -> {width}x{height}, {num_frames} frames @ {frame_rate}fps, "
          f"seed={current_seed}, strength={lora_strength}")

    tiling_config = TilingConfig.default()
    video_chunks_number = get_video_chunks_number(num_frames, tiling_config)

    video, audio = pipeline(
        prompt=prompt or "",
        seed=current_seed,
        height=int(height),
        width=int(width),
        num_frames=num_frames,
        frame_rate=frame_rate,
        images=[],
        video_conditioning=[(str(input_video), float(lora_strength))],
        enhance_prompt=enhance_prompt,
        tiling_config=tiling_config,
        conditioning_attention_strength=1.0,
    )

    output_path = tempfile.mktemp(suffix=".mp4")
    encode_video(
        video=video,
        fps=frame_rate,
        audio=audio,
        output_path=output_path,
        video_chunks_number=video_chunks_number,
    )
    return str(output_path), current_seed


# ─────────────────────────────────────────────────────────────────────────────
# UI
# ─────────────────────────────────────────────────────────────────────────────
CSS = """
#col-container { max-width: 1150px; margin: 0 auto; }
.dark .gradio-container { color: var(--body-text-color); }
video { object-fit: contain !important; }
"""

with gr.Blocks(theme=gr.themes.Citrus(), css=CSS) as demo:
    with gr.Column(elem_id="col-container"):
        gr.Markdown(
            """
# LTX-2.3 Pixel Spatial Upscaler (4Γ—) πŸŽžοΈβ†’βœ¨
Creatively upscale a **low-resolution video 4Γ—** with the
[LTX-2.3-22B IC-LoRA Pixel Spatial Upscaler](https://huggingface.co/Lightricks/LTX-2.3-22b-IC-LoRA-Pixel-Spatial-Upscaler)
on top of [LTX-2.3](https://huggingface.co/Lightricks/LTX-2.3).
This is a **generative** upscaler β€” it *synthesizes* new fine detail rather than interpolating,
so the result is a high-resolution re-render of your clip. Add a prompt describing the scene to guide the detail.
            """
        )
        with gr.Row():
            with gr.Column(scale=1):
                input_video = gr.Video(label="Low-resolution input video")
                prompt = gr.Textbox(
                    label="Prompt (optional but recommended)",
                    placeholder="describe the scene, e.g. 'a jellyfish gliding through deep blue water, bioluminescent detail'",
                    lines=2,
                )
                run_btn = gr.Button("Upscale 4Γ—", variant="primary", size="lg")

                with gr.Accordion("Advanced settings", open=False):
                    lora_strength = gr.Slider(
                        label="Reference strength",
                        info="Lower = closer to source, higher = more synthesized detail",
                        minimum=0.5, maximum=1.0, value=1.0, step=0.05,
                    )
                    enhance_prompt = gr.Checkbox(label="Enhance prompt", value=True)
                    duration_seconds = gr.Slider(
                        label="Output duration (s)", minimum=0.5, maximum=5.0, value=3.0, step=0.5,
                    )
                    seed = gr.Slider(label="Seed", minimum=0, maximum=MAX_SEED, value=42, step=1)
                    randomize_seed = gr.Checkbox(label="Randomize seed", value=True)
                    with gr.Row():
                        width = gr.Number(label="Output width", value=1536, precision=0)
                        height = gr.Number(label="Output height", value=768, precision=0)

            with gr.Column(scale=1):
                output_video = gr.Video(label="Upscaled result (4Γ—)", autoplay=True)

        gr.Examples(
            examples=[
                ["jellyfish_glide.mp4", "a jellyfish gliding gracefully through deep blue water, translucent bioluminescent detail"],
                ["waterfall_forest.mp4", "a waterfall cascading through a lush green forest, misty spray and wet mossy rocks"],
                ["squirrel_eating.mp4", "a squirrel eating a nut, detailed fur, soft natural daylight"],
            ],
            inputs=[input_video, prompt],
            outputs=[output_video, seed],
            fn=upscale_video,
            cache_examples=True,
            cache_mode="lazy",
        )

    input_video.change(fn=on_video_upload, inputs=[input_video], outputs=[width, height])
    run_btn.click(
        fn=upscale_video,
        inputs=[input_video, prompt, lora_strength, enhance_prompt, seed,
                randomize_seed, width, height, duration_seconds],
        outputs=[output_video, seed],
        api_name="upscale",
    )


if __name__ == "__main__":
    demo.launch(mcp_server=True)