BoxOfColors Claude Opus 4.7 (1M context) commited on
Commit
f2818e6
·
1 Parent(s): ed7d6c7

refactor: round 3 — stdout-pipe first-frame extract, _card_title, _msg helpers

Browse files

pipeline/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>

Files changed (2) hide show
  1. app.py +28 -30
  2. pipeline/video.py +19 -23
app.py CHANGED
@@ -45,7 +45,7 @@ from pipeline.crop import (
45
  )
46
  from pipeline.video import (
47
  VideoMeta, VideoWorkspace,
48
- attach_audio, extract_first_frame, extract_frames, frames_to_video, probe,
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 gr.update(), gr.update(), None, "Upload a video to begin."
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
- gr.update(), gr.update(), None,
223
- f"❌ Clip too long ({meta.frame_count} frames at {meta.fps:.2f} fps). "
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
- # Extract first frame — mkstemp so the fd is closed before FFmpeg writes
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 gr.update(), gr.update(), None, f"❌ Error: {e}"
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
- gr.HTML('<div class="card-title"><span class="step-badge">1</span>Upload Video</div>')
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
- gr.HTML('<div class="card-title" style="margin-top:16px"><span class="step-badge">2</span>Mode</div>')
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
- gr.HTML('<div class="card-title" style="margin-top:16px">⚙️ Advanced</div>')
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
- gr.HTML('<div class="card-title"><span class="step-badge">3</span>Draw Over the Watermark</div>')
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
- gr.HTML('<div class="card-title">Crop Preview</div>')
643
  crop_preview = gr.Image(
644
  label="",
645
  type="numpy",
646
  show_label=False,
647
  )
648
  with gr.Column():
649
- gr.HTML('<div class="card-title">Output Video</div>')
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,
pipeline/video.py CHANGED
@@ -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 extract_first_frame(video_path: str | Path, out_path: str | Path) -> Path:
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
- Returns
236
- -------
237
- Path
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
- "-update", "1",
249
- str(out_path),
 
250
  ]
251
- _run(cmd)
252
-
253
- if not out_path.exists():
254
- raise RuntimeError(f"First frame extraction failed for {video_path}")
255
- return out_path
 
 
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
  # ---------------------------------------------------------------------------