refactor: round 3 — stdout-pipe first-frame extract, _card_title, _msg helpers
Browse filespipeline/video.py
- Replace extract_first_frame (file-based) with extract_first_frame_array
that streams the PNG through ffmpeg's stdout pipe and returns an RGB
ndarray directly. Eliminates the mkstemp/open/unlink dance the only
caller (on_video_upload) had to perform — and removes a tempfile
artifact from /tmp on every upload.
app.py
- _card_title(text, step?, top_margin?) helper replaces 6 hand-built
'<div class="card-title">…</div>' gr.HTML calls in the UI block.
- on_video_upload: nested _msg(text) helper builds the (editor, crop,
state, status) 4-tuple for early-error returns. Drops the repetitive
``return gr.update(), gr.update(), None, "…"`` pattern at every guard.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- app.py +28 -30
- pipeline/video.py +19 -23
|
@@ -45,7 +45,7 @@ from pipeline.crop import (
|
|
| 45 |
)
|
| 46 |
from pipeline.video import (
|
| 47 |
VideoMeta, VideoWorkspace,
|
| 48 |
-
attach_audio,
|
| 49 |
)
|
| 50 |
from pipeline.vace import prewarm_vace_cache
|
| 51 |
|
|
@@ -198,8 +198,12 @@ def _meta_from_state(d: dict) -> VideoMeta:
|
|
| 198 |
|
| 199 |
def on_video_upload(video_path: str | None):
|
| 200 |
"""Extract first frame and populate the ImageEditor."""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
if not video_path:
|
| 202 |
-
return
|
| 203 |
|
| 204 |
try:
|
| 205 |
meta = probe(video_path)
|
|
@@ -211,37 +215,24 @@ def on_video_upload(video_path: str | None):
|
|
| 211 |
# arbitrarily long clip through.
|
| 212 |
max_frames = round(UPLOAD_DURATION_S * max(meta.fps, 1.0))
|
| 213 |
if meta.duration_s > UPLOAD_DURATION_S:
|
| 214 |
-
return (
|
| 215 |
-
gr.update(), gr.update(), None,
|
| 216 |
f"❌ Clip too long ({meta.duration_s:.1f}s). "
|
| 217 |
f"Max {UPLOAD_DURATION_S:.0f}s; only the first "
|
| 218 |
-
f"{PROCESS_DURATION_S:.0f}s would be processed anyway."
|
| 219 |
)
|
| 220 |
if meta.frame_count > max_frames:
|
| 221 |
-
return (
|
| 222 |
-
|
| 223 |
-
f"
|
| 224 |
-
f"Max {UPLOAD_DURATION_S:.0f} seconds.",
|
| 225 |
)
|
| 226 |
if meta.width * meta.height > MAX_UPLOAD_W * MAX_UPLOAD_H:
|
| 227 |
-
return (
|
| 228 |
-
gr.update(), gr.update(), None,
|
| 229 |
f"❌ Resolution too high ({meta.width}×{meta.height}). "
|
| 230 |
-
f"Max {MAX_UPLOAD_W}×{MAX_UPLOAD_H}."
|
| 231 |
)
|
| 232 |
will_trim = meta.duration_s > PROCESS_DURATION_S
|
| 233 |
|
| 234 |
-
|
| 235 |
-
fd, tmp_path = tempfile.mkstemp(suffix=".png", prefix="wm_frame_")
|
| 236 |
-
os.close(fd)
|
| 237 |
-
try:
|
| 238 |
-
extract_first_frame(video_path, tmp_path)
|
| 239 |
-
first_frame = np.array(Image.open(tmp_path).convert("RGB"))
|
| 240 |
-
finally:
|
| 241 |
-
try:
|
| 242 |
-
os.unlink(tmp_path)
|
| 243 |
-
except OSError:
|
| 244 |
-
pass
|
| 245 |
|
| 246 |
meta_str = (
|
| 247 |
f"{meta.width}×{meta.height} · {meta.fps:.3g} fps · "
|
|
@@ -268,7 +259,7 @@ def on_video_upload(video_path: str | None):
|
|
| 268 |
f"\n\nNow draw over the watermark with the brush tool.",
|
| 269 |
)
|
| 270 |
except Exception as e:
|
| 271 |
-
return
|
| 272 |
|
| 273 |
|
| 274 |
def on_preview_crop(editor_value: dict | None, meta_state: dict | None, context_px: int):
|
|
@@ -553,6 +544,13 @@ def run_pipeline(
|
|
| 553 |
# UI
|
| 554 |
# ---------------------------------------------------------------------------
|
| 555 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 556 |
with gr.Blocks(title="Video Watermark Remover", css=CSS) as demo:
|
| 557 |
|
| 558 |
# State
|
|
@@ -569,7 +567,7 @@ with gr.Blocks(title="Video Watermark Remover", css=CSS) as demo:
|
|
| 569 |
# ── Step 1 + 2 side by side ─────────────────────────────────────────────
|
| 570 |
with gr.Row(equal_height=False):
|
| 571 |
with gr.Column(scale=1):
|
| 572 |
-
|
| 573 |
video_input = gr.Video(
|
| 574 |
label=(
|
| 575 |
f"Source clip (up to {UPLOAD_DURATION_S:.0f}s, "
|
|
@@ -579,7 +577,7 @@ with gr.Blocks(title="Video Watermark Remover", css=CSS) as demo:
|
|
| 579 |
elem_id="video-input",
|
| 580 |
)
|
| 581 |
|
| 582 |
-
|
| 583 |
mode_radio = gr.Radio(
|
| 584 |
choices=list(ALL_MODES),
|
| 585 |
value=MODE_FAST,
|
|
@@ -587,7 +585,7 @@ with gr.Blocks(title="Video Watermark Remover", css=CSS) as demo:
|
|
| 587 |
elem_classes=["mode-radio"],
|
| 588 |
)
|
| 589 |
|
| 590 |
-
|
| 591 |
context_slider = gr.Slider(
|
| 592 |
minimum=32,
|
| 593 |
maximum=192,
|
|
@@ -598,7 +596,7 @@ with gr.Blocks(title="Video Watermark Remover", css=CSS) as demo:
|
|
| 598 |
)
|
| 599 |
|
| 600 |
with gr.Column(scale=2):
|
| 601 |
-
|
| 602 |
editor = gr.ImageEditor(
|
| 603 |
label="Paint over the watermark (brush tool)",
|
| 604 |
type="numpy",
|
|
@@ -639,14 +637,14 @@ with gr.Blocks(title="Video Watermark Remover", css=CSS) as demo:
|
|
| 639 |
# ── Outputs ──────────────────────────────────────────────────────────────
|
| 640 |
with gr.Row():
|
| 641 |
with gr.Column():
|
| 642 |
-
|
| 643 |
crop_preview = gr.Image(
|
| 644 |
label="",
|
| 645 |
type="numpy",
|
| 646 |
show_label=False,
|
| 647 |
)
|
| 648 |
with gr.Column():
|
| 649 |
-
|
| 650 |
video_output = gr.Video(
|
| 651 |
label="",
|
| 652 |
show_label=False,
|
|
|
|
| 45 |
)
|
| 46 |
from pipeline.video import (
|
| 47 |
VideoMeta, VideoWorkspace,
|
| 48 |
+
attach_audio, extract_first_frame_array, extract_frames, frames_to_video, probe,
|
| 49 |
)
|
| 50 |
from pipeline.vace import prewarm_vace_cache
|
| 51 |
|
|
|
|
| 198 |
|
| 199 |
def on_video_upload(video_path: str | None):
|
| 200 |
"""Extract first frame and populate the ImageEditor."""
|
| 201 |
+
# Tuple shape: (editor_update, crop_preview_update, meta_state, status).
|
| 202 |
+
def _msg(text: str):
|
| 203 |
+
return gr.update(), gr.update(), None, text
|
| 204 |
+
|
| 205 |
if not video_path:
|
| 206 |
+
return _msg("Upload a video to begin.")
|
| 207 |
|
| 208 |
try:
|
| 209 |
meta = probe(video_path)
|
|
|
|
| 215 |
# arbitrarily long clip through.
|
| 216 |
max_frames = round(UPLOAD_DURATION_S * max(meta.fps, 1.0))
|
| 217 |
if meta.duration_s > UPLOAD_DURATION_S:
|
| 218 |
+
return _msg(
|
|
|
|
| 219 |
f"❌ Clip too long ({meta.duration_s:.1f}s). "
|
| 220 |
f"Max {UPLOAD_DURATION_S:.0f}s; only the first "
|
| 221 |
+
f"{PROCESS_DURATION_S:.0f}s would be processed anyway."
|
| 222 |
)
|
| 223 |
if meta.frame_count > max_frames:
|
| 224 |
+
return _msg(
|
| 225 |
+
f"❌ Clip too long ({meta.frame_count} frames at "
|
| 226 |
+
f"{meta.fps:.2f} fps). Max {UPLOAD_DURATION_S:.0f} seconds."
|
|
|
|
| 227 |
)
|
| 228 |
if meta.width * meta.height > MAX_UPLOAD_W * MAX_UPLOAD_H:
|
| 229 |
+
return _msg(
|
|
|
|
| 230 |
f"❌ Resolution too high ({meta.width}×{meta.height}). "
|
| 231 |
+
f"Max {MAX_UPLOAD_W}×{MAX_UPLOAD_H}."
|
| 232 |
)
|
| 233 |
will_trim = meta.duration_s > PROCESS_DURATION_S
|
| 234 |
|
| 235 |
+
first_frame = extract_first_frame_array(video_path)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 236 |
|
| 237 |
meta_str = (
|
| 238 |
f"{meta.width}×{meta.height} · {meta.fps:.3g} fps · "
|
|
|
|
| 259 |
f"\n\nNow draw over the watermark with the brush tool.",
|
| 260 |
)
|
| 261 |
except Exception as e:
|
| 262 |
+
return _msg(f"❌ Error: {e}")
|
| 263 |
|
| 264 |
|
| 265 |
def on_preview_crop(editor_value: dict | None, meta_state: dict | None, context_px: int):
|
|
|
|
| 544 |
# UI
|
| 545 |
# ---------------------------------------------------------------------------
|
| 546 |
|
| 547 |
+
def _card_title(text: str, step: int | None = None, top_margin: bool = False) -> gr.HTML:
|
| 548 |
+
"""Render a card heading. ``step`` adds the numbered badge prefix."""
|
| 549 |
+
margin = ' style="margin-top:16px"' if top_margin else ""
|
| 550 |
+
badge = f'<span class="step-badge">{step}</span>' if step is not None else ""
|
| 551 |
+
return gr.HTML(f'<div class="card-title"{margin}>{badge}{text}</div>')
|
| 552 |
+
|
| 553 |
+
|
| 554 |
with gr.Blocks(title="Video Watermark Remover", css=CSS) as demo:
|
| 555 |
|
| 556 |
# State
|
|
|
|
| 567 |
# ── Step 1 + 2 side by side ─────────────────────────────────────────────
|
| 568 |
with gr.Row(equal_height=False):
|
| 569 |
with gr.Column(scale=1):
|
| 570 |
+
_card_title("Upload Video", step=1)
|
| 571 |
video_input = gr.Video(
|
| 572 |
label=(
|
| 573 |
f"Source clip (up to {UPLOAD_DURATION_S:.0f}s, "
|
|
|
|
| 577 |
elem_id="video-input",
|
| 578 |
)
|
| 579 |
|
| 580 |
+
_card_title("Mode", step=2, top_margin=True)
|
| 581 |
mode_radio = gr.Radio(
|
| 582 |
choices=list(ALL_MODES),
|
| 583 |
value=MODE_FAST,
|
|
|
|
| 585 |
elem_classes=["mode-radio"],
|
| 586 |
)
|
| 587 |
|
| 588 |
+
_card_title("⚙️ Advanced", top_margin=True)
|
| 589 |
context_slider = gr.Slider(
|
| 590 |
minimum=32,
|
| 591 |
maximum=192,
|
|
|
|
| 596 |
)
|
| 597 |
|
| 598 |
with gr.Column(scale=2):
|
| 599 |
+
_card_title("Draw Over the Watermark", step=3)
|
| 600 |
editor = gr.ImageEditor(
|
| 601 |
label="Paint over the watermark (brush tool)",
|
| 602 |
type="numpy",
|
|
|
|
| 637 |
# ── Outputs ──────────────────────────────────────────────────────────────
|
| 638 |
with gr.Row():
|
| 639 |
with gr.Column():
|
| 640 |
+
_card_title("Crop Preview")
|
| 641 |
crop_preview = gr.Image(
|
| 642 |
label="",
|
| 643 |
type="numpy",
|
| 644 |
show_label=False,
|
| 645 |
)
|
| 646 |
with gr.Column():
|
| 647 |
+
_card_title("Output Video")
|
| 648 |
video_output = gr.Video(
|
| 649 |
label="",
|
| 650 |
show_label=False,
|
|
@@ -16,6 +16,7 @@ clean error messages to the Gradio UI.
|
|
| 16 |
|
| 17 |
from __future__ import annotations
|
| 18 |
|
|
|
|
| 19 |
import json
|
| 20 |
import math
|
| 21 |
import shutil
|
|
@@ -25,6 +26,9 @@ from dataclasses import dataclass
|
|
| 25 |
from pathlib import Path
|
| 26 |
from typing import List, Optional
|
| 27 |
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
# ---------------------------------------------------------------------------
|
| 30 |
# Video metadata
|
|
@@ -222,37 +226,29 @@ def extract_frames(
|
|
| 222 |
return frames
|
| 223 |
|
| 224 |
|
| 225 |
-
def
|
| 226 |
-
"""
|
| 227 |
-
Extract only the first frame, e.g. for mask drawing in the UI.
|
| 228 |
-
|
| 229 |
-
Parameters
|
| 230 |
-
----------
|
| 231 |
-
video_path : str | Path
|
| 232 |
-
out_path : str | Path
|
| 233 |
-
Path to write the PNG (parent directory must exist).
|
| 234 |
|
| 235 |
-
|
| 236 |
-
-
|
| 237 |
-
|
| 238 |
-
Same as out_path, now guaranteed to exist.
|
| 239 |
"""
|
| 240 |
-
out_path = Path(out_path)
|
| 241 |
-
out_path.parent.mkdir(parents=True, exist_ok=True)
|
| 242 |
-
|
| 243 |
cmd = [
|
| 244 |
"ffmpeg",
|
| 245 |
"-y",
|
| 246 |
"-i", str(video_path),
|
| 247 |
"-frames:v", "1",
|
| 248 |
-
"-
|
| 249 |
-
|
|
|
|
| 250 |
]
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
raise RuntimeError(
|
| 255 |
-
|
|
|
|
|
|
|
| 256 |
|
| 257 |
|
| 258 |
# ---------------------------------------------------------------------------
|
|
|
|
| 16 |
|
| 17 |
from __future__ import annotations
|
| 18 |
|
| 19 |
+
import io
|
| 20 |
import json
|
| 21 |
import math
|
| 22 |
import shutil
|
|
|
|
| 26 |
from pathlib import Path
|
| 27 |
from typing import List, Optional
|
| 28 |
|
| 29 |
+
import numpy as np
|
| 30 |
+
from PIL import Image
|
| 31 |
+
|
| 32 |
|
| 33 |
# ---------------------------------------------------------------------------
|
| 34 |
# Video metadata
|
|
|
|
| 226 |
return frames
|
| 227 |
|
| 228 |
|
| 229 |
+
def extract_first_frame_array(video_path: str | Path) -> np.ndarray:
|
| 230 |
+
"""Extract the first frame of *video_path* as an RGB uint8 ndarray.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
|
| 232 |
+
Streams the PNG through stdout — no on-disk temp file needed. Used by
|
| 233 |
+
the UI to populate the mask-drawing editor without leaving artefacts
|
| 234 |
+
in /tmp on each upload.
|
|
|
|
| 235 |
"""
|
|
|
|
|
|
|
|
|
|
| 236 |
cmd = [
|
| 237 |
"ffmpeg",
|
| 238 |
"-y",
|
| 239 |
"-i", str(video_path),
|
| 240 |
"-frames:v", "1",
|
| 241 |
+
"-f", "image2pipe",
|
| 242 |
+
"-c:v", "png",
|
| 243 |
+
"pipe:1",
|
| 244 |
]
|
| 245 |
+
result = subprocess.run(cmd, capture_output=True)
|
| 246 |
+
if result.returncode != 0 or not result.stdout:
|
| 247 |
+
stderr = result.stderr.decode("utf-8", errors="replace")[-500:]
|
| 248 |
+
raise RuntimeError(
|
| 249 |
+
f"First-frame extraction failed for {video_path}.\nstderr: {stderr}"
|
| 250 |
+
)
|
| 251 |
+
return np.array(Image.open(io.BytesIO(result.stdout)).convert("RGB"))
|
| 252 |
|
| 253 |
|
| 254 |
# ---------------------------------------------------------------------------
|