Mike0021 commited on
Commit
0f5a06f
·
verified ·
1 Parent(s): 04cb2b1

fix: pad reference video frames to 8k+1 for VAE temporal compression, add size=xlarge, fix 480p resolution divisibility, fix duration estimator for positional args

Browse files
Files changed (1) hide show
  1. app.py +550 -0
app.py ADDED
@@ -0,0 +1,550 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+
3
+ os.environ.setdefault("HF_HOME", "/tmp/huggingface")
4
+ os.environ.setdefault("HF_MODULES_CACHE", "/tmp/hf_modules")
5
+ os.environ.setdefault("MPLCONFIGDIR", "/tmp/matplotlib")
6
+
7
+ import tempfile
8
+ import time
9
+ from pathlib import Path
10
+
11
+ import spaces # must be before torch
12
+ import gradio as gr
13
+ import torch
14
+ from diffusers import LTX2InContextPipeline
15
+
16
+ PROMPT_PREFIX = "3DREAL. Make it photorealistic."
17
+ EXAMPLE_DIR = Path("examples")
18
+ ASSET_DIR = Path("assets")
19
+
20
+ BASE_MODEL_ID = "diffusers/LTX-2.3-Diffusers"
21
+ LORA_MODEL_ID = "fal/LTX-2.3-3DREAL-LoRA"
22
+
23
+ DEFAULT_NEGATIVE_PROMPT = (
24
+ "color distortion, overexposure, static, blurry details, subtitles, style, artwork, "
25
+ "painting, frame, still, dim overall tone, worst quality, low quality, JPEG compression "
26
+ "artifacts, ugly, mutilated, extra fingers, poorly drawn hands, poorly drawn face, "
27
+ "deformed, disfigured, malformed limbs, fused fingers, motionless frame, cluttered "
28
+ "background, three legs, crowded background, walking backwards"
29
+ )
30
+
31
+ RESOLUTION_MAP = {
32
+ "720p": (1280, 704),
33
+ "480p": (832, 480),
34
+ }
35
+
36
+ EXAMPLES = {
37
+ "harbor": {
38
+ "title": "Harbor blockout",
39
+ "render": EXAMPLE_DIR / "harbor_render.mp4",
40
+ "reference": EXAMPLE_DIR / "harbor_reference.png",
41
+ "reference_video": EXAMPLE_DIR / "harbor_reference.mp4",
42
+ "result": EXAMPLE_DIR / "harbor_photoreal.mp4",
43
+ "source_gif": ASSET_DIR / "example_harbor.gif",
44
+ "prompt": (
45
+ "3DREAL. Make it photorealistic. A cinematic harbor scene with fishing boats, "
46
+ "cranes, wet dock surfaces, morning light, and realistic industrial details."
47
+ ),
48
+ "intensity": "strong",
49
+ "resolution": "720p",
50
+ },
51
+ "cargo": {
52
+ "title": "Cargo ship",
53
+ "render": EXAMPLE_DIR / "cargo_render.mp4",
54
+ "reference": EXAMPLE_DIR / "cargo_reference.png",
55
+ "reference_video": EXAMPLE_DIR / "cargo_reference.mp4",
56
+ "result": EXAMPLE_DIR / "cargo_photoreal.mp4",
57
+ "source_gif": ASSET_DIR / "example_cargo.gif",
58
+ "prompt": (
59
+ "3DREAL. Make it photorealistic. A large cargo ship at sea with cinematic lighting, "
60
+ "realistic ocean spray, metal surfaces, and natural atmosphere."
61
+ ),
62
+ "intensity": "strong",
63
+ "resolution": "720p",
64
+ },
65
+ }
66
+
67
+
68
+ CSS = """ /* responsive v2 - 1782728030 */
69
+
70
+ .gradio-container {
71
+ max-width: 1180px !important;
72
+ margin: 0 auto;
73
+ }
74
+
75
+ .app-header {
76
+ padding: 18px 0 8px;
77
+ }
78
+
79
+ .app-header h1 {
80
+ font-size: clamp(1.75rem, 4vw, 3.25rem);
81
+ line-height: 1;
82
+ letter-spacing: 0;
83
+ margin: 0;
84
+ }
85
+
86
+ .app-header p {
87
+ max-width: 760px;
88
+ margin: 10px 0 0;
89
+ color: var(--body-text-color-subdued);
90
+ font-size: 1rem;
91
+ }
92
+
93
+ .gradio-container video,
94
+ .gradio-container .video-container,
95
+ .gradio-container .video-wrapper,
96
+ .gradio-container .media-container {
97
+ background: transparent !important;
98
+ border: 0 !important;
99
+ box-shadow: none !important;
100
+ }
101
+
102
+ .generate-button button {
103
+ min-height: 46px;
104
+ font-weight: 700;
105
+ }
106
+
107
+ /* Responsive: stack columns on narrow screens v2 */
108
+ @media (max-width: 768px) {
109
+ /* Force all gr.Row children to stack vertically */
110
+ .gradio-container .gr-row {
111
+ flex-direction: column !important;
112
+ }
113
+
114
+ /* Reset column min-widths so they don't force horizontal overflow */
115
+ .gradio-container .gr-column {
116
+ min-width: 0 !important;
117
+ width: 100% !important;
118
+ }
119
+
120
+ /* Ensure video/media fills the column width */
121
+ .gradio-container video,
122
+ .gradio-container .video-container,
123
+ .gradio-container .video-wrapper,
124
+ .gradio-container .media-container {
125
+ width: 100% !important;
126
+ max-width: 100% !important;
127
+ }
128
+
129
+ .app-header {
130
+ padding-top: 10px;
131
+ }
132
+
133
+ .app-header h1 {
134
+ font-size: 1.75rem;
135
+ }
136
+
137
+ .app-header p {
138
+ font-size: 0.9rem;
139
+ }
140
+ }
141
+
142
+ /* Extra small screens */
143
+ @media (max-width: 480px) {
144
+ .gradio-container {
145
+ padding: 8px !important;
146
+ }
147
+
148
+ .app-header {
149
+ padding: 10px 0 4px;
150
+ }
151
+
152
+ .app-header h1 {
153
+ font-size: 1.5rem;
154
+ }
155
+
156
+ /* Stack the inner rows in advanced settings too */
157
+ .gradio-container .gr-row .gr-row {
158
+ flex-direction: column !important;
159
+ }
160
+ }
161
+ """
162
+
163
+ _pipe = None
164
+ _pipe_lora_variant = None
165
+
166
+
167
+ def _load_pipeline():
168
+ """Load the pipeline and both LoRA variants at module level on CPU.
169
+
170
+ Downloads happen during APP_STARTING (1h timeout). The first @spaces.GPU
171
+ call moves the pipeline to CUDA; subsequent calls reuse the warmed model.
172
+ """
173
+ global _pipe, _pipe_lora_variant
174
+
175
+ if _pipe is not None:
176
+ return
177
+
178
+ _pipe = LTX2InContextPipeline.from_pretrained(
179
+ BASE_MODEL_ID,
180
+ torch_dtype=torch.bfloat16,
181
+ )
182
+ _pipe.load_lora_weights(
183
+ LORA_MODEL_ID,
184
+ adapter_name="3dreal-light",
185
+ weight_name="3DREAL-light.safetensors",
186
+ )
187
+ _pipe.load_lora_weights(
188
+ LORA_MODEL_ID,
189
+ adapter_name="3dreal-strong",
190
+ weight_name="3DREAL-strong.safetensors",
191
+ )
192
+ _pipe.enable_model_cpu_offload()
193
+ _pipe_lora_variant = None
194
+
195
+
196
+ def _select_lora(intensity: str):
197
+ """Switch the active LoRA adapter based on intensity."""
198
+ global _pipe, _pipe_lora_variant
199
+
200
+ adapter = "3dreal-light" if intensity == "light" else "3dreal-strong"
201
+ if _pipe_lora_variant != adapter:
202
+ _pipe.set_adapters(adapter, 1.0)
203
+ _pipe_lora_variant = adapter
204
+ return _pipe
205
+
206
+
207
+ def _component_path(value) -> str | None:
208
+ if value is None:
209
+ return None
210
+ if isinstance(value, str):
211
+ return value
212
+ if isinstance(value, dict):
213
+ return value.get("path") or value.get("name")
214
+ return getattr(value, "path", None) or str(value)
215
+
216
+
217
+ def _normalize_prompt(prompt: str | None) -> str:
218
+ prompt = (prompt or "").strip()
219
+ if not prompt:
220
+ return PROMPT_PREFIX
221
+
222
+ lower_prompt = prompt.lower()
223
+ lower_prefix = PROMPT_PREFIX.lower()
224
+ if lower_prompt.startswith(lower_prefix):
225
+ return prompt
226
+
227
+ if lower_prompt.startswith("3dreal."):
228
+ tail = prompt[len("3DREAL.") :].strip()
229
+ elif lower_prompt.startswith("3dreal"):
230
+ tail = prompt[len("3DREAL") :].strip(" .")
231
+ else:
232
+ tail = prompt
233
+
234
+ if tail.lower().startswith("make it photorealistic."):
235
+ return f"3DREAL. {tail}"
236
+ return f"{PROMPT_PREFIX} {tail}".strip()
237
+
238
+
239
+ def _load_video_frames(video_path: str, num_frames: int = 121):
240
+ """Load video frames as a list of PIL Images, padded to exactly num_frames.
241
+
242
+ The LTX-2 VAE temporal compression ratio is 8, so num_frames must be 8k+1.
243
+ If the source video has fewer frames, we repeat the last frame to pad.
244
+ """
245
+ from diffusers.utils import load_video
246
+
247
+ frames = load_video(video_path)
248
+ if len(frames) > num_frames:
249
+ stride = max(1, len(frames) // num_frames)
250
+ frames = frames[::stride][:num_frames]
251
+ if len(frames) < num_frames:
252
+ last = frames[-1]
253
+ frames = frames + [last] * (num_frames - len(frames))
254
+ return frames
255
+
256
+
257
+ def _load_image_as_pil(image_path: str):
258
+ from PIL import Image
259
+
260
+ return Image.open(image_path).convert("RGB")
261
+
262
+
263
+ def _round_num_frames(n: int) -> int:
264
+ """Round num_frames to nearest valid value (8k+1)."""
265
+ n = max(9, int(n))
266
+ return ((n - 1) // 8) * 8 + 1
267
+
268
+
269
+ def _estimate_duration(*args, **kwargs):
270
+ if len(args) > 7:
271
+ num_frames = args[5]
272
+ num_inference_steps = args[7]
273
+ else:
274
+ num_frames = kwargs.get("num_frames", 121)
275
+ num_inference_steps = kwargs.get("num_inference_steps", 15)
276
+ try:
277
+ num_frames = int(num_frames)
278
+ num_inference_steps = int(num_inference_steps)
279
+ except (TypeError, ValueError):
280
+ return 120
281
+ base = 30 + num_inference_steps * 2
282
+ frame_factor = max(1.0, num_frames / 121)
283
+ return min(240, int(base * frame_factor))
284
+
285
+
286
+ _load_pipeline()
287
+
288
+
289
+ @spaces.GPU(duration=_estimate_duration, size="xlarge")
290
+ def generate_video(
291
+ render_video,
292
+ reference_image,
293
+ prompt,
294
+ intensity,
295
+ resolution,
296
+ num_frames,
297
+ frames_per_second,
298
+ num_inference_steps,
299
+ guidance_scale,
300
+ generate_audio,
301
+ enable_prompt_expansion,
302
+ seed,
303
+ video_quality,
304
+ video_write_mode,
305
+ progress=gr.Progress(track_tqdm=False),
306
+ ):
307
+ from diffusers.pipelines.ltx2.pipeline_ltx2_ic_lora import LTX2ReferenceCondition
308
+ from diffusers.pipelines.ltx2.pipeline_ltx2_condition import LTX2VideoCondition
309
+ from diffusers.utils import encode_video
310
+
311
+ render_path = _component_path(render_video)
312
+ if not render_path:
313
+ raise gr.Error("Add a 3D or CG render video before generating.")
314
+
315
+ started = time.perf_counter()
316
+ prompt = _normalize_prompt(prompt)
317
+
318
+ pipe = _select_lora(intensity)
319
+
320
+ width, height = RESOLUTION_MAP.get(resolution, RESOLUTION_MAP["720p"])
321
+ num_frames = _round_num_frames(num_frames)
322
+
323
+ progress(0.10, desc="Reading render video")
324
+ render_frames = _load_video_frames(render_path, num_frames=num_frames)
325
+
326
+ progress(0.15, desc="Preparing conditions")
327
+ reference_conditions = [LTX2ReferenceCondition(frames=render_frames, strength=1.0)]
328
+
329
+ conditions = None
330
+ reference_path = _component_path(reference_image)
331
+ if reference_path:
332
+ ref_pil = _load_image_as_pil(reference_path)
333
+ conditions = [LTX2VideoCondition(frames=ref_pil, index=0, strength=1.0)]
334
+
335
+ generator = None
336
+ gen_seed = None
337
+ if seed is not None and str(seed).strip():
338
+ gen_seed = int(seed)
339
+ generator = torch.Generator(device="cuda").manual_seed(gen_seed)
340
+
341
+ progress(0.20, desc="Running inference")
342
+ video, audio = pipe(
343
+ prompt=prompt,
344
+ negative_prompt=DEFAULT_NEGATIVE_PROMPT,
345
+ reference_conditions=reference_conditions,
346
+ conditions=conditions,
347
+ height=height,
348
+ width=width,
349
+ num_frames=num_frames,
350
+ frame_rate=float(frames_per_second),
351
+ num_inference_steps=int(num_inference_steps),
352
+ guidance_scale=float(guidance_scale),
353
+ generator=generator,
354
+ output_type="np",
355
+ return_dict=False,
356
+ )
357
+
358
+ progress(0.85, desc="Encoding video")
359
+ tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".mp4")
360
+ tmp.close()
361
+
362
+ video_array = video[0]
363
+ audio_tensor = None
364
+ audio_sample_rate = None
365
+ if generate_audio and audio is not None:
366
+ audio_tensor = audio[0].float().cpu()
367
+ audio_sample_rate = pipe.vocoder.config.output_sampling_rate
368
+
369
+ encode_video(
370
+ video_array,
371
+ fps=int(frames_per_second),
372
+ output_path=tmp.name,
373
+ audio=audio_tensor,
374
+ audio_sample_rate=audio_sample_rate,
375
+ )
376
+
377
+ elapsed = time.perf_counter() - started
378
+ used_seed = gen_seed if gen_seed is not None else "random"
379
+ status = (
380
+ f"**Complete in {elapsed:.1f}s.** Seed: `{used_seed}`\n\n"
381
+ f"Prompt used: `{prompt}`"
382
+ )
383
+ return tmp.name, status
384
+
385
+
386
+ def load_cached_example(render_video, reference_image, prompt, intensity, resolution):
387
+ render_path = _component_path(render_video) or ""
388
+ name = Path(render_path).stem.replace("_render", "")
389
+ example = EXAMPLES.get(name)
390
+ if not example:
391
+ raise gr.Error("Could not match this cached example.")
392
+ status = (
393
+ f"**Cached example loaded.** Variant: `{example['intensity']}`. "
394
+ f"Resolution: `{example['resolution']}`.\n\n"
395
+ f"Prompt: `{example['prompt']}`"
396
+ )
397
+ return str(example["result"]), status
398
+
399
+
400
+ @spaces.GPU(duration=1)
401
+ def _zerogpu_probe():
402
+ return "ready"
403
+
404
+
405
+ theme = gr.themes.Soft(
406
+ primary_hue="cyan",
407
+ secondary_hue="amber",
408
+ neutral_hue="slate",
409
+ radius_size="sm",
410
+ ).set(
411
+ body_text_color="*neutral_900",
412
+ body_text_color_dark="*neutral_100",
413
+ block_border_width="1px",
414
+ button_primary_background_fill="*primary_600",
415
+ button_primary_background_fill_hover="*primary_700",
416
+ )
417
+
418
+ with gr.Blocks(
419
+ title="LTX 2.3 3DREAL Render-to-Real",
420
+ ) as demo:
421
+ gr.HTML(
422
+ """
423
+ <header class="app-header">
424
+ <h1>LTX 2.3 3DREAL Render-to-Real</h1>
425
+ <p>Turn rough 3D, CG, and game renders into photorealistic video with the LTX-2.3 3DREAL IC-LoRA running locally on ZeroGPU.</p>
426
+ </header>
427
+ """
428
+ )
429
+
430
+ with gr.Row(elem_classes=["main-layout"]):
431
+ with gr.Column(scale=6, min_width=0):
432
+ render_video = gr.Video(
433
+ label="3D / CG render video",
434
+ sources=["upload"],
435
+ format="mp4",
436
+ )
437
+ reference_image = gr.Image(
438
+ label="Optional photoreal first-frame reference",
439
+ sources=["upload"],
440
+ type="filepath",
441
+ height=250,
442
+ )
443
+
444
+ with gr.Column(scale=5, min_width=0):
445
+ prompt = gr.Textbox(
446
+ label="Prompt",
447
+ value=PROMPT_PREFIX,
448
+ lines=4,
449
+ max_lines=8,
450
+ )
451
+ with gr.Row():
452
+ intensity = gr.Radio(
453
+ choices=["light", "strong"],
454
+ value="light",
455
+ label="Variant",
456
+ elem_classes=["variant-toggle"],
457
+ )
458
+ resolution = gr.Dropdown(
459
+ choices=["720p", "480p"],
460
+ value="720p",
461
+ label="Resolution",
462
+ )
463
+
464
+ with gr.Accordion("Advanced", open=False):
465
+ with gr.Row():
466
+ num_frames = gr.Slider(25, 361, value=121, step=1, label="Frames")
467
+ frames_per_second = gr.Slider(12, 30, value=24, step=1, label="FPS")
468
+ with gr.Row():
469
+ num_inference_steps = gr.Slider(8, 30, value=15, step=1, label="Steps")
470
+ guidance_scale = gr.Slider(0.1, 3.0, value=1.0, step=0.1, label="Guidance")
471
+ with gr.Row():
472
+ video_quality = gr.Dropdown(
473
+ choices=["low", "medium", "high", "maximum"],
474
+ value="high",
475
+ label="Video quality",
476
+ )
477
+ video_write_mode = gr.Dropdown(
478
+ choices=["fast", "balanced", "small"],
479
+ value="balanced",
480
+ label="MP4 mode",
481
+ )
482
+ with gr.Row():
483
+ generate_audio = gr.Checkbox(value=False, label="Generate audio")
484
+ enable_prompt_expansion = gr.Checkbox(value=False, label="Prompt expansion")
485
+ seed = gr.Number(value=None, precision=0, label="Seed")
486
+
487
+ generate = gr.Button(
488
+ "Generate video",
489
+ variant="primary",
490
+ elem_classes=["generate-button"],
491
+ )
492
+
493
+ result_video = gr.Video(label="Result video")
494
+ status = gr.Markdown("Generate a new video.")
495
+
496
+ gr.Examples(
497
+ examples=[
498
+ [
499
+ str(EXAMPLES["harbor"]["render"]),
500
+ str(EXAMPLES["harbor"]["reference"]),
501
+ EXAMPLES["harbor"]["prompt"],
502
+ EXAMPLES["harbor"]["intensity"],
503
+ EXAMPLES["harbor"]["resolution"],
504
+ ],
505
+ [
506
+ str(EXAMPLES["cargo"]["render"]),
507
+ str(EXAMPLES["cargo"]["reference"]),
508
+ EXAMPLES["cargo"]["prompt"],
509
+ EXAMPLES["cargo"]["intensity"],
510
+ EXAMPLES["cargo"]["resolution"],
511
+ ],
512
+ ],
513
+ inputs=[render_video, reference_image, prompt, intensity, resolution],
514
+ outputs=[result_video, status],
515
+ fn=load_cached_example,
516
+ cache_examples=True,
517
+ cache_mode="lazy",
518
+ label="Examples",
519
+ examples_per_page=2,
520
+ )
521
+
522
+ generate.click(
523
+ fn=generate_video,
524
+ inputs=[
525
+ render_video,
526
+ reference_image,
527
+ prompt,
528
+ intensity,
529
+ resolution,
530
+ num_frames,
531
+ frames_per_second,
532
+ num_inference_steps,
533
+ guidance_scale,
534
+ generate_audio,
535
+ enable_prompt_expansion,
536
+ seed,
537
+ video_quality,
538
+ video_write_mode,
539
+ ],
540
+ outputs=[result_video, status],
541
+ )
542
+
543
+ demo.queue(max_size=12, default_concurrency_limit=2)
544
+
545
+ if __name__ == "__main__":
546
+ demo.launch(
547
+ theme=theme,
548
+ css=CSS,
549
+ allowed_paths=[str(EXAMPLE_DIR), str(ASSET_DIR)],
550
+ )