multimodalart's picture
multimodalart HF Staff
Calibrate duration from three timed runs; set verified 44k token ceiling; document memory findings
0581e9d verified
Raw
History Blame Contribute Delete
28 kB
"""LTX-2.5 · Licon MSR V1 — multi-subject reference-to-video on ZeroGPU.
Up to five reference stills (four subject slots + one scene slot) plus a prompt that names
them ("Image 1: …", "Image 2: …") produce a video with native audio, keeping each subject's
identity, clothing and the scene's look.
The author's reference implementation is a ComfyUI graph
(https://github.com/liconstudio/ComfyUI-LTX2.5-MSR + `LTX2.5-MSR-sample-workflow.json`);
`msr.py` is a 1:1 port of the custom node's encoder onto `LTX2InContextPipeline`.
"""
import os
# Stage 2 allocates and frees ~1 GiB activation buffers hundreds of times against a nearly
# full 96 GB card. Expandable segments keep that from fragmenting the arena into pieces too
# small to reuse (the `cudaMallocAsync` backend fared measurably worse here).
os.environ.setdefault("PYTORCH_ALLOC_CONF", "expandable_segments:True")
os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
import spaces # noqa: E402 — must precede torch / any CUDA-touching import
import gc # noqa: E402
import math # noqa: E402
import random # noqa: E402
import tempfile # noqa: E402
import time # noqa: E402
import gradio as gr # noqa: E402
import numpy as np # noqa: E402
import PIL.Image # noqa: E402
import torch # noqa: E402
from huggingface_hub import hf_hub_download, snapshot_download # noqa: E402
from safetensors.torch import load_file # noqa: E402
from diffusers import LTX2LatentUpsamplePipeline # noqa: E402
from diffusers.pipelines.ltx2.latent_upsampler import LTX2LatentUpsamplerModel # noqa: E402
from diffusers.pipelines.ltx2.utils import ( # noqa: E402
DISTILLED_SIGMA_VALUES,
STAGE_2_DISTILLED_SIGMA_VALUES,
)
from diffusers.utils import encode_video # noqa: E402
from msr import LTX25MSRPipeline, build_references, split_slot_state # noqa: E402
HF_TOKEN = os.environ.get("HF_TOKEN")
BASE_ID = "Lightricks/LTX-2.5-Diffusers"
LORA_ID = "LiconStudio/LTX-2.5-Multiple-Subject-Reference"
LORA_FILE = "LTX-2.5-Licon-MSR-V1.safetensors"
FRAME_RATE = 24.0
MAX_SEED = 2**31 - 1
MAX_TEXT_TOKENS = 1024
# The workflow's two ManualSigmas nodes, which are exactly diffusers' distilled schedules.
STAGE_1_SIGMAS = DISTILLED_SIGMA_VALUES
STAGE_2_SIGMAS = STAGE_2_DISTILLED_SIGMA_VALUES
# Stage 1 runs at half of each of these, then a x2 latent upsample feeds stage 2.
RESOLUTIONS = {
"1280 × 704 · 16:9": (1280, 704),
"1664 × 960 · 16:9 (workflow, slower)": (1664, 960),
"704 × 1280 · 9:16 portrait": (704, 1280),
"960 × 960 · 1:1": (960, 960),
}
DEFAULT_RESOLUTION = "1280 × 704 · 16:9"
DEFAULT_SECONDS = 4.0
DEFAULT_REFERENCE_FRAMES = 33 # the workflow's `reference_frames`
# Stage-2 sequence length is the memory driver, and here it is user-driven twice over
# (resolution x length, plus up to five reference slots each contributing a full clip of
# tokens). Measured on the 96 GB slice: 76.2 GiB stays resident during denoising and peak
# activations run at 0.33 MiB/token (87.4 GiB peak at 35,200 tokens, 90.1 GiB at 43,680),
# which puts the wall just under 48k. 44,000 is the last value verified to complete; refuse
# past it rather than let the request die in an OOM traceback.
MAX_STAGE_2_TOKENS = 44_000
print("[msr] downloading LTX-2.5 (diffusers)...", flush=True)
# `from_pretrained` derives allow-patterns from every model-like file in the repo, so it would
# pull both DiTs and both shardings of the distilled one (~114 GB). Snapshot explicitly instead;
# prompt_enhancer / diffusion_decoder / temporal_latent_upsampler are not components of the
# in-context pipeline and are pure download + VRAM waste here.
MODEL_DIR = snapshot_download(
BASE_ID,
ignore_patterns=[
"transformer_full/*",
"transformer/*-of-00008*",
"prompt_enhancer/*",
"diffusion_decoder/*",
"temporal_latent_upsampler/*",
# root-level standalone file, not a `model_index.json` component: 9.7 GB of nothing
"ltx-2.5-22b-distilled-lora-450-bf16.safetensors",
],
token=HF_TOKEN,
max_workers=8,
)
LORA_PATH = hf_hub_download(LORA_ID, LORA_FILE, token=HF_TOKEN)
print("[msr] building pipeline...", flush=True)
pipe = LTX25MSRPipeline.from_pretrained(
MODEL_DIR, prompt_enhancer=None, processor=None, dtype=torch.bfloat16
)
latent_upsampler = LTX2LatentUpsamplerModel.from_pretrained(
MODEL_DIR, subfolder="latent_upsampler", dtype=torch.bfloat16
)
# The checkpoint carries two different things under one `diffusion_model.` prefix: rank-128 LoRA
# tensors (diffusers module paths, so a prefix swap is the whole conversion) and the learned
# `reference_slot_embedding` MLP, which is not a LoRA and is applied by hand in `msr.py`.
_raw = load_file(LORA_PATH)
_slot_state, _lora_raw = split_slot_state(_raw)
_lora_sd = {k.replace("diffusion_model.", "transformer.", 1): v for k, v in _lora_raw.items()}
del _raw, _lora_raw
pipe.load_lora_weights(_lora_sd, adapter_name="msr")
pipe.set_adapters(["msr"], [1.0])
del _lora_sd
pipe.msr_slot_state = _slot_state
print(
f"[msr] LoRA applied (strength 1.0); slot-embedding keys: {sorted(_slot_state)}",
flush=True,
)
# ZeroGPU: module scope + eager .to("cuda"). The backend packs the weights to disk at startup
# and streams them into VRAM on the first @spaces.GPU entry.
pipe.to("cuda")
latent_upsampler.to("cuda")
pipe.vae.enable_tiling() # stage-2 decode alone would want >100 GB untiled
upsample_pipe = LTX2LatentUpsamplePipeline(vae=pipe.vae, latent_upsampler=latent_upsampler)
AUDIO_SR = pipe.vocoder.config.output_sampling_rate
# `connectors` (6.3 GB) maps the Gemma hidden states to the DiT's text context. It runs once at
# the top of every `__call__` and is then idle for the whole denoising loop, so it is sent back
# to the host the moment it has produced its output and pulled in again on the next stage.
# Same reasoning as the text encoder in `_encode_once`: eviction of a finished component, not
# per-step offloading — nothing here is moved inside the sampling loop.
_connectors_forward = pipe.connectors.forward
def _connectors_evicting_forward(*args, **kwargs):
pipe.connectors.to("cuda")
output = _connectors_forward(*args, **kwargs)
pipe.connectors.to("cpu")
gc.collect()
torch.cuda.empty_cache()
return output
pipe.connectors.forward = _connectors_evicting_forward
print("[msr] ready", flush=True)
# ------------------------------------------------------------------------------------------
# helpers
# ------------------------------------------------------------------------------------------
def _plan(resolution: str, seconds: float, reference_frames: int, num_refs: int):
"""Resolve UI values into geometry plus a stage-2 token count (the cost driver)."""
width, height = RESOLUTIONS.get(resolution, RESOLUTIONS[DEFAULT_RESOLUTION])
num_frames = max(25, int(round(float(seconds) * FRAME_RATE)) // 8 * 8 + 1)
ref_keep = max(1, ((int(reference_frames) - 1) // 8) * 8 + 1)
latent_frames = (num_frames - 1) // 8 + 1
ref_latent_frames = (ref_keep - 1) // 8 + 1
tokens = (latent_frames + num_refs * ref_latent_frames) * (height // 32) * (width // 32)
return width, height, num_frames, ref_keep, tokens
def _count_refs(*images) -> int:
return max(1, sum(1 for image in images if image is not None))
def gpu_duration(
prompt=None,
subject_1=None,
subject_2=None,
scene_image=None,
subject_3=None,
subject_4=None,
resolution=DEFAULT_RESOLUTION,
seconds=DEFAULT_SECONDS,
reference_frames=DEFAULT_REFERENCE_FRAMES,
*_args,
**_kwargs,
):
"""Scale the ZeroGPU reservation with the work actually requested.
Least-squares fit through three timed runs on this Space — 53.1 s at 24,640 tokens /
87 Mpx, 72.8 s at 35,200 / 174 Mpx, 86.8 s at 43,680 / 155 Mpx — which land on a plain
linear model to within 0.2 s:
seconds = 9.5 + 0.0206 * megapixels + 1.697 * kilotokens
Megapixels cover the tiled VAE decode and the vocoder, kilotokens cover both sampling
stages, and the constant is the Gemma pass plus the mp4 mux. 15% margin on top and
nothing more: `duration` is charged against every visitor's daily quota, and a fat
reservation also costs queue priority.
"""
num_refs = _count_refs(subject_1, subject_2, subject_3, subject_4, scene_image)
width, height, num_frames, _ref_keep, tokens = _plan(
resolution, seconds, reference_frames, num_refs
)
megapixels = width * height * num_frames / 1e6
seconds_est = 9.5 + 0.0206 * megapixels + 1.697 * (tokens / 1000)
return int(min(140, math.ceil(1.15 * seconds_est)))
def _write_video(frames, path: str, audio=None) -> None:
frames = np.asarray(frames)
if frames.dtype == np.uint8:
frames = frames.astype(np.float32) / 255.0
kwargs = {}
if audio is not None:
kwargs = dict(audio=audio, audio_sample_rate=AUDIO_SR)
encode_video(frames, fps=FRAME_RATE, output_path=path, **kwargs)
def _free() -> None:
gc.collect()
torch.cuda.empty_cache()
def _vram(label: str) -> None:
gib = 1024**3
print(
f"[vram] {label}: {torch.cuda.memory_allocated() / gib:.1f} GiB now, "
f"{torch.cuda.max_memory_allocated() / gib:.1f} GiB peak, "
f"{torch.cuda.memory_reserved() / gib:.1f} GiB reserved",
flush=True,
)
def _encode_once(prompt: str):
"""Run Gemma once, then send it back to the host for the rest of the call.
Not CPU offloading: the text encoder is genuinely finished after this, and nothing moves
inside a sampling loop. Both `__call__`s below are handed the resulting `prompt_embeds`,
so `encode_prompt` short-circuits and never touches it again.
Measured effect is only ~1.9 GiB — ZeroGPU packs many parameters into shared CUDA
storages, so releasing one module's references frees far less than its nominal 24 GB —
but it is free and it lowers the peak the Gemma pass itself reaches.
`DiffusionPipeline.device` deliberately prefers a non-CPU component, so
`_execution_device` still reports cuda afterwards.
"""
pipe.text_encoder.to("cuda") # no-op on a fresh worker; restores it if one is reused
try:
prompt_embeds, prompt_mask, _, _ = pipe.encode_prompt(
prompt=[prompt],
negative_prompt=None,
do_classifier_free_guidance=False,
num_videos_per_prompt=1,
max_sequence_length=MAX_TEXT_TOKENS,
device=torch.device("cuda"),
)
finally:
pipe.text_encoder.to("cpu")
_free()
return prompt_embeds, prompt_mask
def _as_pil(image):
if image is None:
return None
if isinstance(image, PIL.Image.Image):
return image.convert("RGB")
if isinstance(image, np.ndarray):
return PIL.Image.fromarray(image.astype(np.uint8)).convert("RGB")
return PIL.Image.open(image).convert("RGB")
# ------------------------------------------------------------------------------------------
# generation
# ------------------------------------------------------------------------------------------
@spaces.GPU(duration=gpu_duration, size="xlarge")
def generate(
prompt: str,
subject_1,
subject_2,
scene_image,
subject_3=None,
subject_4=None,
resolution: str = DEFAULT_RESOLUTION,
seconds: float = DEFAULT_SECONDS,
reference_frames: int = DEFAULT_REFERENCE_FRAMES,
seed: int = 42,
randomize_seed: bool = True,
):
"""Generate a video with audio whose subjects and scene come from the reference stills.
Args:
prompt: describe each reference on its own line ("Image 1: …", "Image 2: …",
"Image 3: Scene, …"), then the shot itself — action, camera moves, dialogue.
subject_1: reference still for subject slot 1. Required.
subject_2: reference still for subject slot 2, or empty.
scene_image: reference still for the scene / background slot, or empty. Unlike the
subject slots it is centre-cropped rather than letterboxed.
subject_3: reference still for subject slot 3, or empty.
subject_4: reference still for subject slot 4, or empty.
resolution: final output resolution; stage 1 samples at half of it.
seconds: video length in seconds at 24 fps.
reference_frames: how many frames each still is repeated to before VAE encoding
(the ComfyUI node's `reference_frames`; 33 is the workflow value).
seed: RNG seed.
randomize_seed: pick a fresh random seed instead of using `seed`.
Returns:
The mp4 path and a one-line run summary.
"""
started = time.perf_counter()
if not (prompt or "").strip():
raise gr.Error("A prompt is required.")
if subject_1 is None:
raise gr.Error("At least one reference image is required (Subject 1).")
# The node collects pic1..pic4 then background, skipping empties, so the scene slot always
# takes the last slot id and each slot lands at pixel-frame -(num_slots - index).
subjects = [_as_pil(x) for x in (subject_1, subject_2, subject_3, subject_4)]
subjects = [x for x in subjects if x is not None]
scene = _as_pil(scene_image)
images = subjects + ([scene] if scene is not None else [])
flags = [False] * len(subjects) + ([True] if scene is not None else [])
references = build_references(images, flags)
width, height, num_frames, ref_keep, tokens = _plan(
resolution, seconds, reference_frames, len(references)
)
if tokens > MAX_STAGE_2_TOKENS:
raise gr.Error(
f"That combination needs ~{tokens:,} stage-2 tokens, over the "
f"{MAX_STAGE_2_TOKENS:,} this GPU can hold. Shorten the clip, drop a reference "
"slot, or pick a smaller resolution."
)
pipe.msr_reference_frames = ref_keep
if randomize_seed:
seed = random.randint(0, MAX_SEED)
generator = torch.Generator("cuda").manual_seed(int(seed))
# One Gemma pass, reused by both stages, then evicted (see `_encode_once`).
torch.cuda.reset_peak_memory_stats()
prompt_embeds, prompt_mask = _encode_once(prompt)
shared = dict(
prompt=None,
prompt_embeds=prompt_embeds,
prompt_attention_mask=prompt_mask,
negative_prompt=None,
frame_rate=FRAME_RATE,
# Distilled checkpoint (SimpleDenoiser): every guidance knob is off, matching the
# workflow's cfg=1 CFGGuider / DualCFGGuider [1, 1]. The merged pipeline's defaults are
# SFT values and each one adds a blended extra transformer pass that wrecks the output.
guidance_scale=1.0,
audio_guidance_scale=1.0,
stg_scale=0.0,
audio_stg_scale=0.0,
modality_scale=1.0,
audio_modality_scale=1.0,
guidance_rescale=0.0,
audio_guidance_rescale=0.0,
spatio_temporal_guidance_blocks=None,
reference_downscale_factor=1,
conditioning_attention_strength=1.0,
generator=generator,
return_dict=False,
)
print(
f"[gen] {len(references)} slot(s) · {width}x{height} · {num_frames}f · "
f"ref {ref_keep}f · ~{tokens} stage-2 tokens · seed {seed}",
flush=True,
)
# ---- stage 1: half resolution, 8 distilled sigmas, MSR reference tokens attached ----
stage_1 = time.perf_counter()
s1_latents, s1_audio = pipe(
reference_conditions=references,
height=height // 2,
width=width // 2,
num_frames=num_frames,
sigmas=STAGE_1_SIGMAS,
output_type="latent",
**shared,
)
print(f"[gen] stage 1 in {time.perf_counter() - stage_1:.1f}s", flush=True)
_vram("after stage 1")
_free()
# ---- x2 spatial latent upsample. `output_type="latent"` has already cropped the
# reference tokens off, which is what the graph's LTXVCropGuides does. ----
stage_up = time.perf_counter()
up_latents = upsample_pipe(latents=s1_latents, output_type="latent", return_dict=False)[0]
del s1_latents
_free()
print(f"[gen] upsample in {time.perf_counter() - stage_up:.1f}s", flush=True)
# ---- stage 2: full resolution, 3 sigmas. The workflow re-attaches the MSR guide here,
# so the reference tokens are encoded again at the larger size. ----
stage_2 = time.perf_counter()
video, audio = pipe(
reference_conditions=references,
height=height,
width=width,
num_frames=num_frames,
sigmas=STAGE_2_SIGMAS,
latents=up_latents,
audio_latents=s1_audio,
noise_scale=STAGE_2_SIGMAS[0],
output_type="np",
**shared,
)
print(f"[gen] stage 2 in {time.perf_counter() - stage_2:.1f}s", flush=True)
_vram("after stage 2")
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as handle:
out_path = handle.name
_write_video(video[0], out_path, audio=audio[0].float().cpu() if audio is not None else None)
elapsed = time.perf_counter() - started
print(f"[gen] done in {elapsed:.1f}s", flush=True)
return out_path, (
f"{len(references)} reference slot(s) · {width}×{height} · "
f"{num_frames} frames ({num_frames / FRAME_RATE:.1f}s) · seed {seed} · {elapsed:.0f}s"
)
# ------------------------------------------------------------------------------------------
# examples (the author's own validation set, apache-2.0, from the model repo)
# ------------------------------------------------------------------------------------------
EXAMPLES = [
[
"Image 1: Beast-girl, fluffy orange cat ears and a long tail, big amber eyes, fluffy "
"orange short hair, cream-white puffy dress with a bow, bare legs and small boots, "
"Pixar-style 3D cartoon rendering, photorealistic render quality.\n"
"Image 2: Elf girl, long pointed ears, silver-white long hair, big emerald eyes, "
"leaf-green robe with a vine belt, barefoot, Pixar-style 3D cartoon rendering, "
"photorealistic render quality.\n"
"Image 3: Scene, enchanted forest clearing, foreground glowing mushrooms and "
"wildflowers, midground the two figures, background ancient trees with light shafts "
"through the leaves, warm dappled golden light, fireflies drifting, Pixar-style 3D "
"cartoon rendering, photorealistic render quality.\n\n"
"A clearing in an enchanted forest, warm dappled golden light falling through the "
"ancient canopy, fireflies drifting in the light shafts, glowing mushrooms and "
"wildflowers dotting the foreground. Figure 1 is the beast-girl, Figure 2 is the elf "
"girl. The two stand in the clearing, Pixar-style 3D cartoon rendering. The shot "
"starts on a medium two-shot, slow dolly push-in about 40%. Figure 1 curiously leans "
"in toward Figure 2, looking at her pointed ears, and asks: \"How are your ears so "
"pointy?\" Figure 2 smiles and answers: \"These are elf ears.\" Figure 1 reaches up to "
"touch her own fluffy orange cat ears and says happily: \"Then mine are fluffy!\" No "
"cut throughout, with birdsong and wind rustling through leaves underneath, their hair "
"and hems swaying lightly in the breeze, the beast-girl's tail flicking.",
"examples/forest_1.jpg",
"examples/forest_2.jpg",
"examples/forest_3.jpg",
],
[
"Image 1: Man, black buzz cut with shaved lines on both sides, hard jawline, black "
"techwear parka with reflective strips, dark grey cargo pants, black tactical boots, "
"photorealistic natural texture.\n"
"Image 2: Woman, neck-length silver-grey gradient bob, cold-blue contact lenses and "
"ear-clip cuffs, translucent PVC long coat over a liquid-silver bodysuit, black "
"wide-leg pants, silver platform boots, photorealistic natural texture.\n"
"Image 3: Scene, late-night cyberpunk club booth, foreground bottles and neon, "
"midground leather booth, background dancefloor strobe light, cyan-blue key light, "
"magenta fill light, low-hanging smoke, low-frequency beat, photorealistic natural "
"texture.\n\n"
"Inside a late-night cyberpunk club booth, cyan-blue key light pressed low, magenta "
"fill light tracing the leather booth's silhouette, the dancefloor strobe flashing in "
"the distance, smoke hanging low, a low-frequency beat running continuously. Image 1 "
"in the black techwear parka sits in the booth, a bottle beside him, head down and "
"spaced out. Image 2 in the translucent PVC long coat walks to Image 1's side and sits "
"down, naturally leaning on his shoulder, her silver-grey bob brushing against his "
"parka. Image 1 turns his head to look at her, she lifts her eyes back to him, the two "
"holding still for one second. The shot starts on a side medium of Image 1, slow dolly "
"push-in about 40% as Image 2 approaches, then a small slow orbit around the two. "
"Photorealistic natural texture.",
"examples/cyberpunk_1.jpg",
"examples/cyberpunk_2.jpg",
"examples/cyberpunk_3.jpg",
],
[
"Image 1: East Asian, 30 years old, man, very short buzz cut, thick dark straight "
"eyebrows, hard jawline, sharp gaze, black high-collar technical windbreaker with "
"silver-grey reflective strips and zippered pockets across the chest and upper arms.\n"
"Image 2: East Asian, 20 years old, woman, neck-length wavy bob with wispy bangs, "
"bright red satin headband, pearl stud earrings.\n"
"Image 3: Scene, late-night apartment entryway narrow hallway, foreground a black "
"metal coat rack and black curved-handle umbrellas leaning against the wall, midground "
"the two facing off, background a half-open white door leading into a brighter room, "
"light-grey walls and matte grey square-tile floor, cool-white overhead light with "
"cold-blue light seeping through the door, sharp high contrast, oppressive standoff "
"mood, photorealistic natural texture.\n\n"
"Late-night apartment entryway narrow hallway, cool-white overhead light, a half-open "
"white door leading into a brighter room, cold-blue light seeping through the door, "
"sharp high contrast no bloom; camera axis slightly diagonal, fixed from the door "
"toward the interior. Figure 1 stands upper-right near the door facing lower-left, "
"Figure 2 stands lower-left in midground facing upper-right, one step apart. Figure "
"1's shoulders are tense, half-turned toward the door; Figure 2 blocks the hallway "
"with reddened eyes under her red headband. Diagonal composition, locked camera, slow "
"push-in about 25%, Figure 2's lashes trembling, Figure 1's jaw muscles tightening, "
"the two's gazes locked, the overhead light humming low, the brighter room beyond in "
"silence. Photorealistic natural texture.",
"examples/hallway_1.jpg",
"examples/hallway_2.jpg",
"examples/hallway_3.jpg",
],
]
CSS = """
.main.fillable { max-width: 1280px !important; }
.dark .gradio-container { color: var(--body-text-color); }
"""
with gr.Blocks(title="LTX-2.5 Multi-Subject Reference") as demo:
gr.Markdown(
"# 🎭 LTX-2.5 · Multiple Subject Reference\n"
"Give up to **four subject stills plus one scene still**, name them in the prompt "
"(`Image 1:`, `Image 2:`, `Image 3: Scene, …`), and get a video with native audio that "
"keeps every subject's identity, clothing and the scene's look.\n\n"
"[`LiconStudio/LTX-2.5-Multiple-Subject-Reference`](https://huggingface.co/LiconStudio/LTX-2.5-Multiple-Subject-Reference)"
" on [`Lightricks/LTX-2.5`](https://huggingface.co/Lightricks/LTX-2.5) (22B, distilled)."
)
with gr.Row():
with gr.Column(scale=1):
prompt = gr.Textbox(
label="Prompt",
lines=10,
max_lines=22,
placeholder=(
"Image 1: <who / what slot 1 is — build, hair, clothes>\n"
"Image 2: <who / what slot 2 is>\n"
"Image 3: Scene, <where it happens, lighting, mood>\n\n"
"<the shot itself: what they do, camera moves, dialogue, sound>"
),
)
with gr.Row():
subject_1 = gr.Image(label="Subject 1", type="pil", height=190)
subject_2 = gr.Image(label="Subject 2 (optional)", type="pil", height=190)
scene_image = gr.Image(label="Scene (optional)", type="pil", height=190)
with gr.Accordion("More subject slots", open=False):
with gr.Row():
subject_3 = gr.Image(label="Subject 3 (optional)", type="pil", height=190)
subject_4 = gr.Image(label="Subject 4 (optional)", type="pil", height=190)
with gr.Accordion("Advanced", open=False):
resolution = gr.Dropdown(
list(RESOLUTIONS),
value=DEFAULT_RESOLUTION,
label="Resolution",
info="Stage 1 samples at half of this, then a ×2 latent upsample.",
)
seconds = gr.Slider(
1.0, 8.0, value=DEFAULT_SECONDS, step=0.5, label="Length (seconds @ 24 fps)"
)
reference_frames = gr.Slider(
9,
33,
value=DEFAULT_REFERENCE_FRAMES,
step=8,
label="Reference frames per slot",
info="How many frames each still is repeated to before VAE encoding. "
"33 is the author's workflow value; lower is faster and weaker.",
)
with gr.Row():
seed = gr.Slider(0, MAX_SEED, value=42, step=1, label="Seed")
randomize_seed = gr.Checkbox(value=True, label="Randomize seed")
run_button = gr.Button("Generate", variant="primary")
with gr.Column(scale=1):
video_out = gr.Video(label="Result", autoplay=True, height=430)
info_out = gr.Textbox(label="Run", lines=2, interactive=False)
inputs = [
prompt,
subject_1,
subject_2,
scene_image,
subject_3,
subject_4,
resolution,
seconds,
reference_frames,
seed,
randomize_seed,
]
outputs = [video_out, info_out]
run_button.click(fn=generate, inputs=inputs, outputs=outputs)
gr.Examples(
examples=EXAMPLES,
inputs=[prompt, subject_1, subject_2, scene_image],
outputs=outputs,
fn=generate,
cache_examples=True,
cache_mode="lazy",
label="The author's own validation references and prompts",
)
gr.Markdown(
"### Prompting\n"
"* Describe **every** reference on its own line, in slot order, using the same labels "
"(`Image 1`, `Image 2`, …). The scene still is always the last slot.\n"
"* Then state who does what, where they stand relative to each other, camera moves and "
"any dialogue — LTX-2.5 generates the audio too.\n"
"* Subject stills are letterboxed onto white; the scene still is centre-cropped. "
"Character-sheet style references (front / three-quarter / back on a plain background) "
"work best — that is what the LoRA was validated on.\n\n"
"Reference images and prompts are the author's own validation set from the model repo "
"(apache-2.0)."
)
demo.queue().launch(
theme=gr.themes.Citrus(), css=CSS, mcp_server=True, show_error=True
)