Text-to-Video
Diffusers
Safetensors
LTX2Pipeline
video-generation
audio-generation
audio-visual
ltx-2
marimo
molab
Instructions to use dragonlimited/LTX2-marimo with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Diffusers
How to use dragonlimited/LTX2-marimo with Diffusers:
pip install -U diffusers transformers accelerate
import torch from diffusers import DiffusionPipeline # switch to "mps" for apple devices pipe = DiffusionPipeline.from_pretrained("dragonlimited/LTX2-marimo", dtype=torch.bfloat16, device_map="cuda") prompt = "Astronaut in a jungle, cold color palette, muted colors, detailed, 8k" image = pipe(prompt).images[0] - Notebooks
- Google Colab
- Kaggle
Download notebook.py from dragonlimited/LTX2-marimo: direct link, hf CLI and curl.
- Browser
- Download file 56.9 kB
-
https://huggingface.co/dragonlimited/LTX2-marimo/resolve/main/notebook.py
- Command line
-
hf download hf://dragonlimited/LTX2-marimo/notebook.py
-
curl -L -o notebook.py https://huggingface.co/dragonlimited/LTX2-marimo/resolve/main/notebook.py
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"]) | |
| 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, | |
| ) | |
| 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 | |
| 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, | |
| ) | |
| 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, | |
| ) | |
| 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 | |
| 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 | |
| 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,) | |
| 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 | |
| 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 | |
| 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 | |
| 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,) | |
| 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,) | |
| 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,) | |
| 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 | |
| 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 | |
| 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 | |
| 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,) | |
| 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 | |
| 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() | |