vclmax2 commited on
Commit
deebc0c
·
verified ·
1 Parent(s): 1af27f8

Upload app.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. app.py +547 -4
app.py CHANGED
@@ -1,4 +1,547 @@
1
- chromium
2
- ffmpeg
3
- fonts-noto-color-emoji
4
- libnvidia-encode1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gradio as gr
2
+ import spaces
3
+ import subprocess
4
+ import asyncio
5
+ import json
6
+ import base64
7
+ import os
8
+ import shutil
9
+ import socket
10
+ import time
11
+ import tempfile
12
+
13
+ import aiohttp
14
+ import websockets
15
+
16
+
17
+ # ── Static FFmpeg with NVENC support ─────────────────────────────────────────
18
+ _STATIC_FFMPEG = "/tmp/ffmpeg-static/ffmpeg"
19
+
20
+ def _install_static_ffmpeg():
21
+ if os.path.exists(_STATIC_FFMPEG):
22
+ print(f"[wr] static ffmpeg already at {_STATIC_FFMPEG}", flush=True)
23
+ return
24
+ print("[wr] downloading static ffmpeg with nvenc (BtbN)...", flush=True)
25
+ os.makedirs("/tmp/ffmpeg-src", exist_ok=True)
26
+ url = "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linux64-gpl.tar.xz"
27
+ subprocess.run(["wget", "-q", "-O", "/tmp/ffmpeg.tar.xz", url], check=True)
28
+ subprocess.run(["tar", "-xf", "/tmp/ffmpeg.tar.xz", "-C", "/tmp/ffmpeg-src"], check=True)
29
+ import glob as _glob
30
+ bins = _glob.glob("/tmp/ffmpeg-src/*/bin/ffmpeg")
31
+ if not bins:
32
+ raise RuntimeError("ffmpeg binary not found after extraction")
33
+ os.makedirs(os.path.dirname(_STATIC_FFMPEG), exist_ok=True)
34
+ shutil.copy2(bins[0], _STATIC_FFMPEG)
35
+ os.chmod(_STATIC_FFMPEG, 0o755)
36
+ print("[wr] static ffmpeg ready", flush=True)
37
+
38
+ _install_static_ffmpeg()
39
+ FFMPEG_BIN = _STATIC_FFMPEG if os.path.exists(_STATIC_FFMPEG) else "ffmpeg"
40
+ # ─────────────────────────────────────────────────────────────────────────────
41
+
42
+
43
+ CHROMIUM_PATHS = [
44
+ "/usr/bin/chromium",
45
+ "/usr/bin/chromium-browser",
46
+ "/usr/bin/google-chrome-stable",
47
+ "/usr/bin/google-chrome",
48
+ ]
49
+
50
+ # Normalize audio to a common format before concat to avoid channel/rate mismatches
51
+ AUDIO_NORMALIZE = "aformat=sample_rates=44100:channel_layouts=stereo"
52
+
53
+
54
+ def get_chromium_path():
55
+ for path in CHROMIUM_PATHS:
56
+ if os.path.exists(path):
57
+ return path
58
+ raise RuntimeError(f"Chromium not found. Tried: {CHROMIUM_PATHS}")
59
+
60
+
61
+ def get_free_port():
62
+ with socket.socket() as s:
63
+ s.bind(("", 0))
64
+ return s.getsockname()[1]
65
+
66
+
67
+ def get_media_duration(path: str) -> float | None:
68
+ result = subprocess.run(
69
+ [
70
+ "ffprobe", "-v", "quiet",
71
+ "-show_entries", "format=duration",
72
+ "-of", "default=noprint_wrappers=1:nokey=1",
73
+ path,
74
+ ],
75
+ capture_output=True, text=True,
76
+ )
77
+ try:
78
+ return float(result.stdout.strip())
79
+ except ValueError:
80
+ return None
81
+
82
+
83
+ def has_audio_stream(path: str) -> bool:
84
+ result = subprocess.run(
85
+ [
86
+ "ffprobe", "-v", "quiet",
87
+ "-show_streams", "-select_streams", "a:0",
88
+ "-of", "default=noprint_wrappers=1",
89
+ path,
90
+ ],
91
+ capture_output=True, text=True,
92
+ )
93
+ return bool(result.stdout.strip())
94
+
95
+
96
+ def build_ffmpeg_cmd(
97
+ concat_path: str,
98
+ output_path: str,
99
+ w: int,
100
+ h: int,
101
+ rec_duration: float,
102
+ audio_path: str | None,
103
+ pre_rec_path: str | None,
104
+ post_rec_path: str | None,
105
+ postroll_path: str | None,
106
+ ) -> list[str]:
107
+ """
108
+ Build a single-pass FFmpeg command for any combination of optional segments.
109
+
110
+ Final order: pre_recording → main recording → post_recording → post_roll
111
+
112
+ FFmpeg inputs are assigned dynamically:
113
+ 0 — main recording (concat demuxer of PNG frames)
114
+ 1 — audio track for main (optional)
115
+ then any video files in the order: pre_rec, post_rec, postroll
116
+ """
117
+
118
+ scale_video = (
119
+ f"scale={w}:{h}:force_original_aspect_ratio=decrease,"
120
+ f"pad={w}:{h}:(ow-iw)/2:(oh-ih)/2,setsar=1"
121
+ )
122
+ scale_main = "scale=trunc(iw/2)*2:trunc(ih/2)*2,setsar=1,fps=25"
123
+
124
+ video_enc = ["-c:v", "h264_nvenc", "-preset", "p4", "-cq", "15", "-pix_fmt", "yuv420p", "-profile:v", "high", "-level:v", "4.2"]
125
+ audio_enc = ["-c:a", "aac", "-b:a", "192k"]
126
+ tail = ["-movflags", "+faststart", "-y", output_path]
127
+
128
+ # ── Assign FFmpeg input indices ───────────────────────────────────────────
129
+ ffmpeg_inputs = ["-f", "concat", "-safe", "0", "-i", concat_path]
130
+ next_idx = 1
131
+
132
+ audio_idx = None
133
+ if audio_path:
134
+ ffmpeg_inputs += ["-i", audio_path]
135
+ audio_idx = next_idx
136
+ next_idx += 1
137
+
138
+ # Video file segments in playback order: pre_rec, post_rec, postroll
139
+ video_files = [] # (label, path, input_idx)
140
+ for label, path in [("pre", pre_rec_path), ("postrec", post_rec_path), ("postroll", postroll_path)]:
141
+ if path:
142
+ ffmpeg_inputs += ["-i", path]
143
+ video_files.append((label, path, next_idx))
144
+ next_idx += 1
145
+
146
+ pre_info = next((v for v in video_files if v[0] == "pre"), None)
147
+ postrec_info = next((v for v in video_files if v[0] == "postrec"), None)
148
+ postroll_info= next((v for v in video_files if v[0] == "postroll"),None)
149
+
150
+ # Probe video file durations and audio tracks
151
+ def probe(info):
152
+ if not info:
153
+ return None
154
+ _, path, _ = info
155
+ return {
156
+ "has_audio": has_audio_stream(path),
157
+ "duration": get_media_duration(path) or 0.0,
158
+ }
159
+
160
+ pre_meta = probe(pre_info)
161
+ postrec_meta = probe(postrec_info)
162
+ postroll_meta= probe(postroll_info)
163
+
164
+ # Segments in final playback order with all needed metadata
165
+ # Each entry: (video_filter, has_real_audio, audio_filter_or_null, duration)
166
+ segments = []
167
+
168
+ if pre_info:
169
+ _, _, idx = pre_info
170
+ segments.append({
171
+ "vf": f"[{idx}:v]{scale_video}",
172
+ "has_audio": pre_meta["has_audio"],
173
+ "af_real": f"[{idx}:a]{AUDIO_NORMALIZE},asetpts=PTS-STARTPTS",
174
+ "duration": pre_meta["duration"],
175
+ })
176
+
177
+ # Main recording always present
178
+ segments.append({
179
+ "vf": f"[0:v]{scale_main}",
180
+ "has_audio": bool(audio_path),
181
+ "af_real": f"[{audio_idx}:a]{AUDIO_NORMALIZE},atrim=duration={rec_duration:.4f},asetpts=PTS-STARTPTS"
182
+ if audio_path else None,
183
+ "duration": rec_duration,
184
+ })
185
+
186
+ if postrec_info:
187
+ _, _, idx = postrec_info
188
+ segments.append({
189
+ "vf": f"[{idx}:v]{scale_video}",
190
+ "has_audio": postrec_meta["has_audio"],
191
+ "af_real": f"[{idx}:a]{AUDIO_NORMALIZE},asetpts=PTS-STARTPTS",
192
+ "duration": postrec_meta["duration"],
193
+ })
194
+
195
+ if postroll_info:
196
+ _, _, idx = postroll_info
197
+ segments.append({
198
+ "vf": f"[{idx}:v]{scale_video}",
199
+ "has_audio": postroll_meta["has_audio"],
200
+ "af_real": f"[{idx}:a]{AUDIO_NORMALIZE},asetpts=PTS-STARTPTS",
201
+ "duration": postroll_meta["duration"],
202
+ })
203
+
204
+ need_audio_out = any(s["has_audio"] for s in segments)
205
+
206
+ # ── Simple path: only main recording ─────────────────────────────────────
207
+ if len(segments) == 1:
208
+ cmd = [FFMPEG_BIN] + ffmpeg_inputs + ["-vf", scale_main]
209
+ if audio_path:
210
+ cmd += audio_enc + ["-shortest"]
211
+ return cmd + video_enc + tail
212
+
213
+ # ── Complex path: multiple segments, single filter_complex pass ───────────
214
+ fp = []
215
+ v_labels = []
216
+ a_labels = []
217
+
218
+ for i, seg in enumerate(segments):
219
+ vl = f"v{i}"
220
+ al = f"a{i}"
221
+ fp.append(f"{seg['vf']}[{vl}]")
222
+ v_labels.append(f"[{vl}]")
223
+
224
+ if need_audio_out:
225
+ if seg["has_audio"]:
226
+ fp.append(f"{seg['af_real']}[{al}]")
227
+ else:
228
+ d = f"{seg['duration']:.4f}"
229
+ fp.append(f"anullsrc=r=44100:cl=stereo,atrim=duration={d},asetpts=PTS-STARTPTS[{al}]")
230
+ a_labels.append(f"[{al}]")
231
+
232
+ n = len(segments)
233
+ fp.append(f"{''.join(v_labels)}concat=n={n}:v=1:a=0[outv]")
234
+ if need_audio_out:
235
+ fp.append(f"{''.join(a_labels)}concat=n={n}:v=0:a=1[outa]")
236
+
237
+ cmd = [FFMPEG_BIN] + ffmpeg_inputs + ["-filter_complex", ";".join(fp), "-map", "[outv]"]
238
+ if need_audio_out:
239
+ cmd += ["-map", "[outa]"] + audio_enc
240
+ return cmd + video_enc + tail
241
+
242
+
243
+ @spaces.GPU(duration=120)
244
+ def _gpu_encode(cmd: list[str]) -> tuple[int, str]:
245
+ """Run FFmpeg on GPU using h264_nvenc with real-time stderr logging."""
246
+ import glob as _glob
247
+
248
+ # Find libnvidia-encode.so.1 wherever ZeroGPU mounts it
249
+ found = (
250
+ _glob.glob("/usr/lib*/**/libnvidia-encode*", recursive=True)
251
+ + _glob.glob("/usr/local/**/libnvidia-encode*", recursive=True)
252
+ + _glob.glob("/run/**/libnvidia-encode*", recursive=True)
253
+ + _glob.glob("/opt/**/libnvidia-encode*", recursive=True)
254
+ )
255
+ print(f"[wr] libnvidia-encode glob: {found}", flush=True)
256
+ # Also check ldconfig cache
257
+ ldc = subprocess.run(["ldconfig", "-p"], capture_output=True, text=True)
258
+ for line in ldc.stdout.splitlines():
259
+ if "nvidia" in line.lower():
260
+ print(f"[wr] ldconfig: {line.strip()}", flush=True)
261
+
262
+ extra_dirs = list({os.path.dirname(p) for p in found})
263
+ env = dict(os.environ)
264
+ env["LD_LIBRARY_PATH"] = ":".join(extra_dirs) + ":" + env.get("LD_LIBRARY_PATH", "")
265
+ print(f"[wr] LD_LIBRARY_PATH={env['LD_LIBRARY_PATH']}", flush=True)
266
+
267
+ proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, bufsize=0, env=env)
268
+ chunks = []
269
+ while True:
270
+ chunk = proc.stderr.read(512)
271
+ if not chunk:
272
+ break
273
+ chunks.append(chunk)
274
+ print(chunk.decode("utf-8", errors="replace"), end="", flush=True)
275
+ proc.wait()
276
+ return proc.returncode, b"".join(chunks).decode("utf-8", errors="replace")
277
+
278
+
279
+ async def _record_async(
280
+ url: str, w: int, h: int, duration: int,
281
+ output_path: str,
282
+ audio_path: str | None,
283
+ pre_rec_path: str | None,
284
+ post_rec_path: str | None,
285
+ postroll_path: str | None,
286
+ ):
287
+ t0 = time.monotonic()
288
+ def log(msg):
289
+ print(f"[wr {time.monotonic()-t0:.1f}s] {msg}", flush=True)
290
+
291
+ port = get_free_port()
292
+ chromium = get_chromium_path()
293
+ log(f"launching chromium on port {port}, url={url}, size={w}x{h}, duration={duration}")
294
+
295
+ proc = subprocess.Popen(
296
+ [
297
+ chromium,
298
+ f"--remote-debugging-port={port}",
299
+ "--headless",
300
+ "--no-sandbox",
301
+ "--disable-dev-shm-usage",
302
+ "--disable-setuid-sandbox",
303
+ "--use-gl=swiftshader",
304
+ "--enable-webgl",
305
+ "--hide-scrollbars",
306
+ "--autoplay-policy=no-user-gesture-required",
307
+ f"--window-size={w},{h}",
308
+ ],
309
+ stdout=subprocess.DEVNULL,
310
+ stderr=subprocess.DEVNULL,
311
+ )
312
+
313
+ try:
314
+ ws_url = None
315
+ async with aiohttp.ClientSession() as session:
316
+ for attempt in range(20):
317
+ await asyncio.sleep(0.5)
318
+ try:
319
+ async with session.get(f"http://localhost:{port}/json/list") as resp:
320
+ pages = await resp.json()
321
+ if pages:
322
+ ws_url = pages[0]["webSocketDebuggerUrl"]
323
+ log(f"chrome devtools ready after {attempt+1} attempts")
324
+ break
325
+ except Exception as e:
326
+ log(f"devtools attempt {attempt+1} failed: {e}")
327
+ continue
328
+
329
+ if not ws_url:
330
+ raise gr.Error("Could not connect to Chrome DevTools.")
331
+
332
+ frames = []
333
+ msg_id = 1
334
+
335
+ async with websockets.connect(ws_url, max_size=50_000_000) as ws:
336
+
337
+ async def send(method, params=None):
338
+ nonlocal msg_id
339
+ current_id = msg_id
340
+ await ws.send(json.dumps({"id": current_id, "method": method, "params": params or {}}))
341
+ msg_id += 1
342
+ return current_id
343
+
344
+ await send("Page.enable")
345
+ await send("Emulation.setDeviceMetricsOverride", {
346
+ "width": w, "height": h, "deviceScaleFactor": 1, "mobile": False,
347
+ })
348
+
349
+ log(f"navigating to {url}")
350
+ nav_id = await send("Page.navigate", {"url": url})
351
+ deadline = time.monotonic() + 10
352
+ while time.monotonic() < deadline:
353
+ try:
354
+ raw = await asyncio.wait_for(ws.recv(), timeout=1.0)
355
+ if json.loads(raw).get("id") == nav_id:
356
+ log("Page.navigate acknowledged")
357
+ break
358
+ except asyncio.TimeoutError:
359
+ continue
360
+
361
+ log("waiting for Page.loadEventFired (max 15s)")
362
+ deadline = time.monotonic() + 15
363
+ while time.monotonic() < deadline:
364
+ try:
365
+ raw = await asyncio.wait_for(ws.recv(), timeout=1.0)
366
+ if json.loads(raw).get("method") == "Page.loadEventFired":
367
+ log("Page.loadEventFired received")
368
+ break
369
+ except asyncio.TimeoutError:
370
+ continue
371
+ else:
372
+ log("load timeout — starting screencast anyway")
373
+
374
+ await send("Runtime.enable")
375
+ await send("Runtime.evaluate", {
376
+ "expression": (
377
+ "document.querySelectorAll('audio,video')"
378
+ ".forEach(m => { m.muted = false; m.play().catch(() => {}); })"
379
+ ),
380
+ "awaitPromise": False,
381
+ })
382
+
383
+ log(f"starting screencast for {duration}s")
384
+ await send("Page.startScreencast", {
385
+ "format": "png",
386
+ "maxWidth": w,
387
+ "maxHeight": h,
388
+ "everyNthFrame": 1,
389
+ })
390
+
391
+ start = time.monotonic()
392
+ last_log = start
393
+ while time.monotonic() - start < duration:
394
+ try:
395
+ raw = await asyncio.wait_for(ws.recv(), timeout=1.0)
396
+ obj = json.loads(raw)
397
+ if obj.get("method") == "Page.screencastFrame":
398
+ ts = time.monotonic() - start
399
+ frames.append((ts, obj["params"]["data"]))
400
+ await send("Page.screencastFrameAck", {
401
+ "sessionId": obj["params"]["sessionId"]
402
+ })
403
+ now = time.monotonic()
404
+ if now - last_log >= 5:
405
+ log(f"recording... {ts:.1f}s elapsed, {len(frames)} frames")
406
+ last_log = now
407
+ except asyncio.TimeoutError:
408
+ continue
409
+
410
+ log(f"screencast done: {len(frames)} frames captured")
411
+ await send("Page.stopScreencast")
412
+
413
+ finally:
414
+ proc.terminate()
415
+ proc.wait()
416
+ log("chromium terminated")
417
+
418
+ if not frames:
419
+ raise gr.Error("No frames captured. Check the URL.")
420
+
421
+ log(f"writing {len(frames)} frames to disk")
422
+ with tempfile.TemporaryDirectory() as tmpdir:
423
+ for i, (ts, data) in enumerate(frames):
424
+ with open(os.path.join(tmpdir, f"frame_{i:06d}.png"), "wb") as f:
425
+ f.write(base64.b64decode(data))
426
+
427
+ concat_path = os.path.join(tmpdir, "concat.txt")
428
+ with open(concat_path, "w") as f:
429
+ f.write("ffconcat version 1.0\n")
430
+ for i, (ts, _) in enumerate(frames):
431
+ dur = (frames[i + 1][0] - ts) if i < len(frames) - 1 else (duration - ts)
432
+ f.write(f"file 'frame_{i:06d}.png'\n")
433
+ f.write(f"duration {dur:.4f}\n")
434
+ f.write(f"file 'frame_{len(frames) - 1:06d}.png'\n")
435
+
436
+ cmd = build_ffmpeg_cmd(
437
+ concat_path, output_path, w, h, duration,
438
+ audio_path, pre_rec_path, post_rec_path, postroll_path,
439
+ )
440
+ log(f"running ffmpeg on GPU: {' '.join(cmd[:8])}...")
441
+ returncode, stderr = _gpu_encode(cmd)
442
+ if returncode != 0:
443
+ log(f"ffmpeg stderr: {stderr[-1000:]}")
444
+ raise gr.Error(f"FFmpeg failed: {stderr[-2000:]}")
445
+ log("ffmpeg done")
446
+
447
+ log("complete")
448
+ return output_path
449
+
450
+
451
+ def _cp(path: str | None) -> str | None:
452
+ """Copy an uploaded file to /tmp before asyncio yields give Gradio a cleanup opportunity."""
453
+ if not path:
454
+ return None
455
+ dst = f"/tmp/wr_{os.getpid()}_{int(time.time() * 1000)}_{os.path.basename(path)}"
456
+ shutil.copy2(path, dst)
457
+ return dst
458
+
459
+
460
+ async def record(
461
+ url: str,
462
+ width: float,
463
+ height: float,
464
+ duration: int,
465
+ audio_file: str | None,
466
+ pre_rec_file: str | None,
467
+ post_rec_file: str | None,
468
+ postroll_file: str | None,
469
+ ) -> str:
470
+ if not url.strip():
471
+ raise gr.Error("Please enter a URL.")
472
+ if not url.startswith(("http://", "https://")):
473
+ url = "https://" + url
474
+
475
+ # Copy uploaded files synchronously before the first await, so Gradio's
476
+ # event-loop cleanup (triggered at await yield points) can't delete them.
477
+ audio_file = _cp(audio_file)
478
+ pre_rec_file = _cp(pre_rec_file)
479
+ post_rec_file = _cp(post_rec_file)
480
+ postroll_file = _cp(postroll_file)
481
+ print(f"[wr] files copied: audio={audio_file} pre={pre_rec_file} post={post_rec_file} postroll={postroll_file}", flush=True)
482
+
483
+ w = max(2, (int(width) // 2) * 2)
484
+ h = max(2, (int(height) // 2) * 2)
485
+
486
+ # If audio uploaded, its duration overrides the slider (capped at 2 min)
487
+ if audio_file:
488
+ audio_dur = get_media_duration(audio_file)
489
+ if audio_dur:
490
+ duration = int(min(audio_dur, 120))
491
+
492
+ output_path = f"/tmp/recording_{os.getpid()}_{int(time.time())}.mp4"
493
+ return await _record_async(
494
+ url, w, h, duration, output_path,
495
+ audio_file, pre_rec_file, post_rec_file, postroll_file,
496
+ )
497
+
498
+
499
+ with gr.Blocks(title="Website Renderer") as demo:
500
+ gr.Markdown(
501
+ "# Website Renderer\n"
502
+ "Recording starts after full page load. "
503
+ "Uploading audio overrides the duration slider (max 2 min)."
504
+ )
505
+
506
+ url_input = gr.Textbox(label="URL", placeholder="https://example.com")
507
+
508
+ with gr.Row():
509
+ width_input = gr.Number(label="Width", value=540, precision=0, minimum=100, maximum=3840)
510
+ height_input = gr.Number(label="Height", value=960, precision=0, minimum=100, maximum=3840)
511
+ duration_input = gr.Slider(minimum=5, maximum=120, value=30, step=5, label="Duration (seconds)")
512
+
513
+ with gr.Row():
514
+ audio_input = gr.Audio(
515
+ label="Audio track (optional — overrides duration)",
516
+ type="filepath",
517
+ sources=["upload"],
518
+ )
519
+
520
+ with gr.Row():
521
+ pre_rec_input = gr.File(
522
+ label="Pre-recording video (optional)",
523
+ file_types=[".mp4", ".mov", ".webm", ".avi", ".mkv"],
524
+ )
525
+ post_rec_input = gr.File(
526
+ label="Post-recording video (optional)",
527
+ file_types=[".mp4", ".mov", ".webm", ".avi", ".mkv"],
528
+ )
529
+ postroll_input = gr.File(
530
+ label="Post-roll video (optional)",
531
+ file_types=[".mp4", ".mov", ".webm", ".avi", ".mkv"],
532
+ )
533
+
534
+ record_btn = gr.Button("Record", variant="primary")
535
+ video_output = gr.Video(label="Recorded Video")
536
+
537
+ record_btn.click(
538
+ fn=record,
539
+ inputs=[
540
+ url_input, width_input, height_input, duration_input,
541
+ audio_input, pre_rec_input, post_rec_input, postroll_input,
542
+ ],
543
+ outputs=video_output,
544
+ )
545
+
546
+ if __name__ == "__main__":
547
+ demo.launch()