linoyts HF Staff commited on
Commit
fffb193
·
verified ·
1 Parent(s): cf300e3

Upload app.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. app.py +207 -0
app.py ADDED
@@ -0,0 +1,207 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+
3
+ # ZeroGPU: torch.compile / dynamo are unsupported — disable before torch import.
4
+ os.environ.setdefault("TORCH_COMPILE_DISABLE", "1")
5
+ os.environ.setdefault("TORCHDYNAMO_DISABLE", "1")
6
+
7
+ import random
8
+ import tempfile
9
+
10
+ import numpy as np
11
+ import spaces
12
+ import torch
13
+ import gradio as gr
14
+ from PIL import Image, ImageFilter
15
+ from huggingface_hub import hf_hub_download
16
+ from safetensors.torch import load_file
17
+
18
+ from diffusers import LTX2InContextPipeline
19
+ from diffusers.pipelines.ltx2.pipeline_ltx2_ic_lora import LTX2ReferenceCondition
20
+ from diffusers.pipelines.ltx2.utils import DISTILLED_SIGMA_VALUES
21
+ from diffusers.utils import load_video, encode_video
22
+
23
+ # --- Config -----------------------------------------------------------------
24
+ BASE_MODEL = "diffusers/LTX-2.3-Distilled-Diffusers"
25
+ LORA_REPO = "linoyts/ltx2.3-inpainting-lora"
26
+ LORA_FILE = "ltx-2.3-22b-ic-lora-inpainting.safetensors"
27
+ LORA_SCALE = 1.0
28
+ FPS = 24
29
+ NUM_STEPS = len(DISTILLED_SIGMA_VALUES) # 8
30
+ MASK_FILL = 128 # masked region painted neutral grey in the reference the model fills
31
+ MAX_SEED = np.iinfo(np.int32).max
32
+ HF_TOKEN = os.environ.get("HF_TOKEN")
33
+
34
+ RES_PRESETS = {"Fast (768×448)": (768, 448), "Quality (960×544)": (960, 544)}
35
+ FRAME_CHOICES = [49, 73, 97, 121]
36
+
37
+ # --- Load pipeline once at module scope (ZeroGPU registers it) ---------------
38
+ pipe = LTX2InContextPipeline.from_pretrained(BASE_MODEL, torch_dtype=torch.bfloat16)
39
+ pipe.to("cuda")
40
+ pipe.vae.enable_tiling()
41
+
42
+ _lora_path = hf_hub_download(LORA_REPO, LORA_FILE, token=HF_TOKEN)
43
+ pipe.load_lora_weights(load_file(_lora_path), adapter_name="inpaint")
44
+ pipe.set_adapters("inpaint", LORA_SCALE)
45
+
46
+
47
+ # --- Helpers ----------------------------------------------------------------
48
+ def _resample(frames, n):
49
+ idx = np.linspace(0, len(frames) - 1, n).round().astype(int)
50
+ return [frames[i] for i in idx]
51
+
52
+
53
+ def _pick_resolution(first_frame: Image.Image, preset: str):
54
+ w, h = RES_PRESETS[preset]
55
+ if first_frame.height > first_frame.width:
56
+ w, h = h, w
57
+ return w, h
58
+
59
+
60
+ def first_frame(video):
61
+ """Populate the mask editor with the uploaded video's first frame."""
62
+ if video is None:
63
+ return None
64
+ frames = load_video(video)
65
+ return np.array(frames[0].convert("RGB")) if frames else None
66
+
67
+
68
+ def _mask_from_editor(editor_value, width, height):
69
+ """Extract a binary mask (H,W) from a gr.ImageEditor value — union of painted layers."""
70
+ if not editor_value:
71
+ return None
72
+ layers = editor_value.get("layers") or []
73
+ bg = editor_value.get("background")
74
+ if bg is None:
75
+ return None
76
+ H0, W0 = np.asarray(bg).shape[:2]
77
+ acc = np.zeros((H0, W0), dtype=bool)
78
+ for layer in layers:
79
+ arr = np.asarray(layer)
80
+ if arr.ndim == 3 and arr.shape[2] == 4:
81
+ acc |= arr[..., 3] > 10
82
+ elif arr.ndim == 3:
83
+ acc |= arr.sum(axis=2) > 10
84
+ if not acc.any():
85
+ return None
86
+ m = Image.fromarray((acc * 255).astype(np.uint8)).resize((width, height), Image.NEAREST)
87
+ return np.array(m) > 127
88
+
89
+
90
+ def _duration(*args, **kwargs):
91
+ preset = args[3] if len(args) > 3 else "Fast"
92
+ num_frames = args[4] if len(args) > 4 else 73
93
+ per_frame = 1.6 if "Quality" in str(preset) else 1.0
94
+ return int(50 + int(num_frames) * per_frame)
95
+
96
+
97
+ # --- Inference --------------------------------------------------------------
98
+ @spaces.GPU(duration=_duration)
99
+ def inpaint(video, mask_editor, prompt, preset, num_frames, seed, randomize,
100
+ progress=gr.Progress(track_tqdm=True)):
101
+ if video is None:
102
+ raise gr.Error("Please upload a video.")
103
+ if not prompt.strip():
104
+ raise gr.Error("Describe what should fill the masked region.")
105
+
106
+ if randomize:
107
+ seed = random.randint(0, MAX_SEED)
108
+ seed = int(seed)
109
+
110
+ frames = load_video(video)
111
+ if not frames:
112
+ raise gr.Error("Could not read any frames from that video.")
113
+
114
+ width, height = _pick_resolution(frames[0], preset)
115
+ num_frames = int(num_frames)
116
+
117
+ mask = _mask_from_editor(mask_editor, width, height)
118
+ if mask is None:
119
+ raise gr.Error("Draw a mask over the region to inpaint (use the brush on the frame).")
120
+
121
+ orig = [np.array(f.convert("RGB").resize((width, height), Image.LANCZOS))
122
+ for f in _resample(frames, num_frames)]
123
+
124
+ # Reference = video with the masked region painted neutral grey; the model fills it.
125
+ ref = []
126
+ for fr in orig:
127
+ m = fr.copy()
128
+ m[mask] = MASK_FILL
129
+ ref.append(Image.fromarray(m))
130
+
131
+ ref_cond = LTX2ReferenceCondition(frames=ref, strength=1.0)
132
+ video_out, _audio = pipe(
133
+ prompt=prompt,
134
+ negative_prompt="",
135
+ reference_conditions=[ref_cond],
136
+ reference_downscale_factor=1,
137
+ width=width,
138
+ height=height,
139
+ num_frames=num_frames,
140
+ frame_rate=FPS,
141
+ num_inference_steps=NUM_STEPS,
142
+ sigmas=DISTILLED_SIGMA_VALUES,
143
+ guidance_scale=1.0,
144
+ stg_scale=0.0,
145
+ audio_guidance_scale=1.0,
146
+ audio_stg_scale=0.0,
147
+ generator=torch.Generator(device="cuda").manual_seed(seed),
148
+ output_type="np",
149
+ return_dict=False,
150
+ )
151
+
152
+ # Composite: keep original pixels outside the mask, generated pixels inside (feathered).
153
+ gen = (np.clip(video_out[0], 0, 1) * 255).astype(np.uint8)
154
+ soft = np.array(Image.fromarray((mask * 255).astype(np.uint8)).filter(ImageFilter.GaussianBlur(3))) / 255.0
155
+ soft = soft[None, :, :, None]
156
+ orig_arr = np.stack(orig).astype(np.float32)
157
+ n = min(len(gen), len(orig_arr))
158
+ out = (gen[:n].astype(np.float32) * soft + orig_arr[:n] * (1 - soft)).astype(np.uint8)
159
+
160
+ out_path = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False).name
161
+ encode_video(out, fps=FPS, output_path=out_path)
162
+ return out_path, seed
163
+
164
+
165
+ # --- UI ---------------------------------------------------------------------
166
+ with gr.Blocks(title="LTX-2.3 Video Inpainting") as demo:
167
+ gr.Markdown(
168
+ "# 🪄 LTX-2.3 Video Inpainting\n"
169
+ "Mask a region of a video and regenerate it from a prompt, keeping the rest of the frame intact. "
170
+ "Upload a clip, **brush over the area to replace** on the first frame, describe what should appear there. "
171
+ "The mask is applied across all frames. "
172
+ "IC-LoRA: [`linoyts/ltx2.3-inpainting-lora`](https://huggingface.co/linoyts/ltx2.3-inpainting-lora) · "
173
+ "base: distilled LTX-2.3."
174
+ )
175
+ with gr.Row():
176
+ with gr.Column():
177
+ video_in = gr.Video(label="Input video")
178
+ mask_editor = gr.ImageEditor(
179
+ label="Brush the region to inpaint (loads from the video's first frame)",
180
+ type="numpy",
181
+ layers=False,
182
+ brush=gr.Brush(colors=["#ff2d55"], color_mode="fixed"),
183
+ )
184
+ prompt = gr.Textbox(
185
+ label="What should fill the masked region",
186
+ placeholder="a lush green bush with small white flowers",
187
+ lines=2,
188
+ )
189
+ with gr.Accordion("Settings", open=False):
190
+ preset = gr.Dropdown(list(RES_PRESETS), value="Fast (768×448)", label="Resolution")
191
+ num_frames = gr.Dropdown(FRAME_CHOICES, value=73, label="Frames (24fps)")
192
+ randomize = gr.Checkbox(True, label="Randomize seed")
193
+ seed = gr.Slider(0, MAX_SEED, value=42, step=1, label="Seed")
194
+ run = gr.Button("Inpaint", variant="primary")
195
+ with gr.Column():
196
+ video_out = gr.Video(label="Inpainted result")
197
+ used_seed = gr.Number(label="Seed used", interactive=False)
198
+
199
+ video_in.change(first_frame, inputs=video_in, outputs=mask_editor)
200
+ run.click(
201
+ inpaint,
202
+ inputs=[video_in, mask_editor, prompt, preset, num_frames, seed, randomize],
203
+ outputs=[video_out, used_seed],
204
+ )
205
+
206
+ if __name__ == "__main__":
207
+ demo.launch(show_error=True)