"""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: \n" "Image 2: \n" "Image 3: Scene, \n\n" "" ), ) 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 )