farouk04 commited on
Commit
8bcab8a
Β·
verified Β·
1 Parent(s): a4ce9d7

Update detector.py

Browse files
Files changed (1) hide show
  1. detector.py +256 -87
detector.py CHANGED
@@ -1,18 +1,11 @@
1
  """
2
  MintVid Deepfake Detector β€” Hugging Face ZeroGPU Space
3
  V-JEPA 2.1 ViT-Giant + AttentiveClassifier probe
4
-
5
- ZEROGPU RULES (strictly followed here):
6
- - No torch.cuda.is_available() in main process
7
- - No map_location="cuda" in main process
8
- - No .to("cuda") that actually executes in main process
9
- (ZeroGPU *intercepts* .to("cuda") calls and postpones them β€”
10
- models stay on CPU until @spaces.GPU runs)
11
- - Only @spaces.GPU functions touch real GPU operations
12
  """
13
 
14
  import os
15
  import glob
 
16
  import subprocess
17
  import tempfile
18
  import warnings
@@ -24,19 +17,23 @@ import torch
24
  import spaces
25
  import gradio as gr
26
  from huggingface_hub import hf_hub_download
 
27
 
28
  from inference_core import AttentiveClassifier, get_video_windows
29
 
30
  # ─────────────────────────────────────────────
31
  # CONFIG
32
  # ─────────────────────────────────────────────
33
- DEVICE = "cuda"
34
- FEATURE_DIM = 1408
35
- ENCODER_BATCH = 3
 
 
 
36
 
37
- HF_TOKEN = os.environ.get("HF_TOKEN")
38
- ENCODER_REPO = os.environ.get("ENCODER_REPO", "")
39
- PROBE_REPO = os.environ.get("PROBE_REPO", ENCODER_REPO)
40
 
41
  # ─────────────────────────────────────────────
42
  # MODEL LOADING (CPU β€” ZeroGPU intercepts CUDA calls)
@@ -88,17 +85,30 @@ print("=" * 50)
88
 
89
 
90
  # ─────────────────────────────────────────────
91
- # VIDEO DOWNLOAD (pure CPU β€” no GPU quota used)
92
  # ─────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  def download_video(url: str) -> tuple[bool, str]:
94
- """Download via yt-dlp to a temp file. Returns (success, path_or_error)."""
95
  tmp = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False)
96
  tmp.close()
97
 
98
  cmd = [
99
  "yt-dlp",
100
  "-f", "best[height<=720]/best",
101
- "--max-filesize", "100M",
102
  "--no-playlist",
103
  "--no-warnings",
104
  "--force-overwrites",
@@ -106,53 +116,128 @@ def download_video(url: str) -> tuple[bool, str]:
106
  url,
107
  ]
108
  try:
109
- result = subprocess.run(
110
- cmd, capture_output=True, text=True, timeout=90
111
- )
112
-
113
- # Always print yt-dlp output so it appears in the Space Logs tab
114
  print(f"[yt-dlp] returncode : {result.returncode}")
115
  print(f"[yt-dlp] stdout : {result.stdout[:800]}")
116
  print(f"[yt-dlp] stderr : {result.stderr[:800]}")
117
 
118
  if result.returncode != 0:
119
- return False, (
120
- f"yt-dlp exited {result.returncode}\n"
121
- f"stderr: {result.stderr[:600]}"
122
- )
123
 
124
- # yt-dlp sometimes writes to a different extension than the template
125
- # (e.g. tmp.mp4 β†’ tmp.mp4.webm when remux isn't needed).
126
- # Search for any file that starts with tmp.name and has content.
127
  candidates = sorted(glob.glob(tmp.name + "*") + [tmp.name])
128
- actual_file = next(
129
  (f for f in candidates if os.path.exists(f) and os.path.getsize(f) > 0),
130
  None,
131
  )
132
-
133
- if actual_file is None:
134
  return False, (
135
- f"yt-dlp wrote no output (empty or missing file).\n"
136
- f"stdout: {result.stdout[:400]}\n"
137
- f"stderr: {result.stderr[:400]}"
138
  )
139
 
140
- print(f"[yt-dlp] actual file: {actual_file} ({os.path.getsize(actual_file)} bytes)")
141
- return True, actual_file
142
 
143
  except subprocess.TimeoutExpired:
144
- return False, "Download timed out (90s limit)."
145
  except FileNotFoundError:
146
  return False, "yt-dlp not installed. Check requirements.txt."
147
 
148
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
149
  # ─────────────────────────────────────────────
150
  # GPU INFERENCE (inside ZeroGPU allocation)
151
  # ─────────────────────────────────────────────
152
  @spaces.GPU(duration=120)
153
  def encode_and_classify(windows: list) -> list:
154
  scores = []
155
-
156
  for window in windows:
157
  clips = window["clips"]
158
  batch = torch.stack(clips).to(DEVICE)
@@ -162,100 +247,184 @@ def encode_and_classify(windows: list) -> list:
162
  chunks = []
163
  for i in range(0, batch.shape[0], ENCODER_BATCH):
164
  chunks.append(encoder(batch[i : i + ENCODER_BATCH]))
165
- all_feats = torch.cat(chunks, dim=0)
166
-
167
  video_feats = all_feats.view(1, -1, FEATURE_DIM)
168
-
169
- logit, _ = probe(video_feats)
170
- prob = torch.sigmoid(logit).item()
171
 
172
  scores.append({
173
  "prob": prob,
174
  "start_sec": window["start_sec"],
175
  "end_sec": window["end_sec"],
176
  })
177
-
178
  return scores
179
 
180
 
181
  # ─────────────────────────────────────────────
182
- # GRADIO PREDICT
183
  # ─────────────────────────────────────────────
184
- def predict(url: str) -> tuple[str, str, str]:
 
 
 
 
 
185
  url = (url or "").strip()
186
  if not url:
187
- return "Please enter a URL.", "", ""
 
 
 
 
188
 
189
  ok, path_or_err = download_video(url)
190
  if not ok:
191
- return f"Download failed: {path_or_err}", "", ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
192
 
193
- video_path = path_or_err
194
  try:
195
- try:
196
- windows, total_frames, fps, duration_sec = get_video_windows(video_path)
197
- except Exception as e:
198
- return f"Video decode failed: {e}", "", ""
 
199
 
200
- scores = encode_and_classify(windows)
 
201
 
202
- finally:
203
- # Clean up all files that share the temp base name
204
- for f in glob.glob(video_path + "*") + [video_path]:
205
- if os.path.exists(f):
206
- os.remove(f)
207
 
208
  if not scores:
209
- return "No windows processed.", "", ""
210
 
211
  final_prob = max(s["prob"] for s in scores)
212
  label = "⚠️ AI-GENERATED (FAKE)" if final_prob > 0.5 else "βœ… REAL"
213
  confidence = f"{final_prob * 100:.1f}%"
214
 
215
- lines = [f"Duration: {duration_sec:.1f}s | Frames: {total_frames} | Windows: {len(scores)}\n"]
216
  for s in scores:
217
  flag = " ← peak" if (s["prob"] == final_prob and final_prob > 0.5) else ""
218
  lines.append(
219
- f" [{s['start_sec']:.1f}s – {s['end_sec']:.1f}s] β†’ {s['prob']*100:.1f}% fake{flag}"
220
  )
221
 
222
- return label, confidence, "\n".join(lines)
223
 
224
 
225
  # ─────────────────────────────────────────────
226
  # GRADIO UI
227
  # ─────────────────────────────────────────────
228
  with gr.Blocks(title="MintVid Deepfake Detector") as demo:
 
 
 
229
  gr.Markdown(
230
- """
231
- ## 🎬 MintVid β€” Deepfake Video Detector
232
- Paste a public Instagram, TikTok, or direct `.mp4` URL.
233
- Uses **V-JEPA 2.1 ViT-Giant** + **AttentiveClassifier** probe.
234
- """
235
  )
236
 
237
- url_box = gr.Textbox(
238
- label="Video URL",
239
- placeholder="https://www.instagram.com/reels/...",
240
- autofocus=True,
 
 
 
 
 
 
 
 
241
  )
242
- run_btn = gr.Button("Analyze", variant="primary")
243
 
244
- with gr.Row():
245
- label_out = gr.Textbox(label="Prediction", scale=2)
246
- conf_out = gr.Textbox(label="Confidence", scale=1)
247
 
248
- breakdown_out = gr.Textbox(label="Window Breakdown", lines=6, interactive=False)
249
 
250
- run_btn.click(
251
- fn=predict,
252
- inputs=url_box,
253
- outputs=[label_out, conf_out, breakdown_out],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
254
  )
255
- url_box.submit(
256
- fn=predict,
257
- inputs=url_box,
258
- outputs=[label_out, conf_out, breakdown_out],
 
 
 
 
 
 
259
  )
260
 
261
- demo.launch(share=True)
 
1
  """
2
  MintVid Deepfake Detector β€” Hugging Face ZeroGPU Space
3
  V-JEPA 2.1 ViT-Giant + AttentiveClassifier probe
 
 
 
 
 
 
 
 
4
  """
5
 
6
  import os
7
  import glob
8
+ import math
9
  import subprocess
10
  import tempfile
11
  import warnings
 
17
  import spaces
18
  import gradio as gr
19
  from huggingface_hub import hf_hub_download
20
+ from decord import VideoReader, cpu as decord_cpu
21
 
22
  from inference_core import AttentiveClassifier, get_video_windows
23
 
24
  # ─────────────────────────────────────────────
25
  # CONFIG
26
  # ─────────────────────────────────────────────
27
+ DEVICE = "cuda"
28
+ FEATURE_DIM = 1408
29
+ ENCODER_BATCH = 3
30
+ FRAMES_PER_CLIP = 64
31
+ CLIPS_PER_WINDOW = 3
32
+ FRAMES_PER_WINDOW = FRAMES_PER_CLIP * CLIPS_PER_WINDOW # 192
33
 
34
+ HF_TOKEN = os.environ.get("HF_TOKEN")
35
+ ENCODER_REPO = os.environ.get("ENCODER_REPO", "")
36
+ PROBE_REPO = os.environ.get("PROBE_REPO", ENCODER_REPO)
37
 
38
  # ─────────────────────────────────────────────
39
  # MODEL LOADING (CPU β€” ZeroGPU intercepts CUDA calls)
 
85
 
86
 
87
  # ─────────────────────────────────────────────
88
+ # HELPERS
89
  # ─────────────────────────────────────────────
90
+
91
+ def cleanup_file(path: str):
92
+ """Delete temp video file and any yt-dlp extension variants."""
93
+ if not path:
94
+ return
95
+ for f in glob.glob(path + "*") + [path]:
96
+ try:
97
+ if os.path.exists(f):
98
+ os.remove(f)
99
+ except Exception:
100
+ pass
101
+
102
+
103
  def download_video(url: str) -> tuple[bool, str]:
104
+ """Download via yt-dlp. Returns (success, path_or_error)."""
105
  tmp = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False)
106
  tmp.close()
107
 
108
  cmd = [
109
  "yt-dlp",
110
  "-f", "best[height<=720]/best",
111
+ "--max-filesize", "200M",
112
  "--no-playlist",
113
  "--no-warnings",
114
  "--force-overwrites",
 
116
  url,
117
  ]
118
  try:
119
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
 
 
 
 
120
  print(f"[yt-dlp] returncode : {result.returncode}")
121
  print(f"[yt-dlp] stdout : {result.stdout[:800]}")
122
  print(f"[yt-dlp] stderr : {result.stderr[:800]}")
123
 
124
  if result.returncode != 0:
125
+ return False, f"yt-dlp exited {result.returncode}\nstderr: {result.stderr[:600]}"
 
 
 
126
 
 
 
 
127
  candidates = sorted(glob.glob(tmp.name + "*") + [tmp.name])
128
+ actual = next(
129
  (f for f in candidates if os.path.exists(f) and os.path.getsize(f) > 0),
130
  None,
131
  )
132
+ if actual is None:
 
133
  return False, (
134
+ f"yt-dlp wrote no output.\n"
135
+ f"stdout: {result.stdout[:400]}\nstderr: {result.stderr[:400]}"
 
136
  )
137
 
138
+ print(f"[yt-dlp] actual file : {actual} ({os.path.getsize(actual)} bytes)")
139
+ return True, actual
140
 
141
  except subprocess.TimeoutExpired:
142
+ return False, "Download timed out (120s limit)."
143
  except FileNotFoundError:
144
  return False, "yt-dlp not installed. Check requirements.txt."
145
 
146
 
147
+ def get_video_info(path: str) -> dict:
148
+ """Peek at video metadata without decoding all frames."""
149
+ vr = VideoReader(path, ctx=decord_cpu(0), num_threads=1)
150
+ total_frames = len(vr)
151
+ fps = vr.get_avg_fps()
152
+ duration = total_frames / fps if fps > 0 else 0.0
153
+ return {"path": path, "duration": duration, "fps": fps, "total_frames": total_frames}
154
+
155
+
156
+ def make_timeline_html(duration: float, start: float, end: float, fps: float) -> str:
157
+ """Render a video-trimmer style timeline bar showing the selected segment."""
158
+ if duration <= 0:
159
+ return ""
160
+
161
+ start = max(0.0, min(float(start), duration))
162
+ end = max(start, min(float(end), duration))
163
+
164
+ pct_start = start / duration * 100
165
+ pct_end = end / duration * 100
166
+ pct_width = pct_end - pct_start
167
+ mid_pct = (pct_start + pct_end) / 2
168
+
169
+ frames_in_range = int((end - start) * fps)
170
+ num_windows = max(1, math.ceil(frames_in_range / FRAMES_PER_WINDOW)) if frames_in_range > 0 else 0
171
+ selected_sec = end - start
172
+ window_sec = FRAMES_PER_WINDOW / fps
173
+
174
+ # One orange tick per window boundary inside the selection
175
+ ticks = ""
176
+ if num_windows > 1:
177
+ for i in range(1, num_windows):
178
+ tick_pct = (start + i * window_sec) / duration * 100
179
+ ticks += (
180
+ f'<div style="position:absolute;left:{tick_pct:.2f}%;top:0;bottom:0;'
181
+ f'width:1px;background:rgba(249,115,22,0.55);z-index:3;"></div>'
182
+ )
183
+
184
+ return f"""
185
+ <div style="margin:4px 0 8px 0;font-family:system-ui,sans-serif;user-select:none;">
186
+
187
+ <div style="position:relative;height:48px;background:#111;border-radius:8px;
188
+ overflow:hidden;border:1px solid #2a2a2a;">
189
+
190
+ <div style="position:absolute;inset:0;pointer-events:none;
191
+ background:repeating-linear-gradient(90deg,transparent,transparent 19px,
192
+ rgba(255,255,255,0.025) 19px,rgba(255,255,255,0.025) 20px);"></div>
193
+
194
+ <div style="position:absolute;left:0;width:{pct_start:.2f}%;
195
+ top:0;bottom:0;background:rgba(0,0,0,0.62);z-index:1;"></div>
196
+
197
+ <div style="position:absolute;left:{pct_end:.2f}%;right:0;
198
+ top:0;bottom:0;background:rgba(0,0,0,0.62);z-index:1;"></div>
199
+
200
+ <div style="position:absolute;left:{pct_start:.2f}%;width:{pct_width:.2f}%;
201
+ top:0;bottom:0;border-left:2px solid #f97316;border-right:2px solid #f97316;
202
+ box-sizing:border-box;z-index:2;"></div>
203
+
204
+ {ticks}
205
+
206
+ <div style="position:absolute;left:6px;top:50%;transform:translateY(-50%);
207
+ font-size:10px;color:#444;z-index:4;">0 s</div>
208
+
209
+ <div style="position:absolute;right:6px;top:50%;transform:translateY(-50%);
210
+ font-size:10px;color:#444;z-index:4;">{duration:.1f} s</div>
211
+
212
+ <div style="position:absolute;left:{mid_pct:.2f}%;top:50%;
213
+ transform:translate(-50%,-50%);font-size:13px;font-weight:600;
214
+ color:#f97316;z-index:5;white-space:nowrap;
215
+ text-shadow:0 1px 6px rgba(0,0,0,0.95);">{selected_sec:.1f} s</div>
216
+ </div>
217
+
218
+ <div style="display:flex;justify-content:space-between;align-items:center;
219
+ margin-top:8px;font-size:12px;">
220
+ <span style="color:#888;">{start:.1f} s</span>
221
+ <span style="background:rgba(249,115,22,0.1);color:#f97316;
222
+ padding:3px 14px;border-radius:20px;font-weight:500;
223
+ border:1px solid rgba(249,115,22,0.22);">
224
+ {num_windows} window{'s' if num_windows != 1 else ''}
225
+ &nbsp;Β·&nbsp; ~{num_windows * window_sec:.1f} s
226
+ &nbsp;Β·&nbsp; {frames_in_range} frames
227
+ </span>
228
+ <span style="color:#888;">{end:.1f} s</span>
229
+ </div>
230
+
231
+ </div>
232
+ """
233
+
234
+
235
  # ─────────────────────────────────────────────
236
  # GPU INFERENCE (inside ZeroGPU allocation)
237
  # ─────────────────────────────────────────────
238
  @spaces.GPU(duration=120)
239
  def encode_and_classify(windows: list) -> list:
240
  scores = []
 
241
  for window in windows:
242
  clips = window["clips"]
243
  batch = torch.stack(clips).to(DEVICE)
 
247
  chunks = []
248
  for i in range(0, batch.shape[0], ENCODER_BATCH):
249
  chunks.append(encoder(batch[i : i + ENCODER_BATCH]))
250
+ all_feats = torch.cat(chunks, dim=0)
 
251
  video_feats = all_feats.view(1, -1, FEATURE_DIM)
252
+ logit, _ = probe(video_feats)
253
+ prob = torch.sigmoid(logit).item()
 
254
 
255
  scores.append({
256
  "prob": prob,
257
  "start_sec": window["start_sec"],
258
  "end_sec": window["end_sec"],
259
  })
 
260
  return scores
261
 
262
 
263
  # ─────────────────────────────────────────────
264
+ # GRADIO CALLBACKS
265
  # ─────────────────────────────────────────────
266
+
267
+ def load_video_cb(url: str, state: dict):
268
+ """
269
+ Phase 1 β€” download the video and expose the segment selector.
270
+ Deletes any previously loaded temp file before downloading.
271
+ """
272
  url = (url or "").strip()
273
  if not url:
274
+ return state, "Please enter a URL.", gr.update(visible=False), gr.update(), gr.update(), ""
275
+
276
+ # Clean up the last loaded file (if any)
277
+ if state and state.get("path"):
278
+ cleanup_file(state["path"])
279
 
280
  ok, path_or_err = download_video(url)
281
  if not ok:
282
+ return None, f"❌ {path_or_err}", gr.update(visible=False), gr.update(), gr.update(), ""
283
+
284
+ info = get_video_info(path_or_err)
285
+ duration = info["duration"]
286
+ fps = info["fps"]
287
+ step = round(max(0.1, 1.0 / fps), 3)
288
+
289
+ timeline = make_timeline_html(duration, 0.0, duration, fps)
290
+
291
+ status = (
292
+ f"βœ… {duration:.1f} s Β· {fps:.0f} fps Β· {info['total_frames']} frames "
293
+ f"β€” drag the sliders to choose which segment to analyze"
294
+ )
295
+
296
+ return (
297
+ info,
298
+ status,
299
+ gr.update(visible=True), # segment_panel
300
+ gr.update(minimum=0.0, maximum=duration, value=0.0, step=step), # start_slider
301
+ gr.update(minimum=0.0, maximum=duration, value=duration, step=step), # end_slider
302
+ timeline,
303
+ )
304
+
305
+
306
+ def update_timeline_cb(state: dict, start: float, end: float) -> str:
307
+ """Redraw the timeline whenever a slider moves."""
308
+ if not state:
309
+ return ""
310
+ return make_timeline_html(state["duration"], start, end, state["fps"])
311
+
312
+
313
+ def analyze_cb(state: dict, start: float, end: float):
314
+ """
315
+ Phase 2 β€” run inference on the selected [start, end] segment.
316
+ The video file is kept so the user can re-analyze a different segment
317
+ without re-downloading. It will be cleaned up on the next Load.
318
+ """
319
+ if not state or not state.get("path"):
320
+ return "No video loaded.", "", "", gr.update(visible=False)
321
+
322
+ start = max(0.0, float(start))
323
+ end = min(float(end), state["duration"])
324
+
325
+ if end <= start:
326
+ return "Start must be before end.", "", "", gr.update(visible=False)
327
+
328
+ video_path = state["path"]
329
 
 
330
  try:
331
+ windows, total_frames, fps, duration_sec = get_video_windows(
332
+ video_path, start_sec=start, end_sec=end
333
+ )
334
+ except Exception as e:
335
+ return f"Video decode failed: {e}", "", "", gr.update(visible=False)
336
 
337
+ if not windows:
338
+ return "No frames in selected range.", "", "", gr.update(visible=False)
339
 
340
+ scores = encode_and_classify(windows)
 
 
 
 
341
 
342
  if not scores:
343
+ return "No windows processed.", "", "", gr.update(visible=False)
344
 
345
  final_prob = max(s["prob"] for s in scores)
346
  label = "⚠️ AI-GENERATED (FAKE)" if final_prob > 0.5 else "βœ… REAL"
347
  confidence = f"{final_prob * 100:.1f}%"
348
 
349
+ lines = [f"Segment: {start:.1f} s – {end:.1f} s | Frames: {total_frames} | Windows: {len(scores)}\n"]
350
  for s in scores:
351
  flag = " ← peak" if (s["prob"] == final_prob and final_prob > 0.5) else ""
352
  lines.append(
353
+ f" [{s['start_sec']:.1f} s – {s['end_sec']:.1f} s] β†’ {s['prob']*100:.1f}% fake{flag}"
354
  )
355
 
356
+ return label, confidence, "\n".join(lines), gr.update(visible=True)
357
 
358
 
359
  # ─────────────────────────────────────────────
360
  # GRADIO UI
361
  # ─────────────────────────────────────────────
362
  with gr.Blocks(title="MintVid Deepfake Detector") as demo:
363
+
364
+ video_state = gr.State(None)
365
+
366
  gr.Markdown(
367
+ "## 🎬 MintVid β€” Deepfake Video Detector\n"
368
+ "Paste a URL, trim to the segment you want to test, then hit **Analyze**."
 
 
 
369
  )
370
 
371
+ # ── Phase 1: URL input ──────────────────────────────────────────
372
+ with gr.Row():
373
+ url_box = gr.Textbox(
374
+ label="Video URL",
375
+ placeholder="https://www.instagram.com/reels/... or https://www.tiktok.com/@user/video/...",
376
+ scale=5,
377
+ )
378
+ load_btn = gr.Button("Load", variant="primary", scale=1, min_width=80)
379
+
380
+ status_box = gr.Textbox(
381
+ label="", interactive=False, show_label=False, container=False,
382
+ placeholder="Status will appear here after loading."
383
  )
 
384
 
385
+ # ── Phase 2: Trim / segment selector ───────────────────────────
386
+ with gr.Column(visible=False) as segment_panel:
387
+ gr.Markdown("### Select segment to analyze")
388
 
389
+ timeline_html = gr.HTML()
390
 
391
+ with gr.Row():
392
+ start_slider = gr.Slider(minimum=0, maximum=100, value=0, step=0.1, label="Start (s)")
393
+ end_slider = gr.Slider(minimum=0, maximum=100, value=100, step=0.1, label="End (s)")
394
+
395
+ analyze_btn = gr.Button("Analyze Selection", variant="primary", size="lg")
396
+
397
+ # ── Phase 3: Results ────────────────────────────────────────────
398
+ with gr.Column(visible=False) as results_panel:
399
+ gr.Markdown("### Result")
400
+ with gr.Row():
401
+ label_out = gr.Textbox(label="Prediction", scale=2)
402
+ conf_out = gr.Textbox(label="Confidence", scale=1)
403
+ breakdown_out = gr.Textbox(label="Window Breakdown", lines=6, interactive=False)
404
+
405
+ # ── Event wiring ────────────────────────────────────────────────
406
+
407
+ _load_inputs = [url_box, video_state]
408
+ _load_outputs = [video_state, status_box, segment_panel, start_slider, end_slider, timeline_html]
409
+
410
+ load_btn.click(fn=load_video_cb, inputs=_load_inputs, outputs=_load_outputs)
411
+ url_box.submit(fn=load_video_cb, inputs=_load_inputs, outputs=_load_outputs)
412
+
413
+ start_slider.change(
414
+ fn=update_timeline_cb,
415
+ inputs=[video_state, start_slider, end_slider],
416
+ outputs=[timeline_html],
417
  )
418
+ end_slider.change(
419
+ fn=update_timeline_cb,
420
+ inputs=[video_state, start_slider, end_slider],
421
+ outputs=[timeline_html],
422
+ )
423
+
424
+ analyze_btn.click(
425
+ fn=analyze_cb,
426
+ inputs=[video_state, start_slider, end_slider],
427
+ outputs=[label_out, conf_out, breakdown_out, results_panel],
428
  )
429
 
430
+ demo.launch()