File size: 27,417 Bytes
acf4896
82403b3
 
acf4896
82403b3
 
 
acf4896
cd7fe6c
82403b3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
acf4896
 
 
 
82403b3
 
 
acf4896
82403b3
 
 
 
acf4896
8c6425a
 
82403b3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9924772
 
acf4896
82403b3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
acf4896
 
82403b3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
acf4896
3b6d71d
82403b3
 
 
 
 
 
 
 
 
 
 
 
 
 
3b6d71d
82403b3
 
 
 
 
 
 
cd7fe6c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82403b3
 
 
 
 
 
 
 
e451b04
 
f46f8d8
 
 
 
 
 
 
 
 
 
 
 
489e53f
1ad16d4
 
 
 
 
 
 
 
f46f8d8
82403b3
f46f8d8
 
 
e451b04
 
 
489e53f
e451b04
1ad16d4
82403b3
 
e451b04
 
1ad16d4
e451b04
 
 
4bdc016
82403b3
 
acf4896
 
 
82403b3
 
f46f8d8
 
acf4896
e451b04
acf4896
 
82403b3
 
 
 
 
 
 
 
 
 
 
 
 
3b6d71d
82403b3
 
 
3dca45b
acf4896
 
8c6425a
 
 
 
 
 
ea746e5
 
8c6425a
 
 
 
 
ea746e5
8c6425a
 
 
 
 
ea746e5
 
 
8c6425a
ea746e5
 
 
8c6425a
ea746e5
8c6425a
ea746e5
 
 
8c6425a
ea746e5
 
 
8c6425a
 
 
 
ea746e5
8c6425a
 
ea746e5
 
e451b04
acf4896
e451b04
f46f8d8
82403b3
 
acf4896
cd7fe6c
acf4896
 
f46f8d8
 
 
 
 
 
 
 
ddb0915
 
ea746e5
f46f8d8
 
ea746e5
acf4896
 
 
 
 
 
 
ea746e5
 
 
 
 
 
 
82403b3
acf4896
9047bce
 
82403b3
 
 
9047bce
82403b3
94f168c
9047bce
 
acf4896
062bd15
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
import os
import subprocess
import sys

# ZeroGPU: torch.compile / dynamo unsupported — disable before any torch import.
os.environ["TORCH_COMPILE_DISABLE"] = "1"
os.environ["TORCHDYNAMO_DISABLE"] = "1"

# (removed runtime xformers install -> would pull torch 2.8 and break the AOTI .pt2; SDPA used)

# --- clone + install the NATIVE LTX-2 codebase at the pinned commit the working ZeroGPU spaces use ---
LTX_REPO_URL = "https://github.com/Lightricks/LTX-2.git"
LTX_REPO_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "LTX-2")
LTX_COMMIT = "ae855f8538843825f9015a419cf4ba5edaf5eec2"
if not os.path.exists(LTX_REPO_DIR):
    subprocess.run(["git", "clone", LTX_REPO_URL, LTX_REPO_DIR], check=True)
    subprocess.run(["git", "-C", LTX_REPO_DIR, "checkout", LTX_COMMIT], check=True)
subprocess.run([sys.executable, "-m", "pip", "install", "--force-reinstall", "--no-deps",
                "-e", os.path.join(LTX_REPO_DIR, "packages", "ltx-core"),
                "-e", os.path.join(LTX_REPO_DIR, "packages", "ltx-pipelines")], check=True)
sys.path.insert(0, os.path.join(LTX_REPO_DIR, "packages", "ltx-pipelines", "src"))
sys.path.insert(0, os.path.join(LTX_REPO_DIR, "packages", "ltx-core", "src"))

import logging
import random
import tempfile

import numpy as np
import imageio.v3 as iio
from PIL import Image, ImageOps

import torch
torch._dynamo.config.suppress_errors = True
torch._dynamo.config.disable = True

import spaces
import gradio as gr
from huggingface_hub import hf_hub_download, snapshot_download
from gradio_client import Client, handle_file

# Import LTX modules in the proven order — importing ltx_core.quantization/loader FIRST hits a
# circular import (fp8_cast <-> loader.fuse_loras). Importing the model modules first forces the
# correct init order (mirrors the working reference Space).
from ltx_core.model.video_vae import TilingConfig, get_video_chunks_number, decode_video as _vae_decode_video  # noqa: F401
from ltx_core.model.upsampler import upsample_video as _upsample_video  # noqa: F401
from ltx_core.model.audio_vae import encode_audio as _vae_encode_audio  # noqa: F401
from ltx_core.quantization import QuantizationPolicy
from ltx_core.loader import LoraPathStrengthAndSDOps, LTXV_LORA_COMFY_RENAMING_MAP
from ltx_pipelines.ic_lora import ICLoraPipeline
from ltx_pipelines.utils.media_io import encode_video

# --- ZeroGPU loader patch -------------------------------------------------------------
# The native loader opens safetensors directly on the CUDA device
# (safe_open(path, device="cuda")), doing the host->device copy in safetensors' own C++
# (cudaMemcpy) — bypassing torch.Tensor.to, the call ZeroGPU patches to virtualise + pack
# weights at module scope. Result: "No CUDA GPUs are available" at startup, nothing packs.
# Patch it to open on CPU then move via torch.Tensor.to (ZeroGPU-virtualisable).
import safetensors as _safetensors
import ltx_core.loader.sft_loader as _sft
from ltx_core.loader.primitives import StateDict as _StateDict

def _zerogpu_safe_load(self, path, sd_ops, device=None):
    device = device or torch.device("cpu")
    sd, size, dtype = {}, 0, set()
    model_paths = path if isinstance(path, list) else [path]
    for shard_path in model_paths:
        with _safetensors.safe_open(shard_path, framework="pt", device="cpu") as f:
            for name in f.keys():
                expected = name if sd_ops is None else sd_ops.apply_to_key(name)
                if expected is None:
                    continue
                value = f.get_tensor(name).to(device=device)  # torch path -> ZeroGPU-virtualised
                kvs = ((expected, value),)
                if sd_ops is not None:
                    kvs = sd_ops.apply_to_key_value(expected, value)
                for k, v in kvs:
                    size += v.nbytes
                    dtype.add(v.dtype)
                    sd[k] = v
    return _StateDict(sd=sd, device=device, size=size, dtype=dtype)

_sft.SafetensorsStateDictLoader.load = _zerogpu_safe_load
print("[PATCH] safetensors loader -> CPU-open + torch.to (ZeroGPU-virtualisable)")
# --------------------------------------------------------------------------------------

# --- attention backend patch (FA3 crashes on Blackwell ZeroGPU; use xformers/SDPA) ---
import torch.nn.functional as F
from ltx_core.model.transformer import attention as _attn_mod

def _sdpa_as_mea(query, key, value, attn_bias=None, scale=None, **kwargs):
    q, k, v = query.transpose(1, 2), key.transpose(1, 2), value.transpose(1, 2)
    return F.scaled_dot_product_attention(q, k, v, scale=scale).transpose(1, 2)

# IMPORTANT (ZeroGPU): never query CUDA at module scope. SDPA works on every GPU (incl.
# Blackwell ZeroGPU, where FA3 crashes), so patch it unconditionally.
_attn_mod.memory_efficient_attention = _sdpa_as_mea
print("[ATTN] SDPA (patched at module scope, no CUDA query)")

logging.getLogger().setLevel(logging.INFO)

# =========================== PER-LORA CONFIG (colorize) ===========================
TITLE = "LTX-2.3 Ingredients (native LTX-2)"
LORA_REPO = "Lightricks/LTX-2.3-22b-IC-LoRA-Ingredients"
LORA_FILE = "ltx-2.3-22b-ic-lora-ingredients-0.9.safetensors"
LORA_SCALE = 1.4
SKIP_STAGE_2 = True
GRAYSCALE_REF = False
RES_PRESETS = {"768×448": (768, 448), "960×544": (960, 544)}
DEFAULT_PRESET = "768×448"
FRAME_CHOICES = [49, 73, 97, 121]
DEFAULT_FRAMES = 121

def build_prompt(sheet, action):
    return f"Reference sheet: {sheet.strip()}\n\nGenerated video: {action.strip()}"

EXAMPLES = [
    ["examples/sheet_garden.png",
     "a friendly cartoon hedgehog with rounded chestnut-brown fur and big eyes, and a grey-and-white rabbit with long ears; a green coiled garden hose and green spray bottles; the bright interior of a 'Greenfield Home & Garden' store with leafy plants",
     "the hedgehog waddles up and says 'welcome to Greenfield!' while the rabbit hops past with a spray bottle; warm acoustic store jingle, cheerful voice and soft footsteps",
     "768×448", 121, 42, False],
    ["examples/sheet_hiker.png",
     "a young asian woman with two long braids in an olive t-shirt and khaki pants; a large blue external-frame backpack; a thick wooden walking stick; a shaggy white yak with an ornate blue-red-yellow saddle blanket; snowy mountains and a small stone shrine",
     "she sits on a rock beside the yak, pats its neck and says in a tired gentle voice 'we've got a long way to go... big guy'; wind, the yak's low grunt and jingling stirrups, no music",
     "768×448", 121, 42, False],
]
# =================================================================================

FPS = 24.0
MAX_SEED = np.iinfo(np.int32).max
HF_TOKEN = os.environ.get("HF_TOKEN")
LTX_MODEL_REPO = "Lightricks/LTX-2.3"
GEMMA_REPO = "google/gemma-3-12b-it-qat-q4_0-unquantized"


def _src_fps(path, default=FPS):
    try:
        return float(iio.immeta(path, plugin="pyav").get("fps", default)) or default
    except Exception:
        return default


def _prep_reference(path, width, height, num_frames):
    """Resample to 24fps, aspect-fit/crop to WxH, NF frames; (optionally grayscale); write temp mp4."""
    vid = iio.imread(path, plugin="pyav")
    src_fps = _src_fps(path)
    n = len(vid)
    out = []
    for i in range(num_frames):
        idx = min(int(round(i / FPS * src_fps)), n - 1)
        im = Image.fromarray(vid[idx]).convert("RGB")
        im = ImageOps.fit(im, (width, height), Image.LANCZOS)
        if GRAYSCALE_REF:
            im = im.convert("L").convert("RGB")
        out.append(np.array(im))
    tmp = tempfile.mktemp(suffix=".mp4")
    iio.imwrite(tmp, np.stack(out), fps=FPS, plugin="pyav", codec="libx264")
    return tmp


def _pick_resolution(path, preset):
    w, h = RES_PRESETS[preset]
    try:
        f0 = iio.imread(path, plugin="pyav", index=0)
        if f0.shape[0] > f0.shape[1]:  # portrait
            w, h = h, w
    except Exception:
        pass
    return w, h


# --- Load native pipeline + IC-LoRA once at module scope (ZeroGPU packs weights here) ---
print("Downloading checkpoints…")
checkpoint_path = hf_hub_download(LTX_MODEL_REPO, "ltx-2.3-22b-distilled-1.1.safetensors", token=HF_TOKEN)
spatial_upsampler_path = hf_hub_download(LTX_MODEL_REPO, "ltx-2.3-spatial-upscaler-x2-1.1.safetensors", token=HF_TOKEN)
gemma_root = snapshot_download(GEMMA_REPO, token=HF_TOKEN)
lora_path = hf_hub_download(LORA_REPO, LORA_FILE, token=HF_TOKEN)

print("Building ICLoraPipeline…")
pipeline = ICLoraPipeline(
    distilled_checkpoint_path=checkpoint_path,
    spatial_upsampler_path=spatial_upsampler_path,
    gemma_root=gemma_root,
    loras=[LoraPathStrengthAndSDOps(lora_path, LORA_SCALE, LTXV_LORA_COMFY_RENAMING_MAP)],
    # bf16 (NOT fp8): the IC-LoRA is fused into the transformer at MODULE SCOPE (the GPU
    # worker can't re-open the checkpoint file). fp8_cast()'s fusion runs a custom CUDA kernel
    # that can't be ZeroGPU-virtualised; the bf16 fuse rule is pure torch -> virtualisable.
    quantization=None,
)


def _preload_pin(ledger, tag):
    if ledger is None:
        return
    for name in ["transformer", "video_encoder", "video_decoder", "audio_encoder",
                 "audio_decoder", "vocoder", "spatial_upsampler", "text_encoder",
                 "gemma_embeddings_processor"]:
        fn = getattr(ledger, name, None)
        if callable(fn):
            try:
                obj = fn()
                setattr(ledger, name, (lambda o=obj: o))
                print(f"[preload {tag}] {name} ✓")
            except Exception as e:
                print(f"[preload {tag}] {name} skipped: {e}")

# Preload stage 1 always; preload stage 2 only when two-stage is used (skip_stage_2=False).
# Eagerly pinning both ledgers materializes TWO ~46GB transformers — too big for the ZeroGPU pack.
_preload_pin(getattr(pipeline, "stage_1_model_ledger", None), "stage1")
if not SKIP_STAGE_2:
    _preload_pin(getattr(pipeline, "stage_2_model_ledger", None), "stage2")
print("Pipeline ready.")

# ============================ AOTI (native bf16 transformer graph) ============================
AOTI_REPO = os.environ.get("AOTI_REPO", "linoyts/LTX-2.3-Native-Transformer-GroupA-sm120-cu130-r20")
import types as _types
from dataclasses import replace as _dc_replace
from ltx_core.model.transformer.transformer_args import TransformerArgs as _TA
_TA_FIELDS = list(_TA.__dataclass_fields__.keys())
def _flatten_ta(ta):
    out = []
    for f in _TA_FIELDS:
        v = getattr(ta, f)
        if torch.is_tensor(v):
            out.append(v)
        elif isinstance(v, tuple) and len(v) > 0 and all(torch.is_tensor(x) for x in v):
            out.extend(v)
    return out
def _install_aoti():
    velocity = pipeline.stage_1_model_ledger.transformer().velocity_model
    spaces.aoti_load(module=velocity, repo_id=AOTI_REPO)
    def _proc(self, video, audio, perturbations):
        for blk in self.transformer_blocks:
            o = blk(*(_flatten_ta(video) + _flatten_ta(audio)))
            video = _dc_replace(video, x=o[0]); audio = _dc_replace(audio, x=o[1])
        return video, audio
    velocity._process_transformer_blocks = _types.MethodType(_proc, velocity)
    print(f"[AOTI] loaded {AOTI_REPO} + patched block loop", flush=True)
print(f"[AOTI] base torch={torch.__version__} cuda={torch.version.cuda}", flush=True)
try:
    _install_aoti(); print("[AOTI] OK", flush=True)
except Exception as _e:
    import traceback; traceback.print_exc(); print(f"[AOTI] FAILED ({_e!r}) -> EAGER", flush=True)
# ==============================================================================================


def _sheet_to_video(img, width, height, num_frames):
    """Repeat the reference-sheet image into an NF-frame video for video_conditioning."""
    im = img.convert("RGB").resize((width, height), Image.LANCZOS)
    vid = np.stack([np.array(im)] * num_frames)
    tmp = tempfile.mktemp(suffix=".mp4")
    iio.imwrite(tmp, vid, fps=FPS, plugin="pyav", codec="libx264")
    return tmp


def _gallery_paths(gallery):
    paths = []
    for item in gallery or []:
        if isinstance(item, (list, tuple)):
            item = item[0]
        if isinstance(item, dict):
            item = item.get("path") or item.get("name") or item.get("image")
        if isinstance(item, str):
            paths.append(item)
    return paths


def _fit_contain(im, cw, ch, bg=(0, 0, 0)):
    """Scale `im` to fit *fully* inside (cw, ch) keeping aspect ratio, then center it on a
    padded cell. Unlike ImageOps.fit (cover + center-crop), nothing is cropped off."""
    fitted = ImageOps.contain(im, (cw, ch), Image.LANCZOS)
    cell = Image.new("RGB", (cw, ch), bg)
    cell.paste(fitted, ((cw - fitted.width) // 2, (ch - fitted.height) // 2))
    return cell


def compose_sheet(gallery):
    import math
    paths = _gallery_paths(gallery)
    if not paths:
        raise gr.Error("Upload at least one subject image to build a sheet.")
    imgs = [Image.open(p).convert("RGB") for p in paths]
    if len(imgs) == 1:
        return imgs[0]
    BG = (0, 0, 0)
    CW, CH = 1536, 896
    canvas = Image.new("RGB", (CW, CH), BG)
    cols = math.ceil(math.sqrt(len(imgs))); rows = math.ceil(len(imgs) / cols); g = 16
    cw = (CW - g * (cols + 1)) // cols; ch = (CH - g * (rows + 1)) // rows
    for i, im in enumerate(imgs):
        r, c = divmod(i, cols)
        canvas.paste(_fit_contain(im, cw, ch, BG), (g + c * (cw + g), g + r * (ch + g)))
    return canvas


def _duration(*args, **kwargs):
    nf = next((a for a in args if isinstance(a, int) and a in FRAME_CHOICES), DEFAULT_FRAMES)
    return int(60 + nf * 1.2)


@spaces.GPU(duration=_duration)
@torch.inference_mode()
def generate(sheet_image, sheet, action, seed, randomize, progress=gr.Progress(track_tqdm=True)):
    if sheet_image is None:
        raise gr.Error("Add a reference sheet (upload one, or build one from subject images in the other tab).")
    if not sheet.strip():
        raise gr.Error("Describe the elements in the reference sheet (characters, props, location).")
    if not action.strip():
        raise gr.Error("Describe the action / shot you want generated.")
    seed = random.randint(0, MAX_SEED) if randomize else int(seed)
    # Fixed generation geometry (matches the public Space). The native IC-LoRA is fused at a
    # fixed strength (LORA_SCALE) at module scope.
    width, height, num_frames = 768, 448, 121
    ref_path = _sheet_to_video(sheet_image, width, height, num_frames)
    tiling = TilingConfig.default()
    gen_w, gen_h = (width * 2, height * 2) if SKIP_STAGE_2 else (width, height)
    video_out, audio_out = pipeline(
        prompt=build_prompt(sheet, action),
        seed=seed, height=gen_h, width=gen_w,
        num_frames=num_frames, frame_rate=FPS,
        images=[], video_conditioning=[(ref_path, 1.0)],
        skip_stage_2=SKIP_STAGE_2, tiling_config=tiling,
    )
    out_path = tempfile.mktemp(suffix=".mp4")
    encode_video(video=video_out, fps=FPS, audio=audio_out, output_path=out_path,
                 video_chunks_number=get_video_chunks_number(num_frames, tiling))
    return out_path, seed


# ==================== PROMPT ASSIST (crafter Space via gradio_client) ====================
# The prompt-crafter vision-LLM lives in a separate ZeroGPU Space; we call it over
# gradio_client so this Space never loads a second model (no extra VRAM / no GPU here).
# Prompt templates and model choice are owned by the crafter Space.
CRAFTER_SPACE = os.environ.get("PROMPT_CRAFTER_SPACE", "ltx-community/ingredients-prompt-crafter")
_crafter = None


def _get_crafter():
    global _crafter
    if _crafter is None:
        _crafter = Client(CRAFTER_SPACE, token=HF_TOKEN)
    return _crafter


def _sheet_to_tmp(img):
    tmp = tempfile.mktemp(suffix=".png")
    img.convert("RGB").save(tmp)
    return tmp


def describe_sheet(sheet_image):
    """Auto-fill the reference-sheet description (drafted by the crafter Space)."""
    if sheet_image is None:
        raise gr.Error("Add or build a reference sheet first.")
    try:
        return _get_crafter().predict(handle_file(_sheet_to_tmp(sheet_image)), api_name="/describe_sheet")
    except Exception as e:
        raise gr.Error(f"Auto-describe failed ({type(e).__name__}). The prompt-crafter Space may be waking up — try again.")


def suggest_action(sheet_image, sheet_desc, action_idea):
    """Draft the action/shot prompt. Expands the idea in the action box; invents one if empty."""
    if sheet_image is None:
        raise gr.Error("Add or build a reference sheet first.")
    try:
        return _get_crafter().predict(
            handle_file(_sheet_to_tmp(sheet_image)),
            (sheet_desc or "").strip(), (action_idea or "").strip(),
            api_name="/suggest_action")
    except Exception as e:
        raise gr.Error(f"Suggest shot failed ({type(e).__name__}). The prompt-crafter Space may be waking up — try again.")
# =========================================================================================


with gr.Blocks(title="LTX-2.3 Ingredients (Fast)") as demo:
    gr.Markdown(
        "# ⚡ LTX-2.3 Ingredients — Fast (Distilled)\n"
        "Reference-sheet control, fast. Upload a ready sheet, or build one from individual subject images. Using "
        "[LTX 2.3 Distilled](https://huggingface.co/Lightricks/LTX-2.3) with the "
        "[Ingredients IC-LoRA](https://huggingface.co/Lightricks/LTX-2.3-22b-IC-LoRA-Ingredients). "
    )
    gr.Markdown("⚡ **Accelerated with [AOTI](https://huggingface.co/linoyts/LTX-2.3-Native-Transformer-GroupA-sm120-cu130-r20)** — precompiled transformer for faster inference.")
    with gr.Row():
        with gr.Column():
            with gr.Tabs():
                with gr.Tab("Reference sheet"):
                    sheet_image = gr.Image(type="pil", label="Reference sheet (composite of characters / props / location)")
                with gr.Tab("Build from subject images"):
                    gallery = gr.Gallery(label="Upload subject images (characters, props, location)",
                                         type="filepath", interactive=True, columns=4, height=240)
                    build_btn = gr.Button("Build reference sheet ➜")
                    gr.Markdown("*Tiles your images into one sheet and loads it into the **Reference sheet** tab.*")
            sheet = gr.Textbox(label="Reference sheet description", lines=3,
                               placeholder="a young woman with red hair in a green jacket (face close-up + turnaround); a brass pocket watch; a cobblestone alley at night")
            describe_btn = gr.Button("✨ Auto-describe sheet", size="sm")
            action = gr.Textbox(label="Generated video — action / shot, plus any speech & sounds", lines=3,
                                placeholder="the woman walks down the alley, checks the pocket watch and whispers 'almost time'; footsteps on cobblestone, distant city hum")
            suggest_btn = gr.Button("✨ Suggest shot — type a one-line idea above, or leave empty to invent", size="sm")
            with gr.Accordion("Settings", open=False):
                randomize = gr.Checkbox(True, label="Randomize seed")
                seed = gr.Slider(0, MAX_SEED, value=42, step=1, label="Seed")
            run = gr.Button("Generate (fast)", variant="primary")
        with gr.Column():
            video_out = gr.Video(label="Generated video")

    # Build the sheet, then auto-describe it into the description box.
    build_btn.click(compose_sheet, inputs=gallery, outputs=sheet_image).then(
        describe_sheet, inputs=sheet_image, outputs=sheet)
    # Auto-describe when a sheet image is uploaded directly, or via the button.
    sheet_image.upload(describe_sheet, inputs=sheet_image, outputs=sheet)
    describe_btn.click(describe_sheet, inputs=sheet_image, outputs=sheet)
    suggest_btn.click(suggest_action, inputs=[sheet_image, sheet, action], outputs=action)
    run.click(generate, inputs=[sheet_image, sheet, action, seed, randomize], outputs=[video_out, seed])

    gr.Examples(
        examples=[
            ['examples/sheet_garden.png', "a friendly cartoon hedgehog with rounded chestnut-brown fur, a cream face and belly, large expressive dark eyes, a small black nose and tiny rounded ears, shown in a face close-up and a full-body turnaround standing upright on two short legs; a cheerful cartoon rabbit with soft grey-and-white fur, long upright ears with pale pink inner lining, round amber eyes and a fluffy white tail, shown in a body turnaround; a green coiled garden hose neatly wound on a matching green wall-mounted reel; a row of green plastic spray bottles with trigger nozzles; the bright interior of a 'Greenfield Home & Garden' store with tall wooden shelves stocked with leafy potted plants, terracotta pots and gardening supplies, warm overhead lighting and a green-and-white storefront sign", "cheerful family-animation commercial scene, a lively medium shot inside the sunlit 'Greenfield Home & Garden' store with its tall shelves of leafy potted plants and terracotta pots. the rounded chestnut-brown hedgehog waddles briskly up toward the camera on its short legs, its cream belly bouncing, then stops, lifts a tiny paw in a friendly wave and beams with wide sparkling eyes, announcing in a warm, bright, sing-song voice: 'welcome to Greenfield!'. behind it the soft grey-and-white rabbit hops past in the aisle, long ears bobbing, cradling a green spray bottle against its chest; it pauses, gives the bottle a playful little squeeze that puffs a fine mist into a shaft of light, and adds in a chirpy, slightly higher voice: 'everything your garden needs!'. the hedgehog nods enthusiastically, gestures with both paws toward the laden shelves and finishes with a cosy chuckle: 'come on in!'. the animation is glossy and expressive with squash-and-stretch motion, rounded shapes and saturated greens; the camera pushes in gently and tilts up to reveal the green-and-white storefront sign. the audio is bright and immersive: the hedgehog's cheerful voice, the rabbit's lighter reply, the soft puff of the spray bottle, light pattering footsteps on the store floor and a warm, upbeat acoustic-ukulele jingle playing softly underneath", 42, False],
            ['examples/sheet_hiker.png', 'a young Asian woman with a warm skin tone, dark hair parted down the middle in two long braids resting on her chest, an olive-green short-sleeved t-shirt, khaki cargo pants, dark brown hiking boots and a black wristwatch on her left arm, with a serious natural expression; a large heavy-duty blue hiking backpack with an external silver metal frame, multiple side and top pouches, black adjustable straps and a brown leather square patch near the bottom; a simple thick natural wooden walking stick with rough bark texture and a slight fork near one end; a large sturdy yak with long shaggy white-and-blonde hair and curved grey horns, wearing an ornate saddle blanket with intricate blue, red and yellow patterns, a saddle with metal stirrups and colorful tassels near its ears and chest; a sweeping majestic mountain landscape where a dirt path winds through green rocky slopes toward towering snow-capped peaks under a bright blue sky with scattered white clouds; a small traditional square stone shrine with a flat slightly tiered roof and a bright yellow fabric valance along the roofline, bright blue window trim and a red wooden door, with a small stone stupa beside it', "cinematic adventure documentary scene, a dynamic medium wide shot of the young asian woman with her dark hair in two long braids, wearing an olive-green t-shirt and khaki cargo pants. she sits on a rock along a mountain dirt path, resting beside the massive white shaggy yak with curved horns and its ornate blue, red and yellow patterned saddle blanket with metal stirrups. leaning against a nearby small stone building with blue window trim, a red door and a yellow fabric roof valance are her large blue external-frame backpack and thick wooden walking stick; majestic snow-capped mountains tower in the distant background under a bright blue sky. she looks at the yak, chest heaving slightly from exertion, and says with a breathy, tired but gentle voice: 'we've got a long way to go...'. she pauses, extending her hand to gently pat the thick white fur on the yak's neck; the yak shifts its weight, the colorful tassels near its ears swaying, and a faint exhausted smile breaks across her face as she continues softly: '...big guy.'. lowering her hand she grabs her wooden walking stick, leaning her weight onto it as she turns her gaze up toward the distant snowy peaks, her expression shifting from exhaustion to quiet determination as she adds, her voice growing firmer: 'but the pass...', then takes a deep grounding breath: '...is just over that ridge.'. the camera is dynamically handheld, slowly orbiting the woman and the yak to reveal the depth of the valley and the towering mountains behind them; naturalistic breathtaking film aesthetic, bright crisp sunlight casting sharp shadows across the rocky path and stone shrine. clear immersive audio: her wind-swept voice, the heavy rhythmic breathing of the yak, the faint jingle of metal stirrups and the distant ambient howl of mountain winds, no background music", 42, False],
            ['examples/subj_composite.png', 'a smiling young woman with warm fair skin and shoulder-length curly dark-brown hair, soft brown eyes and a gentle open expression, wearing a cream cable-knit sweater and dark jeans, shown in a relaxed three-quarter pose; a dappled grey horse with a dark charcoal mane and tail, a soft mottled grey-and-white coat, dark intelligent eyes and a calm posture, wearing a simple brown leather halter; a green misty mountain meadow of tall dewy grass and scattered wildflowers, with faint pine-covered slopes dissolving into low morning fog under a pale silver sky', "tender naturalistic cinematic scene, a soft medium shot in a green misty mountain meadow at dawn, tall dewy grass glistening and low fog drifting between faint pine slopes. the young woman with curly dark-brown hair and a cream cable-knit sweater walks slowly up to the dappled grey horse, her breath faintly visible in the cool air, and raises a careful open hand. she gently strokes the horse's soft mottled neck, her face softening into a warm reassuring smile, and murmurs in a low, soothing, slightly breathy voice: 'good boy… easy now'. the horse lowers its head toward her, its charcoal mane shifting, flicks an ear and lets out a soft snort, its breath misting in the cold; she leans her forehead lightly against its cheek, closes her eyes for a moment, then whispers with quiet affection: 'there you go'. the camera drifts slowly in a gentle arc around the pair, the shallow-focus background of fog and wildflowers blurring softly behind them; the film aesthetic is delicate and breathtaking with cool silver dawn light and fine atmospheric haze. the audio is intimate and immersive: her gentle hushed voice, the horse's soft nicker and snort, the swish of dewy grass, light birdsong waking in the distance and a faint cool breeze, with no background music", 42, False],
        ],
        inputs=[sheet_image, sheet, action, seed, randomize],
        outputs=[video_out, seed], fn=generate, cache_examples=True, cache_mode="lazy",
    )

if __name__ == "__main__":
    demo.launch(show_error=True)