Spaces:
Sleeping
Sleeping
| import gradio as gr | |
| import spaces | |
| import subprocess | |
| import asyncio | |
| import json | |
| import base64 | |
| import os | |
| import shutil | |
| import socket | |
| import time | |
| import io | |
| import aiohttp | |
| import websockets | |
| CHROMIUM_PATHS = [ | |
| "/usr/bin/chromium", | |
| "/usr/bin/chromium-browser", | |
| "/usr/bin/google-chrome-stable", | |
| "/usr/bin/google-chrome", | |
| ] | |
| AUDIO_NORMALIZE = "aformat=sample_rates=44100:channel_layouts=stereo" | |
| TARGET_FPS = 25 | |
| def get_chromium_path(): | |
| for path in CHROMIUM_PATHS: | |
| if os.path.exists(path): | |
| return path | |
| raise RuntimeError(f"Chromium not found. Tried: {CHROMIUM_PATHS}") | |
| def get_free_port(): | |
| with socket.socket() as s: | |
| s.bind(("", 0)) | |
| return s.getsockname()[1] | |
| def get_media_duration(path: str) -> float | None: | |
| result = subprocess.run( | |
| ["ffprobe", "-v", "quiet", | |
| "-show_entries", "format=duration", | |
| "-of", "default=noprint_wrappers=1:nokey=1", | |
| path], | |
| capture_output=True, text=True, | |
| ) | |
| try: | |
| return float(result.stdout.strip()) | |
| except ValueError: | |
| return None | |
| def has_audio_stream(path: str) -> bool: | |
| result = subprocess.run( | |
| ["ffprobe", "-v", "quiet", | |
| "-show_streams", "-select_streams", "a:0", | |
| "-of", "default=noprint_wrappers=1", | |
| path], | |
| capture_output=True, text=True, | |
| ) | |
| return bool(result.stdout.strip()) | |
| def _gpu_warmup(): | |
| pass | |
| def _encode_frames( | |
| frame_bytes: list[bytes], | |
| timestamps: list[float], | |
| duration: float, | |
| w: int, | |
| h: int, | |
| ) -> str: | |
| """ | |
| Decode PNG frames in memory, encode to H.264 with PyAV (libx264). | |
| No resize needed — Chrome always delivers frames at exactly w×h via | |
| Emulation.setDeviceMetricsOverride + screencast maxWidth/maxHeight. | |
| """ | |
| import av | |
| import numpy as np | |
| from PIL import Image | |
| t0 = time.monotonic() | |
| print(f"[wr] encode: {len(frame_bytes)} frames → {w}x{h} @ {TARGET_FPS}fps", flush=True) | |
| # Select frames evenly at TARGET_FPS via binary search on timestamps | |
| n_out = max(1, int(duration * TARGET_FPS)) | |
| selected = [] | |
| for i in range(n_out): | |
| t = i / TARGET_FPS | |
| lo, hi = 0, len(timestamps) - 1 | |
| while lo < hi: | |
| mid = (lo + hi) // 2 | |
| if timestamps[mid] < t: | |
| lo = mid + 1 | |
| else: | |
| hi = mid | |
| selected.append(lo) | |
| print(f"[wr] encode: selected {len(selected)} frames", flush=True) | |
| out_path = f"/tmp/main_{os.getpid()}_{int(time.time())}.mp4" | |
| container = av.open(out_path, "w") | |
| stream = container.add_stream("libx264", rate=TARGET_FPS) | |
| stream.width = w | |
| stream.height = h | |
| stream.pix_fmt = "yuv420p" | |
| stream.options = {"crf": "15", "preset": "fast", "profile": "high", "level": "4.2"} | |
| for i, frame_idx in enumerate(selected): | |
| img = Image.open(io.BytesIO(frame_bytes[frame_idx])).convert("RGB") | |
| av_frame = av.VideoFrame.from_ndarray(np.array(img), format="rgb24") | |
| for pkt in stream.encode(av_frame): | |
| container.mux(pkt) | |
| if i % 100 == 0: | |
| print(f"[wr] encode: {i}/{len(selected)} frames", flush=True) | |
| for pkt in stream.encode(): | |
| container.mux(pkt) | |
| container.close() | |
| print(f"[wr] encode: done in {time.monotonic()-t0:.1f}s → {out_path}", flush=True) | |
| return out_path | |
| def _run_ffmpeg(cmd: list[str]): | |
| """Run an FFmpeg command, streaming stderr to stdout. Raises gr.Error on failure.""" | |
| proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, bufsize=0) | |
| chunks = [] | |
| while True: | |
| chunk = proc.stderr.read(512) | |
| if not chunk: | |
| break | |
| chunks.append(chunk) | |
| print(chunk.decode("utf-8", errors="replace"), end="", flush=True) | |
| proc.wait() | |
| if proc.returncode != 0: | |
| stderr = b"".join(chunks).decode("utf-8", errors="replace") | |
| raise gr.Error(f"FFmpeg failed: {stderr[-2000:]}") | |
| def _assemble_final( | |
| main_path: str, | |
| output_path: str, | |
| w: int, | |
| h: int, | |
| duration: float, | |
| audio_path: str | None, | |
| pre_rec_path: str | None, | |
| post_rec_path: str | None, | |
| postroll_path: str | None, | |
| watermark_path: str | None, | |
| ): | |
| """ | |
| Final assembly via FFmpeg: | |
| - main_path is already-encoded H.264 video (no audio) | |
| - stream-copy main video when there are no segments to concat | |
| - re-encode only when mixing multiple video segments or adding watermark | |
| """ | |
| has_segments = any([pre_rec_path, post_rec_path, postroll_path]) | |
| has_watermark = watermark_path is not None | |
| # ── Simple: no segments ─────────────────────────────────────────────────── | |
| if not has_segments and not audio_path and not has_watermark: | |
| shutil.copy2(main_path, output_path) | |
| return | |
| if not has_segments and not has_watermark: | |
| # Stream-copy video, just add audio | |
| _run_ffmpeg([ | |
| "ffmpeg", "-y", | |
| "-i", main_path, | |
| "-i", audio_path, | |
| "-c:v", "copy", | |
| "-c:a", "aac", "-b:a", "192k", | |
| "-shortest", | |
| "-movflags", "+faststart", | |
| output_path, | |
| ]) | |
| return | |
| # ── Simple with watermark only ───────────────────────────────────────────── | |
| if not has_segments and has_watermark: | |
| # Watermark overlay requires re-encoding | |
| inputs = ["-i", main_path, "-i", watermark_path] | |
| cmd = ["ffmpeg", "-y"] + inputs | |
| # Loop watermark and overlay with proper alpha handling (eval=frame for per-frame alpha) | |
| filter_parts = [f"[1:v]loop=loop=-1:size=1:start=0[wm];[0:v][wm]overlay=0:0:eval=frame:alpha=straight:shortest=1[outv]"] | |
| cmd += ["-filter_complex", ";".join(filter_parts), "-map", "[outv]"] | |
| if audio_path: | |
| cmd += ["-i", audio_path, "-map", "2:a", "-c:a", "aac", "-b:a", "192k", "-shortest"] | |
| cmd += [ | |
| "-c:v", "libx264", "-crf", "15", "-preset", "fast", | |
| "-pix_fmt", "yuv420p", "-profile:v", "high", "-level:v", "4.2", | |
| "-r", "25", | |
| "-movflags", "+faststart", | |
| output_path, | |
| ] | |
| _run_ffmpeg(cmd) | |
| return | |
| # ── Complex: multiple segments ──────────────────────────────────────────── | |
| scale_video = ( | |
| f"scale={w}:{h}:force_original_aspect_ratio=decrease," | |
| f"pad={w}:{h}:(ow-iw)/2:(oh-ih)/2,setsar=1" | |
| ) | |
| inputs = ["-i", main_path] | |
| next_idx = 1 | |
| audio_idx = None | |
| if audio_path: | |
| inputs += ["-i", audio_path] | |
| audio_idx = next_idx | |
| next_idx += 1 | |
| watermark_idx = None | |
| if watermark_path: | |
| inputs += ["-i", watermark_path] | |
| watermark_idx = next_idx | |
| next_idx += 1 | |
| video_files = [] | |
| for label, path in [("pre", pre_rec_path), ("postrec", post_rec_path), ("postroll", postroll_path)]: | |
| if path: | |
| inputs += ["-i", path] | |
| video_files.append((label, path, next_idx)) | |
| next_idx += 1 | |
| pre_info = next((v for v in video_files if v[0] == "pre"), None) | |
| postrec_info = next((v for v in video_files if v[0] == "postrec"), None) | |
| postroll_info = next((v for v in video_files if v[0] == "postroll"), None) | |
| def probe(info): | |
| if not info: | |
| return None | |
| _, path, _ = info | |
| return { | |
| "has_audio": has_audio_stream(path), | |
| "duration": get_media_duration(path) or 0.0, | |
| } | |
| pre_meta = probe(pre_info) | |
| postrec_meta = probe(postrec_info) | |
| postroll_meta = probe(postroll_info) | |
| segments = [] | |
| if pre_info: | |
| _, _, idx = pre_info | |
| segments.append({ | |
| "vf": f"[{idx}:v]{scale_video}", | |
| "has_audio": pre_meta["has_audio"], | |
| "af_real": f"[{idx}:a]{AUDIO_NORMALIZE},asetpts=PTS-STARTPTS", | |
| "duration": pre_meta["duration"], | |
| }) | |
| segments.append({ | |
| "vf": "[0:v]fps=25,setsar=1,setpts=PTS-STARTPTS", | |
| "has_audio": bool(audio_path), | |
| "af_real": ( | |
| f"[{audio_idx}:a]{AUDIO_NORMALIZE},atrim=duration={duration:.4f},asetpts=PTS-STARTPTS" | |
| if audio_path else None | |
| ), | |
| "duration": duration, | |
| }) | |
| if postrec_info: | |
| _, _, idx = postrec_info | |
| segments.append({ | |
| "vf": f"[{idx}:v]{scale_video}", | |
| "has_audio": postrec_meta["has_audio"], | |
| "af_real": f"[{idx}:a]{AUDIO_NORMALIZE},asetpts=PTS-STARTPTS", | |
| "duration": postrec_meta["duration"], | |
| }) | |
| if postroll_info: | |
| _, _, idx = postroll_info | |
| segments.append({ | |
| "vf": f"[{idx}:v]{scale_video}", | |
| "has_audio": postroll_meta["has_audio"], | |
| "af_real": f"[{idx}:a]{AUDIO_NORMALIZE},asetpts=PTS-STARTPTS", | |
| "duration": postroll_meta["duration"], | |
| }) | |
| need_audio_out = any(s["has_audio"] for s in segments) | |
| fp = [] | |
| v_labels = [] | |
| a_labels = [] | |
| for i, seg in enumerate(segments): | |
| vl = f"v{i}" | |
| al = f"a{i}" | |
| fp.append(f"{seg['vf']}[{vl}]") | |
| v_labels.append(f"[{vl}]") | |
| if need_audio_out: | |
| if seg["has_audio"]: | |
| fp.append(f"{seg['af_real']}[{al}]") | |
| else: | |
| d = f"{seg['duration']:.4f}" | |
| fp.append(f"anullsrc=r=44100:cl=stereo,atrim=duration={d},asetpts=PTS-STARTPTS[{al}]") | |
| a_labels.append(f"[{al}]") | |
| n = len(segments) | |
| fp.append(f"{''.join(v_labels)}concat=n={n}:v=1:a=0[concatv]") | |
| if need_audio_out: | |
| fp.append(f"{''.join(a_labels)}concat=n={n}:v=0:a=1[outa]") | |
| # Apply watermark overlay if provided | |
| if watermark_idx is not None: | |
| # Loop watermark and overlay with proper alpha handling (eval=frame for per-frame alpha) | |
| fp.append(f"[{watermark_idx}:v]loop=loop=-1:size=1:start=0[wm]") | |
| fp.append(f"[concatv][wm]overlay=0:0:eval=frame:alpha=straight:shortest=1[outv]") | |
| else: | |
| fp.append(f"[concatv]null[outv]") | |
| cmd = ["ffmpeg"] + inputs + ["-filter_complex", ";".join(fp), "-map", "[outv]"] | |
| if need_audio_out: | |
| cmd += ["-map", "[outa]", "-c:a", "aac", "-b:a", "192k"] | |
| cmd += [ | |
| "-c:v", "libx264", "-crf", "15", "-preset", "fast", | |
| "-pix_fmt", "yuv420p", "-profile:v", "high", "-level:v", "4.2", | |
| "-r", "25", | |
| "-movflags", "+faststart", "-y", output_path, | |
| ] | |
| _run_ffmpeg(cmd) | |
| def _cp(path: str | None) -> str | None: | |
| """Copy an uploaded file to /tmp before asyncio yields give Gradio a cleanup opportunity.""" | |
| if not path: | |
| return None | |
| dst = f"/tmp/wr_{os.getpid()}_{int(time.time() * 1000)}_{os.path.basename(path)}" | |
| shutil.copy2(path, dst) | |
| return dst | |
| async def _record_async( | |
| url: str, w: int, h: int, duration: int, | |
| output_path: str, | |
| audio_path: str | None, | |
| pre_rec_path: str | None, | |
| post_rec_path: str | None, | |
| postroll_path: str | None, | |
| watermark_path: str | None, | |
| ): | |
| t0 = time.monotonic() | |
| def log(msg): | |
| print(f"[wr {time.monotonic()-t0:.1f}s] {msg}", flush=True) | |
| port = get_free_port() | |
| chromium = get_chromium_path() | |
| log(f"launching chromium on port {port}, url={url}, size={w}x{h}, duration={duration}") | |
| proc = subprocess.Popen( | |
| [ | |
| chromium, | |
| f"--remote-debugging-port={port}", | |
| "--headless", | |
| "--no-sandbox", | |
| "--disable-dev-shm-usage", | |
| "--disable-setuid-sandbox", | |
| "--use-gl=swiftshader", | |
| "--enable-webgl", | |
| "--hide-scrollbars", | |
| "--autoplay-policy=no-user-gesture-required", | |
| f"--window-size={w},{h}", | |
| ], | |
| stdout=subprocess.DEVNULL, | |
| stderr=subprocess.DEVNULL, | |
| ) | |
| try: | |
| ws_url = None | |
| async with aiohttp.ClientSession() as session: | |
| for attempt in range(20): | |
| await asyncio.sleep(0.5) | |
| try: | |
| async with session.get(f"http://localhost:{port}/json/list") as resp: | |
| pages = await resp.json() | |
| if pages: | |
| ws_url = pages[0]["webSocketDebuggerUrl"] | |
| log(f"chrome devtools ready after {attempt+1} attempts") | |
| break | |
| except Exception as e: | |
| log(f"devtools attempt {attempt+1} failed: {e}") | |
| continue | |
| if not ws_url: | |
| raise gr.Error("Could not connect to Chrome DevTools.") | |
| # frames: list of (timestamp_seconds, raw_png_bytes) | |
| frames: list[tuple[float, bytes]] = [] | |
| msg_id = 1 | |
| async with websockets.connect(ws_url, max_size=50_000_000) as ws: | |
| async def send(method, params=None): | |
| nonlocal msg_id | |
| current_id = msg_id | |
| await ws.send(json.dumps({"id": current_id, "method": method, "params": params or {}})) | |
| msg_id += 1 | |
| return current_id | |
| await send("Page.enable") | |
| await send("Emulation.setDeviceMetricsOverride", { | |
| "width": w, "height": h, "deviceScaleFactor": 1, "mobile": False, | |
| }) | |
| log(f"navigating to {url}") | |
| nav_id = await send("Page.navigate", {"url": url}) | |
| deadline = time.monotonic() + 10 | |
| while time.monotonic() < deadline: | |
| try: | |
| raw = await asyncio.wait_for(ws.recv(), timeout=1.0) | |
| if json.loads(raw).get("id") == nav_id: | |
| log("Page.navigate acknowledged") | |
| break | |
| except asyncio.TimeoutError: | |
| continue | |
| log("waiting for Page.loadEventFired (max 15s)") | |
| deadline = time.monotonic() + 15 | |
| while time.monotonic() < deadline: | |
| try: | |
| raw = await asyncio.wait_for(ws.recv(), timeout=1.0) | |
| if json.loads(raw).get("method") == "Page.loadEventFired": | |
| log("Page.loadEventFired received") | |
| break | |
| except asyncio.TimeoutError: | |
| continue | |
| else: | |
| log("load timeout — starting screencast anyway") | |
| await send("Runtime.enable") | |
| await send("Runtime.evaluate", { | |
| "expression": ( | |
| "document.querySelectorAll('audio,video')" | |
| ".forEach(m => { m.muted = false; m.play().catch(() => {}); })" | |
| ), | |
| "awaitPromise": False, | |
| }) | |
| log(f"starting screencast for {duration}s") | |
| await send("Page.startScreencast", { | |
| "format": "png", | |
| "maxWidth": w, | |
| "maxHeight": h, | |
| "everyNthFrame": 1, | |
| }) | |
| start = time.monotonic() | |
| last_log = start | |
| while time.monotonic() - start < duration: | |
| try: | |
| raw = await asyncio.wait_for(ws.recv(), timeout=1.0) | |
| obj = json.loads(raw) | |
| if obj.get("method") == "Page.screencastFrame": | |
| ts = time.monotonic() - start | |
| # Decode base64 to raw bytes immediately — avoids storing base64 overhead | |
| frames.append((ts, base64.b64decode(obj["params"]["data"]))) | |
| await send("Page.screencastFrameAck", { | |
| "sessionId": obj["params"]["sessionId"] | |
| }) | |
| now = time.monotonic() | |
| if now - last_log >= 5: | |
| log(f"recording... {ts:.1f}s elapsed, {len(frames)} frames") | |
| last_log = now | |
| except asyncio.TimeoutError: | |
| continue | |
| log(f"screencast done: {len(frames)} frames captured") | |
| await send("Page.stopScreencast") | |
| finally: | |
| proc.terminate() | |
| proc.wait() | |
| log("chromium terminated") | |
| if not frames: | |
| raise gr.Error("No frames captured. Check the URL.") | |
| timestamps = [f[0] for f in frames] | |
| frame_bytes = [f[1] for f in frames] | |
| log(f"encoding {len(frames)} frames (PyAV/libx264, no disk I/O)...") | |
| main_path = _encode_frames(frame_bytes, timestamps, float(duration), w, h) | |
| log(f"main recording encoded: {main_path}") | |
| log("assembling final video (audio + segments + watermark)...") | |
| _assemble_final(main_path, output_path, w, h, float(duration), | |
| audio_path, pre_rec_path, post_rec_path, postroll_path, watermark_path) | |
| try: | |
| os.unlink(main_path) | |
| except OSError: | |
| pass | |
| log("complete") | |
| return output_path | |
| async def record( | |
| url: str, | |
| width: float, | |
| height: float, | |
| duration: int, | |
| audio_file: str | None, | |
| pre_rec_file: str | None, | |
| post_rec_file: str | None, | |
| postroll_file: str | None, | |
| watermark_file: str | None, | |
| ) -> str: | |
| if not url.strip(): | |
| raise gr.Error("Please enter a URL.") | |
| if not url.startswith(("http://", "https://")): | |
| url = "https://" + url | |
| # Copy uploaded files synchronously before the first await, so Gradio's | |
| # event-loop cleanup (triggered at await yield points) can't delete them. | |
| audio_file = _cp(audio_file) | |
| pre_rec_file = _cp(pre_rec_file) | |
| post_rec_file = _cp(post_rec_file) | |
| postroll_file = _cp(postroll_file) | |
| watermark_file = _cp(watermark_file) | |
| print(f"[wr] files copied: audio={audio_file} pre={pre_rec_file} post={post_rec_file} postroll={postroll_file} watermark={watermark_file}", flush=True) | |
| w = max(2, (int(width) // 2) * 2) | |
| h = max(2, (int(height) // 2) * 2) | |
| if audio_file: | |
| audio_dur = get_media_duration(audio_file) | |
| if audio_dur: | |
| duration = int(min(audio_dur, 120)) | |
| output_path = f"/tmp/recording_{os.getpid()}_{int(time.time())}.mp4" | |
| return await _record_async( | |
| url, w, h, duration, output_path, | |
| audio_file, pre_rec_file, post_rec_file, postroll_file, watermark_file, | |
| ) | |
| with gr.Blocks(title="Website Renderer") as demo: | |
| gr.Markdown( | |
| "# Website Renderer\n" | |
| "Recording starts after full page load. " | |
| "Uploading audio overrides the duration slider (max 2 min)." | |
| ) | |
| url_input = gr.Textbox(label="URL", placeholder="https://example.com") | |
| with gr.Row(): | |
| width_input = gr.Number(label="Width", value=540, precision=0, minimum=100, maximum=3840) | |
| height_input = gr.Number(label="Height", value=960, precision=0, minimum=100, maximum=3840) | |
| duration_input = gr.Slider(minimum=5, maximum=120, value=30, step=5, label="Duration (seconds)") | |
| with gr.Row(): | |
| audio_input = gr.Audio( | |
| label="Audio track (optional — overrides duration)", | |
| type="filepath", | |
| sources=["upload"], | |
| ) | |
| with gr.Row(): | |
| pre_rec_input = gr.File( | |
| label="Pre-recording video (optional)", | |
| file_types=[".mp4", ".mov", ".webm", ".avi", ".mkv"], | |
| ) | |
| post_rec_input = gr.File( | |
| label="Post-recording video (optional)", | |
| file_types=[".mp4", ".mov", ".webm", ".avi", ".mkv"], | |
| ) | |
| postroll_input = gr.File( | |
| label="Post-roll video (optional)", | |
| file_types=[".mp4", ".mov", ".webm", ".avi", ".mkv"], | |
| ) | |
| watermark_input = gr.File( | |
| label="Watermark (optional — PNG with transparency)", | |
| file_types=[".png"], | |
| ) | |
| record_btn = gr.Button("Record", variant="primary") | |
| video_output = gr.Video(label="Recorded Video") | |
| record_btn.click( | |
| fn=record, | |
| inputs=[ | |
| url_input, width_input, height_input, duration_input, | |
| audio_input, pre_rec_input, post_rec_input, postroll_input, watermark_input, | |
| ], | |
| outputs=video_output, | |
| ) | |
| if __name__ == "__main__": | |
| demo.launch() | |