LTX2-marimo / notebook.py
dragonlimited's picture
Legend of Valor: Act I rendered, notebook viewer + LTX-2 load fixes
7e7101e verified
Raw History Blame Contribute Delete
56.9 kB
# /// script
# requires-python = ">=3.13"
# dependencies = [
# "accelerate>=0.17.0",
# "av==19.0.2",
# "diffusers==0.42.0",
# "huggingface-hub==1.33.0",
# "numpy==2.5.4",
# "pillow==13.0.0",
# ]
# ///
import marimo
__generated_with = "0.25.2"
app = marimo.App(width="medium", auto_download=["html"])
@app.cell
def intro():
# ----------------------------------------------------------------------------------------------
# Everything this notebook imports lives here: in marimo a public name belongs to exactly one cell,
# so the other cells just use these names.
#
# `torch` and `torchvision` must be a matched pair: diffusers' LTX-2 pipeline pulls Gemma in from
# `transformers`, `transformers.image_utils` imports `torchvision`, and a torchvision built against a different
# torch dies with "operator torchvision::nms does not exist", taking the import chain with it. The dependency
# block at the top of this file records that intent.
# ----------------------------------------------------------------------------------------------
import json
import os
import platform
import shutil
import time
from pathlib import Path
# Ask the CUDA allocator for expandable segments before anything touches the GPU. It costs nothing and
# avoids the fragmentation that turns a render which *almost* fits into an out-of-memory error.
os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
import av
import marimo as mo
import numpy as np
import psutil
import torch
from diffusers import LTX2ConditionPipeline
from diffusers.pipelines.ltx2.pipeline_ltx2_condition import LTX2VideoCondition
from diffusers.pipelines.ltx2.utils import DEFAULT_NEGATIVE_PROMPT
from diffusers.utils import encode_video
from huggingface_hub import HfApi, hf_hub_download, snapshot_download
from PIL import Image
mo.md(
"""
# 🎬 LTX-2 β€” video generation **with synchronized audio**
Write the shot, optionally attach a picture or a clip, choose the **quality** and the **length**, press
**Generate**, then download the `.mp4`. LTX-2
([`Lightricks/LTX-2`](https://huggingface.co/Lightricks/LTX-2), the open checkpoint) draws picture **and sound
in one pass**, so the file you get already carries dialogue, foley and ambience.
1. **Your shot** β€” the prompt, the quality preset, the length.
2. **Assets** β€” optional; a picture or a clip conditions the render.
3. **The model** β€” fetches and loads itself. Nothing to press, nothing to authenticate.
4. **Generate** β€” one press; the take appears with a download link.
5. **Renders** β€” every take stays in `outputs/`, ready to compare and download.
_A first run fetches ~92 GB of weights, so give section 3 a while before it reports the model loaded._
"""
)
return (
DEFAULT_NEGATIVE_PROMPT,
HfApi,
Image,
LTX2ConditionPipeline,
LTX2VideoCondition,
Path,
av,
encode_video,
hf_hub_download,
json,
mo,
np,
os,
platform,
psutil,
shutil,
snapshot_download,
time,
torch,
)
@app.cell
def compute(mo, platform, psutil, shutil, torch):
gpu_ok = torch.cuda.is_available()
gpu_name = torch.cuda.get_device_name(0) if gpu_ok else "no GPU visible"
vram_gb = round(torch.cuda.get_device_properties(0).total_memory / 1e9, 1) if gpu_ok else 0.0
ram_gb = round(psutil.virtual_memory().total / 1e9, 1)
_free_bytes = shutil.disk_usage(".").free
disk_free_gb = round(_free_bytes / 1e9, 1)
# Some sandbox filesystems hand back a placeholder instead of real free space β€” this one claims ~9.2 exabytes,
# about 2,000Γ— the capacity of the physical disk. Print that honestly rather than as a fact.
_disk_free = f"{disk_free_gb:,.0f} GB free" if disk_free_gb < 1e6 else "not reported by this filesystem"
_specs = (
f"**GPU** Β· {gpu_name} ({vram_gb:.0f} GB VRAM)\n\n"
f"**Host** Β· {ram_gb:.0f} GB RAM Β· {psutil.cpu_count()} CPUs Β· Python {platform.python_version()} Β· "
f"torch {torch.__version__}\n\n"
f"**Disk** Β· {_disk_free}"
)
if gpu_ok:
gpu_status = mo.callout(mo.md(f"{_specs}\n\nReady to render."), kind="success", title="Machine")
else:
gpu_status = mo.callout(
mo.md(
f"{_specs}\n\n"
"**No GPU is attached to this session**, so LTX-2 has nowhere to run. The weights can still arrive β€” "
"they land on disk in section 3 and stay there β€” but rendering needs a GPU."
),
kind="danger",
title="Machine",
)
gpu_status
return gpu_name, gpu_ok, vram_gb
@app.cell
def prompt(mo):
# Frame sizes worth offering. The model needs both sides divisible by 32.
SIZES = {
"768 Γ— 512": (768, 512),
"512 Γ— 768": (512, 768),
"704 Γ— 704": (704, 704),
"1024 Γ— 576": (1024, 576),
"576 Γ— 1024": (576, 1024),
}
# Quality presets. A preset seeds the sampling sliders in the next cell, and any of them can then be nudged on its
# own. "Draft" also zeroes the two extra guidance passes β€” spatio-temporal and modality β€” which roughly halves the
# work per denoising step at some cost to motion coherence.
QUALITY = {
"Draft β€” fastest": {"steps": 15, "spatio_temporal": 0.0, "modality": 0.0, "video": 3.0, "audio": 7.0},
"Standard β€” the model's defaults": {"steps": 30, "spatio_temporal": 1.0, "modality": 3.0, "video": 3.0, "audio": 7.0},
"High β€” more steps": {"steps": 40, "spatio_temporal": 1.0, "modality": 3.0, "video": 3.0, "audio": 7.0},
"Maximum β€” slowest": {"steps": 50, "spatio_temporal": 1.0, "modality": 3.0, "video": 3.0, "audio": 7.0},
}
# A ready-to-render example, so pressing the button does something on the first go instead of asking you to type.
# It is written the way LTX-2 likes: camera β†’ subject β†’ action β†’ light β†’ sound, with the spoken line in quotes.
EXAMPLE_PROMPT = (
'Wide shot at dawn: a lighthouse keeper climbs the spiral stairs, the beam swinging past the window, waves '
'breaking below. She pauses, looks into the lens and says: "the boat comes in an hour."'
)
prompt = mo.ui.text_area(
value=EXAMPLE_PROMPT,
rows=6,
full_width=True,
label="πŸŽ₯ Prompt β€” the shot, the motion, the light *and* the sound",
)
quality = mo.ui.dropdown(
options=list(QUALITY),
value="Standard β€” the model's defaults",
label="Quality β€” trade render time for detail",
)
size = mo.ui.dropdown(
options=list(SIZES),
value="768 Γ— 512",
label="Frame size (width Γ— height β€” the model needs both divisible by 32)",
)
# The lengths you can ask for. LTX-2 was trained on clips of a few seconds, so the shot summary below flags anything
# past ~30 s instead of letting you discover the cost after an hour. The pipeline's `num_frames` argument overrides
# its 20-second auto-duration cap, which is why these longer options are accepted at all.
DURATIONS = {
"5 seconds": 5.0,
"10 seconds": 10.0,
"20 seconds": 20.0,
"30 seconds": 30.0,
"1 minute": 60.0,
"5 minutes": 300.0,
"15 minutes": 900.0,
"30 minutes": 1800.0,
}
duration = mo.ui.dropdown(
options=list(DURATIONS),
value="10 seconds",
label="Duration β€” how long the shot runs (5–30 s is the range this model does well)",
)
frame_rate = mo.ui.dropdown(
options=[24.0, 25.0, 30.0, 60.0],
value=24.0,
label="Frame rate (24, 25, 30 and 60 fps are what the model was trained on)",
)
seed = mo.ui.number(start=0, stop=10**9, step=1, value=0, label="Seed (same seed + same settings = same video)")
mo.vstack(
[
mo.md("## 1 Β· Your shot"),
mo.md(
"""
Write one paragraph: **camera β†’ subject β†’ action β†’ light β†’ sound**. LTX-2 is an audio-visual model, so
name the audio you want β€” a voice, footsteps, wind, music β€” and put dialogue in quotes, attributed to
someone on screen.
"""
),
prompt,
quality,
size,
duration,
frame_rate,
seed,
]
)
return (
DURATIONS,
QUALITY,
SIZES,
duration,
frame_rate,
prompt,
quality,
seed,
size,
)
@app.cell
def sampling(DEFAULT_NEGATIVE_PROMPT, QUALITY, mo, quality):
# Every knob a preset touches, seeded from the quality chosen above. Change the preset and these reset to its
# values; nudge one on its own and only that one moves.
_preset = QUALITY[quality.value]
steps = mo.ui.slider(
start=8, stop=50, step=1, value=int(_preset["steps"]), label="Denoising steps", show_value=True
)
video_guidance = mo.ui.slider(
start=1.0, stop=6.0, step=0.1, value=float(_preset["video"]),
label="Video guidance (CFG) β€” LTX-2's default is 3.0", show_value=True,
)
audio_guidance = mo.ui.slider(
start=1.0, stop=12.0, step=0.5, value=float(_preset["audio"]),
label="Audio guidance (CFG) β€” LTX-2's default is 7.0", show_value=True,
)
stg_scale = mo.ui.slider(
start=0.0, stop=3.0, step=0.1, value=float(_preset["spatio_temporal"]),
label="Spatio-temporal guidance β€” 0 drops one forward pass per step", show_value=True,
)
modality_scale = mo.ui.slider(
start=0.0, stop=5.0, step=0.5, value=float(_preset["modality"]),
label="Audio↔video modality guidance β€” 0 drops another", show_value=True,
)
negative_prompt = mo.ui.text_area(
value=DEFAULT_NEGATIVE_PROMPT,
rows=4,
full_width=True,
label="Negative prompt β€” what to keep out of the picture *and* the soundtrack",
)
_advanced = mo.accordion(
{
"Sampling, guidance & negative prompt": mo.vstack(
[steps, video_guidance, audio_guidance, stg_scale, modality_scale, negative_prompt]
)
}
)
mo.vstack(
[
mo.md(
f"_**{quality.value}** seeds these. Leave them alone for a like-for-like take, or open the accordion "
"and move one β€” fewer steps and a zeroed guidance pass are the two cheapest ways to go faster._"
),
_advanced,
]
)
return (
audio_guidance,
modality_scale,
negative_prompt,
steps,
stg_scale,
video_guidance,
)
@app.cell
def shot(
DURATIONS,
SIZES,
duration,
frame_rate,
mo,
modality_scale,
seed,
size,
steps,
stg_scale,
):
width, height = SIZES[size.value]
frame_rate_value = float(frame_rate.value)
seconds_value = DURATIONS[duration.value]
# The causal VAE runs on a 1 + 8Β·k frame grid, so snap to the *nearest* grid point rather than flooring: asking for 10
# seconds and rounding down would quietly hand back 9.67 s.
num_frames = max(9, round((round(seconds_value * frame_rate_value) - 1) / 8) * 8 + 1)
realised_seconds = round(num_frames / frame_rate_value, 2)
# What that length costs. Calibrated on this machine from a measured 241-frame, 1024 Γ— 576, 30-step render: about
# 11.7 s per denoising step with all four forward passes running (classifier-free guidance for picture and sound,
# plus spatio-temporal and modality guidance). Cost scales with frames Γ— pixels Γ— passes, plus the prompt encode
# and the VAE decode.
_passes = 2 + (float(stg_scale.value) > 0.0) + (float(modality_scale.value) > 0.0)
_per_step_s = 11.7 * (_passes / 4) * (num_frames / 241) * ((width * height) / (1024 * 576))
_estimate_min = (int(steps.value) * _per_step_s + (num_frames / 241) * 25 + 35) / 60
_shot_blocks = [
mo.md(
f"**This shot** β€” {width} Γ— {height} Β· {num_frames} frames Β· {realised_seconds:g} s at "
f"{frame_rate_value:g} fps Β· {int(steps.value)} steps Β· seed {int(seed.value)}"
),
mo.md(
f"_Estimated render β€” roughly **{_estimate_min:.0f} min** here: {int(steps.value)} steps Γ— {_passes} "
"forward passes._"
),
]
if 20 <= realised_seconds <= 30:
_shot_blocks.append(
mo.callout(
mo.md(
f"⚠️ **{realised_seconds:g} s sits at the edge of this card's ~95 GB.** A 30-second take at 768 Γ— 512 "
"has succeeded here and also run out of memory here, both. If it fails, the generate step says so and "
"releases the memory rather than leaving the card pinned. 10 s is dependable."
),
kind="warn",
title="Length",
)
)
if realised_seconds > 30:
_shot_blocks.append(
mo.callout(
mo.md(
f"⚠️ **{realised_seconds / 60:g} min is far outside what this model was trained for.** LTX-2 was "
"trained on clips of a few seconds: past ~30 s the picture drifts and the detail collapses, and the "
"transformer's attention cost grows faster than the frame count, so at these lengths it will usually "
"exhaust memory long before it finishes.\n\n"
"For something longer, render a few 10-second shots and join them."
),
kind="warn",
title="Length",
)
)
_shot_blocks.append(
mo.md(
"_The frame count snaps to the model's causal temporal grid (1 + 8Β·k frames), so the duration above is what "
"you get; the assets below are prepared for exactly this many frames._"
)
)
mo.vstack(_shot_blocks)
return frame_rate_value, height, num_frames, realised_seconds, width
@app.cell
def assets(mo):
image_upload = mo.ui.file(
filetypes=[".png", ".jpg", ".jpeg", ".webp"],
multiple=True,
kind="area",
max_size=64_000_000,
label="Picture(s) β€” one opens the shot from that frame, two open and close it",
)
clip_upload = mo.ui.file(
filetypes=[".mp4", ".mov", ".webm", ".mkv"],
multiple=False,
kind="area",
max_size=512_000_000,
label="Clip β€” conditions the opening seconds of the render",
)
mo.vstack(
[
mo.md("## 2 Β· Assets (optional)"),
mo.md(
"""
Attach nothing and LTX-2 renders from the prompt alone. Attach something and it conditions the render:
* **one picture** β†’ the video opens on that frame,
* **two pictures** β†’ it opens on the first and closes on the second,
* **a clip** β†’ it starts from that footage (kept short: it is resampled to this shot's frame rate and
trimmed to this shot's length),
* **a clip *and* a picture** β†’ the clip opens, the picture closes.
Everything you attach is copied into `assets/`, so it is browsable from the file tree and reused on
the next run.
"""
),
image_upload,
clip_upload,
]
)
return clip_upload, image_upload
@app.cell
def conditions(
ASSETS_DIR,
Image,
LTX2VideoCondition,
Path,
av,
clip_upload,
frame_rate_value,
image_upload,
mo,
np,
num_frames,
):
def _keep(upload) -> Path:
"""Write an upload into `assets/` so it is browsable in the file tree and reused on re-runs."""
_contents = upload.contents
_data = _contents() if callable(_contents) else _contents
_path = ASSETS_DIR / Path(upload.name).name
_path.write_bytes(_data)
return _path
def _picture(path) -> Image.Image:
"""An uploaded still as RGB, downscaled if it is larger than the model ever needs."""
_image = Image.open(path).convert("RGB")
if max(_image.size) > 1920:
_image.thumbnail((1920, 1920))
return _image
def _clip(path, fps, max_frames) -> np.ndarray:
"""An uploaded clip to `(frames, height, width, 3)` uint8, resampled to this shot's frame rate."""
_frames = []
with av.open(str(path)) as _container:
_stream = next((_candidate for _candidate in _container.streams if _candidate.type == "video"), None)
if _stream is None:
raise ValueError(f"{path.name} has no video stream")
_source_fps = float(_stream.average_rate) if _stream.average_rate else fps
_stride = max(1, int(round(_source_fps / fps)))
for _number, _frame in enumerate(_container.decode(_stream)):
if _number % _stride == 0:
_frames.append(_frame.to_ndarray(format="rgb24"))
if len(_frames) >= max_frames:
break
if not _frames:
raise ValueError(f"{path.name} decoded to no frames")
return np.stack(_frames)
_picture_paths = [_keep(_upload) for _upload in image_upload.value]
_clip_paths = [_keep(_upload) for _upload in clip_upload.value]
conditions, _notes, _previews, _asset_error = [], [], [], None
try:
if _clip_paths:
_clip_frames = _clip(_clip_paths[0], frame_rate_value, num_frames)
conditions.append(LTX2VideoCondition(frames=_clip_frames, index=0, strength=1.0))
_notes.append(
f"**{_clip_paths[0].name}** conditions the opening ({_clip_frames.shape[0]} frames at {frame_rate_value:g} fps)"
)
_previews.append(
mo.vstack([mo.video(_clip_paths[0], width=220, muted=True), mo.md(f"`{_clip_paths[0].name}`")])
)
_picture_slots = [-1] if _clip_paths else [0, -1]
for _path, _slot in zip(_picture_paths, _picture_slots):
_still = _picture(_path)
conditions.append(LTX2VideoCondition(frames=_still, index=_slot, strength=1.0))
_notes.append(f"**{_path.name}** {'opens' if _slot == 0 else 'closes'} the shot ({_still.width} Γ— {_still.height})")
_previews.append(mo.vstack([mo.image(_still, width=220), mo.md(f"`{_path.name}`")]))
if len(_picture_paths) > len(_picture_slots):
_notes.append(
f"{len(_picture_paths) - len(_picture_slots)} further picture(s) ignored β€” at most two stills condition a render."
)
except Exception as _error: # a corrupt or unsupported upload should explain itself, not kill the cell
_asset_error = f"{type(_error).__name__}: {_error}"
conditions = []
if _asset_error:
conditioning_status = mo.callout(
mo.md(
f"**That asset could not be used**, so this render will run from the prompt alone.\n\n"
f"`{_asset_error}`\n\n"
"Attach a different picture or clip β€” or clear the uploads β€” and this updates."
),
kind="danger",
title="Conditioning",
)
elif not _notes:
conditioning_status = mo.callout(
mo.md("No assets attached β€” this render is driven by the prompt alone."),
kind="neutral",
title="Conditioning",
)
else:
conditioning_status = mo.callout(
mo.md("\n\n".join(f"- {_note}" for _note in _notes)),
kind="success",
title="Conditioning",
)
mo.vstack([conditioning_status, mo.hstack(_previews, wrap=True, align="start") if _previews else mo.md("")])
return (conditions,)
@app.cell
def access(Path, mo):
# The checkpoint this notebook runs. `Lightricks/LTX-2` is an open repository, so every file downloads
# anonymously β€” there is no token anywhere in this notebook. (Point MODEL_ID at the gated
# `Lightricks/LTX-2.5-Diffusers` and you would need one: accept the LTX-2.x Community Licence on its model page,
# then set HF_TOKEN in the environment.)
MODEL_ID = "Lightricks/LTX-2"
# `assets/` holds every picture and clip you attach and `outputs/` every render. Both live in the notebook's
# working directory, which you can browse from the file tree.
ASSETS_DIR = Path("assets")
OUTPUT_DIR = Path("outputs")
# The repository also ships a 2x spatial latent upscaler, but `model_index.json` does not
# declare it as a component β€” so `_pipeline_files` below cannot see it and never fetches it.
# It is 1 GB and it buys a genuine quality step: render at a size the model is happy with
# (768 x 512, 1024 x 576) and let `LTX2LatentUpsamplePipeline` double it, rather than paying
# 3-5x per denoising step to render at the large size β€” which is where this card runs out.
UPSCALER_FILES = [
"latent_upsampler/diffusion_pytorch_model.safetensors",
"latent_upsampler/config.json",
]
for _directory in (ASSETS_DIR, OUTPUT_DIR):
_directory.mkdir(parents=True, exist_ok=True)
# The `LTX2_MODEL_DIR` environment variable wins; otherwise the weights land in `models/LTX-2/`. Point that
# variable at storage that survives a restart and the ~92 GB fetch happens once rather than once per session.
DEFAULT_MODEL_DIR = Path("models") / MODEL_ID.rsplit("/", 1)[-1]
mo.md(
"""
## 3 Β· The model
LTX-2 fetches and loads itself β€” nothing to press, nothing to authenticate
([`Lightricks/LTX-2`](https://huggingface.co/Lightricks/LTX-2) is not gated). The fetch is narrow by design:
the repository is ~314 GB, but the notebook asks `model_index.json` which components this pipeline loads and
pulls only those files, so the first run moves ~92 GB.
The cell below says where they land; the two after it fetch and load, and both are safe to re-run β€” a second
run finds everything on disk and skips straight to loading.
"""
)
return ASSETS_DIR, DEFAULT_MODEL_DIR, MODEL_ID, OUTPUT_DIR, UPSCALER_FILES
@app.cell
def access_check(DEFAULT_MODEL_DIR, HfApi, MODEL_ID, Path, mo, os):
# Where the weights live: `LTX2_MODEL_DIR` wins, otherwise the default. The directory is created here so every
# later cell can treat it as existing.
MODEL_DIR = Path(os.environ.get("LTX2_MODEL_DIR", "").strip() or DEFAULT_MODEL_DIR).expanduser().resolve()
try:
MODEL_DIR.mkdir(parents=True, exist_ok=True)
model_dir_ok = True
except OSError:
model_dir_ok = False
# The repository is public, so checking it needs no credentials β€” this just confirms the Hub answers and the model
# id is right before any bytes move.
try:
with mo.status.spinner(title=f"Checking {MODEL_ID} …"):
_model_info = HfApi().model_info(MODEL_ID, files_metadata=True)
model_files = len(_model_info.siblings or [])
access_ok = model_dir_ok
access_status = mo.callout(
mo.md(
f"The weights go in `{MODEL_DIR}`. `{_model_info.id}` is reachable β€” **{model_files} files**, and "
"**no token is needed**; the cell below fetches only the ones this pipeline loads."
if model_dir_ok
else f"❌ **`{MODEL_DIR}` can't be written to**, so there is nowhere to put the weights. Set "
"`LTX2_MODEL_DIR` to a writable path and re-run."
),
kind="success" if model_dir_ok else "danger",
title="The weights",
)
except Exception as _error: # DNS hiccup, no network, ...
access_ok = False
access_status = mo.callout(
mo.md(f"❌ Could not reach the Hugging Face Hub: `{_error}` β€” re-run this cell once the network is back."),
kind="danger",
title="The weights",
)
access_status
return MODEL_DIR, access_ok
@app.cell
def download(
HfApi,
MODEL_DIR,
MODEL_ID,
UPSCALER_FILES,
access_ok,
hf_hub_download,
json,
mo,
snapshot_download,
):
# The repository carries more than the pipeline reads (an unused full/SFT transformer, alternate shardings, a
# distilled LoRA). So the notebook asks first: `model_index.json` names every component the pipeline loads, and
# each component's own index file names the exact weight shards. Only those are fetched β€” anonymously, since the
# repository is public.
META_PATTERNS = ["*.json", "*.txt", "*.jinja", "*.model"]
def _hub_sizes(model_id):
"""`{path: bytes}` for every file in the repository β€” metadata only, nothing is downloaded."""
_info = HfApi().model_info(model_id, files_metadata=True)
return {_file.rfilename: (_file.size or 0) for _file in (_info.siblings or [])}
def _pipeline_files(local_dir, sizes):
"""The weight files the components named in `model_index.json` will load, largest first."""
_declared = json.loads((local_dir / "model_index.json").read_text())
_components = {_key: _value for _key, _value in _declared.items() if not _key.startswith("_")}
_needed = []
for _component, _entry in _components.items():
# A component's library decides its file naming: `transformers` components load `model.safetensors`,
# diffusers ones `diffusion_pytorch_model.safetensors`. Some repositories carry *both* sets β€” LTX-2 does, for
# its text encoder β€” so choosing the wrong one downloads ~49 GB that the loader never opens while the shards
# it does need stay missing.
_library = _entry[0] if isinstance(_entry, (list, tuple)) and _entry else ""
_index_names = (
("model.safetensors.index.json", "diffusion_pytorch_model.safetensors.index.json")
if _library == "transformers"
else ("diffusion_pytorch_model.safetensors.index.json", "model.safetensors.index.json")
)
_shard_index = next(
(
_candidate
for _candidate in (local_dir / _component / _name for _name in _index_names)
if _candidate.exists()
),
None,
)
if _shard_index is not None:
_needed.extend(
f"{_component}/{_shard}" for _shard in set(json.loads(_shard_index.read_text())["weight_map"].values())
)
continue
_singles = (
("model.safetensors", "model.bin")
if _library == "transformers"
else ("diffusion_pytorch_model.safetensors", "diffusion_pytorch_model.bin")
)
_named = [f"{_component}/{_name}" for _name in _singles if f"{_component}/{_name}" in sizes]
_needed.extend(
_named
or [
_name
for _name in sizes
if _name.startswith(f"{_component}/")
and _name.count("/") == 1
and _name.endswith((".safetensors", ".bin"))
]
)
return sorted({_name for _name in _needed if _name in sizes}, key=lambda _name: sizes[_name], reverse=True)
def _outstanding(local_dir, plan, sizes):
"""Plan entries that are not on disk yet β€” size-checked, so a truncated file is fetched again."""
return [
_name
for _name in plan
if not ((local_dir / _name).is_file() and (local_dir / _name).stat().st_size == sizes[_name])
]
if not access_ok:
model_ready, model_gb = False, 0.0
download_status = mo.callout(
mo.md(
"The weights have nowhere to land yet β€” the cell above says what is wrong. Fix that and this cell "
"fetches them."
),
kind="neutral",
title="Downloading the weights",
)
else:
with mo.status.spinner(title="Asking the Hub which files LTX-2 actually needs …"):
_repo_sizes = _hub_sizes(MODEL_ID)
snapshot_download(MODEL_ID, allow_patterns=META_PATTERNS, local_dir=MODEL_DIR)
_plan = _pipeline_files(MODEL_DIR, _repo_sizes)
# `_pipeline_files` reads components out of `model_index.json`, and the upscaler is not
# one of them. Ask for it here so a fresh checkout gets everything a render needs.
_plan = sorted(
{*_plan, *(_name for _name in UPSCALER_FILES if _name in _repo_sizes)},
key=lambda _name: _repo_sizes[_name],
reverse=True,
)
_outstanding_files = _outstanding(MODEL_DIR, _plan, _repo_sizes)
model_gb = round(sum(_repo_sizes[_name] for _name in _plan) / 1e9, 1)
if not _plan:
model_ready = False
download_status = mo.callout(
mo.md("The repository did not hand back a usable `model_index.json`."),
kind="danger",
title="Downloading the weights",
)
elif _outstanding_files:
_left_gb = round(sum(_repo_sizes[_name] for _name in _outstanding_files) / 1e9, 1)
with mo.status.progress_bar(
total=len(_outstanding_files),
title=f"Downloading LTX-2 β†’ {MODEL_DIR}",
subtitle=f"{_left_gb} GB of {model_gb} GB left ({len(_outstanding_files)} of {len(_plan)} files)",
completion_title="Download complete",
remove_on_exit=True,
) as _bar:
_fetched = 0.0
for _name in _outstanding_files:
hf_hub_download(MODEL_ID, _name, local_dir=MODEL_DIR)
_fetched += _repo_sizes[_name]
_bar.update(increment=1, subtitle=f"{_name} Β· {_fetched:.1f} GB fetched")
model_ready = True
download_status = mo.callout(
mo.md(
f"**{len(_plan)} files Β· {model_gb} GB** are on disk in `{MODEL_DIR}`.\n\n"
"They stay there for the life of this session; re-running this cell only tops up what is missing, "
"so an interrupted download is safe to retry."
),
kind="success",
title="Downloading the weights",
)
else:
model_ready = True
download_status = mo.callout(
mo.md(f"**Already on disk** β€” {len(_plan)} files Β· {model_gb} GB in `{MODEL_DIR}`. Nothing to fetch."),
kind="success",
title="Downloading the weights",
)
download_status
return model_gb, model_ready
@app.cell
def load(
LTX2ConditionPipeline,
MODEL_DIR,
gpu_name,
gpu_ok,
mo,
model_gb,
model_ready,
torch,
vram_gb,
):
if not model_ready:
pipe = None
load_status = mo.callout(
mo.md("Waiting for the weights β€” the cell above says what is still missing."),
kind="neutral",
title="Loading onto the GPU",
)
elif not gpu_ok:
pipe = None
load_status = mo.callout(
mo.md(
f"The **{model_gb} GB** of LTX-2 weights are on disk, but a 22B diffusion transformer needs a GPU to "
"run on and this session has none attached."
),
kind="danger",
title="Loading onto the GPU",
)
else:
# `enable_model_cpu_offload` is broken for this pipeline and must not be used. LTX-2's condition
# pipeline calls the VAE's methods directly (`vae.encode(...)`, `vae.decode(...)`) rather than through
# the module's `__call__`. accelerate's offload hook only wraps `__call__`, so it never fires for the
# VAE: its weights stay on the CPU while the input is on the GPU, and every render dies with
# "Input type (CUDABFloat16Type) and weight type (CPUBFloat16Type) should be the same". Verified: with
# the offload in place the VAE's first parameter sits on `cpu` and nothing renders. Go fully resident.
#
# A previous load can still be pinning the card. Rebinding the name frees the tensors, but the caching
# allocator keeps those blocks reserved for itself, so the next `.to("cuda")` OOMs against memory that
# is already free β€” which silently drops this cell onto the slow fallback below. Drop it first.
import gc as _gc
try:
del pipe
except NameError:
pass
_gc.collect()
torch.cuda.empty_cache()
with mo.status.spinner(title=f"Reading {model_gb} GB of weights from {MODEL_DIR} onto the GPU …"):
pipe = LTX2ConditionPipeline.from_pretrained(str(MODEL_DIR), torch_dtype=torch.bfloat16)
try:
pipe.to("cuda")
_strategy = (
f"fully resident on {gpu_name} β€” {torch.cuda.memory_allocated() / 1e9:.0f} GB of weights "
f"in {vram_gb:.0f} GB VRAM"
)
except torch.cuda.OutOfMemoryError:
# Genuinely will not fit: sequential offload hooks every submodule, so it does not share the
# `__call__` blind spot. Untested β€” a fallback for a smaller card, not this notebook's path.
torch.cuda.empty_cache()
pipe = LTX2ConditionPipeline.from_pretrained(str(MODEL_DIR), torch_dtype=torch.bfloat16)
pipe.enable_sequential_cpu_offload()
_strategy = (
f"sequentially CPU-offloaded onto {gpu_name} β€” does not fit in {vram_gb:.0f} GB VRAM. "
"Same output, much slower"
)
_enable_vae_tiling = getattr(pipe.vae, "enable_tiling", None)
if callable(_enable_vae_tiling):
_enable_vae_tiling()
return (pipe,)
@app.cell
def generate_button(mo):
# The button is created on its own so it is on screen from the moment the notebook starts β€” this cell depends on
# nothing but `mo`. Pressing it before the model is loaded is answered in the cell below rather than crashing.
generate_button = mo.ui.run_button(
kind="success",
label="🎬 Generate video + audio",
tooltip="Runs LTX-2 for this shot. A few seconds of video takes minutes, not seconds.",
)
mo.vstack(
[
mo.md("## 4 Β· Generate"),
mo.md(
"One press renders the shot above β€” the prompt, the quality and length you chose, and anything you "
"attached. The `.mp4` arrives right under this button with its own download link, and it is kept in "
"`outputs/` either way."
),
generate_button,
]
)
return (generate_button,)
@app.cell
def generate(
MODEL_ID,
OUTPUT_DIR,
audio_guidance,
conditions,
encode_video,
frame_rate_value,
generate_button,
height,
json,
mo,
modality_scale,
negative_prompt,
np,
num_frames,
pipe,
prompt,
realised_seconds,
seed,
steps,
stg_scale,
time,
torch,
video_guidance,
width,
):
def _video_batch(frames) -> np.ndarray:
"""Pipeline frames to a float `(batch, frames, height, width, 3)` array in [0, 1]."""
if isinstance(frames, np.ndarray):
return frames.astype(np.float32) / 255.0 if np.issubdtype(frames.dtype, np.integer) else frames
if isinstance(frames, torch.Tensor): # (batch, channels, frames, height, width)
return frames.permute(0, 2, 3, 4, 1).float().cpu().numpy()
return np.stack([np.stack([np.array(_frame.convert("RGB")) for _frame in _clip]) for _clip in frames]).astype(np.float32) / 255.0
def _waveform(audio) -> torch.Tensor:
"""Whatever the pipeline returns for audio to a `(channels, samples)` float tensor."""
_wave = audio[0] if isinstance(audio, (list, tuple)) else audio
_wave = torch.as_tensor(_wave).float().cpu()
if _wave.ndim == 3:
_wave = _wave[0]
if _wave.ndim == 1:
_wave = _wave[None]
if _wave.shape[0] == 1: # the muxer writes stereo frames, so mono needs duplicating
_wave = _wave.repeat(2, 1)
return _wave
def _free_cuda():
"""Hand the card back. A render that overran it leaves tens of GB referenced by its own traceback, which then
breaks every later attempt β€” so release that before anything else."""
import gc
gc.collect()
torch.cuda.empty_cache()
# Section 3 binds `pipe` once the weights are loaded. If the notebook was interrupted before it got that far the
# name does not exist at all β€” which is not something to throw at the reader, just a shot with no model behind it.
try:
_pipe = pipe
except NameError:
_pipe = None
render_path, render_summary, render_oom = None, "", None
# A run button can only carry a value for the run its own click triggered, but guard anyway: without a
# loaded pipeline there is nothing to call, and a hint beats a TypeError.
if generate_button.value and _pipe is not None and prompt.value.strip():
_generator = torch.Generator(device="cuda").manual_seed(int(seed.value))
_started = time.time()
with mo.status.progress_bar(
total=int(steps.value),
title="Denoising picture + soundtrack",
subtitle=f"{num_frames} frames Β· {width} Γ— {height} Β· {len(conditions)} conditioning item(s)",
completion_title="Denoised β€” writing the mp4",
remove_on_exit=True,
) as _bar:
def _on_step(_pipeline, _index, _timestep, _callback_kwargs):
_bar.update(increment=1, subtitle=f"step {_index + 1} / {int(steps.value)}")
return _callback_kwargs
def _render():
return _pipe(
conditions=conditions or None,
prompt=prompt.value.strip(),
negative_prompt=negative_prompt.value.strip() or None,
height=height,
width=width,
num_frames=num_frames,
frame_rate=frame_rate_value,
num_inference_steps=int(steps.value),
guidance_scale=float(video_guidance.value),
audio_guidance_scale=float(audio_guidance.value),
stg_scale=float(stg_scale.value),
modality_scale=float(modality_scale.value),
generator=_generator,
# this checkpoint ships no prompt_enhancer component (the 2.5 one does), so this is off by construction
enable_prompt_enhancement=False,
output_type="np",
callback_on_step_end=_on_step,
)
_free_cuda()
try:
_result = _render()
except torch.cuda.OutOfMemoryError:
# Fragmentation, or a component left resident, is often the whole difference between a render that
# fits and one that does not. Free everything and repeat the identical call once. The exception is
# deliberately not captured: holding it would keep the failed attempt's tensors alive and poison
# every later render in this session.
_free_cuda()
try:
_result = _render()
except torch.cuda.OutOfMemoryError:
_result = None
if _result is None:
render_oom = {
"allocated": round(torch.cuda.memory_allocated() / 1e9, 1),
"free": round(torch.cuda.mem_get_info()[0] / 1e9, 1),
}
if _result is not None:
_video = _video_batch(_result.frames)
_wave = _waveform(_result.audio)
_sample_rate = int(getattr(_pipe.vocoder.config, "output_sampling_rate", 24000))
# A slug from the prompt makes takes identifiable at a glance; the sidecar beside the mp4 records exactly
# how it was made, so a take can be traced back β€” and repeated β€” instead of being a mystery file.
_slug = "-".join(
filter(None, "".join(_c if _c.isalnum() else "-" for _c in prompt.value.strip().lower()[:48]).split("-"))
)[:32] or "take"
render_path = OUTPUT_DIR / f"{time.strftime('%Y%m%d-%H%M%S')}_{_slug}_seed{int(seed.value)}.mp4"
with mo.status.spinner(title="Encoding H.264 video + AAC audio …"):
encode_video(
_video[0],
fps=int(round(frame_rate_value)),
output_path=str(render_path),
audio=_wave,
audio_sample_rate=_sample_rate,
)
render_path.with_suffix(".json").write_text(
json.dumps(
{
"model": MODEL_ID,
"prompt": prompt.value.strip(),
"negative_prompt": negative_prompt.value.strip(),
"width": width,
"height": height,
"frames": num_frames,
"frame_rate": frame_rate_value,
"duration_s": realised_seconds,
"steps": int(steps.value),
"guidance": {
"video": float(video_guidance.value),
"audio": float(audio_guidance.value),
"spatio_temporal": float(stg_scale.value),
"modality": float(modality_scale.value),
},
"seed": int(seed.value),
"conditions": len(conditions),
"created": time.strftime("%Y-%m-%d %H:%M:%S"),
},
indent=2,
)
)
render_summary = (
f"{_video.shape[1]} frames Β· {realised_seconds:g} s Β· {frame_rate_value:g} fps Β· audio {_sample_rate} Hz Β· "
f"{int(steps.value)} steps Β· seed {int(seed.value)} Β· {(time.time() - _started) / 60:.1f} min"
)
if render_path is None:
_kind = "neutral"
if _pipe is None:
_hint = (
"**There is no model to run yet, so that press did not render anything.** The weights are still on "
"their way: section 3 fetches them and loads them onto the GPU. Once it reports the model loaded, this "
"button renders the shot."
)
elif not prompt.value.strip():
_hint = "**Nothing was rendered β€” the prompt is empty.** Write the shot in section 1, then press the button again."
elif render_oom:
_kind = "warn"
_hint = (
f"**The card ran out of memory**, so nothing was written. After the retry the GPU was holding "
f"{render_oom['allocated']} GB with {render_oom['free']} GB free. Long shots sit right at the edge of the "
"~95 GB this card has β€” a 30-second take has both succeeded and failed here. The failed attempt's memory "
"has been released, so change one thing and press again: a shorter duration, a smaller frame size, or "
"fewer steps."
)
else:
_hint = (
"The model is loaded and the shot is written β€” press **🎬 Generate video + audio** above. (Every take "
"this notebook has made is kept in **5 Β· Renders**, with its own download link.)"
)
generate_view = mo.callout(mo.md(_hint), kind=_kind, title="4 Β· Generate")
else:
generate_view = mo.vstack(
[
mo.video(render_path),
mo.hstack(
[
mo.md(f"**`{render_path.name}`** β€” {render_summary}"),
mo.download(
lambda: render_path.read_bytes(),
filename=render_path.name,
mimetype="video/mp4",
label="⬇ download .mp4",
),
],
justify="space-between",
align="center",
),
]
)
generate_view
return (render_path,)
@app.cell
def renders(OUTPUT_DIR, json, mo, render_path):
renders = sorted(OUTPUT_DIR.glob("*.mp4"), key=lambda _path: _path.stat().st_mtime, reverse=True)
_latest = f" β€” latest `{render_path.name}`" if render_path is not None and render_path.exists() else ""
def _settings(_path):
"""The sidecar written beside a take β€” what it was made from."""
_side = _path.with_suffix(".json")
if not _side.is_file():
return None
try:
return json.loads(_side.read_text())
except Exception:
return None
def _take(_path):
"""One take: the preview, how it was made, and its own download button."""
_meta = _settings(_path)
_rows = [mo.video(_path, width=260, muted=True), mo.md(f"`{_path.name}`")]
if _meta:
_prompt = _meta.get("prompt", "")
_rows.append(
mo.md(
f"_{_prompt[:160]}{'…' if len(_prompt) > 160 else ''}_\n\n"
f"{_meta.get('width')} Γ— {_meta.get('height')} Β· {_meta.get('frames')} frames Β· "
f"{_meta.get('duration_s'):g} s Β· {_meta.get('steps')} steps Β· seed {_meta.get('seed')}"
)
)
_rows.append(
mo.download(lambda _path=_path: _path.read_bytes(), filename=_path.name,
mimetype="video/mp4", label="⬇ .mp4")
)
return mo.vstack(_rows)
if not renders:
renders_view = mo.callout(
mo.md("Every render lands in `outputs/` and is listed here, side by side and ready to download."),
kind="neutral",
title="5 Β· Renders",
)
else:
renders_view = mo.vstack(
[
mo.md(f"### 5 Β· Renders β€” {len(renders)} in `outputs/`{_latest}"),
mo.md(
"_Each take keeps a `.json` beside it recording the prompt, seed, size and guidance it was made "
"with, so a look can be reproduced. Download what you want to keep β€” `outputs/` lives in this "
"session's working directory._"
),
mo.hstack([_take(_path) for _path in renders[:12]], wrap=True, align="start"),
]
)
renders_view
return
@app.cell
def film(Path, json, mo):
# Optional: render a whole shot list and assemble it into one film.
#
# The shot list is read from `film.json` in the working directory when this cell runs β€” edit that file and
# re-run the cell to change the film.
#
# Nothing appears here unless `film.json` sits in the working directory (see `make_film.py` for the format). It
# reuses the pipeline section 3 has already loaded β€” a second copy would need another ~92 GB of RAM β€” and it is
# resumable: any shot whose sidecar already exists is skipped, so an interrupted film carries on where it stopped.
# Each shot lands in `outputs/film/<title>/` and the finished film in `outputs/`, where section 5 lists it.
import make_film
_film_spec_path = Path("film.json")
if not _film_spec_path.exists():
film_spec = None
render_film = None
_film_view = mo.md("")
else:
film_spec = json.loads(_film_spec_path.read_text())
_film_shots = sum(len(_piece["shots"]) for _piece in film_spec["pieces"])
_film_seconds = sum(_shot["seconds"] for _piece in film_spec["pieces"] for _shot in _piece["shots"])
_film_est_min = round(_film_shots * 10 * (film_spec.get("steps", 30) / 30))
render_film = mo.ui.run_button(
kind="success",
label=f"🎬 Render the film β€” {_film_shots} shots Β· {_film_seconds:.0f}s Β· about {_film_est_min} min",
tooltip="Renders every shot in film.json, chains them end to start, dissolves them together and burns in "
"the lower third. Long: start it and leave it.",
)
_film_view = mo.vstack(
[
mo.md("## Film (optional)"),
render_film,
mo.md(
f"**{film_spec['title']}** β€” {_film_shots} shots across {len(film_spec['pieces'])} acts Β· "
f"{_film_seconds/60:.1f} min of finished film Β· {film_spec.get('size')} Β· "
f"{film_spec.get('frame_rate')} fps Β· {film_spec.get('steps')} steps Β· dissolves "
f"{make_film.CROSSFADE}s Β· lower third _{make_film.LOWER_THIRD}_"
),
]
)
_film_view
return film_spec, make_film, render_film
@app.cell(hide_code=True)
def film_render(film_spec, make_film, pipe, render_film):
# The film render itself. Its own cell because marimo will not let a cell read a widget it created β€” the button
# lives in the cell above. Resumable, so pressing it again after an interruption costs only the unfinished shots.
if render_film is not None and render_film.value:
film_result = make_film.run(film_spec, pipe=pipe)
else:
film_result = None
film_result
return
@app.cell
def refresh(mo):
# A timer so the viewer below stays current while a film renders. It sits in its own cell because marimo
# will not let a cell read a widget it created β€” the same reason the film button is split across two cells.
refresh_timer = mo.ui.refresh(
options=["10s", "30s", "1m", "5m"],
default_interval="30s",
label="Auto-refresh",
)
refresh_timer
return (refresh_timer,)
@app.cell
def watch(OUTPUT_DIR, film_spec, mo, refresh_timer):
# The one place to watch everything this notebook generates.
#
# Section 4 shows the single-shot render and section 5 the *finished* films (`outputs/*.mp4`). The takes of a
# film being rendered live in `outputs/film/<title>/`, and nothing surfaced those β€” so a render could run for
# an hour while the notebook looked empty. This cell is that viewer.
#
# It reads `refresh_timer`, so it re-runs on the timer and new takes appear on their own.
_refreshed_at = refresh_timer.value
_film_dir = OUTPUT_DIR / "film" / film_spec["title"] if film_spec else None
_takes = sorted(_film_dir.glob("p*-s*.mp4")) if _film_dir and _film_dir.is_dir() else []
_films = sorted(OUTPUT_DIR.glob("*.mp4"), key=lambda _p: _p.stat().st_mtime, reverse=True)
_planned = (
[
(f"p{_p}-s{_s}", _piece.get("name", f"piece-{_p}"), _shot["seconds"])
for _p, _piece in enumerate(film_spec["pieces"], 1)
for _s, _shot in enumerate(_piece["shots"], 1)
]
if film_spec
else []
)
_done = {_take.stem for _take in _takes}
def _player(_path, _caption):
"""A generated video with its own download button."""
return mo.vstack(
[
mo.video(_path, width=200, muted=True),
mo.md(_caption),
mo.download(
lambda _p=_path: _p.read_bytes(),
filename=_path.name,
mimetype="video/mp4",
label="⬇ .mp4",
),
]
)
_blocks = [mo.md(f"## Watch β€” refreshed {_refreshed_at}")]
if _films:
_blocks.append(mo.md(f"### Finished films β€” {len(_films)} in `outputs/`"))
_blocks.append(
mo.hstack(
[_player(_f, f"**`{_f.name}`** β€” {_f.stat().st_size / 1e6:.1f} MB") for _f in _films[:6]],
wrap=True,
align="start",
)
)
if not film_spec:
_blocks.append(mo.md("_No `film.json`, so no film is planned._"))
else:
_blocks.append(mo.md(f"### Film takes β€” **{len(_done)} of {len(_planned)}** rendered"))
_blocks.append(
mo.hstack(
[
_player(_take, f"**`{_take.stem}`** β€” {_seconds:g}s Β· {_take.stat().st_size / 1e6:.2f} MB")
for _take in _takes
for _name, _act, _seconds in _planned
if _name == _take.stem
],
wrap=True,
align="start",
)
if _takes
else mo.md("_Nothing rendered yet._")
)
_pending = [f"`{_n}` β€” {_a} ({_s:g}s)" for _n, _a, _s in _planned if _n not in _done]
_blocks.append(
mo.accordion({"Still to render": mo.md("\n\n".join(f"- {_item}" for _item in _pending))})
if _pending
else mo.md("_**All shots rendered.** The joined film lands in `outputs/` once assembly runs._")
)
video_viewer = mo.vstack(_blocks)
video_viewer
return
@app.cell
def tips(mo):
mo.md("""
## 6 Β· Notes
### Writing the prompt
* **One paragraph, present tense, one continuous shot.** Camera β†’ subject β†’ action β†’ light β†’ sound.
* **Say what you want to hear** β€” voices, footsteps, wind, engines, music. Quote dialogue and attribute it:
*she looks up and says: "the boat comes in an hour."*
* **Describe the look**: "warm dawn light raking across wet stone, shallow depth of field, 35 mm film grain".
* **Short prompts come back generic.** Write the long caption yourself β€” the enhancer that expands short prompts
belongs to the 2.5 checkpoint, and this one ships no `prompt_enhancer` component at all.
* **The negative prompt covers the soundtrack too**; the shipped default already asks for no "distorted voice"
and no "mismatched lip sync".
### Time, speed and quality
* A render is **minutes, not seconds**: each denoising step runs the transformer two to four times
(classifier-free guidance for picture and sound, spatio-temporal guidance, modality guidance). The **Quality**
preset decides how many of those passes run, and the shot summary above prints the estimate before you press
anything.
* 121 frames β‰ˆ 5 s at 24 fps is the length the model is happiest at. **Draft** is for trying ideas; raise the
steps for a take you keep.
* **Changing only the seed is the cheapest way to try again** β€” everything else can stay put.
### Where things live
* `models/LTX-2/` β€” the weights (only the files this pipeline loads). Set `LTX2_MODEL_DIR` to storage that
survives a restart and the ~92 GB fetch happens once rather than once per session.
* `assets/` β€” every picture and clip you attached.
* `outputs/` β€” every render: the `.mp4` with its audio muxed in, plus a `.json` recording how it was made.
### If something goes wrong
* **An interrupted download** β†’ re-run the fetch cell; every file is size-checked, so only what is missing comes
down again.
* **Out of memory** β†’ a shorter duration, a smaller frame size, or fewer steps. The generate step frees a failed
attempt's memory and retries once by itself, then reports the GB held and free.
* **The weights disappeared after a restart** β†’ point `LTX2_MODEL_DIR` at durable storage, or simply re-run the
fetch: it is resumable.
### Licence
The weights carry Lightricks' own licence (the LTX-2.x Community Licence). This checkpoint is **not gated**, so
nothing needs authenticating. Check what that licence allows before publishing what you render.
""")
return
if __name__ == "__main__":
app.run()