BoxOfColors Claude Opus 4.7 (1M context) commited on
Commit
b1a5003
Β·
1 Parent(s): 19dea1e

Initial pipeline upload

Browse files

Crop-inpaint-composite pipeline with LaMa (fast) and VACE-14B (quality) modes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files changed (11) hide show
  1. .gitignore +46 -0
  2. README.md +44 -6
  3. app.py +611 -0
  4. packages.txt +1 -0
  5. pipeline/__init__.py +3 -0
  6. pipeline/composite.py +218 -0
  7. pipeline/crop.py +447 -0
  8. pipeline/lama.py +150 -0
  9. pipeline/vace.py +59 -0
  10. pipeline/video.py +445 -0
  11. requirements.txt +23 -0
.gitignore ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ build/
8
+ dist/
9
+ *.egg-info/
10
+ .eggs/
11
+
12
+ # Virtual environments
13
+ venv/
14
+ .venv/
15
+ env/
16
+ .env
17
+
18
+ # OS
19
+ .DS_Store
20
+ Thumbs.db
21
+
22
+ # IDE / tooling
23
+ .idea/
24
+ .vscode/
25
+ .claude/
26
+ *.swp
27
+ *.swo
28
+
29
+ # HF / model caches
30
+ .cache/
31
+ hub/
32
+ *.ckpt
33
+ *.pt
34
+ *.bin
35
+ *.safetensors
36
+
37
+ # Local test artifacts
38
+ *.mp4
39
+ *.mov
40
+ *.png
41
+ *.jpg
42
+ *.jpeg
43
+ !**/assets/**
44
+
45
+ # Logs
46
+ *.log
README.md CHANGED
@@ -1,12 +1,50 @@
1
  ---
2
- title: Watermark Remover
3
- emoji: πŸ¦€
4
- colorFrom: gray
5
- colorTo: green
6
  sdk: gradio
7
- sdk_version: 6.13.0
8
  app_file: app.py
9
  pinned: false
 
10
  ---
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Video Watermark Remover
3
+ emoji: 🦎
4
+ colorFrom: green
5
+ colorTo: blue
6
  sdk: gradio
7
+ sdk_version: "4.44.0"
8
  app_file: app.py
9
  pinned: false
10
+ license: apache-2.0
11
  ---
12
 
13
+ # Video Watermark Remover
14
+
15
+ Self-hosted Hugging Face Space for removing static, opaque watermarks from video footage.
16
+
17
+ ## Modes
18
+
19
+ | Mode | Model | Speed | Best for |
20
+ |------|-------|-------|----------|
21
+ | **Fast** | LaMa (per-frame) | Seconds | Sky, water, foliage β€” low-frequency backgrounds |
22
+ | **Quality** | Wan2.1-VACE-14B | ~2 min | Structured or textured backgrounds |
23
+
24
+ ## How it works
25
+
26
+ 1. Upload a video clip (≀15 seconds, 1080p)
27
+ 2. On the extracted first frame, **draw around the watermark** using the brush tool
28
+ 3. Choose **Fast** or **Quality** mode
29
+ 4. Hit **Remove Watermark** β€” the output is composited back at full 1080p
30
+
31
+ ### Crop-inpaint-composite
32
+
33
+ The pipeline never runs the model on the full 1920Γ—1080 frame. Instead:
34
+ - Your drawn region determines a tight crop (expanded to a VACE-compatible resolution with surrounding context)
35
+ - Only that crop (~7Γ— fewer pixels) is processed by the model
36
+ - The result is feather-blended back into the original frame
37
+ - All other pixels are **byte-identical** to the source
38
+
39
+ V-Log color metadata is preserved via FFmpeg flag passthrough.
40
+
41
+ ## Platform
42
+
43
+ - ZeroGPU PRO (H200 MIG slice, 70 GB VRAM)
44
+ - PyTorch 2.7.1 + diffusers
45
+
46
+ ## License
47
+
48
+ - Pipeline code: Apache 2.0
49
+ - LaMa: Apache 2.0
50
+ - Wan2.1-VACE-14B: [Wan-AI license](https://huggingface.co/Wan-AI/Wan2.1-VACE-14B-diffusers)
app.py ADDED
@@ -0,0 +1,611 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ app.py
3
+ ------
4
+ Gradio UI for the Video Watermark Remover Space (ZeroGPU PRO).
5
+
6
+ Flow:
7
+ 1. Upload video β†’ extract first frame β†’ display in ImageEditor for mask drawing
8
+ 2. User brushes over the watermark
9
+ 3. Preview Crop β†’ shows crop region + mask overlay
10
+ 4. Mode: Fast (LaMa) | Quality (VACE-14B)
11
+ 5. Remove Watermark β†’ runs pipeline β†’ output video
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import shutil
18
+ import tempfile
19
+ from pathlib import Path
20
+
21
+ import gradio as gr
22
+ import numpy as np
23
+ from PIL import Image, ImageDraw
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # ZeroGPU spaces shim β€” import succeeds locally too
27
+ # ---------------------------------------------------------------------------
28
+ try:
29
+ import spaces # type: ignore
30
+ HAS_SPACES = True
31
+ except ImportError:
32
+ HAS_SPACES = False
33
+ class spaces: # type: ignore
34
+ @staticmethod
35
+ def GPU(duration=60):
36
+ def decorator(fn):
37
+ return fn
38
+ return decorator
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # Pipeline imports
42
+ # ---------------------------------------------------------------------------
43
+ from pipeline.crop import CropRegion, build_inpaint_mask, compute_crop_region, mask_to_bbox
44
+ from pipeline.video import (
45
+ VideoMeta, VideoWorkspace,
46
+ attach_audio, extract_first_frame, extract_frames, frames_to_video, probe,
47
+ )
48
+
49
+ # ---------------------------------------------------------------------------
50
+ # CSS β€” dark premium theme
51
+ # ---------------------------------------------------------------------------
52
+ CSS = """
53
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
54
+
55
+ * { box-sizing: border-box; }
56
+
57
+ body, .gradio-container {
58
+ font-family: 'Inter', sans-serif !important;
59
+ background: #0d1117 !important;
60
+ color: #e6edf3 !important;
61
+ }
62
+
63
+ .gradio-container {
64
+ max-width: 1200px !important;
65
+ margin: 0 auto !important;
66
+ padding: 24px !important;
67
+ }
68
+
69
+ /* Header */
70
+ .header-block {
71
+ text-align: center;
72
+ padding: 32px 0 24px;
73
+ }
74
+ .header-block h1 {
75
+ font-size: 2rem;
76
+ font-weight: 700;
77
+ background: linear-gradient(135deg, #2dd4bf, #06b6d4);
78
+ -webkit-background-clip: text;
79
+ -webkit-text-fill-color: transparent;
80
+ margin-bottom: 8px;
81
+ }
82
+ .header-block p {
83
+ color: #8b949e;
84
+ font-size: 0.95rem;
85
+ }
86
+
87
+ /* Cards */
88
+ .card {
89
+ background: #161b22;
90
+ border: 1px solid #30363d;
91
+ border-radius: 12px;
92
+ padding: 20px;
93
+ margin-bottom: 16px;
94
+ }
95
+ .card-title {
96
+ font-size: 0.8rem;
97
+ font-weight: 600;
98
+ text-transform: uppercase;
99
+ letter-spacing: 0.08em;
100
+ color: #8b949e;
101
+ margin-bottom: 12px;
102
+ }
103
+
104
+ /* Buttons */
105
+ .btn-primary {
106
+ background: linear-gradient(135deg, #0f766e, #0e7490) !important;
107
+ border: none !important;
108
+ color: white !important;
109
+ font-weight: 600 !important;
110
+ border-radius: 8px !important;
111
+ padding: 10px 24px !important;
112
+ transition: opacity 0.2s !important;
113
+ }
114
+ .btn-primary:hover { opacity: 0.85 !important; }
115
+
116
+ .btn-secondary {
117
+ background: #21262d !important;
118
+ border: 1px solid #30363d !important;
119
+ color: #e6edf3 !important;
120
+ font-weight: 500 !important;
121
+ border-radius: 8px !important;
122
+ transition: border-color 0.2s !important;
123
+ }
124
+ .btn-secondary:hover { border-color: #2dd4bf !important; }
125
+
126
+ /* Status */
127
+ .status-box textarea {
128
+ background: #0d1117 !important;
129
+ border: 1px solid #30363d !important;
130
+ color: #8b949e !important;
131
+ font-family: 'JetBrains Mono', monospace !important;
132
+ font-size: 0.82rem !important;
133
+ border-radius: 8px !important;
134
+ }
135
+
136
+ /* Mode radio */
137
+ .mode-radio label { font-weight: 500 !important; }
138
+
139
+ /* Steps badge */
140
+ .step-badge {
141
+ display: inline-flex;
142
+ align-items: center;
143
+ justify-content: center;
144
+ width: 24px;
145
+ height: 24px;
146
+ border-radius: 50%;
147
+ background: #0f766e;
148
+ color: white;
149
+ font-size: 0.75rem;
150
+ font-weight: 700;
151
+ margin-right: 8px;
152
+ }
153
+ """
154
+
155
+ # ---------------------------------------------------------------------------
156
+ # Helpers
157
+ # ---------------------------------------------------------------------------
158
+
159
+ def _get_mask_from_editor(editor_value: dict | None) -> np.ndarray | None:
160
+ """Extract a uint8 mask (HΓ—W) from gr.ImageEditor output.
161
+
162
+ Unions all layers so drawings across multiple Gradio layers are preserved.
163
+ The returned array is at *display* resolution (the editor canvas size),
164
+ not necessarily source frame resolution. Always rescale with
165
+ _rescale_mask_to_frame() before using mask coordinates against VideoMeta.
166
+ """
167
+ if editor_value is None:
168
+ return None
169
+ raw_layers = [l for l in (editor_value.get("layers") or []) if l is not None]
170
+ if not raw_layers:
171
+ return None
172
+
173
+ combined: np.ndarray | None = None
174
+ for raw in raw_layers:
175
+ arr = np.array(raw)
176
+ if arr.ndim == 3 and arr.shape[2] == 4:
177
+ channel = arr[:, :, 3] # RGBA β†’ alpha
178
+ elif arr.ndim == 3:
179
+ channel = arr.max(axis=2) # RGB β†’ luminance max
180
+ else:
181
+ channel = arr.astype(np.uint8) # already single-channel
182
+ combined = channel if combined is None else np.maximum(combined, channel)
183
+
184
+ return combined
185
+
186
+
187
+ def _rescale_mask_to_frame(
188
+ raw_mask: np.ndarray,
189
+ target_w: int,
190
+ target_h: int,
191
+ ) -> np.ndarray:
192
+ """Rescale a mask from editor/display resolution to full source-frame resolution.
193
+
194
+ gr.ImageEditor renders at a fixed CSS height, so the returned layer may be
195
+ e.g. 854Γ—480 for a 1920Γ—1080 source. Using display-resolution coordinates
196
+ against full-frame VideoMeta dimensions would place the crop in the wrong
197
+ position.
198
+
199
+ Also enforces uint8 binary (0 / 255) output regardless of the layer dtype
200
+ returned by Gradio β€” guards against future API changes where the layer
201
+ becomes float32 (0–1) rather than uint8 (0–255).
202
+ """
203
+ if raw_mask.shape[0] == target_h and raw_mask.shape[1] == target_w:
204
+ result = raw_mask
205
+ else:
206
+ result = np.array(
207
+ Image.fromarray(raw_mask).resize((target_w, target_h), Image.NEAREST)
208
+ )
209
+ # Normalise to binary uint8 regardless of incoming dtype
210
+ return (result > 0).astype(np.uint8) * 255
211
+
212
+
213
+ def _create_crop_preview(
214
+ first_frame: np.ndarray,
215
+ crop_region: CropRegion,
216
+ inpaint_mask: np.ndarray,
217
+ ) -> np.ndarray:
218
+ """Overlay crop rectangle and mask on the first frame for preview."""
219
+ img = Image.fromarray(first_frame).convert("RGBA")
220
+ overlay = Image.new("RGBA", img.size, (0, 0, 0, 0))
221
+ draw = ImageDraw.Draw(overlay)
222
+
223
+ cr = crop_region
224
+
225
+ # Semi-transparent teal fill for crop region
226
+ draw.rectangle(
227
+ [cr.frame_x, cr.frame_y, cr.frame_x + cr.frame_w, cr.frame_y + cr.frame_h],
228
+ fill=(13, 148, 136, 40),
229
+ outline=(45, 212, 191, 200),
230
+ width=2,
231
+ )
232
+
233
+ # Inpaint mask overlay (red)
234
+ if inpaint_mask is not None:
235
+ mask_rgba = Image.new("RGBA", img.size, (0, 0, 0, 0))
236
+ mask_full = np.zeros((img.height, img.width), dtype=np.uint8)
237
+ y1 = cr.frame_y
238
+ y2 = cr.frame_y + cr.frame_h
239
+ x1 = cr.frame_x
240
+ x2 = cr.frame_x + cr.frame_w
241
+ mask_full[y1:y2, x1:x2] = inpaint_mask
242
+ red_channel = np.zeros((*mask_full.shape, 4), dtype=np.uint8)
243
+ red_channel[mask_full > 0] = [239, 68, 68, 140]
244
+ mask_rgba = Image.fromarray(red_channel, mode="RGBA")
245
+ overlay = Image.alpha_composite(overlay, mask_rgba)
246
+
247
+ composite = Image.alpha_composite(img, overlay).convert("RGB")
248
+ return np.array(composite)
249
+
250
+
251
+ def _meta_to_dict(meta: VideoMeta) -> dict:
252
+ return {
253
+ "width": meta.width,
254
+ "height": meta.height,
255
+ "fps": meta.fps,
256
+ "frame_count": meta.frame_count,
257
+ "duration_s": meta.duration_s,
258
+ "color_primaries": meta.color_primaries,
259
+ "color_trc": meta.color_trc,
260
+ "colorspace": meta.colorspace,
261
+ "color_range": meta.color_range,
262
+ "codec_name": meta.codec_name,
263
+ "bit_depth": meta.bit_depth,
264
+ }
265
+
266
+
267
+ def _dict_to_meta(d: dict) -> VideoMeta:
268
+ return VideoMeta(**d)
269
+
270
+
271
+ # ---------------------------------------------------------------------------
272
+ # Callbacks
273
+ # ---------------------------------------------------------------------------
274
+
275
+ def on_video_upload(video_path: str | None):
276
+ """Extract first frame and populate the ImageEditor."""
277
+ if not video_path:
278
+ return gr.update(), gr.update(), None, "Upload a video to begin."
279
+
280
+ try:
281
+ meta = probe(video_path)
282
+
283
+ # ── Input validation β€” guard against disk exhaustion on ZeroGPU ──
284
+ MAX_DURATION_S = 16.0
285
+ MAX_PIXELS = 1920 * 1080
286
+ if meta.duration_s > MAX_DURATION_S:
287
+ return (
288
+ gr.update(), gr.update(), None,
289
+ f"❌ Clip too long ({meta.duration_s:.1f}s). Max {MAX_DURATION_S:.0f} seconds.",
290
+ )
291
+ if meta.width * meta.height > MAX_PIXELS:
292
+ return (
293
+ gr.update(), gr.update(), None,
294
+ f"❌ Resolution too high ({meta.width}Γ—{meta.height}). Max 1920Γ—1080.",
295
+ )
296
+
297
+ # Extract first frame β€” mkstemp so the fd is closed before FFmpeg writes
298
+ fd, tmp_path = tempfile.mkstemp(suffix=".png", prefix="wm_frame_")
299
+ os.close(fd)
300
+ try:
301
+ extract_first_frame(video_path, tmp_path)
302
+ first_frame = np.array(Image.open(tmp_path).convert("RGB"))
303
+ finally:
304
+ try:
305
+ os.unlink(tmp_path)
306
+ except OSError:
307
+ pass
308
+
309
+ meta_str = (
310
+ f"{meta.width}Γ—{meta.height} Β· {meta.fps:.3g} fps Β· "
311
+ f"{meta.duration_s:.1f}s Β· {meta.frame_count} frames"
312
+ )
313
+ if meta.color_trc:
314
+ meta_str += f" Β· {meta.color_trc}"
315
+
316
+ editor_val = {
317
+ "background": first_frame,
318
+ "layers": [],
319
+ "composite": None,
320
+ }
321
+ return (
322
+ gr.update(value=editor_val),
323
+ gr.update(value=None),
324
+ _meta_to_dict(meta),
325
+ f"βœ“ Loaded β€” {meta_str}\n\nNow draw over the watermark with the brush tool.",
326
+ )
327
+ except Exception as e:
328
+ return gr.update(), gr.update(), None, f"❌ Error: {e}"
329
+
330
+
331
+ def on_preview_crop(editor_value: dict | None, meta_state: dict | None, context_px: int):
332
+ """Compute crop region from mask and render a preview overlay."""
333
+ if meta_state is None:
334
+ return gr.update(), "Upload a video first."
335
+ if editor_value is None:
336
+ return gr.update(), "Upload a video first."
337
+
338
+ raw_mask = _get_mask_from_editor(editor_value)
339
+ if raw_mask is None or raw_mask.max() == 0:
340
+ return gr.update(), "⚠️ No drawing detected. Use the brush to paint over the watermark."
341
+
342
+ try:
343
+ meta = _dict_to_meta(meta_state)
344
+ # Rescale mask from editor/display resolution to full source-frame resolution
345
+ full_mask = _rescale_mask_to_frame(raw_mask, meta.width, meta.height)
346
+ bbox = mask_to_bbox(full_mask)
347
+ crop_region = compute_crop_region(
348
+ bbox, meta.width, meta.height, context_px=context_px
349
+ )
350
+ inpaint_mask = build_inpaint_mask(
351
+ crop_region, source_mask=full_mask, dilate_px=6
352
+ )
353
+
354
+ bg = editor_value.get("background")
355
+ if bg is None:
356
+ first_frame = np.zeros((meta.height, meta.width, 3), dtype=np.uint8)
357
+ else:
358
+ first_frame = np.array(Image.fromarray(np.array(bg)).convert("RGB"))
359
+ # Ensure first_frame is at full source resolution for the overlay
360
+ if first_frame.shape[1] != meta.width or first_frame.shape[0] != meta.height:
361
+ first_frame = np.array(
362
+ Image.fromarray(first_frame).resize(
363
+ (meta.width, meta.height), Image.LANCZOS
364
+ )
365
+ )
366
+
367
+ preview = _create_crop_preview(first_frame, crop_region, inpaint_mask)
368
+
369
+ status = (
370
+ f"βœ“ Crop computed\n"
371
+ f" Watermark bbox : {bbox.width}Γ—{bbox.height} px\n"
372
+ f" Crop region : {crop_region.frame_w}Γ—{crop_region.frame_h} "
373
+ f"@ ({crop_region.frame_x}, {crop_region.frame_y})\n"
374
+ f" VACE target : {crop_region.target_w}Γ—{crop_region.target_h}\n"
375
+ f"\nLooks good? Hit Remove Watermark."
376
+ )
377
+ return gr.update(value=preview), status
378
+
379
+ except Exception as e:
380
+ return gr.update(), f"❌ {e}"
381
+
382
+
383
+ @spaces.GPU(duration=180)
384
+ def _inpaint_frames_gpu(
385
+ frame_paths: list,
386
+ crop_region: CropRegion,
387
+ inpaint_mask: np.ndarray,
388
+ mode: str,
389
+ total: int,
390
+ progress,
391
+ ) -> list:
392
+ """
393
+ GPU-accelerated inpainting step.
394
+
395
+ This is the *only* function that holds the ZeroGPU allocation.
396
+ Frame extraction, compositing, and video encoding are all CPU-only and
397
+ run outside this function to avoid burning GPU quota on I/O work.
398
+ """
399
+ if mode == "Fast (LaMa)":
400
+ from pipeline.lama import inpaint_frames_lama
401
+
402
+ def _lama_progress(i: int) -> None:
403
+ progress(
404
+ 0.20 + 0.70 * ((i + 1) / total),
405
+ desc=f"LaMa frame {i + 1}/{total}…",
406
+ )
407
+
408
+ return inpaint_frames_lama(
409
+ frame_paths, crop_region, inpaint_mask, progress_fn=_lama_progress
410
+ )
411
+ else: # Quality (VACE)
412
+ from pipeline.vace import inpaint_frames_vace
413
+ progress(0.5, desc="Running VACE-14B…")
414
+ return inpaint_frames_vace(frame_paths, crop_region, inpaint_mask)
415
+
416
+
417
+ def run_pipeline(
418
+ video_path: str | None,
419
+ editor_value: dict | None,
420
+ mode: str,
421
+ context_px: int,
422
+ meta_state: dict | None,
423
+ progress=gr.Progress(),
424
+ ):
425
+ """
426
+ Pipeline orchestrator β€” CPU work only.
427
+
428
+ GPU allocation is acquired and released inside _inpaint_frames_gpu; the
429
+ rest of the pipeline (frame extraction, compositing, encoding) is pure
430
+ CPU/disk I/O and does not consume GPU quota.
431
+ """
432
+ if video_path is None:
433
+ raise gr.Error("Upload a video first.")
434
+ if meta_state is None:
435
+ raise gr.Error("Video metadata missing β€” re-upload the video.")
436
+
437
+ raw_mask = _get_mask_from_editor(editor_value)
438
+ if raw_mask is None or raw_mask.max() == 0:
439
+ raise gr.Error("Draw over the watermark before processing.")
440
+
441
+ meta = _dict_to_meta(meta_state)
442
+ full_mask = _rescale_mask_to_frame(raw_mask, meta.width, meta.height)
443
+
444
+ # ── Compute crop + mask ─────────────────────────────────────────────
445
+ progress(0.05, desc="Computing crop region…")
446
+ bbox = mask_to_bbox(full_mask)
447
+ crop_region = compute_crop_region(
448
+ bbox, meta.width, meta.height, context_px=context_px
449
+ )
450
+ inpaint_mask = build_inpaint_mask(
451
+ crop_region, source_mask=full_mask, dilate_px=6
452
+ )
453
+
454
+ with VideoWorkspace() as ws:
455
+ safe_video = ws.path("source" + Path(video_path).suffix)
456
+ shutil.copy2(video_path, safe_video)
457
+
458
+ # ── Extract frames (CFR-forced for VFR safety) ─────────────────
459
+ progress(0.10, desc="Extracting frames…")
460
+ frame_paths = extract_frames(safe_video, ws.frames_dir, fps=meta.fps)
461
+ total = len(frame_paths)
462
+
463
+ # ── GPU: inpainting only ───────────────────────────────────
464
+ progress(0.15, desc="Starting inpainting…")
465
+ inpainted_crops = _inpaint_frames_gpu(
466
+ frame_paths, crop_region, inpaint_mask, mode, total, progress
467
+ )
468
+
469
+ # ── CPU: composite ──────────────────────────────────────────
470
+ progress(0.90, desc="Compositing frames…")
471
+ from pipeline.composite import composite_frame
472
+
473
+ for i, (fp, crop) in enumerate(zip(frame_paths, inpainted_crops)):
474
+ original = np.array(Image.open(fp).convert("RGB"))
475
+ composited = composite_frame(original, crop, crop_region, inpaint_mask)
476
+ Image.fromarray(composited).save(ws.out_frames_dir / f"{i+1:06d}.png")
477
+
478
+ # ── CPU: encode + mux ───────────────────────────────────────
479
+ progress(0.95, desc="Encoding video…")
480
+ silent_path = ws.path("silent.mp4")
481
+ frames_to_video(ws.out_frames_dir, silent_path, meta)
482
+
483
+ fd, final_path = tempfile.mkstemp(suffix=".mp4", prefix="wm_out_")
484
+ os.close(fd)
485
+ try:
486
+ attach_audio(safe_video, silent_path, final_path)
487
+ except Exception:
488
+ try:
489
+ os.unlink(final_path)
490
+ except OSError:
491
+ pass
492
+ raise
493
+ # ws (frames, composited pngs, silent.mp4) cleaned up here
494
+
495
+ progress(1.0, desc="Done!")
496
+ return final_path, f"βœ“ Done β€” {total} frames processed ({mode})"
497
+
498
+
499
+ # ---------------------------------------------------------------------------
500
+ # UI
501
+ # ---------------------------------------------------------------------------
502
+
503
+ with gr.Blocks(title="Video Watermark Remover", css=CSS) as demo:
504
+
505
+ # State
506
+ meta_state = gr.State(None)
507
+
508
+ # ── Header ──────────────────────────────────────────────────────────────
509
+ gr.HTML("""
510
+ <div class="header-block">
511
+ <h1>🦎 Video Watermark Remover</h1>
512
+ <p>Draw over the watermark Β· choose a mode Β· get clean footage</p>
513
+ </div>
514
+ """)
515
+
516
+ # ── Step 1 + 2 side by side ─────────────────────────────────────────────
517
+ with gr.Row(equal_height=False):
518
+ with gr.Column(scale=1):
519
+ gr.HTML('<div class="card-title"><span class="step-badge">1</span>Upload Video</div>')
520
+ video_input = gr.Video(
521
+ label="Source clip (≀15 s, 1080p)",
522
+ elem_id="video-input",
523
+ )
524
+
525
+ gr.HTML('<div class="card-title" style="margin-top:16px"><span class="step-badge">2</span>Mode</div>')
526
+ mode_radio = gr.Radio(
527
+ choices=["Fast (LaMa)", "Quality (VACE-14B)"],
528
+ value="Fast (LaMa)",
529
+ label="",
530
+ elem_classes=["mode-radio"],
531
+ )
532
+
533
+ gr.HTML('<div class="card-title" style="margin-top:16px">βš™οΈ Advanced</div>')
534
+ context_slider = gr.Slider(
535
+ minimum=32,
536
+ maximum=192,
537
+ value=64,
538
+ step=16,
539
+ label="Context padding (px)",
540
+ info="Extra scene context around the watermark given to the model",
541
+ )
542
+
543
+ with gr.Column(scale=2):
544
+ gr.HTML('<div class="card-title"><span class="step-badge">3</span>Draw Over the Watermark</div>')
545
+ editor = gr.ImageEditor(
546
+ label="Paint over the watermark (brush tool)",
547
+ type="numpy",
548
+ height=480,
549
+ brush=gr.Brush(colors=["#ef4444"], default_size=12),
550
+ eraser=gr.Eraser(default_size=12),
551
+ )
552
+
553
+ # ── Action buttons ───────────────────────────────────────────────────────
554
+ with gr.Row():
555
+ preview_btn = gr.Button(
556
+ "πŸ” Preview Crop Region",
557
+ elem_classes=["btn-secondary"],
558
+ )
559
+ process_btn = gr.Button(
560
+ "✨ Remove Watermark",
561
+ variant="primary",
562
+ elem_classes=["btn-primary"],
563
+ )
564
+
565
+ # ── Status ───────────────────────────────────────────────────────────────
566
+ status_box = gr.Textbox(
567
+ label="Status",
568
+ value="Upload a video to begin.",
569
+ lines=4,
570
+ interactive=False,
571
+ elem_classes=["status-box"],
572
+ )
573
+
574
+ # ── Outputs ──────────────────────────────────────────────────────────────
575
+ with gr.Row():
576
+ with gr.Column():
577
+ gr.HTML('<div class="card-title">Crop Preview</div>')
578
+ crop_preview = gr.Image(
579
+ label="",
580
+ type="numpy",
581
+ show_label=False,
582
+ )
583
+ with gr.Column():
584
+ gr.HTML('<div class="card-title">Output Video</div>')
585
+ video_output = gr.Video(
586
+ label="",
587
+ show_label=False,
588
+ )
589
+
590
+ # ── Wiring ───────────────────────────────────────────────────────────────
591
+ video_input.upload(
592
+ fn=on_video_upload,
593
+ inputs=[video_input],
594
+ outputs=[editor, crop_preview, meta_state, status_box],
595
+ )
596
+
597
+ preview_btn.click(
598
+ fn=on_preview_crop,
599
+ inputs=[editor, meta_state, context_slider],
600
+ outputs=[crop_preview, status_box],
601
+ )
602
+
603
+ process_btn.click(
604
+ fn=run_pipeline,
605
+ inputs=[video_input, editor, mode_radio, context_slider, meta_state],
606
+ outputs=[video_output, status_box],
607
+ )
608
+
609
+
610
+ if __name__ == "__main__":
611
+ demo.launch()
packages.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ ffmpeg
pipeline/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ """
2
+ pipeline/__init__.py
3
+ """
pipeline/composite.py ADDED
@@ -0,0 +1,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ pipeline/composite.py
3
+ ---------------------
4
+ Feathered alpha compositing: paste an inpainted crop back into the
5
+ original full frame.
6
+
7
+ The feather/blend zone creates a smooth transition along the crop border,
8
+ avoiding hard edge seams. Only pixels inside the inpaint mask are replaced;
9
+ the rest of the crop (the "context" ring) is discarded.
10
+
11
+ Design
12
+ ------
13
+ Two-stage blend:
14
+ 1. Mask feathering: the inpaint mask is Gaussian-blurred to create a
15
+ soft alpha ramp around the watermark boundary. This blends between
16
+ the inpainted content and the original frame within the crop.
17
+ 2. Crop-border feathering (optional, default off): a second linear ramp
18
+ from the crop border inward, so the context ring blends too. Usually
19
+ not needed because context pixels in the crop are identical to the
20
+ original frame anyway (LaMa / VACE don't alter them much).
21
+
22
+ All operations are pure NumPy + PIL β€” no cv2 required.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import numpy as np
28
+ from PIL import Image, ImageFilter
29
+
30
+ from pipeline.crop import CropRegion
31
+
32
+
33
+ def composite_frame(
34
+ original: np.ndarray,
35
+ inpainted_crop: np.ndarray,
36
+ crop_region: CropRegion,
37
+ inpaint_mask: np.ndarray,
38
+ feather_radius: int = 8,
39
+ ) -> np.ndarray:
40
+ """
41
+ Blend an inpainted crop back into the original full frame.
42
+
43
+ Parameters
44
+ ----------
45
+ original : np.ndarray
46
+ Full-frame image (H x W x 3, uint8 RGB). Modified in-place
47
+ on a copy β€” caller's array is not mutated.
48
+ inpainted_crop : np.ndarray
49
+ Inpainted crop image (crop_h x crop_w x 3, uint8 RGB).
50
+ Must match crop_region dimensions exactly.
51
+ crop_region : CropRegion
52
+ Defines where the crop sits in the full frame.
53
+ inpaint_mask : np.ndarray
54
+ Crop-local binary mask (crop_h x crop_w, uint8). 255=inpaint, 0=keep.
55
+ Same mask that was passed to the inpainting model.
56
+ feather_radius : int
57
+ Gaussian blur radius applied to the mask before blending.
58
+ Larger values = softer transition.
59
+ Set to 0 to disable feathering (hard composite).
60
+
61
+ Returns
62
+ -------
63
+ np.ndarray
64
+ Full-frame output (H x W x 3, uint8 RGB) with watermark removed.
65
+ """
66
+ cr = crop_region
67
+ result = original.copy()
68
+
69
+ # Sanity check dimensions
70
+ expected_h, expected_w = cr.frame_h, cr.frame_w
71
+ actual_h, actual_w = inpainted_crop.shape[:2]
72
+ if (actual_h, actual_w) != (expected_h, expected_w):
73
+ # Resize inpainted crop to match crop region (handles VACE resize case)
74
+ inpainted_crop = np.array(
75
+ Image.fromarray(inpainted_crop).resize(
76
+ (expected_w, expected_h), Image.LANCZOS
77
+ )
78
+ )
79
+
80
+ # ------------------------------------------------------------------
81
+ # Build the blend alpha from the inpaint mask
82
+ # ------------------------------------------------------------------
83
+ alpha = _feathered_alpha(inpaint_mask, feather_radius) # float32, 0..1
84
+
85
+ # ------------------------------------------------------------------
86
+ # Composite: result = alpha * inpainted + (1 - alpha) * original_crop
87
+ # ------------------------------------------------------------------
88
+ original_crop = result[
89
+ cr.frame_y : cr.frame_y + cr.frame_h,
90
+ cr.frame_x : cr.frame_x + cr.frame_w,
91
+ ].astype(np.float32)
92
+
93
+ inpainted_f = inpainted_crop.astype(np.float32)
94
+ alpha_3 = alpha[:, :, np.newaxis] # broadcast over RGB channels
95
+
96
+ blended = alpha_3 * inpainted_f + (1.0 - alpha_3) * original_crop
97
+ blended_uint8 = np.clip(blended, 0, 255).astype(np.uint8)
98
+
99
+ result[
100
+ cr.frame_y : cr.frame_y + cr.frame_h,
101
+ cr.frame_x : cr.frame_x + cr.frame_w,
102
+ ] = blended_uint8
103
+
104
+ return result
105
+
106
+
107
+ def composite_frames(
108
+ original_frame_paths,
109
+ inpainted_crops: list[np.ndarray],
110
+ crop_region: CropRegion,
111
+ inpaint_mask: np.ndarray,
112
+ feather_radius: int = 8,
113
+ ) -> list[np.ndarray]:
114
+ """
115
+ Batch version: composite a list of inpainted crops onto their
116
+ corresponding original frames.
117
+
118
+ Parameters
119
+ ----------
120
+ original_frame_paths : List[Path]
121
+ Full-frame PNG paths (same order as inpainted_crops).
122
+ inpainted_crops : List[np.ndarray]
123
+ One inpainted crop per frame.
124
+ crop_region : CropRegion
125
+ inpaint_mask : np.ndarray
126
+ Shared mask (same for all frames β€” watermark is static).
127
+ feather_radius : int
128
+
129
+ Returns
130
+ -------
131
+ List[np.ndarray]
132
+ Full-frame composited images (uint8 RGB), one per input frame.
133
+ """
134
+ # Pre-compute feathered alpha once (shared mask)
135
+ alpha = _feathered_alpha(inpaint_mask, feather_radius)
136
+
137
+ composited: list[np.ndarray] = []
138
+ for frame_path, crop in zip(original_frame_paths, inpainted_crops):
139
+ original = np.array(Image.open(frame_path).convert("RGB"))
140
+ frame_out = _composite_with_alpha(original, crop, crop_region, alpha)
141
+ composited.append(frame_out)
142
+
143
+ return composited
144
+
145
+
146
+ # ---------------------------------------------------------------------------
147
+ # Private helpers
148
+ # ---------------------------------------------------------------------------
149
+
150
+ def _feathered_alpha(mask: np.ndarray, radius: int) -> np.ndarray:
151
+ """
152
+ Convert a uint8 binary mask (0/255) to a float32 alpha map (0..1)
153
+ with a Gaussian-blurred soft edge.
154
+
155
+ Parameters
156
+ ----------
157
+ mask : np.ndarray
158
+ Crop-local binary mask (H x W, uint8).
159
+ radius : int
160
+ Gaussian blur radius. 0 = hard composite.
161
+
162
+ Returns
163
+ -------
164
+ np.ndarray
165
+ Float32 alpha map (H x W), values in [0.0, 1.0].
166
+ """
167
+ alpha_f = mask.astype(np.float32) / 255.0
168
+
169
+ if radius > 0:
170
+ # Use scipy gaussian_filter on float32 directly β€” avoids uint8 quantisation
171
+ # that would staircase the feather ramp over 8 discrete levels.
172
+ # sigma: radius β‰ˆ 3Οƒ is the standard Gaussian convention.
173
+ from scipy.ndimage import gaussian_filter
174
+ sigma = max(radius / 3.0, 0.5)
175
+ alpha_f = gaussian_filter(alpha_f, sigma=sigma)
176
+ alpha_f = np.clip(alpha_f, 0.0, 1.0)
177
+
178
+ return alpha_f
179
+
180
+
181
+ def _composite_with_alpha(
182
+ original: np.ndarray,
183
+ inpainted_crop: np.ndarray,
184
+ crop_region: CropRegion,
185
+ alpha: np.ndarray,
186
+ ) -> np.ndarray:
187
+ """
188
+ Internal composite given a pre-computed alpha map.
189
+ Returns a copy of original with the crop region blended in.
190
+ """
191
+ cr = crop_region
192
+ result = original.copy()
193
+
194
+ # Resize crop if needed (e.g. VACE returned a different resolution)
195
+ expected_h, expected_w = cr.frame_h, cr.frame_w
196
+ actual_h, actual_w = inpainted_crop.shape[:2]
197
+ if (actual_h, actual_w) != (expected_h, expected_w):
198
+ inpainted_crop = np.array(
199
+ Image.fromarray(inpainted_crop).resize(
200
+ (expected_w, expected_h), Image.LANCZOS
201
+ )
202
+ )
203
+
204
+ original_crop = result[
205
+ cr.frame_y : cr.frame_y + cr.frame_h,
206
+ cr.frame_x : cr.frame_x + cr.frame_w,
207
+ ].astype(np.float32)
208
+
209
+ alpha_3 = alpha[:, :, np.newaxis]
210
+ blended = alpha_3 * inpainted_crop.astype(np.float32) + (1.0 - alpha_3) * original_crop
211
+ blended_uint8 = np.clip(blended, 0, 255).astype(np.uint8)
212
+
213
+ result[
214
+ cr.frame_y : cr.frame_y + cr.frame_h,
215
+ cr.frame_x : cr.frame_x + cr.frame_w,
216
+ ] = blended_uint8
217
+
218
+ return result
pipeline/crop.py ADDED
@@ -0,0 +1,447 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ pipeline/crop.py
3
+ ----------------
4
+ Crop region computation for the watermark inpainting pipeline.
5
+
6
+ Given a user-drawn mask on the first frame, this module:
7
+ 1. Computes the bounding box of the drawn region.
8
+ 2. Finds the smallest VACE-compatible resolution that fits the bbox
9
+ (with a configurable context border added on each side).
10
+ 3. Centers the crop on the watermark centroid and shifts it
11
+ asymmetrically if it would fall off a frame edge.
12
+ 4. Returns a CropRegion dataclass consumed by the rest of the pipeline.
13
+
14
+ No model dependencies β€” pure NumPy / math.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import math
20
+ from dataclasses import dataclass
21
+ from typing import Tuple
22
+
23
+ import numpy as np
24
+
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # Supported VACE / Wan2.1 resolutions
28
+ # ---------------------------------------------------------------------------
29
+ # These are the canonical (width, height) pairs that Wan2.1-VACE accepts.
30
+ # They must be divisible by 32 (VAE spatial downsampling factor).
31
+ # Ordered by total pixel count ascending so we can pick the smallest fitting one.
32
+
33
+ VACE_RESOLUTIONS: list[Tuple[int, int]] = sorted(
34
+ [
35
+ # Square
36
+ (512, 512),
37
+ (576, 576),
38
+ (640, 640),
39
+ (704, 704),
40
+ (768, 768),
41
+ (832, 832),
42
+ (896, 896),
43
+ (960, 960),
44
+ (1024, 1024),
45
+ (1280, 1280),
46
+ # Landscape (width > height)
47
+ (640, 480),
48
+ (704, 480),
49
+ (768, 512),
50
+ (832, 576),
51
+ (896, 576),
52
+ (960, 544),
53
+ (1024, 576),
54
+ (1024, 640),
55
+ (1024, 704),
56
+ (1024, 768),
57
+ (1152, 640),
58
+ (1152, 768),
59
+ (1280, 720),
60
+ (1280, 768),
61
+ (1280, 1024),
62
+ # Portrait (height > width)
63
+ (480, 640),
64
+ (480, 704),
65
+ (512, 768),
66
+ (576, 832),
67
+ (576, 896),
68
+ (544, 960),
69
+ (576, 1024),
70
+ (640, 1024),
71
+ (704, 1024),
72
+ (768, 1024),
73
+ (640, 1152),
74
+ (768, 1152),
75
+ (720, 1280),
76
+ (768, 1280),
77
+ (1024, 1280),
78
+ ],
79
+ key=lambda wh: wh[0] * wh[1], # ascending by pixel count
80
+ )
81
+
82
+
83
+ # ---------------------------------------------------------------------------
84
+ # Data types
85
+ # ---------------------------------------------------------------------------
86
+
87
+ @dataclass(frozen=True)
88
+ class BBox:
89
+ """Pixel-space bounding box. All values are inclusive, 0-indexed."""
90
+ x1: int
91
+ y1: int
92
+ x2: int
93
+ y2: int
94
+
95
+ @property
96
+ def width(self) -> int:
97
+ # x1 and x2 are both inclusive pixel indices, so pixel count = x2 - x1 + 1
98
+ return self.x2 - self.x1 + 1
99
+
100
+ @property
101
+ def height(self) -> int:
102
+ return self.y2 - self.y1 + 1
103
+
104
+ @property
105
+ def cx(self) -> float:
106
+ return (self.x1 + self.x2) / 2.0
107
+
108
+ @property
109
+ def cy(self) -> float:
110
+ return (self.y1 + self.y2) / 2.0
111
+
112
+ @property
113
+ def area(self) -> int:
114
+ return self.width * self.height
115
+
116
+ def __repr__(self) -> str:
117
+ return (
118
+ f"BBox(x1={self.x1}, y1={self.y1}, x2={self.x2}, y2={self.y2}, "
119
+ f"w={self.width}, h={self.height})"
120
+ )
121
+
122
+
123
+ @dataclass(frozen=True)
124
+ class CropRegion:
125
+ """
126
+ The crop rectangle to cut from each full frame before inpainting.
127
+
128
+ - ``frame_x / frame_y``: top-left corner in full-frame pixel space.
129
+ - ``frame_w / frame_h``: size in full-frame pixel space.
130
+ - ``target_w / target_h``: VACE-compatible resolution the crop is scaled
131
+ to (usually equal to frame_w/frame_h when we only expand, no downscale).
132
+ - ``mask_bbox``: original watermark bbox in crop-local coordinates.
133
+ """
134
+ frame_x: int
135
+ frame_y: int
136
+ frame_w: int
137
+ frame_h: int
138
+ target_w: int
139
+ target_h: int
140
+ mask_bbox: BBox # in crop-local pixel space
141
+
142
+ @property
143
+ def frame_rect(self) -> Tuple[int, int, int, int]:
144
+ """(x, y, w, h) in full-frame space β€” handy for cv2.getRectSubPix etc."""
145
+ return (self.frame_x, self.frame_y, self.frame_w, self.frame_h)
146
+
147
+ @property
148
+ def needs_resize(self) -> bool:
149
+ return self.frame_w != self.target_w or self.frame_h != self.target_h
150
+
151
+ def __repr__(self) -> str:
152
+ return (
153
+ f"CropRegion(frame=({self.frame_x},{self.frame_y},"
154
+ f"{self.frame_w}x{self.frame_h}), "
155
+ f"target={self.target_w}x{self.target_h}, "
156
+ f"mask={self.mask_bbox})"
157
+ )
158
+
159
+
160
+ # ---------------------------------------------------------------------------
161
+ # Public API
162
+ # ---------------------------------------------------------------------------
163
+
164
+ def mask_to_bbox(mask: np.ndarray) -> BBox:
165
+ """
166
+ Convert a binary mask (H x W uint8, 255=drawn) to a tight BBox.
167
+
168
+ Parameters
169
+ ----------
170
+ mask : np.ndarray
171
+ Single-channel mask, dtype uint8. Non-zero pixels = drawn area.
172
+
173
+ Returns
174
+ -------
175
+ BBox
176
+ Tight bounding box around all non-zero pixels.
177
+
178
+ Raises
179
+ ------
180
+ ValueError
181
+ If the mask contains no drawn pixels.
182
+ """
183
+ if mask.ndim == 3:
184
+ # Accept RGB/RGBA β€” collapse to single channel
185
+ mask = mask.max(axis=2)
186
+
187
+ ys, xs = np.where(mask > 0)
188
+ if len(xs) == 0:
189
+ raise ValueError(
190
+ "The mask has no drawn pixels. "
191
+ "Please draw around the watermark before processing."
192
+ )
193
+
194
+ return BBox(
195
+ x1=int(xs.min()),
196
+ y1=int(ys.min()),
197
+ x2=int(xs.max()),
198
+ y2=int(ys.max()),
199
+ )
200
+
201
+
202
+ def find_target_resolution(
203
+ required_w: int,
204
+ required_h: int,
205
+ ) -> Tuple[int, int]:
206
+ """
207
+ Find the smallest VACE-compatible (width, height) pair such that
208
+ width >= required_w AND height >= required_h.
209
+
210
+ The algorithm iterates the catalogue (sorted by area ascending) and
211
+ picks the first entry where both width >= required_w AND height >= required_h.
212
+
213
+ If no resolution in the catalogue fits, the function rounds up both
214
+ dimensions to the next multiple of 32 and returns them β€” effectively
215
+ a custom resolution that VACE handles via its flexible attention.
216
+
217
+ Parameters
218
+ ----------
219
+ required_w, required_h : int
220
+ Minimum pixel dimensions needed (the crop area with context padding).
221
+
222
+ Returns
223
+ -------
224
+ (target_w, target_h) : Tuple[int, int]
225
+ VACE resolution to use. Always >= (required_w, required_h).
226
+ """
227
+ # VACE_RESOLUTIONS is sorted ascending by area; the first entry that
228
+ # satisfies both constraints is therefore the minimum-area fit.
229
+ best = next(
230
+ ((w, h) for (w, h) in VACE_RESOLUTIONS if w >= required_w and h >= required_h),
231
+ None,
232
+ )
233
+ if best is not None:
234
+ return best
235
+
236
+ # Fallback: round up to nearest multiple of 32
237
+ fallback_w = _ceil_to_multiple(required_w, 32)
238
+ fallback_h = _ceil_to_multiple(required_h, 32)
239
+ return (fallback_w, fallback_h)
240
+
241
+
242
+ def compute_crop_region(
243
+ watermark_bbox: BBox,
244
+ frame_w: int,
245
+ frame_h: int,
246
+ context_px: int = 64,
247
+ min_crop_dim: int = 320,
248
+ ) -> CropRegion:
249
+ """
250
+ Compute the fixed crop region for the entire video.
251
+
252
+ Steps
253
+ -----
254
+ 1. Add ``context_px`` padding on all four sides of the watermark bbox
255
+ to give the model scene context.
256
+ 2. Enforce a minimum crop dimension (``min_crop_dim``).
257
+ 3. Find the smallest VACE-compatible resolution that fits the padded bbox.
258
+ 4. Centre the crop on the watermark centroid.
259
+ 5. Shift the crop (without resizing) if it would overflow the frame edge.
260
+ 6. Express the watermark mask in crop-local coordinates.
261
+
262
+ Parameters
263
+ ----------
264
+ watermark_bbox : BBox
265
+ Tight bounding box of the drawn watermark mask, in full-frame pixels.
266
+ frame_w, frame_h : int
267
+ Full frame dimensions (e.g. 1920 x 1080).
268
+ context_px : int
269
+ How many pixels of context to add around the watermark on each side.
270
+ Default 64 gives ample scene context for the model.
271
+ min_crop_dim : int
272
+ Smallest allowed crop dimension. Prevents tiny crops (e.g. a 10px
273
+ watermark) from confusing the model.
274
+
275
+ Returns
276
+ -------
277
+ CropRegion
278
+ Fully resolved crop spec, ready for use by video.py and composite.py.
279
+
280
+ Raises
281
+ ------
282
+ ValueError
283
+ If the watermark bbox is larger than the full frame (sanity check).
284
+ """
285
+ if watermark_bbox.width > frame_w or watermark_bbox.height > frame_h:
286
+ raise ValueError(
287
+ f"Watermark bbox {watermark_bbox} is larger than the frame "
288
+ f"({frame_w}x{frame_h}). Check your mask."
289
+ )
290
+
291
+ # ------------------------------------------------------------------
292
+ # 1. Padded required dimensions
293
+ # ------------------------------------------------------------------
294
+ required_w = max(watermark_bbox.width + 2 * context_px, min_crop_dim)
295
+ required_h = max(watermark_bbox.height + 2 * context_px, min_crop_dim)
296
+
297
+ # Clamp: the crop can never exceed the frame itself
298
+ required_w = min(required_w, frame_w)
299
+ required_h = min(required_h, frame_h)
300
+
301
+ # ------------------------------------------------------------------
302
+ # 2. VACE target resolution
303
+ # ------------------------------------------------------------------
304
+ target_w, target_h = find_target_resolution(required_w, required_h)
305
+
306
+ # Again clamp target to frame size (no point upscaling beyond source)
307
+ target_w = min(target_w, frame_w)
308
+ target_h = min(target_h, frame_h)
309
+
310
+ # Round down to multiple of 32 after clamping
311
+ target_w = _floor_to_multiple(target_w, 32)
312
+ target_h = _floor_to_multiple(target_h, 32)
313
+
314
+ # ------------------------------------------------------------------
315
+ # 3. Centre on watermark centroid, then clamp to frame bounds
316
+ # ------------------------------------------------------------------
317
+ crop_x, crop_y = _centre_crop(
318
+ cx=watermark_bbox.cx,
319
+ cy=watermark_bbox.cy,
320
+ crop_w=target_w,
321
+ crop_h=target_h,
322
+ frame_w=frame_w,
323
+ frame_h=frame_h,
324
+ )
325
+
326
+ # ------------------------------------------------------------------
327
+ # 4. Mask in crop-local coordinates
328
+ # ------------------------------------------------------------------
329
+ local_bbox = BBox(
330
+ x1=watermark_bbox.x1 - crop_x,
331
+ y1=watermark_bbox.y1 - crop_y,
332
+ x2=watermark_bbox.x2 - crop_x,
333
+ y2=watermark_bbox.y2 - crop_y,
334
+ )
335
+
336
+ return CropRegion(
337
+ frame_x=crop_x,
338
+ frame_y=crop_y,
339
+ frame_w=target_w,
340
+ frame_h=target_h,
341
+ target_w=target_w,
342
+ target_h=target_h,
343
+ mask_bbox=local_bbox,
344
+ )
345
+
346
+
347
+ def build_inpaint_mask(
348
+ crop_region: CropRegion,
349
+ source_mask: np.ndarray | None = None,
350
+ dilate_px: int = 6,
351
+ ) -> np.ndarray:
352
+ """
353
+ Build the binary inpainting mask in crop-local coordinates.
354
+
355
+ If ``source_mask`` is provided (a full-frame mask from the user's drawing),
356
+ it is cropped to the crop region. Otherwise, the mask is synthesised from
357
+ ``crop_region.mask_bbox`` (i.e. a filled rectangle).
358
+
359
+ The mask is then dilated by ``dilate_px`` pixels to prevent edge artefacts.
360
+
361
+ Parameters
362
+ ----------
363
+ crop_region : CropRegion
364
+ source_mask : np.ndarray, optional
365
+ Full-frame binary mask (H x W uint8), 255=inpaint. If None, uses bbox.
366
+ dilate_px : int
367
+ Dilation radius in pixels. Default 6.
368
+
369
+ Returns
370
+ -------
371
+ np.ndarray
372
+ Crop-local mask (target_h x target_w, uint8). 255=inpaint, 0=keep.
373
+ """
374
+ h, w = crop_region.frame_h, crop_region.frame_w
375
+ mask = np.zeros((h, w), dtype=np.uint8)
376
+
377
+ if source_mask is not None:
378
+ # Collapse to single channel if needed
379
+ if source_mask.ndim == 3:
380
+ source_mask = source_mask.max(axis=2)
381
+ # Crop to the crop region
382
+ y1 = crop_region.frame_y
383
+ y2 = crop_region.frame_y + crop_region.frame_h
384
+ x1 = crop_region.frame_x
385
+ x2 = crop_region.frame_x + crop_region.frame_w
386
+ mask = source_mask[y1:y2, x1:x2].copy()
387
+ else:
388
+ # Fill the watermark bbox rectangle
389
+ b = crop_region.mask_bbox
390
+ mask[b.y1 : b.y2 + 1, b.x1 : b.x2 + 1] = 255
391
+
392
+ # Dilate
393
+ if dilate_px > 0:
394
+ mask = _dilate_mask(mask, dilate_px)
395
+
396
+ return mask
397
+
398
+
399
+ # ---------------------------------------------------------------------------
400
+ # Private helpers
401
+ # ---------------------------------------------------------------------------
402
+
403
+ def _centre_crop(
404
+ cx: float,
405
+ cy: float,
406
+ crop_w: int,
407
+ crop_h: int,
408
+ frame_w: int,
409
+ frame_h: int,
410
+ ) -> Tuple[int, int]:
411
+ """
412
+ Compute top-left (x, y) of a crop_w x crop_h rectangle centred at
413
+ (cx, cy), then clamp so the rectangle stays within the frame.
414
+
415
+ Clamping shifts asymmetrically β€” the crop size never changes.
416
+ """
417
+ x = int(round(cx - crop_w / 2.0))
418
+ y = int(round(cy - crop_h / 2.0))
419
+
420
+ # Clamp: do not go off any edge
421
+ x = max(0, min(x, frame_w - crop_w))
422
+ y = max(0, min(y, frame_h - crop_h))
423
+
424
+ return x, y
425
+
426
+
427
+ def _ceil_to_multiple(value: int, multiple: int) -> int:
428
+ return math.ceil(value / multiple) * multiple
429
+
430
+
431
+ def _floor_to_multiple(value: int, multiple: int) -> int:
432
+ return (value // multiple) * multiple
433
+
434
+
435
+ def _dilate_mask(mask: np.ndarray, radius: int) -> np.ndarray:
436
+ """
437
+ Dilate a binary mask using a square structuring element of side
438
+ (2*radius + 1). Uses scipy.ndimage for correctness without requiring cv2.
439
+
440
+ For the typical 4-8 px dilation used here this is fast enough.
441
+ """
442
+ from scipy.ndimage import binary_dilation # lazy import
443
+
444
+ struct = np.ones((2 * radius + 1, 2 * radius + 1), dtype=bool)
445
+ binary = mask > 0
446
+ dilated = binary_dilation(binary, structure=struct)
447
+ return (dilated * 255).astype(np.uint8)
pipeline/lama.py ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ pipeline/lama.py
3
+ ----------------
4
+ Fast mode: per-frame LaMa inpainting.
5
+
6
+ Uses the `simple-lama-inpainting` PyPI package, which wraps the
7
+ Resolution-robust Large Mask inpainting (LaMa) model. Runs on CPU
8
+ or GPU without requiring a @spaces.GPU call β€” negligible quota burn.
9
+
10
+ Pipeline for each frame:
11
+ 1. Crop to the CropRegion.
12
+ 2. Run LaMa on the crop with the dilated inpaint mask.
13
+ 3. Return the inpainted crop; compositing is handled by composite.py.
14
+
15
+ License: LaMa is Apache 2.0.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from pathlib import Path
21
+ from typing import List
22
+
23
+ import numpy as np
24
+ from PIL import Image
25
+
26
+ from pipeline.crop import CropRegion
27
+
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # Model singleton
31
+ # ---------------------------------------------------------------------------
32
+ # Loaded lazily on first call; shared across all frames in a run.
33
+ # Tracks the device it was loaded on β€” reloads if GPU becomes available
34
+ # after a cold CPU-only initialisation (ZeroGPU warm/cold start handling).
35
+ _lama_model = None
36
+ _lama_device: str | None = None
37
+
38
+
39
+ def _get_model():
40
+ global _lama_model, _lama_device
41
+ import torch
42
+ current_device = "cuda" if torch.cuda.is_available() else "cpu"
43
+ # One-way latch: only reload when *upgrading* from CPU to GPU.
44
+ # After the @spaces.GPU call ends the process returns to CPU, but we keep
45
+ # the model reference β€” it will be valid again on the next GPU allocation.
46
+ # Reloading on every cpu→cuda transition (once per cold start) is correct;
47
+ # reloading on cuda→cpu would double the startup cost for no benefit.
48
+ if _lama_model is None or (current_device == "cuda" and _lama_device != "cuda"):
49
+ from simple_lama_inpainting import SimpleLama # type: ignore
50
+ _lama_model = SimpleLama()
51
+ _lama_device = current_device
52
+ return _lama_model
53
+
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # Public API
57
+ # ---------------------------------------------------------------------------
58
+
59
+ def inpaint_frames_lama(
60
+ frame_paths: List[Path],
61
+ crop_region: CropRegion,
62
+ inpaint_mask: np.ndarray,
63
+ progress_fn=None,
64
+ ) -> List[np.ndarray]:
65
+ """
66
+ Run LaMa inpainting on the crop region of each frame.
67
+
68
+ Parameters
69
+ ----------
70
+ frame_paths : List[Path]
71
+ Ordered list of full-frame PNG paths.
72
+ crop_region : CropRegion
73
+ Defines the rectangle to extract from each frame.
74
+ inpaint_mask : np.ndarray
75
+ Crop-local binary mask (H x W, uint8). 255=inpaint, 0=keep.
76
+ Must match crop_region dimensions.
77
+ progress_fn : callable, optional
78
+ Called as ``progress_fn(i)`` after each frame where i is the
79
+ 0-based frame index. Use for Gradio progress reporting.
80
+
81
+ Returns
82
+ -------
83
+ List[np.ndarray]
84
+ List of inpainted crop images (H x W x 3, uint8 RGB),
85
+ one per input frame. Full-frame compositing is done in composite.py.
86
+ """
87
+ model = _get_model()
88
+ # Create mask PIL image once β€” it is identical for every frame
89
+ mask_pil = _mask_to_pil(inpaint_mask)
90
+ results: List[np.ndarray] = []
91
+
92
+ for i, frame_path in enumerate(frame_paths):
93
+ crop_np = _load_crop(frame_path, crop_region)
94
+ crop_pil = Image.fromarray(crop_np)
95
+
96
+ # simple-lama-inpainting expects (image: PIL.Image, mask: PIL.Image)
97
+ # mask must be mode "L": 255=inpaint, 0=keep
98
+ inpainted_pil: Image.Image = model(crop_pil, mask_pil)
99
+ results.append(np.array(inpainted_pil.convert("RGB")))
100
+
101
+ if progress_fn is not None:
102
+ progress_fn(i)
103
+
104
+ return results
105
+
106
+
107
+ def inpaint_image_lama(
108
+ image: np.ndarray,
109
+ crop_region: CropRegion,
110
+ inpaint_mask: np.ndarray,
111
+ ) -> np.ndarray:
112
+ """
113
+ Run LaMa on a single already-loaded image array (H x W x 3 uint8 RGB).
114
+ Convenience wrapper used by the preview step in app.py.
115
+
116
+ Returns the inpainted crop (crop_region dimensions, RGB uint8).
117
+ """
118
+ model = _get_model()
119
+ mask_pil = _mask_to_pil(inpaint_mask)
120
+
121
+ cr = crop_region
122
+ crop_np = image[
123
+ cr.frame_y : cr.frame_y + cr.frame_h,
124
+ cr.frame_x : cr.frame_x + cr.frame_w,
125
+ ]
126
+ inpainted_pil: Image.Image = model(Image.fromarray(crop_np), mask_pil)
127
+ return np.array(inpainted_pil.convert("RGB"))
128
+
129
+
130
+ # ---------------------------------------------------------------------------
131
+ # Private helpers
132
+ # ---------------------------------------------------------------------------
133
+
134
+ def _load_crop(frame_path: Path, crop_region: CropRegion) -> np.ndarray:
135
+ """Load a frame and return only the crop region (RGB uint8)."""
136
+ img = Image.open(frame_path).convert("RGB")
137
+ cr = crop_region
138
+ # PIL box is (left, upper, right, lower)
139
+ box = (
140
+ cr.frame_x,
141
+ cr.frame_y,
142
+ cr.frame_x + cr.frame_w,
143
+ cr.frame_y + cr.frame_h,
144
+ )
145
+ return np.array(img.crop(box))
146
+
147
+
148
+ def _mask_to_pil(mask: np.ndarray) -> Image.Image:
149
+ """Convert a uint8 numpy mask to a PIL L-mode image for LaMa."""
150
+ return Image.fromarray(mask, mode="L")
pipeline/vace.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ pipeline/vace.py
3
+ ----------------
4
+ Quality mode: VACE-14B video inpainting via Wan2.1-VACE-14B-diffusers.
5
+
6
+ STUB β€” implemented in the next iteration after pipeline validation with LaMa.
7
+
8
+ Planned implementation:
9
+ - WanVACEPipeline from diffusers
10
+ - FP8 quantization via torchao
11
+ - AoT compilation for speed
12
+ - 8-step inference (step-distilled schedule)
13
+ - Temporal chunking: split frame list into ~33-frame windows, run each
14
+ chunk with overlapping context frames to avoid seam artefacts
15
+ - Memory: model.enable_model_cpu_offload() + torch.cuda.empty_cache()
16
+ between chunks
17
+ - @spaces.GPU(duration=200) decorator on the main entry function
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from pathlib import Path
23
+ from typing import List
24
+
25
+ import numpy as np
26
+
27
+ from pipeline.crop import CropRegion
28
+
29
+
30
+ def inpaint_frames_vace(
31
+ frame_paths: List[Path],
32
+ crop_region: CropRegion,
33
+ inpaint_mask: np.ndarray,
34
+ num_inference_steps: int = 8,
35
+ guidance_scale: float = 5.0,
36
+ ) -> List[np.ndarray]:
37
+ """
38
+ Run VACE-14B inpainting on the crop region of each frame.
39
+
40
+ Parameters
41
+ ----------
42
+ frame_paths : List[Path]
43
+ Ordered full-frame PNG paths.
44
+ crop_region : CropRegion
45
+ inpaint_mask : np.ndarray
46
+ Crop-local binary mask (H x W, uint8). 255=inpaint.
47
+ num_inference_steps : int
48
+ Default 8 for step-distilled fast inference.
49
+ guidance_scale : float
50
+
51
+ Returns
52
+ -------
53
+ List[np.ndarray]
54
+ Inpainted crop arrays (H x W x 3, uint8 RGB), one per frame.
55
+ """
56
+ raise NotImplementedError(
57
+ "VACE-14B pipeline is not yet implemented. "
58
+ "Use Fast (LaMa) mode for now."
59
+ )
pipeline/video.py ADDED
@@ -0,0 +1,445 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ pipeline/video.py
3
+ -----------------
4
+ FFmpeg I/O wrappers for the watermark removal pipeline.
5
+
6
+ Responsibilities:
7
+ - Extract frames from input video to a temp directory (PNG, lossless).
8
+ - Probe frame rate, resolution, and color metadata from source.
9
+ - Reassemble inpainted frames into a no-audio video.
10
+ - Reattach the original audio stream (simple copy remux).
11
+ - Passthrough V-Log / HLG / HDR color metadata flags.
12
+
13
+ All FFmpeg calls raise RuntimeError on non-zero exit so callers can surface
14
+ clean error messages to the Gradio UI.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import os
21
+ import shutil
22
+ import subprocess
23
+ import tempfile
24
+ from dataclasses import dataclass
25
+ from pathlib import Path
26
+ from typing import List, Optional
27
+
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # Video metadata
31
+ # ---------------------------------------------------------------------------
32
+
33
+ @dataclass
34
+ class VideoMeta:
35
+ """Probed properties of the input video."""
36
+ width: int
37
+ height: int
38
+ fps: float
39
+ frame_count: int
40
+ duration_s: float
41
+ # FFmpeg color metadata strings (may be None if not present)
42
+ color_primaries: Optional[str]
43
+ color_trc: Optional[str] # transfer characteristics (e.g. "arib-std-b67" for HLG, "bt709")
44
+ colorspace: Optional[str] # YCbCr matrix (e.g. "bt2020nc")
45
+ color_range: Optional[str] # "tv" or "pc"
46
+ # Raw codec name, e.g. "h264"
47
+ codec_name: str
48
+ # Bit depth per channel. 8 = standard SDR; 10 = V-Log / HLG / HDR10.
49
+ # Default 8 so existing callers that omit this field keep working.
50
+ bit_depth: int = 8
51
+
52
+
53
+ def probe(video_path: str | Path) -> VideoMeta:
54
+ """
55
+ Run ffprobe on *video_path* and return a VideoMeta.
56
+
57
+ Parameters
58
+ ----------
59
+ video_path : str | Path
60
+
61
+ Returns
62
+ -------
63
+ VideoMeta
64
+
65
+ Raises
66
+ ------
67
+ RuntimeError
68
+ If ffprobe fails or the file has no video stream.
69
+ """
70
+ cmd = [
71
+ "ffprobe",
72
+ "-v", "quiet",
73
+ "-print_format", "json",
74
+ "-show_streams",
75
+ "-show_format",
76
+ str(video_path),
77
+ ]
78
+ result = _run(cmd)
79
+ data = json.loads(result.stdout)
80
+
81
+ # Find the first video stream
82
+ video_stream = next(
83
+ (s for s in data.get("streams", []) if s.get("codec_type") == "video"),
84
+ None,
85
+ )
86
+ if video_stream is None:
87
+ raise RuntimeError(f"No video stream found in {video_path}")
88
+
89
+ # Parse frame rate (stored as "num/den" string)
90
+ fps = _parse_rational(video_stream.get("r_frame_rate", "30/1"))
91
+
92
+ # Duration: prefer stream-level, fall back to format-level
93
+ dur_str = video_stream.get("duration") or data.get("format", {}).get("duration", "0")
94
+ duration_s = float(dur_str)
95
+
96
+ # Frame count
97
+ nb_frames = video_stream.get("nb_frames")
98
+ if nb_frames and nb_frames != "N/A":
99
+ frame_count = int(nb_frames)
100
+ else:
101
+ frame_count = max(1, round(duration_s * fps))
102
+
103
+ # Bit depth: prefer bits_per_raw_sample; fall back to pixel format name.
104
+ # bits_per_raw_sample is "0" or missing for most H.264 sources.
105
+ bits_raw = video_stream.get("bits_per_raw_sample") or "0"
106
+ pix_fmt_val = video_stream.get("pix_fmt", "")
107
+
108
+ def _bd_from_pix_fmt() -> int:
109
+ return 10 if any(d in pix_fmt_val for d in ("10le", "10be", "12le", "12be")) else 8
110
+
111
+ try:
112
+ bd = int(bits_raw)
113
+ bit_depth = bd if bd > 0 else _bd_from_pix_fmt()
114
+ except (ValueError, TypeError):
115
+ bit_depth = _bd_from_pix_fmt()
116
+
117
+ return VideoMeta(
118
+ width=int(video_stream["width"]),
119
+ height=int(video_stream["height"]),
120
+ fps=fps,
121
+ frame_count=frame_count,
122
+ duration_s=duration_s,
123
+ color_primaries=video_stream.get("color_primaries"),
124
+ color_trc=video_stream.get("color_transfer"),
125
+ colorspace=video_stream.get("color_space"),
126
+ color_range=video_stream.get("color_range"),
127
+ codec_name=video_stream.get("codec_name", "unknown"),
128
+ bit_depth=bit_depth,
129
+ )
130
+
131
+
132
+ # ---------------------------------------------------------------------------
133
+ # Frame extraction
134
+ # ---------------------------------------------------------------------------
135
+
136
+ def extract_frames(
137
+ video_path: str | Path,
138
+ out_dir: str | Path,
139
+ pattern: str = "%06d.png",
140
+ fps: float | None = None,
141
+ ) -> List[Path]:
142
+ """
143
+ Extract every frame from *video_path* as PNG images into *out_dir*.
144
+
145
+ Uses lossless PNG so that frame quality is preserved for inpainting.
146
+ When *fps* is provided, a ``-vf fps=<n>`` filter forces constant frame rate
147
+ output, which is required for correct audio sync on VFR sources.
148
+
149
+ Parameters
150
+ ----------
151
+ video_path : str | Path
152
+ Source video file.
153
+ out_dir : str | Path
154
+ Directory to write frames into. Created if it does not exist.
155
+ pattern : str
156
+ Output filename pattern, e.g. "%06d.png".
157
+ fps : float, optional
158
+ If given, force constant-rate output at this rate. Pass
159
+ ``VideoMeta.fps`` to guarantee A/V sync on reassembly.
160
+
161
+ Returns
162
+ -------
163
+ List[Path]
164
+ Sorted list of extracted frame paths.
165
+ """
166
+ out_dir = Path(out_dir)
167
+ out_dir.mkdir(parents=True, exist_ok=True)
168
+
169
+ cmd = [
170
+ "ffmpeg",
171
+ "-y",
172
+ "-i", str(video_path),
173
+ ]
174
+ if fps is not None:
175
+ # Force constant frame rate; handles VFR sources without re-encoding
176
+ # by inserting duplicate frames where the source skips.
177
+ cmd += ["-vf", f"fps={_fps_str(fps)}"]
178
+ else:
179
+ cmd += ["-vsync", "0"] # passthrough for CFR sources (no dup/drop)
180
+
181
+ cmd.append(str(out_dir / pattern))
182
+ _run(cmd)
183
+
184
+ frames = sorted(out_dir.glob("*.png"), key=lambda p: int(p.stem))
185
+ if not frames:
186
+ raise RuntimeError(f"No frames extracted from {video_path} into {out_dir}")
187
+ return frames
188
+
189
+
190
+ def extract_first_frame(video_path: str | Path, out_path: str | Path) -> Path:
191
+ """
192
+ Extract only the first frame, e.g. for mask drawing in the UI.
193
+
194
+ Parameters
195
+ ----------
196
+ video_path : str | Path
197
+ out_path : str | Path
198
+ Path to write the PNG (parent directory must exist).
199
+
200
+ Returns
201
+ -------
202
+ Path
203
+ Same as out_path, now guaranteed to exist.
204
+ """
205
+ out_path = Path(out_path)
206
+ out_path.parent.mkdir(parents=True, exist_ok=True)
207
+
208
+ cmd = [
209
+ "ffmpeg",
210
+ "-y",
211
+ "-i", str(video_path),
212
+ "-frames:v", "1",
213
+ "-update", "1",
214
+ str(out_path),
215
+ ]
216
+ _run(cmd)
217
+
218
+ if not out_path.exists():
219
+ raise RuntimeError(f"First frame extraction failed for {video_path}")
220
+ return out_path
221
+
222
+
223
+ # ---------------------------------------------------------------------------
224
+ # Frame reassembly
225
+ # ---------------------------------------------------------------------------
226
+
227
+ def frames_to_video(
228
+ frames_dir: str | Path,
229
+ out_path: str | Path,
230
+ meta: VideoMeta,
231
+ pattern: str = "%06d.png",
232
+ crf: int = 16,
233
+ ) -> Path:
234
+ """
235
+ Reassemble a directory of inpainted PNG frames into an H.264 (8-bit) or
236
+ H.265 (10-bit) video, passing through V-Log / HDR colour metadata.
237
+
238
+ No audio is embedded here β€” use :func:`attach_audio` afterwards.
239
+
240
+ Parameters
241
+ ----------
242
+ frames_dir : str | Path
243
+ Directory containing sequentially named PNG frames.
244
+ out_path : str | Path
245
+ Output .mp4 path.
246
+ meta : VideoMeta
247
+ Source video metadata (for fps and color flags).
248
+ pattern : str
249
+ Input filename pattern matching the PNGs.
250
+ crf : int
251
+ H.264 CRF quality level. 16 = near-lossless; increase for smaller files.
252
+
253
+ Returns
254
+ -------
255
+ Path
256
+ Path to the written output video.
257
+ """
258
+ out_path = Path(out_path)
259
+ out_path.parent.mkdir(parents=True, exist_ok=True)
260
+
261
+ # Build color metadata flags β€” pass through whatever the source had
262
+ color_flags: list[str] = []
263
+ if meta.color_primaries and meta.color_primaries != "unknown":
264
+ color_flags += ["-color_primaries", meta.color_primaries]
265
+ if meta.color_trc and meta.color_trc != "unknown":
266
+ color_flags += ["-color_trc", meta.color_trc]
267
+ if meta.colorspace and meta.colorspace != "unknown":
268
+ color_flags += ["-colorspace", meta.colorspace]
269
+ if meta.color_range and meta.color_range != "unknown":
270
+ color_flags += ["-color_range", meta.color_range]
271
+
272
+ # Choose codec + pixel format based on source bit depth.
273
+ # libx264 is 8-bit only β€” using it on 10-bit V-Log footage silently
274
+ # quantises every pixel value, destroying the HDR tonal range.
275
+ if meta.bit_depth >= 10:
276
+ vid_codec, pix_fmt = "libx265", "yuv420p10le"
277
+ # hvc1 tag makes HEVC readable by QuickTime / Safari / iOS.
278
+ # Only valid for MP4/MOV containers β€” skip for MKV etc.
279
+ mp4_like = Path(str(out_path)).suffix.lower() in (".mp4", ".mov")
280
+ extra_codec_flags: list[str] = ["-tag:v", "hvc1"] if mp4_like else []
281
+ else:
282
+ vid_codec, pix_fmt = "libx264", "yuv420p"
283
+ extra_codec_flags = []
284
+
285
+ cmd = [
286
+ "ffmpeg",
287
+ "-y",
288
+ "-framerate", _fps_str(meta.fps),
289
+ "-i", str(Path(frames_dir) / pattern),
290
+ "-c:v", vid_codec,
291
+ "-preset", "slow",
292
+ "-crf", str(crf),
293
+ "-pix_fmt", pix_fmt,
294
+ *extra_codec_flags,
295
+ *color_flags,
296
+ str(out_path),
297
+ ]
298
+ _run(cmd)
299
+ return out_path
300
+
301
+
302
+ def attach_audio(
303
+ source_video: str | Path,
304
+ silent_video: str | Path,
305
+ out_path: str | Path,
306
+ ) -> Path:
307
+ """
308
+ Remux audio from *source_video* into *silent_video* (copy both streams,
309
+ no re-encode).
310
+
311
+ If the source has no audio stream the silent video is simply copied
312
+ to *out_path* unchanged.
313
+
314
+ Parameters
315
+ ----------
316
+ source_video : str | Path
317
+ Original video (audio source).
318
+ silent_video : str | Path
319
+ Inpainted video without audio.
320
+ out_path : str | Path
321
+ Final output path.
322
+
323
+ Returns
324
+ -------
325
+ Path
326
+ """
327
+ out_path = Path(out_path)
328
+ out_path.parent.mkdir(parents=True, exist_ok=True)
329
+
330
+ if not _has_audio(source_video):
331
+ shutil.copy2(str(silent_video), str(out_path))
332
+ return out_path
333
+
334
+ cmd = [
335
+ "ffmpeg",
336
+ "-y",
337
+ "-i", str(silent_video), # stream 0: video
338
+ "-i", str(source_video), # stream 1: audio donor
339
+ "-c", "copy",
340
+ "-map", "0:v:0",
341
+ "-map", "1:a:0",
342
+ "-shortest", # trim to shorter stream (video)
343
+ str(out_path),
344
+ ]
345
+ _run(cmd)
346
+ return out_path
347
+
348
+
349
+ # ---------------------------------------------------------------------------
350
+ # Convenience: managed temp workspace
351
+ # ---------------------------------------------------------------------------
352
+
353
+ class VideoWorkspace:
354
+ """
355
+ Context manager that creates a temporary working directory and cleans it up.
356
+
357
+ Usage::
358
+
359
+ with VideoWorkspace() as ws:
360
+ frames = extract_frames(video_path, ws.frames_dir)
361
+ ...
362
+ out = ws.path("output_silent.mp4")
363
+ """
364
+
365
+ def __init__(self, prefix: str = "wm_remove_") -> None:
366
+ self._prefix = prefix
367
+ self._tmpdir: Optional[tempfile.TemporaryDirectory] = None
368
+
369
+ def __enter__(self) -> "VideoWorkspace":
370
+ self._tmpdir = tempfile.TemporaryDirectory(prefix=self._prefix)
371
+ self.root = Path(self._tmpdir.name)
372
+ self.frames_dir = self.root / "frames"
373
+ self.out_frames_dir = self.root / "out_frames"
374
+ self.frames_dir.mkdir()
375
+ self.out_frames_dir.mkdir()
376
+ return self
377
+
378
+ def __exit__(self, *args) -> None:
379
+ if self._tmpdir:
380
+ self._tmpdir.cleanup()
381
+
382
+ def path(self, name: str) -> Path:
383
+ """Return a path inside the workspace root."""
384
+ return self.root / name
385
+
386
+
387
+ # ---------------------------------------------------------------------------
388
+ # Private helpers
389
+ # ---------------------------------------------------------------------------
390
+
391
+ def _run(cmd: list[str]) -> subprocess.CompletedProcess:
392
+ """Run a subprocess, raising RuntimeError on failure."""
393
+ result = subprocess.run(
394
+ cmd,
395
+ capture_output=True,
396
+ text=True,
397
+ )
398
+ if result.returncode != 0:
399
+ raise RuntimeError(
400
+ f"FFmpeg/ffprobe command failed (exit {result.returncode}):\n"
401
+ f"cmd: {' '.join(cmd)}\n"
402
+ f"stderr: {result.stderr[-2000:]}" # last 2k chars
403
+ )
404
+ return result
405
+
406
+
407
+ def _parse_rational(rat: str) -> float:
408
+ """Parse a 'num/den' rational string to float."""
409
+ parts = rat.split("/")
410
+ if len(parts) == 2:
411
+ num, den = int(parts[0]), int(parts[1])
412
+ return num / den if den else 0.0
413
+ return float(rat)
414
+
415
+
416
+ def _fps_str(fps: float) -> str:
417
+ """Convert fps float to a clean string for FFmpeg -framerate."""
418
+ # Keep common exact fractions (24000/1001, 30000/1001, etc.)
419
+ common = {
420
+ 23.976: "24000/1001",
421
+ 29.97: "30000/1001",
422
+ 59.94: "60000/1001",
423
+ }
424
+ for approx, s in common.items():
425
+ if abs(fps - approx) < 0.01:
426
+ return s
427
+ return f"{fps:.6g}"
428
+
429
+
430
+ def _has_audio(video_path: str | Path) -> bool:
431
+ """Return True if the video contains at least one audio stream."""
432
+ cmd = [
433
+ "ffprobe",
434
+ "-v", "quiet",
435
+ "-print_format", "json",
436
+ "-show_streams",
437
+ "-select_streams", "a",
438
+ str(video_path),
439
+ ]
440
+ try:
441
+ result = _run(cmd)
442
+ data = json.loads(result.stdout)
443
+ return bool(data.get("streams"))
444
+ except RuntimeError:
445
+ return False
requirements.txt ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Runtime dependencies for the Hugging Face Space
2
+ # Pinned ranges are intentionally loose so HF's resolver can pick compatible versions.
3
+
4
+ # ── Core ──────────────────────────────────────────────────────────────────
5
+ gradio>=4.44.0,<5.0.0
6
+ numpy>=1.24.0
7
+ Pillow>=10.0.0
8
+ scipy>=1.11.0 # mask dilation in pipeline/crop.py
9
+
10
+ # ── Fast mode (LaMa) ──────────────────────────────────────────────────────
11
+ simple-lama-inpainting>=0.1.2
12
+
13
+ # ── Quality mode (VACE-14B) ───────────────────────────────────────────────
14
+ # torch / torchvision are pre-installed on ZeroGPU; do not pin here.
15
+ diffusers>=0.32.0
16
+ transformers>=4.44.0
17
+ accelerate>=0.33.0
18
+ sentencepiece>=0.1.99
19
+ # torchao β€” FP8 quantization (uncomment when implementing vace.py)
20
+ # torchao>=0.6.0
21
+
22
+ # ── Video I/O ─────────────────────────────────────────────────────────────
23
+ # ffmpeg binary is provided via packages.txt; no Python wrapper needed.