Add dewatermark and locate_watermark tools (v0.7.0)

AI-powered watermark removal using ProPainter video inpainting:

dewatermark.py (2400+ lines):
- Cloud processing via RunPod serverless GPUs
- Local processing for NVIDIA GPU users
- Auto resize-ratio based on video duration (1.0 for <30s, 0.75 for <1min)
- Cloudflare R2 for reliable file transfer
- Automated --setup for one-command RunPod configuration
- Chunked processing for long videos
- Preset watermark regions (notebooklm, tiktok, stock variants)

locate_watermark.py:
- Coordinate grid overlay for identifying watermark positions
- Region verification across multiple frames
- Preset support for common watermarks

Infrastructure:
- docker/runpod-propainter/ - Serverless Docker image + handler
- docs/runpod-setup.md - Cloud GPU setup guide
- docs/optional-components.md - ML component documentation
- R2 + RunPod configuration in .env.example

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Conal Mullan
2025-12-30 20:22:16 +00:00
parent 2edb69dee9
commit 54ec952130
13 changed files with 4557 additions and 1 deletions
+20
View File
@@ -6,3 +6,23 @@ ELEVENLABS_API_KEY=your_api_key_here
# Find voice IDs at https://elevenlabs.io/app/voice-library
# Or create a voice clone and use that ID
ELEVENLABS_VOICE_ID=your_voice_id_here
# RunPod API key for cloud GPU processing (dewatermark tool)
# Get yours at https://runpod.io/console/user/settings
# See docs/runpod-setup.md for full setup guide
RUNPOD_API_KEY=your_api_key_here
# RunPod serverless endpoint ID
# Create endpoint at https://runpod.io/console/serverless
# Use the Docker image from docker/runpod-propainter/
RUNPOD_ENDPOINT_ID=your_endpoint_id_here
# Cloudflare R2 - Reliable file transfer for RunPod jobs (recommended)
# Free tier: 10GB storage, 10M ops/month, zero egress fees, no expiration
# Setup: https://dash.cloudflare.com → R2 Object Storage → Create bucket
# Then: Manage R2 API Tokens → Create API token (Object Read & Write)
# If not configured, falls back to free file hosting services (less reliable)
R2_ACCOUNT_ID=your_account_id_here
R2_ACCESS_KEY_ID=your_access_key_id_here
R2_SECRET_ACCESS_KEY=your_secret_access_key_here
R2_BUCKET_NAME=video-toolkit
+1
View File
@@ -89,3 +89,4 @@ examples/*/out/
# Playwright recordings (can be large)
playwright/output/*.mp4
playwright/recordings/*.mp4
.ai_dev/
+85 -1
View File
@@ -181,10 +181,93 @@ python tools/addmusic.py --input video.mp4 --music bg.mp3 --music-volume 0.2 --f
| Type | Tools | When to Use |
|------|-------|-------------|
| **Project tools** | voiceover, music, sfx | During video creation workflow |
| **Utility tools** | redub, addmusic, notebooklm_brand | Quick transformations on existing videos |
| **Utility tools** | redub, addmusic, notebooklm_brand, locate_watermark | Quick transformations on existing videos |
| **Optional tools** | dewatermark | Requires additional installation (see below) |
Utility tools work on any video file without requiring a project structure.
### Watermark Removal (Optional Component)
The `dewatermark.py` tool uses AI inpainting (ProPainter) to remove watermarks.
**Two processing modes:**
- **RunPod (cloud)** - Works from any machine, ~$0.05-0.30/video
- **Local** - Requires NVIDIA GPU with 8GB+ VRAM
```bash
# Cloud processing via RunPod (recommended for Mac users)
# Default outputs at 50% resolution for memory safety
python tools/dewatermark.py --input video.mp4 --region 1080,660,195,40 --output clean.mp4 --runpod
# Full resolution (may fail on some GPUs due to memory limits)
python tools/dewatermark.py --input video.mp4 --region 1080,660,195,40 --output clean.mp4 --runpod --resize-ratio 1.0
# Local processing (requires NVIDIA GPU + ProPainter installation)
python tools/dewatermark.py --input video.mp4 --region 1080,660,195,40 --output clean.mp4
# Check local installation status
python tools/dewatermark.py --status
# Install ProPainter for local processing (~2GB download)
python tools/dewatermark.py --install
```
**RunPod setup (for cloud processing):**
```bash
# 1. Add API key to .env
echo "RUNPOD_API_KEY=your_key_here" >> .env
# 2. Run automated setup
python tools/dewatermark.py --setup
# Done! The endpoint ID is automatically saved to .env
```
For manual setup or advanced options, see `docs/runpod-setup.md`.
**Hardware requirements (local mode):**
- **Required:** NVIDIA GPU (8GB+ VRAM)
- **Not supported:** Apple Silicon, CPU-only (use `--runpod` instead)
- **Disk:** ~2GB for model weights
**Installation location:** `~/.video-toolkit/propainter/`
### Locating Watermarks
Before removing a watermark, you need to identify its exact coordinates. The `locate_watermark.py` tool helps with this.
**Requires:** ImageMagick (`brew install imagemagick`)
```bash
# Explore with coordinate grid overlay
python tools/locate_watermark.py --input video.mp4 --grid --output-dir ./review/
# Use a preset for common watermarks
python tools/locate_watermark.py --input video.mp4 --preset notebooklm --verify
# Verify custom region across multiple frames
python tools/locate_watermark.py --input video.mp4 --region 1100,650,150,50 --verify
# List available presets
python tools/locate_watermark.py --list-presets
```
**Workflow:**
1. Extract frames with `--grid` to identify watermark position
2. Note coordinates from the grid overlay
3. Verify with `--region x,y,w,h --verify` across multiple frames
4. Use confirmed region with `dewatermark.py`
**Presets:** notebooklm, tiktok, stock-br, stock-bl, stock-center
**Options:**
- `--samples N` - Number of frames to extract (default: 5)
- `--grid` - Overlay coordinate grid
- `--mark` - Draw rectangle on frames
- `--verify` - Mark region across multiple frames for verification
- `--crop` - Also output cropped watermark regions
- `--open` - Open output directory in Finder (macOS)
### Redub Sync Mode
The `--sync` flag enables word-level time remapping for redubbing. This is essential when the TTS voice speaks at a different pace than the original.
@@ -501,3 +584,4 @@ Keep these separate. Don't mix toolkit improvements with video production.
- `docs/getting-started.md` - First video walkthrough
- `docs/creating-templates.md` - Build new templates
- `docs/creating-brands.md` - Create brand profiles
- `docs/optional-components.md` - Setup for optional ML-based tools (ProPainter, etc.)
+41
View File
@@ -162,6 +162,47 @@
"status": "beta",
"created": "2025-12-28",
"updated": "2025-12-28"
},
"dewatermark": {
"path": "tools/dewatermark.py",
"description": "Remove watermarks using AI inpainting (ProPainter) - supports local and RunPod cloud",
"usage": "python tools/dewatermark.py --input video.mp4 --region 1080,660,195,40 --output clean.mp4 --runpod",
"status": "beta",
"optional": true,
"requires": "RunPod account OR local NVIDIA GPU + ProPainter (~2GB)",
"created": "2025-12-29",
"updated": "2025-12-30"
}
},
"optionalComponents": {
"propainter": {
"name": "ProPainter",
"description": "AI video inpainting for watermark removal (local mode)",
"installPath": "~/.video-toolkit/propainter/",
"repository": "https://github.com/sczhou/ProPainter.git",
"diskSpace": "~2GB",
"hardware": {
"recommended": "NVIDIA GPU (8GB+ VRAM)",
"notSupported": "Apple Silicon, CPU (use --runpod instead)",
"memory": "8-10GB VRAM for 720p with fp16"
},
"installCommand": "python tools/dewatermark.py --install",
"statusCommand": "python tools/dewatermark.py --status",
"documentation": "docs/optional-components.md"
}
},
"cloudProviders": {
"runpod": {
"name": "RunPod",
"description": "Serverless GPU cloud for video processing",
"dockerImage": "docker/runpod-propainter/",
"documentation": "docs/runpod-setup.md",
"operations": ["dewatermark"],
"envVars": ["RUNPOD_API_KEY", "RUNPOD_ENDPOINT_ID"],
"estimatedCost": "$0.05-0.30 per video",
"created": "2025-12-30"
}
},
+80
View File
@@ -0,0 +1,80 @@
# RunPod Serverless handler for ProPainter (dewatermark)
#
# Build: docker build -t yourusername/video-toolkit-propainter:latest .
# Push: docker push yourusername/video-toolkit-propainter:latest
#
# Image size: ~4GB (includes pre-baked model weights for fast cold starts)
#
# Version: 2.0.0 - CUDA 12.4, PyTorch 2.4, fixed GPU detection
FROM nvidia/cuda:12.4.1-cudnn-runtime-ubuntu22.04
# Prevent interactive prompts during package installation
ENV DEBIAN_FRONTEND=noninteractive
# Install Python and system dependencies
RUN apt-get update && apt-get install -y \
python3.10 \
python3-pip \
python3.10-venv \
git \
ffmpeg \
curl \
&& rm -rf /var/lib/apt/lists/* \
&& ln -sf /usr/bin/python3.10 /usr/bin/python3 \
&& ln -sf /usr/bin/python3.10 /usr/bin/python
WORKDIR /app
# Install PyTorch with CUDA 12.4 support
RUN pip3 install --no-cache-dir \
torch==2.4.1 \
torchvision==0.19.1 \
--index-url https://download.pytorch.org/whl/cu124
# Clone ProPainter repository
RUN git clone --depth 1 https://github.com/sczhou/ProPainter.git /app/propainter
# Install ProPainter requirements
# PyTorch 2.4+ has better numpy 2.x compatibility, but ProPainter may still need numpy 1.x
WORKDIR /app/propainter
RUN pip3 install --no-cache-dir "numpy>=1.26,<2" && \
pip3 install --no-cache-dir -r requirements.txt
# Pre-download model weights (baked into image for ~30s cold starts vs ~2min)
# Weights are ~2GB total
RUN mkdir -p /app/propainter/weights && \
echo "Downloading ProPainter.pth..." && \
curl -L -o /app/propainter/weights/ProPainter.pth \
"https://github.com/sczhou/ProPainter/releases/download/v0.1.0/ProPainter.pth" && \
echo "Downloading recurrent_flow_completion.pth..." && \
curl -L -o /app/propainter/weights/recurrent_flow_completion.pth \
"https://github.com/sczhou/ProPainter/releases/download/v0.1.0/recurrent_flow_completion.pth" && \
echo "Downloading raft-things.pth..." && \
curl -L -o /app/propainter/weights/raft-things.pth \
"https://github.com/sczhou/ProPainter/releases/download/v0.1.0/raft-things.pth" && \
echo "Downloading i3d_rgb_imagenet.pt..." && \
curl -L -o /app/propainter/weights/i3d_rgb_imagenet.pt \
"https://github.com/sczhou/ProPainter/releases/download/v0.1.0/i3d_rgb_imagenet.pt" && \
echo "All weights downloaded successfully"
# Install RunPod SDK and additional utilities
RUN pip3 install --no-cache-dir \
runpod>=1.7.0 \
requests>=2.31.0 \
boto3>=1.34.0
# Copy handler
WORKDIR /app
COPY handler.py /app/handler.py
# Environment
ENV PYTHONUNBUFFERED=1
# NOTE: Do NOT set CUDA_VISIBLE_DEVICES here - RunPod sets this dynamically
# to assign the correct GPU to each serverless worker
# Health check - verify CUDA is available
RUN python3 -c "import torch; print(f'PyTorch {torch.__version__}, CUDA available: {torch.cuda.is_available()}')"
# Run handler
CMD ["python3", "-u", "/app/handler.py"]
+235
View File
@@ -0,0 +1,235 @@
# RunPod ProPainter Docker Image
Serverless GPU handler for video watermark removal using ProPainter AI inpainting.
## Quick Start
### Option A: Use Pre-built Public Image (Recommended)
A public image is available on GitHub Container Registry:
```
ghcr.io/conalmullan/video-toolkit-propainter:latest
```
Skip to **Step 2: Deploy on RunPod** below.
### Option B: Build Your Own Image
```bash
cd docker/runpod-propainter
# Build for linux/amd64 (required for RunPod)
docker buildx build --platform linux/amd64 -t yourusername/video-toolkit-propainter:latest --push .
```
Build takes ~15-20 minutes (downloads ~2GB of model weights).
### Deploy on RunPod
1. Go to [RunPod Serverless](https://www.runpod.io/console/serverless)
2. Click **New Endpoint**
3. Configure:
- **Docker Image**: `ghcr.io/conalmullan/video-toolkit-propainter:latest`
- **GPU**: RTX 3090 or RTX 4090 (24GB VRAM recommended)
- **Max Workers**: 1 (scale up as needed)
- **Idle Timeout**: 5 seconds (fast scale-down)
- **Execution Timeout**: 3600 seconds (1 hour max)
4. Copy the **Endpoint ID** for your `.env` file
### Configure Local Tool
Add to your `.env`:
```bash
RUNPOD_API_KEY=your_api_key_here
RUNPOD_ENDPOINT_ID=your_endpoint_id_here
```
### Use It
```bash
python tools/dewatermark.py \
--input video.mp4 \
--region 1080,660,195,40 \
--output clean.mp4 \
--runpod
```
## Image Details
| Property | Value |
|----------|-------|
| Base | `nvidia/cuda:12.1.0-cudnn8-runtime-ubuntu22.04` |
| Size | ~4GB |
| Cold Start | ~30 seconds |
| Python | 3.10 |
| PyTorch | 2.1.0 + CUDA 12.1 |
### Pre-baked Components
- ProPainter repository
- Model weights (~2GB):
- ProPainter.pth
- recurrent_flow_completion.pth
- raft-things.pth
- i3d_rgb_imagenet.pt
- FFmpeg for video processing
- RunPod SDK
## API Reference
### Input Format
```json
{
"input": {
"operation": "dewatermark",
"video_url": "https://example.com/video.mp4",
"region": "1080,660,195,40"
}
}
```
Or with a pre-made mask:
```json
{
"input": {
"operation": "dewatermark",
"video_url": "https://example.com/video.mp4",
"mask_url": "https://example.com/mask.png"
}
}
```
### Output Format
```json
{
"success": true,
"output_url": "https://runpod-storage.../job123_dewatermarked.mp4",
"video_dimensions": "1920x1080",
"video_duration_seconds": 45.5,
"gpu_vram_gb": 24,
"profile_used": {
"subvideo_length": 60,
"neighbor_length": 10,
"ref_stride": 10
},
"processing_time_seconds": 120.5
}
```
### Error Format
```json
{
"error": "Description of what went wrong"
}
```
## GPU Memory Profiles
The handler auto-detects GPU VRAM and selects optimal settings:
| VRAM | subvideo_length | neighbor_length | ref_stride | Speed |
|------|-----------------|-----------------|------------|-------|
| 8GB | 30 | 5 | 25 | Slow |
| 12GB | 40 | 5 | 20 | Medium |
| 16GB | 50 | 8 | 15 | Good |
| 24GB | 60 | 10 | 10 | Fast |
| 48GB | 80 | 10 | 10 | Fastest |
**Recommendation**: Use RTX 3090 or RTX 4090 (24GB) for best price/performance.
## Cost Estimates
Using RTX 3090 (~$0.34/hr):
| Video Length | Processing Time | Estimated Cost |
|--------------|-----------------|----------------|
| < 30 seconds | 2-5 minutes | ~$0.02 |
| 30s - 2 min | 5-15 minutes | ~$0.08 |
| 2 - 5 min | 15-45 minutes | ~$0.25 |
| > 5 min | 45+ minutes | ~$0.40+ |
## Local Testing
Test the image locally with NVIDIA GPU:
```bash
# Build
docker build -t propainter-test .
# Run interactive shell
docker run --gpus all -it propainter-test /bin/bash
# Inside container, test GPU
python3 -c "import torch; print(f'CUDA: {torch.cuda.is_available()}')"
# Test handler with mock job
python3 -c "
from handler import handler
result = handler({
'id': 'test123',
'input': {
'operation': 'dewatermark',
'video_url': 'https://example.com/test.mp4',
'region': '100,100,50,50'
}
})
print(result)
"
```
## Troubleshooting
### "CUDA out of memory"
The video is too long for the GPU. Options:
1. Use a GPU with more VRAM
2. The local tool will auto-chunk long videos (coming soon)
### "Failed to download video"
- Check the video URL is publicly accessible
- URLs must be direct downloads (not web pages)
- Timeout is 5 minutes for download
### "No output file found"
ProPainter failed silently. Check:
- Video format is supported (MP4, MOV, AVI)
- Mask dimensions match video dimensions
- Region coordinates are valid
### Cold start is slow
First request after idle takes ~30s to load models. Subsequent requests are faster. Consider:
- Setting longer idle timeout (but costs more)
- Using "always on" worker for frequent usage
## Extending for New Operations
The handler is designed for extensibility. To add a new GPU operation:
1. Add handler function in `handler.py`:
```python
def handle_upscale(job_input: dict, job_id: str, work_dir: Path) -> dict:
# Implementation
pass
```
2. Register in main handler:
```python
if operation == "upscale":
return handle_upscale(job_input, job_id, work_dir)
```
3. Rebuild and push image
Future operations might include:
- `upscale` - Video upscaling with Real-ESRGAN
- `denoise` - Audio/video denoising
- `stabilize` - Video stabilization
+627
View File
@@ -0,0 +1,627 @@
#!/usr/bin/env python3
"""
RunPod serverless handler for video toolkit GPU operations.
Currently supports:
- dewatermark: Remove watermarks using ProPainter AI inpainting
Extensible design for future GPU tools (upscaling, denoising, etc.).
Input format:
{
"operation": "dewatermark",
"video_url": "https://...",
"region": "x,y,width,height", # OR
"mask_url": "https://..." # Pre-made mask image
}
Output format:
{
"success": true,
"output_url": "https://...",
"video_dimensions": "1920x1080",
"gpu_vram_gb": 24,
"processing_time_seconds": 120.5
}
"""
import os
import shutil
import subprocess
import sys
import tempfile
import time
from pathlib import Path
from typing import Optional
import requests
import runpod
# ProPainter installation path (baked into Docker image)
PROPAINTER_PATH = Path("/app/propainter")
# Memory profiles based on GPU VRAM (GB)
# CONSERVATIVE settings to avoid OOM - ProPainter's RAFT optical flow is extremely memory hungry
# Key parameters:
# - subvideo_length: frames per batch (lower = less memory, more batches)
# - neighbor_length: local temporal context (lower = less memory)
# - ref_stride: global reference sampling (higher = fewer refs = less memory)
#
# NOTE: These are deliberately conservative. ProPainter defaults are:
# subvideo_length=80, neighbor_length=10, ref_stride=10
# But those OOM on long videos even with 80GB VRAM.
MEMORY_PROFILES = {
8: {"subvideo_length": 20, "neighbor_length": 3, "ref_stride": 30}, # 8GB - minimal
11: {"subvideo_length": 25, "neighbor_length": 4, "ref_stride": 25}, # 12GB cards
15: {"subvideo_length": 30, "neighbor_length": 5, "ref_stride": 20}, # 16GB cards
22: {"subvideo_length": 40, "neighbor_length": 5, "ref_stride": 15}, # 24GB cards (3090, 4090)
45: {"subvideo_length": 50, "neighbor_length": 5, "ref_stride": 15}, # 48GB cards (A6000, A40)
75: {"subvideo_length": 60, "neighbor_length": 5, "ref_stride": 15}, # 80GB cards (A100, H100)
}
def log(message: str) -> None:
"""Log message to stderr (visible in RunPod logs)."""
print(message, file=sys.stderr, flush=True)
def get_gpu_vram_gb() -> int:
"""Detect GPU VRAM using PyTorch (respects CUDA_VISIBLE_DEVICES set by RunPod)."""
try:
import torch
if torch.cuda.is_available():
# Get the current device (respects CUDA_VISIBLE_DEVICES)
device_id = torch.cuda.current_device()
props = torch.cuda.get_device_properties(device_id)
vram_bytes = props.total_memory
vram_gb = vram_bytes // (1024 ** 3)
log(f"Detected GPU: {props.name}, VRAM: {vram_gb}GB ({vram_bytes // (1024**2)}MB)")
return vram_gb
else:
log("Warning: CUDA not available")
except Exception as e:
log(f"Warning: Could not detect GPU VRAM via torch: {e}")
# Fallback to nvidia-smi if torch detection fails
try:
result = subprocess.run(
["nvidia-smi", "--query-gpu=memory.total", "--format=csv,noheader,nounits"],
capture_output=True,
text=True,
timeout=10
)
if result.returncode == 0:
lines = result.stdout.strip().split('\n')
vram_values = [int(line.strip()) for line in lines if line.strip()]
if vram_values:
max_vram_mb = max(vram_values)
log(f"Fallback nvidia-smi detection: {max_vram_mb // 1024}GB")
return max_vram_mb // 1024
except Exception as e:
log(f"Warning: nvidia-smi fallback also failed: {e}")
return 16 # Default assumption
def get_memory_profile(vram_gb: int) -> dict:
"""Get optimal ProPainter settings based on available VRAM."""
for threshold in sorted(MEMORY_PROFILES.keys(), reverse=True):
if vram_gb >= threshold:
return MEMORY_PROFILES[threshold].copy()
return MEMORY_PROFILES[8].copy()
def download_file(url: str, output_path: str, description: str = "file") -> bool:
"""Download file from URL with progress logging."""
try:
log(f"Downloading {description} from {url[:80]}...")
response = requests.get(url, stream=True, timeout=300)
response.raise_for_status()
total_size = int(response.headers.get('content-length', 0))
downloaded = 0
with open(output_path, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192):
f.write(chunk)
downloaded += len(chunk)
if total_size > 0 and downloaded % (1024 * 1024) == 0:
pct = (downloaded / total_size) * 100
log(f" Downloaded {downloaded // (1024*1024)}MB ({pct:.0f}%)")
log(f" Downloaded {description}: {Path(output_path).stat().st_size // (1024*1024)}MB")
return True
except Exception as e:
log(f"Error downloading {description}: {e}")
return False
def upload_to_r2(file_path: str, job_id: str, r2_config: dict) -> tuple[Optional[str], Optional[str]]:
"""Upload file to Cloudflare R2 and return (presigned_url, object_key)."""
try:
import boto3
from botocore.config import Config
import uuid
log(f"Uploading result to R2 ({Path(file_path).stat().st_size // (1024*1024)}MB)...")
client = boto3.client(
"s3",
endpoint_url=r2_config["endpoint_url"],
aws_access_key_id=r2_config["access_key_id"],
aws_secret_access_key=r2_config["secret_access_key"],
config=Config(signature_version="s3v4"),
)
object_key = f"dewatermark/results/{job_id}_{uuid.uuid4().hex[:8]}.mp4"
client.upload_file(file_path, r2_config["bucket_name"], object_key)
# Generate presigned URL (valid for 2 hours)
presigned_url = client.generate_presigned_url(
"get_object",
Params={"Bucket": r2_config["bucket_name"], "Key": object_key},
ExpiresIn=7200,
)
log(f" R2 upload complete: {object_key}")
return presigned_url, object_key
except ImportError:
log("Error: boto3 not available for R2 upload")
return None, None
except Exception as e:
log(f"Error uploading to R2: {e}")
return None, None
def upload_file(file_path: str, job_id: str, r2_config: Optional[dict] = None) -> dict:
"""
Upload file and return upload info.
Returns dict with:
- output_url: Presigned URL for download (always present if successful)
- r2_key: R2 object key (only if R2 was used)
"""
# Try R2 first if configured
if r2_config:
url, r2_key = upload_to_r2(file_path, job_id, r2_config)
if url:
return {"output_url": url, "r2_key": r2_key}
log("R2 upload failed, falling back to RunPod storage")
# Fall back to RunPod storage
try:
log(f"Uploading result to RunPod storage ({Path(file_path).stat().st_size // (1024*1024)}MB)...")
result_url = runpod.serverless.utils.rp_upload.upload_file_to_bucket(
file_name=f"{job_id}_dewatermarked.mp4",
file_location=file_path
)
log(f" Upload complete: {result_url[:80]}...")
return {"output_url": result_url}
except Exception as e:
log(f"Error uploading file: {e}")
return {}
def get_video_info(video_path: str) -> dict:
"""Get video dimensions, duration, fps, and frame count using ffprobe."""
try:
result = subprocess.run([
"ffprobe", "-v", "error",
"-select_streams", "v:0",
"-show_entries", "stream=width,height,duration,r_frame_rate,nb_frames",
"-show_entries", "format=duration",
"-of", "json",
video_path,
], capture_output=True, text=True, timeout=30)
if result.returncode == 0:
import json
data = json.loads(result.stdout)
stream = data.get("streams", [{}])[0]
fmt = data.get("format", {})
# Parse frame rate (can be "30/1" or "29.97")
fps_str = stream.get("r_frame_rate", "30/1")
if "/" in str(fps_str):
num, den = str(fps_str).split("/")
fps = float(num) / float(den) if float(den) != 0 else 30.0
else:
fps = float(fps_str) if fps_str else 30.0
duration = float(stream.get("duration") or fmt.get("duration", 0))
# Calculate frame count
nb_frames = stream.get("nb_frames")
if nb_frames:
frame_count = int(nb_frames)
else:
frame_count = int(duration * fps)
return {
"width": int(stream.get("width", 0)),
"height": int(stream.get("height", 0)),
"duration": duration,
"fps": fps,
"frame_count": frame_count,
}
except Exception as e:
log(f"Warning: Could not get video info: {e}")
return {"width": 0, "height": 0, "duration": 0, "fps": 30.0, "frame_count": 0}
# Memory estimation constants (empirically determined from ProPainter testing)
# ProPainter uses ~6.5MB per frame at 720p for RGB tensors, flow, masks, etc.
BYTES_PER_FRAME_720P = 6.5 * 1024 * 1024
def calculate_safe_resize_ratio(
vram_gb: int,
width: int,
height: int,
frame_count: int,
requested_ratio: float = 1.0,
safety_margin: float = 0.7,
) -> tuple[float, str]:
"""
Calculate a safe resize_ratio based on available VRAM and video properties.
Returns (resize_ratio, reason) tuple.
The ratio is the MINIMUM of:
- requested_ratio (what the user asked for)
- calculated safe ratio (based on memory estimation)
Args:
vram_gb: Available GPU VRAM in GB
width: Video width in pixels
height: Video height in pixels
frame_count: Number of frames in video
requested_ratio: User's requested resize ratio (default 1.0 = full res)
safety_margin: Use this fraction of VRAM (default 0.7 = 70%)
Returns:
(resize_ratio, reason): The ratio to use and why
"""
# Calculate memory needed at full resolution
pixels = width * height
pixels_720p = 1280 * 720
scale_factor = pixels / pixels_720p
bytes_per_frame = BYTES_PER_FRAME_720P * scale_factor
total_bytes_full_res = bytes_per_frame * frame_count
# Add overhead for model weights, intermediate tensors, etc. (~2GB base)
model_overhead_bytes = 2 * (1024 ** 3)
total_needed_full_res = total_bytes_full_res + model_overhead_bytes
# Available memory with safety margin
available_bytes = vram_gb * (1024 ** 3) * safety_margin
# If full resolution fits, use requested ratio
if total_needed_full_res <= available_bytes:
log(f"Memory estimate: {total_needed_full_res / (1024**3):.1f}GB needed, {available_bytes / (1024**3):.1f}GB available - full resolution OK")
return (requested_ratio, "full_resolution_fits")
# Calculate the resize ratio needed to fit in memory
# Memory scales with resize_ratio^2 (both width and height reduced)
# So: needed_memory * ratio^2 + overhead = available
# ratio^2 = (available - overhead) / frame_memory
# ratio = sqrt((available - overhead) / frame_memory)
frame_memory = total_bytes_full_res - model_overhead_bytes
if frame_memory <= 0:
return (0.5, "fallback_conservative")
usable_for_frames = available_bytes - model_overhead_bytes
if usable_for_frames <= 0:
return (0.25, "very_low_vram")
import math
safe_ratio = math.sqrt(usable_for_frames / frame_memory)
# Clamp to reasonable range [0.25, 1.0]
safe_ratio = max(0.25, min(1.0, safe_ratio))
# Use the more conservative of user request and calculated safe ratio
final_ratio = min(requested_ratio, safe_ratio)
# Round to nice values for consistency
nice_ratios = [1.0, 0.75, 0.5, 0.375, 0.25]
for nice in nice_ratios:
if final_ratio >= nice:
final_ratio = nice
break
log(f"Memory estimate: {total_needed_full_res / (1024**3):.1f}GB needed at full res, {available_bytes / (1024**3):.1f}GB available")
log(f"Calculated safe ratio: {safe_ratio:.2f}, using: {final_ratio}")
reason = "auto_calculated" if final_ratio < requested_ratio else "user_requested"
return (final_ratio, reason)
def create_mask_from_region(region: str, width: int, height: int, output_path: str) -> bool:
"""Create white-on-black mask image from x,y,w,h region string."""
try:
parts = [v.strip() for v in region.split(",")]
if len(parts) != 4:
log(f"Error: Region must be x,y,width,height - got: {region}")
return False
x, y, w, h = [int(p) for p in parts]
# Validate bounds
if x < 0 or y < 0 or w <= 0 or h <= 0:
log(f"Error: Invalid region values: {region}")
return False
if x + w > width or y + h > height:
log(f"Error: Region {region} exceeds video dimensions {width}x{height}")
return False
log(f"Creating mask: {w}x{h} region at ({x},{y}) on {width}x{height} canvas")
result = subprocess.run([
"ffmpeg", "-y",
"-f", "lavfi",
"-i", f"color=black:s={width}x{height}:d=1",
"-vf", f"drawbox=x={x}:y={y}:w={w}:h={h}:c=white:t=fill",
"-frames:v", "1",
output_path,
], capture_output=True, text=True, timeout=30)
if result.returncode != 0:
log(f"FFmpeg error: {result.stderr}")
return False
return Path(output_path).exists()
except Exception as e:
log(f"Error creating mask: {e}")
return False
def run_propainter(
video_path: str,
mask_path: str,
output_dir: str,
profile: dict,
fp16: bool = True,
resize_ratio: float = 1.0
) -> Optional[str]:
"""Run ProPainter inference and return path to output video."""
inference_script = PROPAINTER_PATH / "inference_propainter.py"
cmd = [
"python3", str(inference_script),
"-i", video_path,
"-m", mask_path,
"-o", output_dir,
"--neighbor_length", str(profile["neighbor_length"]),
"--ref_stride", str(profile["ref_stride"]),
"--subvideo_length", str(profile["subvideo_length"]),
]
if fp16:
cmd.append("--fp16")
if resize_ratio != 1.0:
cmd.extend(["--resize_ratio", str(resize_ratio)])
log(f"Running ProPainter with settings: {profile}, resize_ratio={resize_ratio}")
log(f"Command: {' '.join(cmd)}")
start_time = time.time()
result = subprocess.run(
cmd,
cwd=PROPAINTER_PATH,
capture_output=True,
text=True,
timeout=3600 # 1 hour max
)
elapsed = time.time() - start_time
log(f"ProPainter completed in {elapsed:.1f}s")
if result.returncode != 0:
log(f"ProPainter error (exit {result.returncode}):")
log("=== STDOUT (last 3000 chars) ===")
log(result.stdout[-3000:] if result.stdout else "No stdout")
log("=== STDERR (last 3000 chars) ===")
log(result.stderr[-3000:] if result.stderr else "No stderr")
return None
# Log success output for debugging
log("=== ProPainter completed successfully ===")
if result.stdout:
log(f"STDOUT (last 1000 chars): {result.stdout[-1000:]}")
# Find output file - ProPainter creates: output_dir/video_name/inpaint_out.mp4
video_stem = Path(video_path).stem
expected = Path(output_dir) / video_stem / "inpaint_out.mp4"
if expected.exists():
log(f"Output found: {expected}")
return str(expected)
# Fallback: search for inpaint_out.mp4 anywhere in results
for mp4 in Path(output_dir).rglob("inpaint_out.mp4"):
log(f"Output found (search): {mp4}")
return str(mp4)
# Last resort: any mp4 that's NOT masked_in.mp4
for mp4 in Path(output_dir).rglob("*.mp4"):
if "masked" not in mp4.name:
log(f"Output found (fallback): {mp4}")
return str(mp4)
log(f"Error: No output file found in {output_dir}")
return None
def handle_dewatermark(job_input: dict, job_id: str, work_dir: Path) -> dict:
"""
Handle dewatermark operation using ProPainter.
Required inputs:
video_url: URL to video file
region: "x,y,width,height" OR mask_url: URL to mask image
Optional inputs:
fp16: Use half precision (default: true, faster)
resize_ratio: Scale factor for processing (default: "auto" - calculated based on VRAM)
Set to a specific value (0.25-1.0) to override auto-calculation
r2: R2 config for result upload (endpoint_url, access_key_id, secret_access_key, bucket_name)
"""
start_time = time.time()
# Validate inputs
video_url = job_input.get("video_url")
region = job_input.get("region")
mask_url = job_input.get("mask_url")
fp16 = job_input.get("fp16", True)
requested_resize_ratio = job_input.get("resize_ratio", "auto") # Default to auto-calculation
r2_config = job_input.get("r2") # Optional R2 config for result upload
if not video_url:
return {"error": "Missing required 'video_url' in input"}
if not region and not mask_url:
return {"error": "Either 'region' (x,y,w,h) or 'mask_url' is required"}
if r2_config:
log("R2 config provided - will upload result to R2")
log(f"Processing options: fp16={fp16}, requested_resize_ratio={requested_resize_ratio}")
# Download video
video_path = str(work_dir / "input_video.mp4")
if not download_file(video_url, video_path, "video"):
return {"error": "Failed to download video from URL"}
# Get video info
video_info = get_video_info(video_path)
width, height = video_info["width"], video_info["height"]
duration = video_info["duration"]
frame_count = video_info["frame_count"]
if not width or not height:
return {"error": "Could not read video dimensions"}
log(f"Video: {width}x{height}, {duration:.1f}s, {frame_count} frames")
# Detect GPU and get optimal settings
vram_gb = get_gpu_vram_gb()
profile = get_memory_profile(vram_gb)
log(f"GPU VRAM: {vram_gb}GB, using profile: {profile}")
# Calculate safe resize_ratio based on VRAM and video properties
if requested_resize_ratio == "auto":
# Auto mode: calculate optimal ratio, aim for full resolution if possible
resize_ratio, resize_reason = calculate_safe_resize_ratio(
vram_gb, width, height, frame_count, requested_ratio=1.0
)
else:
# User specified a ratio - use it but warn if it might OOM
user_ratio = float(requested_resize_ratio)
safe_ratio, _ = calculate_safe_resize_ratio(
vram_gb, width, height, frame_count, requested_ratio=user_ratio
)
if safe_ratio < user_ratio:
log(f"WARNING: Requested resize_ratio={user_ratio} may cause OOM. Safe ratio is {safe_ratio}")
resize_ratio = user_ratio
resize_reason = "user_specified"
log(f"Using resize_ratio={resize_ratio} ({resize_reason})")
# Prepare mask
mask_path = str(work_dir / "mask.png")
if mask_url:
if not download_file(mask_url, mask_path, "mask"):
return {"error": "Failed to download mask from URL"}
else:
if not create_mask_from_region(region, width, height, mask_path):
return {"error": f"Failed to create mask from region: {region}"}
# Run ProPainter
output_dir = str(work_dir / "results")
os.makedirs(output_dir, exist_ok=True)
result_path = run_propainter(video_path, mask_path, output_dir, profile, fp16, resize_ratio)
if not result_path:
return {"error": "ProPainter processing failed - check logs for details"}
# Upload result (to R2 if configured, otherwise RunPod storage)
upload_result = upload_file(result_path, job_id, r2_config)
if not upload_result.get("output_url"):
return {"error": "Failed to upload result video"}
elapsed = time.time() - start_time
result = {
"success": True,
"output_url": upload_result["output_url"],
"video_dimensions": f"{width}x{height}",
"video_duration_seconds": round(duration, 2),
"video_frame_count": frame_count,
"gpu_vram_gb": vram_gb,
"profile_used": profile,
"resize_ratio": resize_ratio,
"resize_reason": resize_reason,
"processing_time_seconds": round(elapsed, 2),
}
# Include R2 key if result was uploaded to R2
if upload_result.get("r2_key"):
result["r2_key"] = upload_result["r2_key"]
return result
def handler(job: dict) -> dict:
"""
Main RunPod handler - routes to specific operations.
Supports operations:
- dewatermark: Remove watermarks using ProPainter
- (future: upscale, denoise, etc.)
"""
job_id = job.get("id", "unknown")
job_input = job.get("input", {})
operation = job_input.get("operation", "dewatermark")
log(f"Job {job_id}: operation={operation}")
# Create temp working directory
work_dir = Path(tempfile.mkdtemp(prefix=f"runpod_{job_id}_"))
log(f"Working directory: {work_dir}")
try:
if operation == "dewatermark":
return handle_dewatermark(job_input, job_id, work_dir)
else:
return {"error": f"Unknown operation: {operation}. Supported: dewatermark"}
except Exception as e:
import traceback
log(f"Handler exception: {e}")
log(traceback.format_exc())
return {"error": f"Internal error: {str(e)}"}
finally:
# Cleanup temp files
try:
shutil.rmtree(work_dir, ignore_errors=True)
log("Cleaned up working directory")
except Exception:
pass
# RunPod serverless entry point
if __name__ == "__main__":
log("Starting RunPod ProPainter handler...")
log(f"ProPainter path: {PROPAINTER_PATH}")
log(f"Weights exist: {(PROPAINTER_PATH / 'weights').exists()}")
runpod.serverless.start({"handler": handler})
+110
View File
@@ -0,0 +1,110 @@
# Optional Components
Some toolkit features require additional software that isn't included by default. These **optional components** are:
- Installed on-demand (not part of base toolkit)
- Stored in `~/.video-toolkit/` (outside the project)
- Only needed for specific use cases
## Available Optional Components
| Component | Tool | Purpose | Size |
|-----------|------|---------|------|
| ProPainter | `dewatermark.py` | AI video inpainting for watermark removal | ~2GB |
## ProPainter (Watermark Removal)
[ProPainter](https://github.com/sczhou/ProPainter) is an AI video inpainting model that can intelligently remove watermarks by reconstructing the underlying content.
### Hardware Requirements
| Hardware | Status | Notes |
|----------|--------|-------|
| NVIDIA GPU (8GB+ VRAM) | **Supported** | Recommended, ~5-15 min per minute of video |
| Cloud GPU (RunPod, etc.) | **Supported** | Good alternative, ~$0.20-0.50 per video |
| Apple Silicon (M1/M2/M3/M4) | **Not supported** | MPS is too slow (40+ hours for short videos) |
| CPU only | **Not supported** | Impractical processing times |
### Why Apple Silicon Doesn't Work
ProPainter relies on optical flow (RAFT) which performs extremely poorly on Apple's MPS backend:
1. **MPS INT_MAX Limit**: MPS cannot handle tensors > 2^31 elements, limiting chunks to ~32 seconds at 720p
2. **MPS Performance**: Optical flow on MPS is orders of magnitude slower than CUDA
3. **Real-world result**: 5 seconds of video takes 4+ hours on M1/M2/M3/M4
This is a PyTorch/MPS limitation, not something we can fix in the tool.
### Installation
```bash
# Check current status
python tools/dewatermark.py --status
# Install ProPainter
python tools/dewatermark.py --install
```
This will:
1. Clone ProPainter to `~/.video-toolkit/propainter/`
2. Create a Python virtual environment
3. Install PyTorch and dependencies
4. Download model weights (~2GB)
### Usage
**Remove watermark by specifying region:**
```bash
python tools/dewatermark.py \
--input video.mp4 \
--region 1080,660,195,40 \
--output clean.mp4
```
**Use a custom mask image:**
```bash
python tools/dewatermark.py \
--input video.mp4 \
--mask mask.png \
--output clean.mp4
```
### Finding Watermark Coordinates
Use the `locate_watermark.py` helper:
```bash
# Extract frames with coordinate grid
python tools/locate_watermark.py --input video.mp4 --grid --output-dir ./review/
# Verify a region across multiple frames
python tools/locate_watermark.py --input video.mp4 --region 1100,650,150,50 --verify
```
### Cloud GPU Alternative
For users without NVIDIA GPUs, cloud services offer affordable processing:
| Provider | GPU | Cost | Processing Time |
|----------|-----|------|-----------------|
| RunPod | RTX 4090 | ~$0.34/hr | ~15-30 min for 3-min video |
| RunPod | A100 | ~$1.99/hr | ~5-15 min for 3-min video |
| Vast.ai | RTX 3090 | ~$0.20/hr | ~20-40 min for 3-min video |
Both RunPod and Vast.ai have Python APIs for programmatic access.
### Uninstalling
```bash
rm -rf ~/.video-toolkit/propainter
```
## Future Optional Components
The optional components system is designed to support additional ML-based tools:
- **Video upscaling** (Real-ESRGAN, etc.)
- **Audio enhancement** (noise removal, etc.)
- **Scene detection** (automatic scene splitting)
These will follow the same pattern: install on first use, stored in `~/.video-toolkit/`.
+312
View File
@@ -0,0 +1,312 @@
# RunPod Cloud GPU Setup
This guide covers setting up RunPod serverless GPUs for watermark removal (and future GPU-intensive video tools).
## Why RunPod?
The dewatermark tool uses ProPainter, an AI inpainting model that requires significant GPU power:
| Hardware | Processing Time (30s video) | Viable? |
|----------|----------------------------|---------|
| NVIDIA RTX 3090 | 2-5 minutes | Yes |
| Apple Silicon M1/M2/M3 | 4+ hours | No |
| CPU only | 10+ hours | No |
RunPod provides on-demand NVIDIA GPUs at ~$0.34/hour, making it cost-effective for occasional use (~$0.05-0.30 per video).
## Quick Start (Automated)
The fastest way to set up RunPod:
```bash
# 1. Add your RunPod API key to .env
echo "RUNPOD_API_KEY=your_key_here" >> .env
# 2. Run automated setup (creates template + endpoint)
python tools/dewatermark.py --setup
# 3. Done! Now use it:
python tools/dewatermark.py --input video.mp4 --region x,y,w,h --output out.mp4 --runpod
```
The `--setup` command will:
- Create a serverless template using the public Docker image
- Create an endpoint with RTX 3090 GPU (AMPERE_24)
- Save the endpoint ID to your `.env` file
Use `--setup-gpu AMPERE_16` for RTX 3080 or `--setup-gpu ADA_24` for RTX 4090.
---
## Manual Setup
If you prefer to set up manually via the web console:
### 1. Create RunPod Account
1. Go to [runpod.io](https://runpod.io) and sign up
2. Add credits to your account ($10 minimum, lasts for many videos)
3. Go to Settings > API Keys and create an API key
### 2. Create Serverless Endpoint
A pre-built public image is available:
```
ghcr.io/conalmullan/video-toolkit-propainter:v2.0.0
```
> **Note:** Use versioned tags (not `:latest`) to ensure workers pull the correct image.
Alternatively, build your own (see `docker/runpod-propainter/README.md`).
1. Go to [RunPod Serverless Console](https://www.runpod.io/console/serverless)
2. Click **New Endpoint**
3. Configure:
| Setting | Value | Notes |
|---------|-------|-------|
| Docker Image | `ghcr.io/conalmullan/video-toolkit-propainter:latest` | Public image |
| GPU | RTX 3090 or RTX 4090 | 24GB VRAM recommended |
| Max Workers | 1 | Scale up if processing many videos |
| Idle Timeout | 5 seconds | Fast scale-down to save costs |
| Execution Timeout | 3600 seconds | 1 hour max per job |
4. Click **Create Endpoint**
5. Copy the **Endpoint ID** (looks like: `abc123xyz`)
### 3. Configure Local Environment
Add to your `.env` file:
```bash
# RunPod Configuration
RUNPOD_API_KEY=your_api_key_here
RUNPOD_ENDPOINT_ID=your_endpoint_id_here
```
### 4. Test It
```bash
# Dry run (doesn't actually process)
python tools/dewatermark.py \
--input video.mp4 \
--region 1080,660,195,40 \
--output clean.mp4 \
--runpod \
--dry-run
# Real processing
python tools/dewatermark.py \
--input video.mp4 \
--region 1080,660,195,40 \
--output clean.mp4 \
--runpod
```
## How It Works
```
1. Local tool uploads video to temporary storage
2. Submits job to RunPod endpoint
3. RunPod spins up GPU worker (~30s cold start)
4. Worker downloads video, runs ProPainter
5. Worker uploads result, returns URL
6. Local tool downloads result
7. Worker scales down (you stop paying)
```
## Cost Breakdown
### Per-Video Costs
| Video Length | Processing Time | Cost (RTX 3090) |
|--------------|-----------------|-----------------|
| < 30 seconds | 2-5 minutes | ~$0.02 |
| 30s - 2 min | 5-15 minutes | ~$0.08 |
| 2 - 5 min | 15-45 minutes | ~$0.25 |
| > 5 min | 45+ minutes | ~$0.40+ |
### GPU Options
| GPU | VRAM | Cost/hr | Speed | Best For |
|-----|------|---------|-------|----------|
| RTX 3090 | 24GB | $0.34 | Fast | Most videos (recommended) |
| RTX 4090 | 24GB | $0.69 | Faster | Tight deadlines |
| A100 | 80GB | $1.99 | Fastest | Very long videos |
### Tips to Minimize Costs
1. **Use 5-second idle timeout** - Workers scale down quickly
2. **Process in batches** - Submit multiple videos to same warm worker
3. **Right-size your GPU** - RTX 3090 is plenty for most videos
4. **Set max workers = 1** initially - Prevents runaway costs
## Troubleshooting
### "RUNPOD_API_KEY not set"
Add your API key to `.env`:
```bash
RUNPOD_API_KEY=your_key_here
```
### "RUNPOD_ENDPOINT_ID not set"
Add your endpoint ID to `.env`:
```bash
RUNPOD_ENDPOINT_ID=abc123xyz
```
### Job times out
Default timeout is 30 minutes. For longer videos:
```bash
python tools/dewatermark.py ... --runpod --runpod-timeout 3600
```
### "Failed to upload video"
- Check your internet connection
- Verify the video file exists and is readable
- Large files (>500MB) may take several minutes to upload
### Cold start is slow (~30-60 seconds)
This is normal for the first request after idle. The worker needs to:
1. Spin up the container
2. Load PyTorch and models into GPU memory
Subsequent requests to a warm worker are faster.
### "ProPainter processing failed"
Check the RunPod logs:
1. Go to RunPod Console > Serverless > Your Endpoint > Logs
2. Look for error messages from the handler
Common issues:
- Video format not supported (try converting to MP4)
- Region coordinates exceed video dimensions
- GPU ran out of memory (shouldn't happen with 24GB GPUs)
## File Transfer: Cloudflare R2 (Recommended)
By default, videos are uploaded via free file hosting services (litterbox.catbox.moe, etc.). These work but can be unreliable for large files.
**Cloudflare R2** provides reliable, fast file transfer with a generous free tier:
- **10 GB storage** (we clean up after each job)
- **10 million operations/month**
- **Zero egress fees** (unlike AWS S3)
- **No expiration** (unlike AWS's 12-month free tier)
### R2 Setup
1. **Create Cloudflare Account** (free): https://dash.cloudflare.com
2. **Create R2 Bucket**:
- Go to R2 Object Storage → Create bucket
- Name: `video-toolkit` (or any name)
- Click Create
3. **Create API Token**:
- R2 → Overview → Manage R2 API Tokens
- Create API Token → Object Read & Write
- Specify bucket: `video-toolkit`
- Copy the **Access Key ID** and **Secret Access Key** (shown once!)
4. **Get Account ID**:
- Visible in dashboard URL: `dash.cloudflare.com/<ACCOUNT_ID>/r2`
5. **Add to .env**:
```bash
R2_ACCOUNT_ID=your_account_id
R2_ACCESS_KEY_ID=your_access_key_id
R2_SECRET_ACCESS_KEY=your_secret_access_key
R2_BUCKET_NAME=video-toolkit
```
6. **Install boto3** (if not already):
```bash
pip install boto3
```
That's it! The dewatermark tool will automatically use R2 for file transfer.
### Without R2
If R2 is not configured, the tool falls back to free file hosting services:
- `litterbox.catbox.moe` (200MB, 24h retention)
- `file.io` (2GB, 1 download)
- `transfer.sh` (10GB, 14 days) - often down
- `0x0.st` (512MB, 30 days) - blocks many requests
These work for testing but may fail intermittently for production use.
## Advanced Configuration
### Multiple Endpoints
You can create multiple endpoints for different use cases:
```bash
# .env
RUNPOD_ENDPOINT_ID=abc123xyz # Default (RTX 3090)
RUNPOD_ENDPOINT_ID_FAST=def456uvw # Fast (RTX 4090)
```
### Monitoring Usage
1. Go to RunPod Console > Usage
2. View spend by endpoint, GPU type, and time period
3. Set up billing alerts to avoid surprises
## Security Notes
- API keys grant full access to your RunPod account - keep them secret
- R2 credentials are passed to RunPod workers for result upload - ensure your bucket is private
- Without R2, videos go through public file hosting services (not recommended for sensitive content)
- R2 objects are automatically cleaned up after download
- Presigned URLs expire after 2 hours
## Future GPU Tools
The RunPod handler is designed for extensibility. Future operations may include:
- **upscale** - Video upscaling with Real-ESRGAN
- **denoise** - Audio/video denoising
- **stabilize** - Video stabilization
- **style-transfer** - AI style transfer
These would use the same endpoint and Docker image, just with different `operation` values.
## Current Status & Known Limitations
**Working (as of v2.0.0):**
- ✅ End-to-end watermark removal via RunPod
- ✅ Cloudflare R2 file transfer (reliable, fast)
- ✅ Automatic GPU detection (respects RunPod's CUDA_VISIBLE_DEVICES)
- ✅ Smart auto resize_ratio based on VRAM + video size
- ✅ 30-second video processing confirmed working
**Current Limitations:**
| Issue | Description | Workaround |
|-------|-------------|------------|
| Untested with long videos | Only 30-second clips tested | Try longer chunks in next session |
| Full resolution OOM | `resize_ratio=1.0` may fail on GPUs with <48GB VRAM | Use `auto` (default) or upscale result post-processing |
**Next Steps (planned):**
- [ ] Test longer video chunks (1-3 minutes)
- [ ] Add post-processing upscale option to restore full resolution
- [ ] Profile memory usage at different resolutions
- [ ] Consider chunking very long videos client-side
## Version History
| Version | Date | Changes |
|---------|------|---------|
| v2.0.0 | 2025-12-30 | **Major fix:** GPU detection (removed CUDA_VISIBLE_DEVICES), CUDA 12.4, auto resize_ratio |
| v1.2.1 | 2025-12-30 | Fix output file detection (`inpaint_out.mp4` not `masked_in.mp4`) |
| v1.2.0 | 2025-12-30 | Fix GPU detection (use max across all GPUs), improve memory profiles |
| v1.1.0 | 2025-12-30 | Add `resize_ratio` parameter, improve error logging |
| v1.0.0 | 2025-12-30 | Initial R2 integration, NumPy 1.x fix |
+42
View File
@@ -55,3 +55,45 @@ def get_default_output_dir(project_path: str | None = None) -> Path:
if project_path:
return Path(project_path) / "public" / "audio"
return find_workspace_root() / "public" / "audio"
def get_runpod_api_key() -> str | None:
"""Get RunPod API key from environment."""
from dotenv import load_dotenv
load_dotenv()
return os.getenv("RUNPOD_API_KEY")
def get_runpod_endpoint_id() -> str | None:
"""Get RunPod endpoint ID from environment."""
from dotenv import load_dotenv
load_dotenv()
return os.getenv("RUNPOD_ENDPOINT_ID")
def get_r2_config() -> dict | None:
"""Get Cloudflare R2 configuration from environment.
Returns dict with account_id, access_key_id, secret_access_key, bucket_name
or None if not configured.
"""
from dotenv import load_dotenv
load_dotenv()
account_id = os.getenv("R2_ACCOUNT_ID")
access_key_id = os.getenv("R2_ACCESS_KEY_ID")
secret_access_key = os.getenv("R2_SECRET_ACCESS_KEY")
bucket_name = os.getenv("R2_BUCKET_NAME", "video-toolkit")
# Check if R2 is configured (all required fields present and not placeholder)
if (account_id and access_key_id and secret_access_key
and account_id != "your_account_id_here"
and access_key_id != "your_access_key_id_here"):
return {
"account_id": account_id,
"access_key_id": access_key_id,
"secret_access_key": secret_access_key,
"bucket_name": bucket_name,
"endpoint_url": f"https://{account_id}.r2.cloudflarestorage.com",
}
return None
+2442
View File
File diff suppressed because it is too large Load Diff
+560
View File
@@ -0,0 +1,560 @@
#!/usr/bin/env python3
"""
Locate and verify watermark positions in video files.
This tool helps identify watermark coordinates for use with dewatermark.py.
It extracts frames, overlays grids, and marks regions for visual verification.
Usage:
# Interactive exploration - extract frames with grid overlay
python tools/locate_watermark.py --input video.mp4 --grid --output-dir /tmp/review/
# Verify a specific region across multiple frames
python tools/locate_watermark.py --input video.mp4 --region 1100,650,150,50 --verify
# Use a preset for common watermarks
python tools/locate_watermark.py --input video.mp4 --preset notebooklm --verify
# Quick check - mark single frame
python tools/locate_watermark.py --input video.mp4 --region 1100,650,150,50 --mark
Presets:
notebooklm - Bottom-right corner (Google NotebookLM videos)
tiktok - Bottom-center username area
stock-br - Bottom-right stock footage watermark
stock-bl - Bottom-left stock footage watermark
stock-center - Center watermark (common in stock footage)
"""
import argparse
import json
import os
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
# Watermark presets (x, y, width, height) - will be scaled to video dimensions
PRESETS = {
"notebooklm": {
"description": "Google NotebookLM - bottom-right corner",
"region_1280x720": (1100, 650, 150, 50),
"region_1920x1080": (1650, 975, 225, 75),
},
"tiktok": {
"description": "TikTok username - bottom-center",
"region_1080x1920": (340, 1750, 400, 80), # Portrait
"region_1280x720": (440, 650, 400, 50), # Landscape
},
"stock-br": {
"description": "Stock footage - bottom-right",
"region_1280x720": (1000, 620, 260, 80),
"region_1920x1080": (1500, 930, 390, 120),
},
"stock-bl": {
"description": "Stock footage - bottom-left",
"region_1280x720": (20, 620, 260, 80),
"region_1920x1080": (30, 930, 390, 120),
},
"stock-center": {
"description": "Stock footage - center watermark",
"region_1280x720": (440, 260, 400, 200),
"region_1920x1080": (660, 390, 600, 300),
},
}
def parse_args():
parser = argparse.ArgumentParser(
description="Locate and verify watermark positions in video",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Extract frames with coordinate grid for exploration
python tools/locate_watermark.py --input video.mp4 --grid --output-dir ./review/
# Verify NotebookLM watermark position
python tools/locate_watermark.py --input video.mp4 --preset notebooklm --verify
# Mark custom region on multiple frames
python tools/locate_watermark.py --input video.mp4 --region 1100,650,150,50 --verify
# Output coordinates as JSON (for scripting)
python tools/locate_watermark.py --input video.mp4 --preset notebooklm --json
""",
)
parser.add_argument(
"--input", "-i",
type=str,
help="Input video file path",
)
parser.add_argument(
"--region", "-r",
type=str,
help="Watermark region as x,y,width,height (e.g., 1100,650,150,50)",
)
parser.add_argument(
"--preset", "-p",
type=str,
choices=list(PRESETS.keys()),
help="Use a preset watermark position",
)
parser.add_argument(
"--output-dir", "-o",
type=str,
help="Directory to save marked frames (default: temp directory)",
)
# Actions
parser.add_argument(
"--grid",
action="store_true",
help="Overlay coordinate grid on frames",
)
parser.add_argument(
"--mark",
action="store_true",
help="Mark region with rectangle on frames",
)
parser.add_argument(
"--verify",
action="store_true",
help="Extract multiple frames and mark region for verification",
)
parser.add_argument(
"--crop",
action="store_true",
help="Also output cropped watermark regions",
)
# Sampling options
parser.add_argument(
"--samples",
type=int,
default=5,
help="Number of frames to extract (default: 5)",
)
parser.add_argument(
"--timestamps",
type=str,
help="Specific timestamps to extract (comma-separated, e.g., '10,30,60,90')",
)
# Grid options
parser.add_argument(
"--grid-spacing",
type=int,
default=50,
help="Grid line spacing in pixels (default: 50)",
)
parser.add_argument(
"--grid-region",
type=str,
help="Only show grid in region x,y,width,height (default: bottom-right quadrant)",
)
# Output options
parser.add_argument(
"--json",
action="store_true",
help="Output result as JSON",
)
parser.add_argument(
"--list-presets",
action="store_true",
help="List available watermark presets",
)
parser.add_argument(
"--open",
action="store_true",
help="Open output directory in Finder after processing (macOS)",
)
return parser.parse_args()
def get_video_info(video_path: str) -> dict | None:
"""Get video dimensions and duration using ffprobe."""
try:
result = subprocess.run(
[
"ffprobe", "-v", "error",
"-select_streams", "v:0",
"-show_entries", "stream=width,height",
"-show_entries", "format=duration",
"-of", "json",
video_path,
],
capture_output=True,
text=True,
)
if result.returncode == 0:
data = json.loads(result.stdout)
stream = data.get("streams", [{}])[0]
fmt = data.get("format", {})
return {
"width": stream.get("width"),
"height": stream.get("height"),
"duration": float(fmt.get("duration", 0)),
}
except Exception:
pass
return None
def parse_region(region_str: str) -> tuple[int, int, int, int] | None:
"""Parse region string 'x,y,width,height' into tuple."""
try:
parts = [int(x.strip()) for x in region_str.split(",")]
if len(parts) == 4:
return tuple(parts)
except ValueError:
pass
return None
def get_preset_region(preset_name: str, width: int, height: int) -> tuple[int, int, int, int] | None:
"""Get region for a preset, scaled to video dimensions."""
if preset_name not in PRESETS:
return None
preset = PRESETS[preset_name]
key = f"region_{width}x{height}"
# Try exact match first
if key in preset:
return preset[key]
# Find closest match and scale
for preset_key, region in preset.items():
if preset_key.startswith("region_"):
dims = preset_key.replace("region_", "").split("x")
preset_w, preset_h = int(dims[0]), int(dims[1])
# Scale proportionally
scale_x = width / preset_w
scale_y = height / preset_h
x, y, w, h = region
return (
int(x * scale_x),
int(y * scale_y),
int(w * scale_x),
int(h * scale_y),
)
return None
def extract_frame(video_path: str, timestamp: float, output_path: str) -> bool:
"""Extract a single frame from video."""
result = subprocess.run(
[
"ffmpeg", "-y",
"-ss", str(timestamp),
"-i", video_path,
"-frames:v", "1",
output_path,
],
capture_output=True,
text=True,
)
return result.returncode == 0
def add_grid_overlay(
input_path: str,
output_path: str,
width: int,
height: int,
spacing: int = 50,
region: tuple[int, int, int, int] | None = None,
) -> bool:
"""Add coordinate grid overlay to image using ImageMagick."""
# Determine grid region (default to bottom-right quadrant)
if region:
grid_x, grid_y, grid_w, grid_h = region
else:
# Bottom-right quadrant
grid_x = width // 2
grid_y = height // 2
grid_w = width // 2
grid_h = height // 2
draw_commands = []
# Vertical lines
for x in range(grid_x, grid_x + grid_w + 1, spacing):
if x <= width:
draw_commands.append(f"line {x},{grid_y} {x},{min(grid_y + grid_h, height)}")
# Label at bottom
label_y = min(grid_y + grid_h - 5, height - 5)
draw_commands.append(f"text {x+2},{label_y} '{x}'")
# Horizontal lines
for y in range(grid_y, grid_y + grid_h + 1, spacing):
if y <= height:
draw_commands.append(f"line {grid_x},{y} {min(grid_x + grid_w, width)},{y}")
# Label at left
draw_commands.append(f"text {grid_x+2},{y-2} '{y}'")
cmd = [
"magick", input_path,
"-stroke", "yellow",
"-strokewidth", "1",
"-fill", "yellow",
"-pointsize", "12",
]
for draw_cmd in draw_commands:
cmd.extend(["-draw", draw_cmd])
cmd.append(output_path)
result = subprocess.run(cmd, capture_output=True, text=True)
return result.returncode == 0
def mark_region(
input_path: str,
output_path: str,
region: tuple[int, int, int, int],
color: str = "red",
stroke_width: int = 3,
) -> bool:
"""Mark a region with a rectangle using ImageMagick."""
x, y, w, h = region
x2, y2 = x + w, y + h
result = subprocess.run(
[
"magick", input_path,
"-stroke", color,
"-strokewidth", str(stroke_width),
"-fill", "none",
"-draw", f"rectangle {x},{y} {x2},{y2}",
output_path,
],
capture_output=True,
text=True,
)
return result.returncode == 0
def crop_region(
input_path: str,
output_path: str,
region: tuple[int, int, int, int],
) -> bool:
"""Crop image to specified region."""
x, y, w, h = region
result = subprocess.run(
[
"magick", input_path,
"-crop", f"{w}x{h}+{x}+{y}",
"+repage",
output_path,
],
capture_output=True,
text=True,
)
return result.returncode == 0
def calculate_timestamps(duration: float, num_samples: int, margin: float = 5.0) -> list[float]:
"""Calculate evenly spaced timestamps across video duration."""
# Avoid very start and end of video
start = min(margin, duration * 0.05)
end = max(duration - margin, duration * 0.95)
if num_samples == 1:
return [duration / 2]
step = (end - start) / (num_samples - 1)
return [start + i * step for i in range(num_samples)]
def list_presets():
"""Print available presets."""
print("Available watermark presets:")
print("-" * 50)
for name, preset in PRESETS.items():
print(f"\n {name}")
print(f" {preset['description']}")
for key, value in preset.items():
if key.startswith("region_"):
dims = key.replace("region_", "")
print(f" {dims}: x={value[0]}, y={value[1]}, w={value[2]}, h={value[3]}")
def main():
args = parse_args()
# Handle --list-presets
if args.list_presets:
list_presets()
return
# Check input is provided for other operations
if not args.input:
print("Error: --input is required", file=sys.stderr)
sys.exit(1)
# Check input file
if not Path(args.input).exists():
print(f"Error: Input file not found: {args.input}", file=sys.stderr)
sys.exit(1)
# Check for ImageMagick
if shutil.which("magick") is None:
print("Error: ImageMagick not found. Install with: brew install imagemagick", file=sys.stderr)
sys.exit(1)
# Get video info
video_info = get_video_info(args.input)
if not video_info:
print("Error: Could not read video info", file=sys.stderr)
sys.exit(1)
width = video_info["width"]
height = video_info["height"]
duration = video_info["duration"]
verbose = not args.json
if verbose:
print(f"Video: {args.input}")
print(f"Dimensions: {width}x{height}")
print(f"Duration: {duration:.1f}s")
# Determine region
region = None
if args.region:
region = parse_region(args.region)
if not region:
print(f"Error: Invalid region format: {args.region}", file=sys.stderr)
print("Expected format: x,y,width,height (e.g., 1100,650,150,50)", file=sys.stderr)
sys.exit(1)
elif args.preset:
region = get_preset_region(args.preset, width, height)
if verbose:
print(f"Preset '{args.preset}': {region[0]},{region[1]},{region[2]},{region[3]}")
# Determine timestamps
if args.timestamps:
timestamps = [float(t.strip()) for t in args.timestamps.split(",")]
else:
timestamps = calculate_timestamps(duration, args.samples)
# Set up output directory
if args.output_dir:
output_dir = Path(args.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
cleanup_temp = False
else:
output_dir = Path(tempfile.mkdtemp(prefix="locate_wm_"))
cleanup_temp = not args.open # Keep if opening in Finder
if verbose:
print(f"Output directory: {output_dir}")
print()
# Process frames
results = []
for i, ts in enumerate(timestamps):
if verbose:
print(f"Processing frame {i+1}/{len(timestamps)} at {ts:.1f}s...")
# Extract frame
frame_path = output_dir / f"frame_{ts:.0f}s.png"
if not extract_frame(args.input, ts, str(frame_path)):
print(f" Warning: Failed to extract frame at {ts}s", file=sys.stderr)
continue
frame_result = {
"timestamp": ts,
"frame": str(frame_path),
}
# Add grid overlay
if args.grid:
grid_region = None
if args.grid_region:
grid_region = parse_region(args.grid_region)
grid_path = output_dir / f"frame_{ts:.0f}s_grid.png"
if add_grid_overlay(str(frame_path), str(grid_path), width, height, args.grid_spacing, grid_region):
frame_result["grid"] = str(grid_path)
if verbose:
print(f" Created: {grid_path.name}")
# Mark region
if (args.mark or args.verify) and region:
marked_path = output_dir / f"frame_{ts:.0f}s_marked.png"
source = frame_result.get("grid", str(frame_path))
if mark_region(source, str(marked_path), region):
frame_result["marked"] = str(marked_path)
if verbose:
print(f" Created: {marked_path.name}")
# Crop region
if args.crop and region:
crop_path = output_dir / f"frame_{ts:.0f}s_crop.png"
if crop_region(str(frame_path), str(crop_path), region):
frame_result["crop"] = str(crop_path)
if verbose:
print(f" Created: {crop_path.name}")
results.append(frame_result)
# Output
output = {
"input": args.input,
"dimensions": f"{width}x{height}",
"duration": duration,
"region": f"{region[0]},{region[1]},{region[2]},{region[3]}" if region else None,
"preset": args.preset,
"output_dir": str(output_dir),
"frames": results,
}
if region:
output["dewatermark_command"] = (
f"python tools/dewatermark.py --input \"{args.input}\" "
f"--region {region[0]},{region[1]},{region[2]},{region[3]} "
f"--output \"output_clean.mp4\""
)
if args.json:
print(json.dumps(output, indent=2))
else:
print()
print("=" * 50)
print(f"Extracted {len(results)} frames to: {output_dir}")
if region:
print(f"Region: {region[0]},{region[1]},{region[2]},{region[3]}")
print()
print("To remove watermark, run:")
print(f" python tools/dewatermark.py \\")
print(f" --input \"{args.input}\" \\")
print(f" --region {region[0]},{region[1]},{region[2]},{region[3]} \\")
print(f" --output \"output_clean.mp4\"")
print("=" * 50)
# Open in Finder (macOS)
if args.open:
subprocess.run(["open", str(output_dir)])
# Cleanup temp directory if not needed
if cleanup_temp and not args.output_dir:
# Don't cleanup - let user review
pass
if __name__ == "__main__":
main()
+2
View File
@@ -1,3 +1,5 @@
# Video Toolkit Python Dependencies
elevenlabs>=1.0.0
python-dotenv>=1.0.0
requests>=2.28.0
boto3>=1.28.0 # For Cloudflare R2 (S3-compatible)