Spaces:
Running on Zero
Running on Zero
multimodalart HF Staff
Calibrate duration from three timed runs; set verified 44k token ceiling; document memory findings
0581e9d verified | """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 | |
| # ------------------------------------------------------------------------------------------ | |
| 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 | |
| ) | |