Spaces:
Running on Zero
Running on Zero
| """ | |
| Video Blur AI - Main Gradio Web Application | |
| Text-prompted video object blurring using Grounding DINO + SAM 2 | |
| Runs locally and on Hugging Face Spaces (including ZeroGPU). | |
| Local usage: | |
| python app.py | |
| Then open http://localhost:7860 in your browser. | |
| """ | |
| import os | |
| import sys | |
| import time | |
| import tempfile | |
| import subprocess | |
| from pathlib import Path | |
| # βββ ZeroGPU / Hugging Face Spaces support βββββββββββββββββββββ | |
| # `spaces` MUST be imported before torch (pulled in transitively by | |
| # config/pipeline) so ZeroGPU can patch CUDA. Locally the package is | |
| # absent, so we fall back to a no-op decorator that supports the same | |
| # @spaces.GPU and @spaces.GPU(duration=...) forms. | |
| try: | |
| import spaces # type: ignore | |
| _HAS_SPACES = True | |
| except Exception: | |
| _HAS_SPACES = False | |
| class _SpacesShim: | |
| def GPU(*args, **kwargs): | |
| # Used as a bare decorator: @spaces.GPU | |
| if len(args) == 1 and callable(args[0]) and not kwargs: | |
| return args[0] | |
| # Used with arguments: @spaces.GPU(duration=...) | |
| def _decorator(fn): | |
| return fn | |
| return _decorator | |
| spaces = _SpacesShim() # type: ignore | |
| import gradio as gr | |
| import numpy as np | |
| # βββ Work around a gradio_client JSON-schema bug βββββββββββββββ | |
| # In some gradio 4.x builds, gradio_client.utils.get_type() raises | |
| # "argument of type 'bool' is not iterable" when a component's schema has a | |
| # boolean `additionalProperties`. This happens during API-info generation, | |
| # which Gradio calls in its startup health check β the failed check then | |
| # makes launch() abort demanding share=True. We make the schema walkers | |
| # tolerate non-dict / boolean schemas so startup succeeds on Spaces. | |
| try: | |
| import gradio_client.utils as _gc_utils | |
| _orig_get_type = _gc_utils.get_type | |
| def _get_type_safe(schema): | |
| if not isinstance(schema, dict): | |
| return "Any" | |
| return _orig_get_type(schema) | |
| _gc_utils.get_type = _get_type_safe | |
| _orig_j2pt = _gc_utils._json_schema_to_python_type | |
| def _j2pt_safe(schema, defs=None): | |
| if isinstance(schema, bool): | |
| return "Any" | |
| return _orig_j2pt(schema, defs) | |
| _gc_utils._json_schema_to_python_type = _j2pt_safe | |
| except Exception as _patch_err: | |
| print(f"β οΈ Could not apply gradio_client schema patch: {_patch_err}") | |
| # βββ Compat shim for Starlette TemplateResponse ββββββββββββββββ | |
| # Gradio 4.x calls templates.TemplateResponse(name, context) (old signature). | |
| # Newer Starlette put `request` first and dropped old-style support, so the | |
| # template name gets mistaken for the request and Gradio's page render crashes | |
| # with "unhashable type: 'dict'" (which then makes launch() demand share=True). | |
| # We restore old-style support. We patch the EXACT instance Gradio's route uses | |
| # (gradio.routes.templates) β the most reliable target β and the class as a | |
| # fallback, so it works regardless of Starlette version. | |
| def _old_style_args(args, kwargs): | |
| """Return (name, request, context, rest, kwargs) for an old-style call.""" | |
| name = args[0] | |
| context = args[1] if len(args) > 1 else kwargs.pop("context", None) | |
| context = dict(context) if isinstance(context, dict) else {} | |
| request = context.get("request") | |
| return name, request, context, args[2:], kwargs | |
| def _install_templateresponse_compat(): | |
| patched = [] | |
| # 1) Patch Gradio's actual templates instance (primary β bulletproof). | |
| try: | |
| import gradio.routes as _gr_routes | |
| _inst = getattr(_gr_routes, "templates", None) | |
| if _inst is not None and not getattr(_inst.TemplateResponse, "_vb_compat", False): | |
| _orig_bound = _inst.TemplateResponse # bound method (self baked in) | |
| def _inst_compat(*args, **kwargs): # instance attr => no self | |
| if args and isinstance(args[0], str): | |
| name, request, context, rest, kwargs = _old_style_args(args, kwargs) | |
| return _orig_bound(request, name, context, *rest, **kwargs) | |
| return _orig_bound(*args, **kwargs) | |
| _inst_compat._vb_compat = True | |
| _inst.TemplateResponse = _inst_compat | |
| patched.append("gradio.routes.templates") | |
| except Exception as e: | |
| print(f" (instance-level TemplateResponse patch skipped: {e})") | |
| # 2) Patch the class too (fallback for any other Jinja2Templates instances). | |
| try: | |
| import starlette.templating as _st | |
| _JT = _st.Jinja2Templates | |
| if not getattr(_JT.TemplateResponse, "_vb_compat", False): | |
| _class_orig = _JT.TemplateResponse | |
| def _class_compat(self, *args, **kwargs): | |
| if args and isinstance(args[0], str): | |
| name, request, context, rest, kwargs = _old_style_args(args, kwargs) | |
| return _class_orig(self, request, name, context, *rest, **kwargs) | |
| return _class_orig(self, *args, **kwargs) | |
| _class_compat._vb_compat = True | |
| _JT.TemplateResponse = _class_compat | |
| patched.append("Jinja2Templates(class)") | |
| except Exception as e: | |
| print(f" (class-level TemplateResponse patch skipped: {e})") | |
| return patched | |
| try: | |
| _tr_patched = _install_templateresponse_compat() | |
| if _tr_patched: | |
| print(f"β TemplateResponse compat shim installed on: {', '.join(_tr_patched)}") | |
| else: | |
| print("βΉοΈ TemplateResponse compat shim not needed (nothing to patch).") | |
| except Exception as _tr_err: | |
| print(f"β οΈ TemplateResponse compat shim failed: {_tr_err}") | |
| from config import get_config, AppConfig, IS_ZERO_GPU | |
| from pipeline import VideoBlurPipeline | |
| # βββ Global State ββββββββββββββββββββββββββββββββββββββββββββββ | |
| config = get_config() | |
| pipeline = VideoBlurPipeline(config) | |
| # GPU time budget (seconds) requested from ZeroGPU per call. | |
| PROCESS_DURATION = getattr(config, "gpu_duration", 120) | |
| PREVIEW_DURATION = 60 | |
| # NOTE: models are loaded LAZILY inside the @spaces.GPU functions below | |
| # (pipeline.ensure_models_loaded() runs at the start of process/preview). | |
| # We deliberately do NOT preload at import time: on ZeroGPU the real GPU is | |
| # only attached *inside* a @spaces.GPU call, so building SAM 2 on CUDA at | |
| # import mixes devices ("cuda:0 (ZeroGPU) and cpu"). Loading on first request | |
| # β inside the GPU context β avoids that. The first request is a little | |
| # slower because it loads the weights; subsequent ones reuse the cache. | |
| # βββ Custom CSS ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| CUSTOM_CSS = """ | |
| /* Main container */ | |
| .gradio-container { | |
| max-width: 1200px !important; | |
| margin: auto !important; | |
| font-family: 'Segoe UI', system-ui, -apple-system, sans-serif !important; | |
| } | |
| /* Header */ | |
| .app-header { | |
| text-align: center; | |
| padding: 20px 0; | |
| margin-bottom: 10px; | |
| background: linear-gradient(135deg, #0f0c29, #302b63, #24243e); | |
| border-radius: 16px; | |
| color: white; | |
| } | |
| .app-header h1 { | |
| font-size: 2.2em !important; | |
| font-weight: 700 !important; | |
| margin: 0 !important; | |
| background: linear-gradient(90deg, #00d2ff, #7b2ff7, #ff6b6b); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| background-clip: text; | |
| } | |
| .app-header p { | |
| color: #a0a0c0; | |
| font-size: 1.05em; | |
| margin: 8px 0 0 0; | |
| } | |
| /* Prompt input */ | |
| .prompt-input textarea { | |
| font-size: 1.15em !important; | |
| border: 2px solid #7b2ff7 !important; | |
| border-radius: 12px !important; | |
| padding: 14px !important; | |
| transition: all 0.3s ease !important; | |
| } | |
| .prompt-input textarea:focus { | |
| border-color: #00d2ff !important; | |
| box-shadow: 0 0 0 3px rgba(0, 210, 255, 0.15) !important; | |
| } | |
| /* Process button */ | |
| .process-btn { | |
| background: linear-gradient(135deg, #7b2ff7, #00d2ff) !important; | |
| border: none !important; | |
| font-size: 1.15em !important; | |
| font-weight: 600 !important; | |
| padding: 14px 32px !important; | |
| border-radius: 12px !important; | |
| transition: all 0.3s ease !important; | |
| } | |
| .process-btn:hover { | |
| transform: translateY(-2px) !important; | |
| box-shadow: 0 6px 20px rgba(123, 47, 247, 0.35) !important; | |
| } | |
| /* Preview button */ | |
| .preview-btn { | |
| background: linear-gradient(135deg, #ff6b6b, #ffa07a) !important; | |
| border: none !important; | |
| border-radius: 12px !important; | |
| font-weight: 600 !important; | |
| } | |
| /* Status */ | |
| .status-text { | |
| font-size: 1.05em; | |
| padding: 10px; | |
| border-radius: 8px; | |
| text-align: center; | |
| } | |
| /* Settings panel */ | |
| .settings-panel { | |
| background: rgba(123, 47, 247, 0.04); | |
| border: 1px solid rgba(123, 47, 247, 0.12); | |
| border-radius: 12px; | |
| padding: 16px; | |
| } | |
| /* Example prompts */ | |
| .example-box { | |
| border: 1px solid rgba(123, 47, 247, 0.2); | |
| border-radius: 10px; | |
| padding: 12px; | |
| margin: 4px 0; | |
| } | |
| """ | |
| # βββ Processing Functions ββββββββββββββββββββββββββββββββββββββ | |
| def trim_clip(video_path, start, length): | |
| """Cut [start, start+length] seconds from a video with ffmpeg. | |
| Returns the path to a new .mp4. Re-encodes (fast preset) so the cut is | |
| frame-accurate. This is CPU-only work (no GPU needed). | |
| """ | |
| start = max(0.0, float(start)) | |
| length = max(0.1, float(length)) | |
| out_dir = tempfile.mkdtemp(prefix="trim_") | |
| out_path = os.path.join(out_dir, f"trimmed_{Path(video_path).stem}.mp4") | |
| cmd = [ | |
| "ffmpeg", "-y", | |
| "-ss", str(start), | |
| "-i", video_path, | |
| "-t", str(length), | |
| "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", | |
| "-c:a", "aac", | |
| "-movflags", "+faststart", | |
| out_path, | |
| ] | |
| proc = subprocess.run(cmd, capture_output=True, text=True) | |
| if proc.returncode != 0 or not os.path.exists(out_path): | |
| raise RuntimeError((proc.stderr or "ffmpeg failed")[-400:]) | |
| return out_path | |
| def preview_trim(video_file, trim_start, trim_length): | |
| """Produce a preview of the trimmed segment so the user can confirm it.""" | |
| if video_file is None: | |
| gr.Warning("β οΈ Please upload a video first!") | |
| return None | |
| try: | |
| video_path = video_file if isinstance(video_file, str) else video_file.name | |
| cap = getattr(config, "max_video_duration", 600) | |
| length = min(float(trim_length) if trim_length else cap, cap) | |
| return trim_clip(video_path, trim_start, length) | |
| except Exception as e: | |
| gr.Warning(f"Trim error: {e}") | |
| return None | |
| def process_video( | |
| video_file, | |
| text_prompt, | |
| blur_type, | |
| blur_strength, | |
| edge_feather, | |
| processing_mode, | |
| keyframe_interval, | |
| detection_threshold, | |
| model_quality, | |
| trim_enable, | |
| trim_start, | |
| trim_length, | |
| progress=gr.Progress() | |
| ): | |
| """Main video processing function""" | |
| # Validate inputs | |
| if video_file is None: | |
| gr.Warning("β οΈ Please upload a video first!") | |
| return None, "β No video uploaded" | |
| if not text_prompt or not text_prompt.strip(): | |
| gr.Warning("β οΈ Please enter a text prompt describing what to blur!") | |
| return None, "β No prompt entered" | |
| text_prompt = text_prompt.strip() | |
| # Update model based on quality selection | |
| if model_quality == "base": | |
| config.model.gdino_model_id = "IDEA-Research/grounding-dino-base" | |
| else: | |
| config.model.gdino_model_id = "IDEA-Research/grounding-dino-tiny" | |
| # Progress wrapper for Gradio | |
| def gradio_progress(value, text): | |
| progress(value, desc=text) | |
| try: | |
| # Get video path | |
| if isinstance(video_file, str): | |
| video_path = video_file | |
| else: | |
| video_path = video_file.name if hasattr(video_file, 'name') else str(video_file) | |
| # ββ Trim (for long videos) ββ | |
| # Trim when the user asked to, OR automatically when the clip is longer | |
| # than the cap β so long videos are handled instead of rejected. | |
| max_secs = getattr(config, "max_video_duration", 600) | |
| try: | |
| _info = pipeline.video_processor.get_video_info(video_path) | |
| duration = _info.duration or 0.0 | |
| except Exception: | |
| duration = 0.0 | |
| should_trim = bool(trim_enable) or (duration and duration > max_secs) | |
| if should_trim: | |
| length = min(float(trim_length) if trim_length else max_secs, max_secs) | |
| start = max(0.0, float(trim_start) if trim_start else 0.0) | |
| if duration and start >= duration: | |
| start = 0.0 | |
| gradio_progress(0.02, f"βοΈ Trimming to {start:.0f}β{start + length:.0f}s...") | |
| try: | |
| video_path = trim_clip(video_path, start, length) | |
| except Exception as e: | |
| gr.Warning(f"Trim failed: {e}") | |
| return None, f"β Trim failed: {e}" | |
| # Generate output path (from the possibly-trimmed video) | |
| output_dir = tempfile.mkdtemp(prefix="blur_output_") | |
| output_name = f"blurred_{Path(video_path).stem}.mp4" | |
| output_path = os.path.join(output_dir, output_name) | |
| # Process | |
| result_path = pipeline.process_video( | |
| video_path=video_path, | |
| text_prompt=text_prompt, | |
| output_path=output_path, | |
| blur_type=blur_type, | |
| blur_strength=int(blur_strength), | |
| edge_feather=int(edge_feather), | |
| processing_mode=processing_mode, | |
| keyframe_interval=int(keyframe_interval), | |
| detection_threshold=detection_threshold, | |
| progress_callback=gradio_progress, | |
| ) | |
| status = "β Done! Video saved successfully." | |
| return result_path, status | |
| except Exception as e: | |
| error_msg = f"β Error: {str(e)}" | |
| gr.Warning(error_msg) | |
| return None, error_msg | |
| def preview_detection(video_file, text_prompt, frame_slider): | |
| """Preview detection on a single frame""" | |
| if video_file is None: | |
| gr.Warning("β οΈ Please upload a video first!") | |
| return None | |
| if not text_prompt or not text_prompt.strip(): | |
| gr.Warning("β οΈ Please enter a prompt!") | |
| return None | |
| try: | |
| video_path = video_file if isinstance(video_file, str) else video_file.name | |
| vis = pipeline.preview_detection( | |
| video_path, | |
| text_prompt.strip(), | |
| frame_number=int(frame_slider) | |
| ) | |
| # Convert BGR -> RGB for Gradio | |
| vis_rgb = vis[..., ::-1] | |
| return vis_rgb | |
| except Exception as e: | |
| gr.Warning(f"Preview error: {str(e)}") | |
| return None | |
| def get_video_info_text(video_file): | |
| """Get video info when uploaded, and set up the trim controls.""" | |
| cap = getattr(config, "max_video_duration", 600) | |
| if video_file is None: | |
| return ( | |
| "No video uploaded", | |
| gr.Slider(maximum=0, value=0), # frame_slider | |
| gr.Checkbox(value=False), # trim_enable | |
| gr.Slider(maximum=1, value=0), # trim_start | |
| gr.Slider(maximum=cap, value=cap), # trim_length | |
| "", # trim_info | |
| ) | |
| try: | |
| video_path = video_file if isinstance(video_file, str) else video_file.name | |
| info = pipeline.video_processor.get_video_info(video_path) | |
| text = ( | |
| f"πΉ **{Path(video_path).name}**\n" | |
| f"Resolution: {info.width}Γ{info.height} | " | |
| f"FPS: {info.fps:.1f} | " | |
| f"Frames: {info.total_frames} | " | |
| f"Duration: {info.duration:.1f}s | " | |
| f"Audio: {'Yes β ' if info.has_audio else 'No β'}" | |
| ) | |
| max_frame = max(0, info.total_frames - 1) | |
| dur = info.duration or 0.0 | |
| too_long = bool(dur and dur > cap) | |
| length_default = min(cap, dur) if dur else cap | |
| trim_info = "" | |
| if too_long: | |
| trim_info = ( | |
| f"β±οΈ This clip is **{dur:.0f}s**, longer than the **{cap}s** limit. " | |
| f"It will be trimmed automatically β set the start below to choose " | |
| f"which part to keep (defaults to the first {cap}s), and optionally " | |
| f"**Preview trimmed clip** to check it." | |
| ) | |
| return ( | |
| text, | |
| gr.Slider(maximum=max_frame, value=0), # frame_slider | |
| gr.Checkbox(value=too_long), # trim_enable (auto-on if long) | |
| gr.Slider(maximum=max(0.1, dur), value=0), # trim_start | |
| gr.Slider(maximum=cap, value=length_default), # trim_length | |
| trim_info, # trim_info | |
| ) | |
| except Exception as e: | |
| return ( | |
| f"β Error reading video: {e}", | |
| gr.Slider(maximum=0, value=0), | |
| gr.Checkbox(value=False), | |
| gr.Slider(maximum=1, value=0), | |
| gr.Slider(maximum=cap, value=cap), | |
| "", | |
| ) | |
| # βββ Build UI ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def create_app(): | |
| """Build the Gradio interface""" | |
| # Match the model-quality selector to the active config so the first | |
| # run doesn't trigger an unnecessary model reload. | |
| default_quality = "tiny" if "tiny" in config.model.gdino_model_id else "base" | |
| # Note shown to users about the environment limits (ZeroGPU only). | |
| limits_note = "" | |
| if IS_ZERO_GPU: | |
| limits_note = ( | |
| f"<p style='font-size:0.8em;color:#8a8aa8;'>Running on ZeroGPU Β· " | |
| f"processed at {config.video.max_resolution}p Β· clips up to " | |
| f"{config.max_video_duration}s (longer videos can be trimmed)</p>" | |
| ) | |
| with gr.Blocks( | |
| title="Video Blur AI", | |
| css=CUSTOM_CSS, | |
| theme=gr.themes.Soft(primary_hue="violet", secondary_hue="cyan"), | |
| ) as app: | |
| # ββ Header ββ | |
| gr.HTML(f""" | |
| <div class="app-header"> | |
| <h1>π¬ Video Blur AI</h1> | |
| <p>Type the regions you want to blur β faces, hands, text, plates, logos, anything.</p> | |
| <p style="font-size: 0.85em; color: #7a7a9a;"> | |
| Grounding DINO + SAM 2 Β· Runs on Hugging Face ZeroGPU | |
| </p> | |
| {limits_note} | |
| </div> | |
| """) | |
| with gr.Row(): | |
| # ββ Left Column: Input ββ | |
| with gr.Column(scale=1): | |
| # Video Upload | |
| video_input = gr.Video( | |
| label="π€ Upload Video", | |
| ) | |
| # Video Info | |
| video_info_text = gr.Markdown("No video uploaded") | |
| # ββ Trim controls (for long videos) ββ | |
| with gr.Accordion("βοΈ Trim (for long videos)", open=False): | |
| trim_info = gr.Markdown("") | |
| trim_enable = gr.Checkbox( | |
| label="Trim this video before processing", | |
| value=False, | |
| info=( | |
| f"Clips here are limited to {config.max_video_duration}s. " | |
| "Turn this on to process only a chosen segment. Long videos " | |
| "are trimmed automatically." | |
| ), | |
| ) | |
| with gr.Row(): | |
| trim_start = gr.Slider( | |
| minimum=0, maximum=60, value=0, step=0.5, | |
| label="Start (seconds)", | |
| ) | |
| trim_length = gr.Slider( | |
| minimum=1, maximum=config.max_video_duration, | |
| value=config.max_video_duration, step=1, | |
| label=f"Clip length (max {config.max_video_duration}s)", | |
| ) | |
| trim_preview_btn = gr.Button( | |
| "βοΈ Preview trimmed clip", variant="secondary" | |
| ) | |
| trim_preview_video = gr.Video( | |
| label="Trimmed preview", interactive=False | |
| ) | |
| # Prompt Input β this is where the user WRITES the regions to blur | |
| text_prompt = gr.Textbox( | |
| label="βοΈ What to blur? (Text Prompt)", | |
| placeholder="e.g., face, hand, license plate, person, text, logo...", | |
| info="Describe the regions you want to blur. Use periods to separate multiple objects: 'face. hand. text.'", | |
| lines=2, | |
| elem_classes=["prompt-input"], | |
| ) | |
| # Example Prompts | |
| gr.Examples( | |
| examples=[ | |
| ["face."], | |
| ["face. hand."], | |
| ["license plate."], | |
| ["text. writing."], | |
| ["person."], | |
| ["logo. brand."], | |
| ["phone. screen."], | |
| ["face. license plate. text."], | |
| ], | |
| inputs=[text_prompt], | |
| label="π‘ Example Prompts (click to use)", | |
| ) | |
| # Settings | |
| with gr.Accordion("βοΈ Advanced Settings", open=False): | |
| model_quality = gr.Radio( | |
| choices=[ | |
| ("β‘ Fast (Tiny model ~350MB, good accuracy)", "tiny"), | |
| ("π― Accurate (Base model ~999MB, best accuracy)", "base"), | |
| ], | |
| value=default_quality, | |
| label="Model Quality", | |
| info="Tiny downloads faster and runs faster. Base is more accurate." | |
| ) | |
| blur_type = gr.Radio( | |
| choices=["gaussian", "pixelate", "black"], | |
| value="gaussian", | |
| label="Blur Type", | |
| info="Gaussian = smooth blur, Pixelate = mosaic, Black = solid cover" | |
| ) | |
| blur_strength = gr.Slider( | |
| minimum=11, maximum=151, value=51, step=2, | |
| label="Blur Strength", | |
| info="Higher = more blur (must be odd number)" | |
| ) | |
| edge_feather = gr.Slider( | |
| minimum=3, maximum=51, value=11, step=2, | |
| label="Edge Feathering", | |
| info="Smooth transition at blur edges" | |
| ) | |
| processing_mode = gr.Radio( | |
| choices=[ | |
| ("π― SAM 2 Video Tracking (recommended)", "video_tracking"), | |
| ("β‘ Frame-by-Frame (faster, less consistent)", "frame_by_frame"), | |
| ], | |
| value="video_tracking", | |
| label="Processing Mode" | |
| ) | |
| keyframe_interval = gr.Slider( | |
| minimum=1, maximum=30, value=5, step=1, | |
| label="Keyframe Interval (frame-by-frame mode)", | |
| info="Re-detect every N frames" | |
| ) | |
| detection_threshold = gr.Slider( | |
| minimum=0.1, maximum=0.9, value=0.3, step=0.05, | |
| label="Detection Confidence Threshold", | |
| info="Lower = more detections, higher = more precise" | |
| ) | |
| # ββ Right Column: Output ββ | |
| with gr.Column(scale=1): | |
| # Preview | |
| with gr.Tab("π Preview Detection"): | |
| preview_image = gr.Image( | |
| label="Detection Preview", | |
| type="numpy" | |
| ) | |
| frame_slider = gr.Slider( | |
| minimum=0, maximum=100, value=0, step=1, | |
| label="Frame Number" | |
| ) | |
| preview_btn = gr.Button( | |
| "π Preview Detection", | |
| variant="secondary", | |
| elem_classes=["preview-btn"] | |
| ) | |
| # Output Video | |
| with gr.Tab("π¬ Result"): | |
| video_output = gr.Video( | |
| label="Blurred Video", | |
| ) | |
| status_text = gr.Markdown( | |
| "Ready to process", | |
| elem_classes=["status-text"] | |
| ) | |
| # Process Button | |
| process_btn = gr.Button( | |
| "π Process Video", | |
| variant="primary", | |
| size="lg", | |
| elem_classes=["process-btn"] | |
| ) | |
| # ββ Usage Guide ββ | |
| with gr.Accordion("π How to Use", open=False): | |
| gr.Markdown(""" | |
| ### Quick Start | |
| 1. **Upload** a video file (MP4, AVI, MOV, MKV supported) | |
| 2. **Type** the regions you want to blur in the text prompt | |
| 3. **Click** "Preview Detection" to check if the AI found the right objects | |
| 4. **Click** "Process Video" to apply the blur | |
| 5. **Download** your blurred video | |
| ### Tips for Better Results | |
| - Use **specific** descriptions: "face" works better than "person's head" | |
| - Separate multiple objects with **periods**: `face. license plate. text.` | |
| - Lower the **detection threshold** if objects aren't being found | |
| - Use **SAM 2 Video Tracking** mode for smooth, consistent blur across frames | |
| - Use **Frame-by-Frame** mode if objects appear/disappear frequently | |
| ### Processing Modes | |
| - **SAM 2 Video Tracking**: Detects on first frame, then tracks objects through the entire video using SAM 2's memory mechanism. Best for consistent results. | |
| - **Frame-by-Frame**: Runs detection periodically and reuses masks between keyframes. Faster but less temporally consistent. | |
| ### Running on ZeroGPU | |
| - The first run loads the models onto the GPU, so it's a bit slower; later runs are faster. | |
| - 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. | |
| - **Long video?** Open **βοΈ Trim** to pick a segment. Videos over the limit are trimmed automatically (to the first part, or your chosen start). | |
| - Want longer / higher-res? Upgrade the Space GPU and raise `max_video_duration`, `max_resolution` and the `@spaces.GPU(duration=...)` budget. | |
| """) | |
| # ββ Event Handlers ββ | |
| # Video upload β show info + set up trim controls | |
| video_input.change( | |
| fn=get_video_info_text, | |
| inputs=[video_input], | |
| outputs=[ | |
| video_info_text, frame_slider, | |
| trim_enable, trim_start, trim_length, trim_info, | |
| ] | |
| ) | |
| # Trim preview button | |
| trim_preview_btn.click( | |
| fn=preview_trim, | |
| inputs=[video_input, trim_start, trim_length], | |
| outputs=[trim_preview_video] | |
| ) | |
| # Preview button | |
| preview_btn.click( | |
| fn=preview_detection, | |
| inputs=[video_input, text_prompt, frame_slider], | |
| outputs=[preview_image] | |
| ) | |
| # Process button | |
| process_btn.click( | |
| fn=process_video, | |
| inputs=[ | |
| video_input, text_prompt, blur_type, blur_strength, | |
| edge_feather, processing_mode, keyframe_interval, | |
| detection_threshold, model_quality, | |
| trim_enable, trim_start, trim_length | |
| ], | |
| outputs=[video_output, status_text] | |
| ) | |
| return app | |
| # βββ Entry Point βββββββββββββββββββββββββββββββββββββββββββββββ | |
| if __name__ == "__main__": | |
| import argparse | |
| parser = argparse.ArgumentParser(description="Video Blur AI") | |
| parser.add_argument("--port", type=int, default=7860, help="Server port") | |
| parser.add_argument("--share", action="store_true", help="Create public link") | |
| parser.add_argument("--host", type=str, default="0.0.0.0", help="Server host") | |
| args = parser.parse_args() | |
| print("\n" + "=" * 60) | |
| print(" π¬ Video Blur AI - Starting...") | |
| print("=" * 60) | |
| app = create_app() | |
| app.queue() # required for long jobs + progress + ZeroGPU scheduling | |
| app.launch( | |
| server_name=args.host, | |
| server_port=args.port, | |
| share=args.share, | |
| show_api=False, # no public API page; also avoids schema edge cases | |
| ) | |