Hug0endob commited on
Commit
b9e450b
·
verified ·
1 Parent(s): cfe8576

Update streamlit_app.py

Browse files
Files changed (1) hide show
  1. streamlit_app.py +282 -166
streamlit_app.py CHANGED
@@ -2,51 +2,23 @@
2
  # -*- coding: utf-8 -*-
3
 
4
  """
5
- Video‑analysis Streamlit app.
6
-
7
- Features
8
- --------
9
- * Download videos from direct links, Twitter, or any site supported by yt‑dlp.
10
- * Convert to MP4 (ffmpeg) and compress if larger than a user‑defined threshold.
11
- * Send the video (base64‑encoded) + a custom prompt to Gemini‑Flash models.
12
- * Simple sidebar UI with clear video handling.
13
  """
14
 
15
  # ----------------------------------------------------------------------
16
- # Standard library
17
  # ----------------------------------------------------------------------
18
- import base64
19
- import hashlib
20
- import os
21
- import string
22
- import traceback
23
  from pathlib import Path
24
- from typing import Tuple, Optional
25
  from difflib import SequenceMatcher
 
26
 
27
- # ----------------------------------------------------------------------
28
- # Third‑party libraries
29
- # ----------------------------------------------------------------------
30
  import ffmpeg
31
  import google.generativeai as genai
32
  import requests
33
  import streamlit as st
34
  import yt_dlp
35
-
36
- # Compatibility layer for Streamlit ≥ 1.24
37
- if not hasattr(st, "experimental_rerun"):
38
- # In newer releases the function is simply called `st.rerun`
39
- st.experimental_rerun = st.rerun
40
-
41
- # Optional Twitter scraper – show a friendly error if missing
42
- try:
43
- import snscrape.modules.twitter as sntwitter
44
- except ImportError: # pragma: no cover
45
- st.error(
46
- "Package `snscrape` is required for Twitter extraction. "
47
- "Install with `pip install snscrape`."
48
- )
49
- st.stop()
50
 
51
  # ----------------------------------------------------------------------
52
  # Constants & defaults
@@ -54,12 +26,253 @@ except ImportError: # pragma: no cover
54
  DATA_DIR = Path("./data")
55
  DATA_DIR.mkdir(exist_ok=True)
56
 
57
- MODEL_OPTIONS = [
58
- "gemini-2.5-flash-lite",
59
- "gemini-2.5-flash",
60
- "gemini-2.0-flash-lite",
61
- "gemini-2.0-flash",
62
- "custom",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  ]
64
  DEFAULT_MODEL = "gemini-2.0-flash-lite"
65
 
@@ -70,7 +283,7 @@ DEFAULT_PROMPT = (
70
  )
71
 
72
  # ----------------------------------------------------------------------
73
- # Session‑state defaults (run once per session)
74
  # ----------------------------------------------------------------------
75
  def _init_state() -> None:
76
  defaults = {
@@ -78,7 +291,7 @@ def _init_state() -> None:
78
  "video_path": "",
79
  "model_input": DEFAULT_MODEL,
80
  "prompt": DEFAULT_PROMPT,
81
- "api_key": os.getenv("GOOGLE_API_KEY", "AIzaSyBiAW2GQLid0HGe9Vs_ReKwkwsSVNegNzs"),
82
  "video_password": "",
83
  "compress_mb": 200,
84
  "busy": False,
@@ -98,13 +311,11 @@ _init_state()
98
  # Helper utilities
99
  # ----------------------------------------------------------------------
100
  def _sanitize_filename(url: str) -> str:
101
- """Create a lower‑case, punctuation‑free filename from a URL."""
102
  name = Path(url).name.lower()
103
  return name.translate(str.maketrans("", "", string.punctuation)).replace(" ", "_")
104
 
105
 
106
  def _file_sha256(path: Path) -> Optional[str]:
107
- """Return SHA‑256 hex digest of *path* or ``None`` on failure."""
108
  try:
109
  h = hashlib.sha256()
110
  with path.open("rb") as f:
@@ -116,7 +327,6 @@ def _file_sha256(path: Path) -> Optional[str]:
116
 
117
 
118
  def _convert_to_mp4(src: Path) -> Path:
119
- """Convert *src* to MP4 with ffmpeg; return the MP4 path."""
120
  dst = src.with_suffix(".mp4")
121
  if dst.exists():
122
  return dst
@@ -165,16 +375,19 @@ def _download_direct(url: str, dst: Path) -> Path:
165
 
166
 
167
  def _download_with_yt_dlp(url: str, dst: Path, password: str = "") -> Path:
168
- """Download via yt‑dlp with Streamlit progress and MP4‑first format."""
169
  tmpl = str(dst / "%(id)s.%(ext)s")
170
- # Prefer MP4, fall back to best if not available
171
- fmt = "bestvideo[ext=mp4]+bestaudio[ext=m4a]/bestvideo+bestaudio"
 
172
  opts = {
173
  "outtmpl": tmpl,
174
  "format": fmt,
175
- "quiet": True, # we handle progress ourselves
176
  "noprogress": True,
177
  "nocheckcertificate": True,
 
 
178
  }
179
  if password:
180
  opts["videopassword"] = password
@@ -198,7 +411,7 @@ def _download_with_yt_dlp(url: str, dst: Path, password: str = "") -> Path:
198
 
199
  try:
200
  with yt_dlp.YoutubeDL(opts) as ydl:
201
- info = ydl.extract_info(url, download=True)
202
  except Exception as e:
203
  raise RuntimeError(f"yt‑dlp could not download the URL: {e}") from e
204
  finally:
@@ -289,11 +502,30 @@ def _strip_prompt_echo(prompt: str, text: str, threshold: float = 0.68) -> str:
289
  return text
290
 
291
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
292
  # ----------------------------------------------------------------------
293
  # Streamlit UI
294
  # ----------------------------------------------------------------------
295
  def main() -> None:
296
  st.set_page_config(page_title="Video Analysis", layout="wide")
 
297
 
298
  # ---------- Sidebar ----------
299
  st.sidebar.header("Video Input")
@@ -349,126 +581,10 @@ def main() -> None:
349
  key="compress_mb",
350
  )
351
 
352
- # ---------- Preview & clear ----------
353
- if st.session_state.get("video_path"):
354
- try:
355
- mp4 = _convert_to_mp4(Path(st.session_state["video_path"]))
356
- with open(mp4, "rb") as f:
357
- video_bytes = f.read()
358
- st.sidebar.video(video_bytes)
359
- except Exception:
360
- st.sidebar.write("Preview unavailable")
361
-
362
  if st.sidebar.button("Clear Video"):
363
  for f in DATA_DIR.iterdir():
364
  try:
365
  f.unlink()
366
  except Exception:
367
  pass
368
- st.session_state.update(
369
- {
370
- "url": "",
371
- "video_path": "",
372
- "analysis_out": "",
373
- "raw_output": "",
374
- "last_error": "",
375
- "busy": False,
376
- "show_raw_on_error": False,
377
- "show_analysis": False,
378
- }
379
- )
380
- st.success("Session cleared.")
381
- st.experimental_rerun()
382
-
383
- # ---------- Generation ----------
384
- col1, col2 = st.columns([1, 3])
385
- with col1:
386
- generate_now = st.sidebar.button(
387
- "Generate analysis",
388
- type="primary",
389
- disabled=st.session_state.get("busy", False),
390
- )
391
- with col2:
392
- if not st.session_state.get("video_path"):
393
- st.info("Load a video first.", icon="ℹ️")
394
-
395
- # ------------------------------------------------------------------
396
- # Generation handling (patched – keep this **after** the button code)
397
- # ------------------------------------------------------------------
398
- if generate_now and not st.session_state.get("busy", False):
399
- api_key = st.session_state.get("api_key") or os.getenv("GOOGLE_API_KEY")
400
- if not st.session_state.get("video_path"):
401
- st.error("No video loaded.")
402
- elif not api_key:
403
- st.error("Google API key missing.")
404
- else:
405
- try:
406
- st.session_state["busy"] = True
407
- genai.configure(api_key=api_key)
408
-
409
- # ----- optional compression -----
410
- with st.spinner("Checking video size…"):
411
- video_path, was_compressed = _maybe_compress(
412
- Path(st.session_state["video_path"]),
413
- st.session_state["compress_mb"],
414
- )
415
-
416
- # ----- generation -----
417
- with st.spinner("Generating analysis…"):
418
- raw_out = generate_report(
419
- video_path,
420
- st.session_state["prompt"],
421
- st.session_state["model_input"],
422
- st.session_state.get("generation_timeout", 300),
423
- )
424
- st.session_state["raw_output"] = raw_out
425
-
426
- # ----- clean up compressed file -----
427
- if was_compressed:
428
- try:
429
- video_path.unlink()
430
- except OSError:
431
- pass
432
-
433
- # ----- clean the Gemini response -----
434
- cleaned = _strip_prompt_echo(st.session_state["prompt"], raw_out)
435
- st.session_state["analysis_out"] = cleaned
436
- st.session_state["show_analysis"] = True # only flag, no direct print
437
- st.success("Analysis generated.")
438
-
439
- except Exception as exc:
440
- tb = traceback.format_exc()
441
- st.session_state["last_error_detail"] = (
442
- f"{tb}\n\nRaw Gemini output:\n{st.session_state.get('raw_output', '')}"
443
- )
444
- st.session_state["last_error"] = f"Generation error: {exc}"
445
- st.session_state["show_raw_on_error"] = True
446
- st.error("An error occurred during generation.")
447
- finally:
448
- st.session_state["busy"] = False
449
-
450
- # ------------------------------------------------------------------
451
- # Results display
452
- # ------------------------------------------------------------------
453
- if st.session_state.get("show_analysis"):
454
- st.subheader("📝 Analysis")
455
- st.markdown(st.session_state["analysis_out"])
456
- st.session_state["show_analysis"] = False
457
-
458
- # Full Gemini output – collapsed by default, expanded on error
459
- if st.session_state.get("raw_output"):
460
- if st.session_state.get("show_raw_on_error"):
461
- st.subheader("🔎 Full Gemini output")
462
- st.code(st.session_state["raw_output"], language="text")
463
- else:
464
- with st.expander("🔎 Full Gemini output (collapsed)"):
465
- st.code(st.session_state["raw_output"], language="text")
466
-
467
- # Errors
468
- if st.session_state.get("last_error"):
469
- with st.expander("❗️ Error details"):
470
- st.code(st.session_state["last_error_detail"], language="text")
471
-
472
-
473
- if __name__ == "__main__":
474
- main()
 
2
  # -*- coding: utf-8 -*-
3
 
4
  """
5
+ Video‑analysis Streamlit app (refactored).
 
 
 
 
 
 
 
6
  """
7
 
8
  # ----------------------------------------------------------------------
9
+ # Imports
10
  # ----------------------------------------------------------------------
11
+ import base64, hashlib, os, string, traceback
 
 
 
 
12
  from pathlib import Path
 
13
  from difflib import SequenceMatcher
14
+ from typing import Tuple, Optional
15
 
 
 
 
16
  import ffmpeg
17
  import google.generativeai as genai
18
  import requests
19
  import streamlit as st
20
  import yt_dlp
21
+ import snscrape.modules.twitter as sntwitter
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
 
23
  # ----------------------------------------------------------------------
24
  # Constants & defaults
 
26
  DATA_DIR = Path("./data")
27
  DATA_DIR.mkdir(exist_ok=True)
28
 
29
+ def _compress_video(inp: Path, crf: int = 28, preset: str = "fast") -> Path:
30
+ """Compress *inp* using libx264; return the compressed file."""
31
+ out = inp.with_name(f"{inp.stem}_compressed.mp4")
32
+ try:
33
+ ffmpeg.input(str(inp)).output(
34
+ str(out), vcodec="libx264", crf=crf, preset=preset
35
+ ).overwrite_output().run(capture_stdout=True, capture_stderr=True)
36
+ except ffmpeg.Error as e:
37
+ raise RuntimeError(f"ffmpeg compression failed: {e.stderr.decode()}") from e
38
+ return out if out.exists() else inp
39
+
40
+
41
+ def _maybe_compress(path: Path, limit_mb: int) -> Tuple[Path, bool]:
42
+ """Compress *path* if its size exceeds *limit_mb*."""
43
+ size_mb = path.stat().st_size / (1024 * 1024)
44
+ if size_mb <= limit_mb:
45
+ return path, False
46
+ return _compress_video(path), True
47
+
48
+
49
+ def _download_direct(url: str, dst: Path) -> Path:
50
+ """Download a raw video file via HTTP GET."""
51
+ r = requests.get(url, stream=True, timeout=30)
52
+ r.raise_for_status()
53
+ out = dst / _sanitize_filename(url.split("/")[-1])
54
+ with out.open("wb") as f:
55
+ for chunk in r.iter_content(chunk_size=8192):
56
+ if chunk:
57
+ f.write(chunk)
58
+ return out
59
+
60
+
61
+ def _download_with_yt_dlp(url: str, dst: Path, password: str = "") -> Path:
62
+ """Download via yt‑dlp, ensuring the complete file is retrieved."""
63
+ tmpl = str(dst / "%(id)s.%(ext)s")
64
+ # Prefer a full‑container MP4; fall back to the best available format.
65
+ fmt = "best[ext=mp4]/best"
66
+
67
+ opts = {
68
+ "outtmpl": tmpl,
69
+ "format": fmt,
70
+ "quiet": True,
71
+ "noprogress": True,
72
+ "nocheckcertificate": True,
73
+ "merge_output_format": "mp4", # force a single MP4 file
74
+ "fragment_retries": 0, # avoid fragmented downloads
75
+ }
76
+ if password:
77
+ opts["videopassword"] = password
78
+
79
+ progress_bar = st.empty()
80
+ status_text = st.empty()
81
+
82
+ def _progress_hook(d):
83
+ if d["status"] == "downloading":
84
+ total = d.get("total_bytes") or d.get("total_bytes_estimate")
85
+ downloaded = d.get("downloaded_bytes", 0)
86
+ if total:
87
+ pct = downloaded / total
88
+ progress_bar.progress(pct)
89
+ status_text.caption(f"Downloading… {pct:.0%}")
90
+ elif d["status"] == "finished":
91
+ progress_bar.progress(1.0)
92
+ status_text.caption("Download complete, processing…")
93
+
94
+ opts["progress_hooks"] = [_progress_hook]
95
+
96
+ try:
97
+ with yt_dlp.YoutubeDL(opts) as ydl:
98
+ ydl.extract_info(url, download=True)
99
+ except Exception as e:
100
+ raise RuntimeError(f"yt‑dlp could not download the URL: {e}") from e
101
+ finally:
102
+ progress_bar.empty()
103
+ status_text.empty()
104
+
105
+ # yt‑dlp may have produced several files; pick the newest MP4
106
+ mp4_files = list(dst.glob("*.mp4"))
107
+ if not mp4_files:
108
+ raise RuntimeError("No MP4 file was created.")
109
+ newest = max(mp4_files, key=lambda p: p.stat().st_mtime)
110
+
111
+ # Optional cache: if a file with the same SHA‑256 already exists, reuse it
112
+ sha = _file_sha256(newest)
113
+ if sha:
114
+ for existing in dst.iterdir():
115
+ if existing != newest and _file_sha256(existing) == sha:
116
+ newest.unlink() # remove duplicate
117
+ return existing
118
+
119
+ return newest
120
+
121
+
122
+ def download_video(url: str, dst: Path, password: str = "") -> Path:
123
+ """
124
+ Download a video from *url* and return an MP4 path.
125
+ Strategy
126
+ ---------
127
+ 1. Direct video URL → HTTP GET.
128
+ 2. Twitter status → scrape for embedded video URLs.
129
+ 3. yt‑dlp fallback for everything else.
130
+ """
131
+ video_exts = (".mp4", ".mov", ".webm", ".mkv", ".avi", ".flv")
132
+
133
+ if url.lower().endswith(video_exts):
134
+ return _download_direct(url, dst)
135
+
136
+ if "twitter.com" in url and "/status/" in url:
137
+ tweet_id = url.split("/")[-1].split("?")[0]
138
+ for tweet in sntwitter.TwitterTweetScraper(tweet_id).get_items():
139
+ for m in getattr(tweet, "media", []):
140
+ if getattr(m, "video_url", None):
141
+ return download_video(m.video_url, dst)
142
+ for u in getattr(tweet, "urls", []):
143
+ if u.expandedUrl.lower().endswith(video_exts):
144
+ return download_video(u.expandedUrl, dst)
145
+ raise RuntimeError("No video found in the tweet.")
146
+
147
+ # Fallback to yt‑dlp for any other URL
148
+ return _download_with_yt_dlp(url, dst, password)
149
+
150
+
151
+ def _encode_video_b64(path: Path) -> str:
152
+ """Read *path* and return a base64‑encoded string."""
153
+ return base64.b64encode(path.read_bytes()).decode()
154
+
155
+
156
+ def generate_report(
157
+ video_path: Path,
158
+ prompt: str,
159
+ model_id: str,
160
+ timeout: int = 300,
161
+ ) -> str:
162
+ """Send video + prompt to Gemini and return the text response."""
163
+ b64 = _encode_video_b64(video_path)
164
+ video_part = {"inline_data": {"mime_type": "video/mp4", "data": b64}}
165
+ model = genai.GenerativeModel(model_name=model_id)
166
+
167
+ resp = model.generate_content(
168
+ [prompt, video_part],
169
+ generation_config={"max_output_tokens": 1024},
170
+ request_options={"timeout": timeout},
171
+ )
172
+ return getattr(resp, "text", str(resp))
173
+
174
+
175
+ def _strip_prompt_echo(prompt: str, text: str, threshold: float = 0.68) -> str:
176
+ """Remove the prompt if the model repeats it at the start of *text*."""
177
+ if not prompt or not text:
178
+ return text
179
+
180
+ clean_prompt = " ".join(prompt.lower().split())
181
+ snippet = " ".join(text.lower().split()[:600])
182
+
183
+ if SequenceMatcher(None, clean_prompt, snippet).ratio() > threshold:
184
+ cut = max(len(clean_prompt), int(len(prompt) * 0.9))
185
+ return text[cut:].lstrip(" \n:-")
186
+ return text
187
+
188
+
189
+ # ----------------------------------------------------------------------
190
+ # UI helpers
191
+ # ----------------------------------------------------------------------
192
+ def _expand_sidebar(width: int = 380) -> None:
193
+ """Inject CSS to make the sidebar wider."""
194
+ st.markdown(
195
+ f"""
196
+ <style>
197
+ .css-1d391kg {{ /* may vary with Streamlit versions */
198
+ width: {width}px !important;
199
+ min-width: {width}px !important;
200
+ }}
201
+ </style>
202
+ """,
203
+ unsafe_allow_html=True,
204
+ )
205
+
206
+
207
+ # ----------------------------------------------------------------------
208
+ # Streamlit UI
209
+ # ----------------------------------------------------------------------
210
+ def main() -> None:
211
+ st.set_page_config(page_title="Video Analysis", layout="wide")
212
+ _expand_sidebar()
213
+
214
+ # ---------- Sidebar ----------
215
+ st.sidebar.header("Video Input")
216
+ st.sidebar.text_input("Video URL", key="url", placeholder="https://")
217
+
218
+ if st.sidebar.button("Load Video"):
219
+ try:
220
+ with st.spinner("Downloading video…"):
221
+ raw_path = download_video(
222
+ st.session_state["url"], DATA_DIR, st.session_state["video_password"]
223
+ )
224
+ mp4_path = _convert_to_mp4(Path(raw_path))
225
+ st.session_state["video_path"] = str(mp4_path)
226
+ st.session_state["last_error"] = ""
227
+ st.toast("Video ready")
228
+ st.experimental_rerun()
229
+ except Exception as e:
230
+ st.session_state["last_error"] = f"Download failed: {e}"
231
+ st.sidebar.error(st.session_state["last_error"])
232
+
233
+ # ---------- Settings ----------
234
+ with st.sidebar.expander("Settings", expanded=False):
235
+ model = st.selectbox(
236
+ "Model", MODEL_OPTIONS, index=MODEL_OPTIONS.index(DEFAULT_MODEL)
237
+ )
238
+ if model == "custom":
239
+ model = st.text_input("Custom model ID", value=DEFAULT_MODEL, key="custom_model")
240
+ st.session_state["model_input"] = model
241
+
242
+ # API key handling
243
+ secret_key = os.getenv("GOOGLE_API_KEY", "")
244
+ if secret_key:
245
+ st.session_state["api_key"] = secret_key
246
+ st.text_input("Google API Key", key="api_key", type="password")
247
+
248
+ st.text_area(
249
+ "Analysis prompt",
250
+ value=DEFAULT_PROMPT,
251
+ key="prompt",
252
+ height=140,
253
+ )
254
+ st.text_input(
255
+ "Video password (if needed)",
256
+ key="video_password",
257
+ type="password",
258
+ )
259
+ st.number_input(
260
+ "Compress if > (MB)",
261
+ min_value=10,
262
+ max_value=2000,
263
+ value=st.session_state.get("compress_mb", 200),
264
+ step=10,
265
+ key="compress_mb",
266
+ )
267
+
268
+ if st.sidebar.button("Clear Video"):
269
+ for f in DATA_DIR.iterdir():
270
+ try:
271
+ f.unlink()
272
+ except Exception:
273
+ pass
274
+ st
275
+
276
  ]
277
  DEFAULT_MODEL = "gemini-2.0-flash-lite"
278
 
 
283
  )
284
 
285
  # ----------------------------------------------------------------------
286
+ # Session‑state defaults
287
  # ----------------------------------------------------------------------
288
  def _init_state() -> None:
289
  defaults = {
 
291
  "video_path": "",
292
  "model_input": DEFAULT_MODEL,
293
  "prompt": DEFAULT_PROMPT,
294
+ "api_key": os.getenv("GOOGLE_API_KEY", ""),
295
  "video_password": "",
296
  "compress_mb": 200,
297
  "busy": False,
 
311
  # Helper utilities
312
  # ----------------------------------------------------------------------
313
  def _sanitize_filename(url: str) -> str:
 
314
  name = Path(url).name.lower()
315
  return name.translate(str.maketrans("", "", string.punctuation)).replace(" ", "_")
316
 
317
 
318
  def _file_sha256(path: Path) -> Optional[str]:
 
319
  try:
320
  h = hashlib.sha256()
321
  with path.open("rb") as f:
 
327
 
328
 
329
  def _convert_to_mp4(src: Path) -> Path:
 
330
  dst = src.with_suffix(".mp4")
331
  if dst.exists():
332
  return dst
 
375
 
376
 
377
  def _download_with_yt_dlp(url: str, dst: Path, password: str = "") -> Path:
378
+ """Download via yt‑dlp, ensuring the complete file is retrieved."""
379
  tmpl = str(dst / "%(id)s.%(ext)s")
380
+ # Prefer a full‑container MP4; fall back to the best available format.
381
+ fmt = "best[ext=mp4]/best"
382
+
383
  opts = {
384
  "outtmpl": tmpl,
385
  "format": fmt,
386
+ "quiet": True,
387
  "noprogress": True,
388
  "nocheckcertificate": True,
389
+ "merge_output_format": "mp4", # force a single MP4 file
390
+ "fragment_retries": 0, # avoid fragmented downloads
391
  }
392
  if password:
393
  opts["videopassword"] = password
 
411
 
412
  try:
413
  with yt_dlp.YoutubeDL(opts) as ydl:
414
+ ydl.extract_info(url, download=True)
415
  except Exception as e:
416
  raise RuntimeError(f"yt‑dlp could not download the URL: {e}") from e
417
  finally:
 
502
  return text
503
 
504
 
505
+ # ----------------------------------------------------------------------
506
+ # UI helpers
507
+ # ----------------------------------------------------------------------
508
+ def _expand_sidebar(width: int = 380) -> None:
509
+ """Inject CSS to make the sidebar wider."""
510
+ st.markdown(
511
+ f"""
512
+ <style>
513
+ .css-1d391kg {{ /* may vary with Streamlit versions */
514
+ width: {width}px !important;
515
+ min-width: {width}px !important;
516
+ }}
517
+ </style>
518
+ """,
519
+ unsafe_allow_html=True,
520
+ )
521
+
522
+
523
  # ----------------------------------------------------------------------
524
  # Streamlit UI
525
  # ----------------------------------------------------------------------
526
  def main() -> None:
527
  st.set_page_config(page_title="Video Analysis", layout="wide")
528
+ _expand_sidebar()
529
 
530
  # ---------- Sidebar ----------
531
  st.sidebar.header("Video Input")
 
581
  key="compress_mb",
582
  )
583
 
 
 
 
 
 
 
 
 
 
 
584
  if st.sidebar.button("Clear Video"):
585
  for f in DATA_DIR.iterdir():
586
  try:
587
  f.unlink()
588
  except Exception:
589
  pass
590
+ st