Spaces:
Running on Zero
Running on Zero
Upload 19 files
Browse files- DEPLOY_HF.md +2 -1
- README.md +3 -2
- app.py +154 -25
DEPLOY_HF.md
CHANGED
|
@@ -87,7 +87,8 @@ If you hit the 10 MB file limit, make sure you did **not** commit `venv/` or any
|
|
| 87 |
|
| 88 |
## Using it
|
| 89 |
|
| 90 |
-
1. Upload a video.
|
|
|
|
| 91 |
2. In **"What to blur?"** type the regions, e.g. `face. license plate.`
|
| 92 |
3. Click **Preview Detection** to confirm the right regions are caught.
|
| 93 |
4. Click **Process Video**, then download the result.
|
|
|
|
| 87 |
|
| 88 |
## Using it
|
| 89 |
|
| 90 |
+
1. Upload a video. (Longer than the limit? Open **βοΈ Trim** and choose a
|
| 91 |
+
segment β over-length clips are trimmed automatically.)
|
| 92 |
2. In **"What to blur?"** type the regions, e.g. `face. license plate.`
|
| 93 |
3. Click **Preview Detection** to confirm the right regions are caught.
|
| 94 |
4. Click **Process Video**, then download the result.
|
README.md
CHANGED
|
@@ -37,8 +37,9 @@ Full step-by-step with troubleshooting: see **[`DEPLOY_HF.md`](DEPLOY_HF.md)**.
|
|
| 37 |
|
| 38 |
> ZeroGPU gives each request the GPU for a limited time, so this app defaults to
|
| 39 |
> a reduced processing resolution (480p), fast models (Grounding DINO *tiny* +
|
| 40 |
-
> SAM 2 *small*), and a short per-clip length cap.
|
| 41 |
-
>
|
|
|
|
| 42 |
|
| 43 |
---
|
| 44 |
|
|
|
|
| 37 |
|
| 38 |
> ZeroGPU gives each request the GPU for a limited time, so this app defaults to
|
| 39 |
> a reduced processing resolution (480p), fast models (Grounding DINO *tiny* +
|
| 40 |
+
> SAM 2 *small*), and a short per-clip length cap. **Longer videos aren't
|
| 41 |
+
> rejected** β use the **βοΈ Trim** panel to pick a segment (over-length clips
|
| 42 |
+
> are trimmed automatically). Raise these for a paid GPU β see "Tuning" below.
|
| 43 |
|
| 44 |
---
|
| 45 |
|
app.py
CHANGED
|
@@ -12,6 +12,7 @@ import os
|
|
| 12 |
import sys
|
| 13 |
import time
|
| 14 |
import tempfile
|
|
|
|
| 15 |
from pathlib import Path
|
| 16 |
|
| 17 |
# βββ ZeroGPU / Hugging Face Spaces support βββββββββββββββββββββ
|
|
@@ -259,6 +260,47 @@ CUSTOM_CSS = """
|
|
| 259 |
|
| 260 |
# βββ Processing Functions ββββββββββββββββββββββββββββββββββββββ
|
| 261 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 262 |
@spaces.GPU(duration=PROCESS_DURATION)
|
| 263 |
def process_video(
|
| 264 |
video_file,
|
|
@@ -270,6 +312,9 @@ def process_video(
|
|
| 270 |
keyframe_interval,
|
| 271 |
detection_threshold,
|
| 272 |
model_quality,
|
|
|
|
|
|
|
|
|
|
| 273 |
progress=gr.Progress()
|
| 274 |
):
|
| 275 |
"""Main video processing function"""
|
|
@@ -302,26 +347,33 @@ def process_video(
|
|
| 302 |
else:
|
| 303 |
video_path = video_file.name if hasattr(video_file, 'name') else str(video_file)
|
| 304 |
|
| 305 |
-
#
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
output_path = os.path.join(output_dir, output_name)
|
| 309 |
-
|
| 310 |
-
# Enforce a max duration so a single GPU call fits inside the
|
| 311 |
-
# ZeroGPU time budget (long clips would otherwise time out).
|
| 312 |
max_secs = getattr(config, "max_video_duration", 600)
|
| 313 |
try:
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
msg = (
|
| 317 |
-
f"β±οΈ Video is {info.duration:.0f}s but the limit here is "
|
| 318 |
-
f"{max_secs}s. Please trim it, or raise max_video_duration "
|
| 319 |
-
f"/ use a larger GPU."
|
| 320 |
-
)
|
| 321 |
-
gr.Warning(msg)
|
| 322 |
-
return None, "β " + msg
|
| 323 |
except Exception:
|
| 324 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 325 |
|
| 326 |
# Process
|
| 327 |
result_path = pipeline.process_video(
|
|
@@ -377,9 +429,18 @@ def preview_detection(video_file, text_prompt, frame_slider):
|
|
| 377 |
|
| 378 |
|
| 379 |
def get_video_info_text(video_file):
|
| 380 |
-
"""Get video info when uploaded"""
|
|
|
|
|
|
|
| 381 |
if video_file is None:
|
| 382 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 383 |
|
| 384 |
try:
|
| 385 |
video_path = video_file if isinstance(video_file, str) else video_file.name
|
|
@@ -395,10 +456,37 @@ def get_video_info_text(video_file):
|
|
| 395 |
)
|
| 396 |
|
| 397 |
max_frame = max(0, info.total_frames - 1)
|
| 398 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 399 |
|
| 400 |
except Exception as e:
|
| 401 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 402 |
|
| 403 |
|
| 404 |
# βββ Build UI ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -416,7 +504,7 @@ def create_app():
|
|
| 416 |
limits_note = (
|
| 417 |
f"<p style='font-size:0.8em;color:#8a8aa8;'>Running on ZeroGPU Β· "
|
| 418 |
f"processed at {config.video.max_resolution}p Β· clips up to "
|
| 419 |
-
f"{config.max_video_duration}s</p>"
|
| 420 |
)
|
| 421 |
|
| 422 |
with gr.Blocks(
|
|
@@ -449,6 +537,35 @@ def create_app():
|
|
| 449 |
# Video Info
|
| 450 |
video_info_text = gr.Markdown("No video uploaded")
|
| 451 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 452 |
# Prompt Input β this is where the user WRITES the regions to blur
|
| 453 |
text_prompt = gr.Textbox(
|
| 454 |
label="βοΈ What to blur? (Text Prompt)",
|
|
@@ -591,16 +708,27 @@ def create_app():
|
|
| 591 |
### Running on ZeroGPU
|
| 592 |
- The first run loads the models onto the GPU, so it's a bit slower; later runs are faster.
|
| 593 |
- Each run gets the GPU for a limited time, so clips are processed at a reduced resolution and capped in length by default for speed.
|
|
|
|
| 594 |
- Want longer / higher-res? Upgrade the Space GPU and raise `max_video_duration`, `max_resolution` and the `@spaces.GPU(duration=...)` budget.
|
| 595 |
""")
|
| 596 |
|
| 597 |
# ββ Event Handlers ββ
|
| 598 |
|
| 599 |
-
# Video upload β show info
|
| 600 |
video_input.change(
|
| 601 |
fn=get_video_info_text,
|
| 602 |
inputs=[video_input],
|
| 603 |
-
outputs=[
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 604 |
)
|
| 605 |
|
| 606 |
# Preview button
|
|
@@ -616,7 +744,8 @@ def create_app():
|
|
| 616 |
inputs=[
|
| 617 |
video_input, text_prompt, blur_type, blur_strength,
|
| 618 |
edge_feather, processing_mode, keyframe_interval,
|
| 619 |
-
detection_threshold, model_quality
|
|
|
|
| 620 |
],
|
| 621 |
outputs=[video_output, status_text]
|
| 622 |
)
|
|
|
|
| 12 |
import sys
|
| 13 |
import time
|
| 14 |
import tempfile
|
| 15 |
+
import subprocess
|
| 16 |
from pathlib import Path
|
| 17 |
|
| 18 |
# βββ ZeroGPU / Hugging Face Spaces support βββββββββββββββββββββ
|
|
|
|
| 260 |
|
| 261 |
# βββ Processing Functions ββββββββββββββββββββββββββββββββββββββ
|
| 262 |
|
| 263 |
+
def trim_clip(video_path, start, length):
|
| 264 |
+
"""Cut [start, start+length] seconds from a video with ffmpeg.
|
| 265 |
+
|
| 266 |
+
Returns the path to a new .mp4. Re-encodes (fast preset) so the cut is
|
| 267 |
+
frame-accurate. This is CPU-only work (no GPU needed).
|
| 268 |
+
"""
|
| 269 |
+
start = max(0.0, float(start))
|
| 270 |
+
length = max(0.1, float(length))
|
| 271 |
+
out_dir = tempfile.mkdtemp(prefix="trim_")
|
| 272 |
+
out_path = os.path.join(out_dir, f"trimmed_{Path(video_path).stem}.mp4")
|
| 273 |
+
cmd = [
|
| 274 |
+
"ffmpeg", "-y",
|
| 275 |
+
"-ss", str(start),
|
| 276 |
+
"-i", video_path,
|
| 277 |
+
"-t", str(length),
|
| 278 |
+
"-c:v", "libx264", "-preset", "veryfast", "-crf", "20",
|
| 279 |
+
"-c:a", "aac",
|
| 280 |
+
"-movflags", "+faststart",
|
| 281 |
+
out_path,
|
| 282 |
+
]
|
| 283 |
+
proc = subprocess.run(cmd, capture_output=True, text=True)
|
| 284 |
+
if proc.returncode != 0 or not os.path.exists(out_path):
|
| 285 |
+
raise RuntimeError((proc.stderr or "ffmpeg failed")[-400:])
|
| 286 |
+
return out_path
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
def preview_trim(video_file, trim_start, trim_length):
|
| 290 |
+
"""Produce a preview of the trimmed segment so the user can confirm it."""
|
| 291 |
+
if video_file is None:
|
| 292 |
+
gr.Warning("β οΈ Please upload a video first!")
|
| 293 |
+
return None
|
| 294 |
+
try:
|
| 295 |
+
video_path = video_file if isinstance(video_file, str) else video_file.name
|
| 296 |
+
cap = getattr(config, "max_video_duration", 600)
|
| 297 |
+
length = min(float(trim_length) if trim_length else cap, cap)
|
| 298 |
+
return trim_clip(video_path, trim_start, length)
|
| 299 |
+
except Exception as e:
|
| 300 |
+
gr.Warning(f"Trim error: {e}")
|
| 301 |
+
return None
|
| 302 |
+
|
| 303 |
+
|
| 304 |
@spaces.GPU(duration=PROCESS_DURATION)
|
| 305 |
def process_video(
|
| 306 |
video_file,
|
|
|
|
| 312 |
keyframe_interval,
|
| 313 |
detection_threshold,
|
| 314 |
model_quality,
|
| 315 |
+
trim_enable,
|
| 316 |
+
trim_start,
|
| 317 |
+
trim_length,
|
| 318 |
progress=gr.Progress()
|
| 319 |
):
|
| 320 |
"""Main video processing function"""
|
|
|
|
| 347 |
else:
|
| 348 |
video_path = video_file.name if hasattr(video_file, 'name') else str(video_file)
|
| 349 |
|
| 350 |
+
# ββ Trim (for long videos) ββ
|
| 351 |
+
# Trim when the user asked to, OR automatically when the clip is longer
|
| 352 |
+
# than the cap β so long videos are handled instead of rejected.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 353 |
max_secs = getattr(config, "max_video_duration", 600)
|
| 354 |
try:
|
| 355 |
+
_info = pipeline.video_processor.get_video_info(video_path)
|
| 356 |
+
duration = _info.duration or 0.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 357 |
except Exception:
|
| 358 |
+
duration = 0.0
|
| 359 |
+
|
| 360 |
+
should_trim = bool(trim_enable) or (duration and duration > max_secs)
|
| 361 |
+
if should_trim:
|
| 362 |
+
length = min(float(trim_length) if trim_length else max_secs, max_secs)
|
| 363 |
+
start = max(0.0, float(trim_start) if trim_start else 0.0)
|
| 364 |
+
if duration and start >= duration:
|
| 365 |
+
start = 0.0
|
| 366 |
+
gradio_progress(0.02, f"βοΈ Trimming to {start:.0f}β{start + length:.0f}s...")
|
| 367 |
+
try:
|
| 368 |
+
video_path = trim_clip(video_path, start, length)
|
| 369 |
+
except Exception as e:
|
| 370 |
+
gr.Warning(f"Trim failed: {e}")
|
| 371 |
+
return None, f"β Trim failed: {e}"
|
| 372 |
+
|
| 373 |
+
# Generate output path (from the possibly-trimmed video)
|
| 374 |
+
output_dir = tempfile.mkdtemp(prefix="blur_output_")
|
| 375 |
+
output_name = f"blurred_{Path(video_path).stem}.mp4"
|
| 376 |
+
output_path = os.path.join(output_dir, output_name)
|
| 377 |
|
| 378 |
# Process
|
| 379 |
result_path = pipeline.process_video(
|
|
|
|
| 429 |
|
| 430 |
|
| 431 |
def get_video_info_text(video_file):
|
| 432 |
+
"""Get video info when uploaded, and set up the trim controls."""
|
| 433 |
+
cap = getattr(config, "max_video_duration", 600)
|
| 434 |
+
|
| 435 |
if video_file is None:
|
| 436 |
+
return (
|
| 437 |
+
"No video uploaded",
|
| 438 |
+
gr.Slider(maximum=0, value=0), # frame_slider
|
| 439 |
+
gr.Checkbox(value=False), # trim_enable
|
| 440 |
+
gr.Slider(maximum=1, value=0), # trim_start
|
| 441 |
+
gr.Slider(maximum=cap, value=cap), # trim_length
|
| 442 |
+
"", # trim_info
|
| 443 |
+
)
|
| 444 |
|
| 445 |
try:
|
| 446 |
video_path = video_file if isinstance(video_file, str) else video_file.name
|
|
|
|
| 456 |
)
|
| 457 |
|
| 458 |
max_frame = max(0, info.total_frames - 1)
|
| 459 |
+
dur = info.duration or 0.0
|
| 460 |
+
too_long = bool(dur and dur > cap)
|
| 461 |
+
length_default = min(cap, dur) if dur else cap
|
| 462 |
+
|
| 463 |
+
trim_info = ""
|
| 464 |
+
if too_long:
|
| 465 |
+
trim_info = (
|
| 466 |
+
f"β±οΈ This clip is **{dur:.0f}s**, longer than the **{cap}s** limit. "
|
| 467 |
+
f"It will be trimmed automatically β set the start below to choose "
|
| 468 |
+
f"which part to keep (defaults to the first {cap}s), and optionally "
|
| 469 |
+
f"**Preview trimmed clip** to check it."
|
| 470 |
+
)
|
| 471 |
+
|
| 472 |
+
return (
|
| 473 |
+
text,
|
| 474 |
+
gr.Slider(maximum=max_frame, value=0), # frame_slider
|
| 475 |
+
gr.Checkbox(value=too_long), # trim_enable (auto-on if long)
|
| 476 |
+
gr.Slider(maximum=max(0.1, dur), value=0), # trim_start
|
| 477 |
+
gr.Slider(maximum=cap, value=length_default), # trim_length
|
| 478 |
+
trim_info, # trim_info
|
| 479 |
+
)
|
| 480 |
|
| 481 |
except Exception as e:
|
| 482 |
+
return (
|
| 483 |
+
f"β Error reading video: {e}",
|
| 484 |
+
gr.Slider(maximum=0, value=0),
|
| 485 |
+
gr.Checkbox(value=False),
|
| 486 |
+
gr.Slider(maximum=1, value=0),
|
| 487 |
+
gr.Slider(maximum=cap, value=cap),
|
| 488 |
+
"",
|
| 489 |
+
)
|
| 490 |
|
| 491 |
|
| 492 |
# βββ Build UI ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 504 |
limits_note = (
|
| 505 |
f"<p style='font-size:0.8em;color:#8a8aa8;'>Running on ZeroGPU Β· "
|
| 506 |
f"processed at {config.video.max_resolution}p Β· clips up to "
|
| 507 |
+
f"{config.max_video_duration}s (longer videos can be trimmed)</p>"
|
| 508 |
)
|
| 509 |
|
| 510 |
with gr.Blocks(
|
|
|
|
| 537 |
# Video Info
|
| 538 |
video_info_text = gr.Markdown("No video uploaded")
|
| 539 |
|
| 540 |
+
# ββ Trim controls (for long videos) ββ
|
| 541 |
+
with gr.Accordion("βοΈ Trim (for long videos)", open=False):
|
| 542 |
+
trim_info = gr.Markdown("")
|
| 543 |
+
trim_enable = gr.Checkbox(
|
| 544 |
+
label="Trim this video before processing",
|
| 545 |
+
value=False,
|
| 546 |
+
info=(
|
| 547 |
+
f"Clips here are limited to {config.max_video_duration}s. "
|
| 548 |
+
"Turn this on to process only a chosen segment. Long videos "
|
| 549 |
+
"are trimmed automatically."
|
| 550 |
+
),
|
| 551 |
+
)
|
| 552 |
+
with gr.Row():
|
| 553 |
+
trim_start = gr.Slider(
|
| 554 |
+
minimum=0, maximum=60, value=0, step=0.5,
|
| 555 |
+
label="Start (seconds)",
|
| 556 |
+
)
|
| 557 |
+
trim_length = gr.Slider(
|
| 558 |
+
minimum=1, maximum=config.max_video_duration,
|
| 559 |
+
value=config.max_video_duration, step=1,
|
| 560 |
+
label=f"Clip length (max {config.max_video_duration}s)",
|
| 561 |
+
)
|
| 562 |
+
trim_preview_btn = gr.Button(
|
| 563 |
+
"βοΈ Preview trimmed clip", variant="secondary"
|
| 564 |
+
)
|
| 565 |
+
trim_preview_video = gr.Video(
|
| 566 |
+
label="Trimmed preview", interactive=False
|
| 567 |
+
)
|
| 568 |
+
|
| 569 |
# Prompt Input β this is where the user WRITES the regions to blur
|
| 570 |
text_prompt = gr.Textbox(
|
| 571 |
label="βοΈ What to blur? (Text Prompt)",
|
|
|
|
| 708 |
### Running on ZeroGPU
|
| 709 |
- The first run loads the models onto the GPU, so it's a bit slower; later runs are faster.
|
| 710 |
- Each run gets the GPU for a limited time, so clips are processed at a reduced resolution and capped in length by default for speed.
|
| 711 |
+
- **Long video?** Open **βοΈ Trim** to pick a segment. Videos over the limit are trimmed automatically (to the first part, or your chosen start).
|
| 712 |
- Want longer / higher-res? Upgrade the Space GPU and raise `max_video_duration`, `max_resolution` and the `@spaces.GPU(duration=...)` budget.
|
| 713 |
""")
|
| 714 |
|
| 715 |
# ββ Event Handlers ββ
|
| 716 |
|
| 717 |
+
# Video upload β show info + set up trim controls
|
| 718 |
video_input.change(
|
| 719 |
fn=get_video_info_text,
|
| 720 |
inputs=[video_input],
|
| 721 |
+
outputs=[
|
| 722 |
+
video_info_text, frame_slider,
|
| 723 |
+
trim_enable, trim_start, trim_length, trim_info,
|
| 724 |
+
]
|
| 725 |
+
)
|
| 726 |
+
|
| 727 |
+
# Trim preview button
|
| 728 |
+
trim_preview_btn.click(
|
| 729 |
+
fn=preview_trim,
|
| 730 |
+
inputs=[video_input, trim_start, trim_length],
|
| 731 |
+
outputs=[trim_preview_video]
|
| 732 |
)
|
| 733 |
|
| 734 |
# Preview button
|
|
|
|
| 744 |
inputs=[
|
| 745 |
video_input, text_prompt, blur_type, blur_strength,
|
| 746 |
edge_feather, processing_mode, keyframe_interval,
|
| 747 |
+
detection_threshold, model_quality,
|
| 748 |
+
trim_enable, trim_start, trim_length
|
| 749 |
],
|
| 750 |
outputs=[video_output, status_text]
|
| 751 |
)
|