""" 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: @staticmethod 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 @spaces.GPU(duration=PROCESS_DURATION) 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 @spaces.GPU(duration=PREVIEW_DURATION) 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"
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)
" ) 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"""Type the regions you want to blur — faces, hands, text, plates, logos, anything.
Grounding DINO + SAM 2 · Runs on Hugging Face ZeroGPU
{limits_note}