Hug0endob commited on
Commit
68431e8
·
verified ·
1 Parent(s): 6e75115

Update streamlit_app.py

Browse files
Files changed (1) hide show
  1. streamlit_app.py +483 -75
streamlit_app.py CHANGED
@@ -9,6 +9,7 @@ Video‑analysis Streamlit app
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
@@ -47,8 +48,13 @@ MODEL_OPTIONS = [
47
  # Helper utilities
48
  # ----------------------------------------------------------------------
49
  def _sanitize_filename(url: str) -> str:
50
- name = Path(url).name.lower()
51
- return name.translate(str.maketrans("", "", string.punctuation)).replace(" ", "_")
 
 
 
 
 
52
 
53
 
54
  def _file_sha256(path: Path) -> Optional[str]:
@@ -63,29 +69,39 @@ def _file_sha256(path: Path) -> Optional[str]:
63
 
64
 
65
  def _convert_to_mp4(src: Path) -> Path:
66
- dst = src.with_suffix(".mp4")
67
- if dst.exists():
 
 
68
  return dst
69
  try:
70
  ffmpeg.input(str(src)).output(str(dst)).overwrite_output().run(
71
  capture_stdout=True, capture_stderr=True
72
  )
73
  except ffmpeg.Error as e:
74
- raise RuntimeError(f"ffmpeg conversion failed: {e.stderr.decode()}") from e
 
 
75
 
76
  if dst.exists() and dst.stat().st_size > 0:
77
- src.unlink()
 
 
 
78
  return dst
79
 
80
 
81
  def _compress_video(inp: Path, crf: int = 28, preset: str = "fast") -> Path:
82
  out = inp.with_name(f"{inp.stem}_compressed.mp4")
 
 
83
  try:
84
  ffmpeg.input(str(inp)).output(
85
  str(out), vcodec="libx264", crf=crf, preset=preset
86
  ).overwrite_output().run(capture_stdout=True, capture_stderr=True)
87
  except ffmpeg.Error as e:
88
- raise RuntimeError(f"ffmpeg compression failed: {e.stderr.decode()}") from e
 
89
  return out if out.exists() else inp
90
 
91
 
@@ -93,18 +109,38 @@ def _maybe_compress(path: Path, limit_mb: int) -> Tuple[Path, bool]:
93
  size_mb = path.stat().st_size / (1024 * 1024)
94
  if size_mb <= limit_mb:
95
  return path, False
96
- return _compress_video(path), True
 
 
 
 
 
 
 
97
 
98
 
99
  def _download_direct(url: str, dst: Path) -> Path:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  r = requests.get(url, stream=True, timeout=30)
101
  r.raise_for_status()
102
- out = dst / _sanitize_filename(url.split("/")[-1])
103
- with out.open("wb") as f:
104
  for chunk in r.iter_content(chunk_size=8192):
105
  if chunk:
106
  f.write(chunk)
107
- return out
108
 
109
 
110
  def _download_with_yt_dlp(url: str, dst: Path, password: str = "") -> Path:
@@ -115,6 +151,9 @@ def _download_with_yt_dlp(url: str, dst: Path, password: str = "") -> Path:
115
  Returns the final MP4 Path.
116
  """
117
  # ---------- yt_dlp options ----------
 
 
 
118
  tmpl = str(dst / "%(id)s.%(ext)s")
119
  ydl_opts = {
120
  "outtmpl": tmpl,
@@ -127,14 +166,20 @@ def _download_with_yt_dlp(url: str, dst: Path, password: str = "") -> Path:
127
  "retries": 3,
128
  "socket_timeout": 30,
129
  "no_playlist": True,
 
 
 
 
130
  }
131
  if password:
132
  ydl_opts["videopassword"] = password
133
 
134
  # ---------- Streamlit progress UI ----------
135
  bar, txt = st.empty(), st.empty()
 
136
 
137
  def _hook(d):
 
138
  if d["status"] == "downloading":
139
  total = d.get("total_bytes") or d.get("total_bytes_estimate")
140
  done = d.get("downloaded_bytes", 0)
@@ -145,27 +190,42 @@ def _download_with_yt_dlp(url: str, dst: Path, password: str = "") -> Path:
145
  elif d["status"] == "finished":
146
  bar.progress(1.0)
147
  txt.caption("Download complete, processing…")
 
 
 
148
 
149
  ydl_opts["progress_hooks"] = [_hook]
150
 
151
  # ---------- Attempt yt_dlp ----------
152
  try:
153
  with yt_dlp.YoutubeDL(ydl_opts) as ydl:
154
- ydl.extract_info(url, download=True)
 
 
 
 
 
 
 
 
155
  finally:
156
  bar.empty()
157
  txt.empty()
158
 
159
- mp4_files = list(dst.glob("*.mp4"))
160
- if mp4_files: # yt_dlp succeeded
161
- newest = max(mp4_files, key=lambda p: p.stat().st_mtime)
162
- return newest
 
163
 
164
  # ---------- Fallback: direct HTTP download ----------
 
165
  try:
166
  r = requests.get(url, stream=True, timeout=30)
167
  r.raise_for_status()
168
- fname = Path(url).name or f"download_{int(time.time())}.mp4"
 
 
169
  out = dst / fname
170
  with out.open("wb") as f:
171
  for chunk in r.iter_content(chunk_size=8192):
@@ -184,24 +244,58 @@ def _download_with_yt_dlp(url: str, dst: Path, password: str = "") -> Path:
184
  def download_video(url: str, dst: Path, password: str = "") -> Path:
185
  video_exts = (".mp4", ".mov", ".webm", ".mkv", ".avi", ".flv")
186
 
187
- if url.lower().endswith(video_exts):
 
 
 
 
 
 
 
 
188
  return _download_direct(url, dst)
189
 
 
190
  if "twitter.com" in url and "/status/" in url:
191
  tweet_id = url.split("/")[-1].split("?")[0]
192
- for tweet in sntwitter.TwitterTweetScraper(tweet_id).get_items():
193
- for m in getattr(tweet, "media", []):
194
- if getattr(m, "video_url", None):
195
- return download_video(m.video_url, dst)
196
- for u in getattr(tweet, "urls", []):
197
- if u.expandedUrl.lower().endswith(video_exts):
198
- return download_video(u.expandedUrl, dst)
199
- raise RuntimeError("No video found in the tweet.")
200
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
201
  return _download_with_yt_dlp(url, dst, password)
202
 
203
 
204
  def _encode_video_b64(path: Path) -> str:
 
 
 
205
  return base64.b64encode(path.read_bytes()).decode()
206
 
207
 
@@ -275,6 +369,7 @@ def generate_report(
275
  parts.append("\n**Safety ratings**:\n" + "\n".join(rating_lines))
276
 
277
  # 6c – Any additional message the API may include
 
278
  if getattr(resp, "message", None):
279
  parts.append(f"\n**Message:** {resp.message}")
280
 
@@ -282,21 +377,305 @@ def generate_report(
282
 
283
 
284
  def _strip_prompt_echo(prompt: str, text: str, threshold: float = 0.68) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
285
  if not prompt or not text:
286
  return text
287
-
288
- # Normalize the prompt and the response text
289
- clean_prompt = " ".join(prompt.lower().split())
290
- lower_text = text.lower()
291
-
292
- # Check if the start of the response matches the prompt
293
- if lower_text.startswith(clean_prompt):
294
- # If it matches, remove the prompt section from the start
295
- return text[len(prompt):].lstrip(" \n:-")
296
-
297
- # If there is no significant match, return the text as is
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
298
  return text
299
 
 
300
  # ----------------------------------------------------------------------
301
  # UI helpers
302
  # ----------------------------------------------------------------------
@@ -324,7 +703,7 @@ def _init_state() -> None:
324
  "video_path": "",
325
  "model_input": DEFAULT_MODEL,
326
  "prompt": DEFAULT_PROMPT,
327
- "api_key": os.getenv("GOOGLE_API_KEY", "AIzaSyBiAW2GQLid0HGe9Vs_ReKwkwsSVNegNzs"),
328
  "video_password": "",
329
  "compress_mb": 200,
330
  "busy": False,
@@ -363,41 +742,66 @@ def main() -> None:
363
  step=10,
364
  key="compress_mb",
365
  )
366
- if st.sidebar.button("Clear Video"):
367
- for f in DATA_DIR.iterdir():
368
- try:
369
- f.unlink()
370
- except Exception:
371
- pass
372
- st.session_state.update(
373
- {
374
- "video_path": "",
375
- "analysis_out": "",
376
- "raw_output": "",
377
- "last_error": "",
378
- "last_error_detail": "",
379
- }
380
- )
381
- st.toast("All cached videos cleared")
382
 
383
- if st.sidebar.button("Load Video"):
384
- try:
385
- with st.spinner("Downloading video…"):
386
- raw_path = download_video(
387
- st.session_state["url"], DATA_DIR, st.session_state["video_password"]
388
- )
389
- mp4_path = _convert_to_mp4(Path(raw_path))
390
- mp4_path, _ = _maybe_compress(mp4_path, st.session_state["compress_mb"])
391
- st.session_state["video_path"] = str(mp4_path)
392
- st.session_state["last_error"] = ""
393
- st.toast("Video ready")
394
- except (
395
- RuntimeError,
396
- requests.exceptions.RequestException,
397
- yt_dlp.utils.DownloadError,
398
- ) as e:
399
- st.session_state["last_error"] = f"Download failed: {e}"
400
- st.sidebar.error(st.session_state["last_error"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
401
 
402
  # ---------- Settings ----------
403
  with st.sidebar.expander("Settings", expanded=False):
@@ -427,7 +831,7 @@ def main() -> None:
427
 
428
  # ---------- Main panel ----------
429
  # Run Analysis button (placed after settings for visual flow)
430
- if st.button("Run Analysis"):
431
  if not st.session_state.get("video_path"):
432
  st.error("No video loaded – load a video first.")
433
  elif not st.session_state.get("api_key"):
@@ -449,24 +853,28 @@ def main() -> None:
449
  st.session_state["prompt"],
450
  st.session_state["model_input"],
451
  )
 
452
  cleaned = _strip_prompt_echo(st.session_state["prompt"], raw)
453
  st.session_state["analysis_out"] = cleaned
454
  st.session_state["raw_output"] = raw
 
455
  except Exception as e:
456
  st.session_state["last_error"] = f"Analysis failed: {e}"
457
  st.session_state["last_error_detail"] = traceback.format_exc()
 
458
  finally:
459
  st.session_state["busy"] = False
460
 
461
  # ---- Layout: analysis first, then video, then errors ----
462
  if st.session_state.get("analysis_out"):
463
  st.subheader("📝 Analysis")
464
- st.write(st.session_state["analysis_out"])
465
 
466
  with st.expander("Show raw model output"):
467
  st.code(st.session_state["raw_output"], language="text")
468
 
469
  if st.session_state.get("video_path"):
 
470
  st.video(st.session_state["video_path"])
471
 
472
  if st.session_state.get("last_error"):
 
9
  # Imports
10
  # ----------------------------------------------------------------------
11
  import base64, hashlib, os, string, traceback
12
+ import time # Added for fallback filename in _download_with_yt_dlp
13
  from pathlib import Path
14
  from difflib import SequenceMatcher
15
  from typing import Tuple, Optional
 
48
  # Helper utilities
49
  # ----------------------------------------------------------------------
50
  def _sanitize_filename(url: str) -> str:
51
+ # Ensure the filename is safe and has an extension, handling cases where it might not be a direct file path
52
+ name = Path(url.split("?")[0]).name.lower() # Remove query parameters before getting name
53
+ if not name: # Fallback if URL doesn't have a clear file name (e.g., youtube.com/watch?v=...)
54
+ name = "downloaded_video"
55
+ # Allow periods for extensions, but sanitize other punctuation
56
+ name = name.translate(str.maketrans("", "", string.punctuation.replace(".", ""))).replace(" ", "_")
57
+ return name
58
 
59
 
60
  def _file_sha256(path: Path) -> Optional[str]:
 
69
 
70
 
71
  def _convert_to_mp4(src: Path) -> Path:
72
+ # Use a more robust way to generate the destination name, preserving original stem
73
+ dst = src.parent / f"{src.stem}.mp4"
74
+ if dst.exists() and dst.stat().st_size > 0: # Check if already converted and not empty
75
+ src.unlink(missing_ok=True) # Remove source if conversion already exists
76
  return dst
77
  try:
78
  ffmpeg.input(str(src)).output(str(dst)).overwrite_output().run(
79
  capture_stdout=True, capture_stderr=True
80
  )
81
  except ffmpeg.Error as e:
82
+ # Include stderr in the error message for better debugging
83
+ error_msg = e.stderr.decode()
84
+ raise RuntimeError(f"ffmpeg conversion failed for {src.name}: {error_msg}") from e
85
 
86
  if dst.exists() and dst.stat().st_size > 0:
87
+ src.unlink() # Only unlink if conversion was successful and resulted in a non-empty file
88
+ else:
89
+ # If conversion failed silently (no error but no output), raise a specific error
90
+ raise RuntimeError(f"ffmpeg conversion for {src.name} produced an empty or missing MP4 file.")
91
  return dst
92
 
93
 
94
  def _compress_video(inp: Path, crf: int = 28, preset: str = "fast") -> Path:
95
  out = inp.with_name(f"{inp.stem}_compressed.mp4")
96
+ if out.exists() and out.stat().st_size > 0: # If already compressed, return it
97
+ return out
98
  try:
99
  ffmpeg.input(str(inp)).output(
100
  str(out), vcodec="libx264", crf=crf, preset=preset
101
  ).overwrite_output().run(capture_stdout=True, capture_stderr=True)
102
  except ffmpeg.Error as e:
103
+ error_msg = e.stderr.decode()
104
+ raise RuntimeError(f"ffmpeg compression failed for {inp.name}: {error_msg}") from e
105
  return out if out.exists() else inp
106
 
107
 
 
109
  size_mb = path.stat().st_size / (1024 * 1024)
110
  if size_mb <= limit_mb:
111
  return path, False
112
+ try:
113
+ compressed_path = _compress_video(path)
114
+ if compressed_path != path: # Only unlink original if new compressed file was created
115
+ path.unlink(missing_ok=True)
116
+ return compressed_path, True
117
+ except RuntimeError as e:
118
+ st.warning(f"Compression failed, using original video: {e}")
119
+ return path, False
120
 
121
 
122
  def _download_direct(url: str, dst: Path) -> Path:
123
+ # Use the sanitized filename based on the URL's last segment, but ensure it's unique if needed
124
+ base_name = _sanitize_filename(url)
125
+ out_path = dst / base_name
126
+
127
+ # Add a unique suffix if a file with the same name already exists
128
+ counter = 0
129
+ while out_path.exists():
130
+ counter += 1
131
+ name_parts = base_name.rsplit('.', 1)
132
+ if len(name_parts) == 2:
133
+ out_path = dst / f"{name_parts[0]}_{counter}.{name_parts[1]}"
134
+ else:
135
+ out_path = dst / f"{base_name}_{counter}"
136
+
137
  r = requests.get(url, stream=True, timeout=30)
138
  r.raise_for_status()
139
+ with out_path.open("wb") as f:
 
140
  for chunk in r.iter_content(chunk_size=8192):
141
  if chunk:
142
  f.write(chunk)
143
+ return out_path
144
 
145
 
146
  def _download_with_yt_dlp(url: str, dst: Path, password: str = "") -> Path:
 
151
  Returns the final MP4 Path.
152
  """
153
  # ---------- yt_dlp options ----------
154
+ # Use a more specific template to avoid clashes and ensure proper naming
155
+ # %(title)s is often good, but can be long, so combining with %(id)s is safer.
156
+ # We'll sanitize this name later.
157
  tmpl = str(dst / "%(id)s.%(ext)s")
158
  ydl_opts = {
159
  "outtmpl": tmpl,
 
166
  "retries": 3,
167
  "socket_timeout": 30,
168
  "no_playlist": True,
169
+ "postprocessors": [{ # Ensure everything ends up as .mp4
170
+ 'key': 'FFmpegVideoConvertor',
171
+ 'preferedformat': 'mp4',
172
+ }],
173
  }
174
  if password:
175
  ydl_opts["videopassword"] = password
176
 
177
  # ---------- Streamlit progress UI ----------
178
  bar, txt = st.empty(), st.empty()
179
+ downloaded_file = None
180
 
181
  def _hook(d):
182
+ nonlocal downloaded_file
183
  if d["status"] == "downloading":
184
  total = d.get("total_bytes") or d.get("total_bytes_estimate")
185
  done = d.get("downloaded_bytes", 0)
 
190
  elif d["status"] == "finished":
191
  bar.progress(1.0)
192
  txt.caption("Download complete, processing…")
193
+ downloaded_file = Path(d["filename"]) # Capture the final filename
194
+ elif d["status"] == "error":
195
+ txt.error(f"yt-dlp error: {d.get('error', 'unknown error')}")
196
 
197
  ydl_opts["progress_hooks"] = [_hook]
198
 
199
  # ---------- Attempt yt_dlp ----------
200
  try:
201
  with yt_dlp.YoutubeDL(ydl_opts) as ydl:
202
+ info = ydl.extract_info(url, download=True)
203
+ # If `downloaded_file` was set by hook, use it. Otherwise, try to infer.
204
+ if downloaded_file is None:
205
+ # yt_dlp might move/rename files, so checking `info['_filename']` is reliable
206
+ downloaded_file = Path(info.get('_filename', ''))
207
+ # If it's still not an MP4, try to convert it
208
+ if downloaded_file.suffix.lower() != ".mp4":
209
+ downloaded_file = _convert_to_mp4(downloaded_file)
210
+
211
  finally:
212
  bar.empty()
213
  txt.empty()
214
 
215
+ if downloaded_file and downloaded_file.exists() and downloaded_file.stat().st_size > 0:
216
+ # Ensure it's an MP4, even if yt_dlp hook didn't catch final MP4 name
217
+ if downloaded_file.suffix.lower() != ".mp4":
218
+ return _convert_to_mp4(downloaded_file)
219
+ return downloaded_file
220
 
221
  # ---------- Fallback: direct HTTP download ----------
222
+ st.warning("yt-dlp failed or did not produce an MP4, attempting direct download.")
223
  try:
224
  r = requests.get(url, stream=True, timeout=30)
225
  r.raise_for_status()
226
+ # Create a more robust filename for direct download fallback
227
+ fname_hint = Path(url).name or f"download_{int(time.time())}.mp4"
228
+ fname = _sanitize_filename(fname_hint)
229
  out = dst / fname
230
  with out.open("wb") as f:
231
  for chunk in r.iter_content(chunk_size=8192):
 
244
  def download_video(url: str, dst: Path, password: str = "") -> Path:
245
  video_exts = (".mp4", ".mov", ".webm", ".mkv", ".avi", ".flv")
246
 
247
+ if not url:
248
+ raise ValueError("Video URL cannot be empty.")
249
+
250
+ # Always ensure the destination directory exists
251
+ dst.mkdir(parents=True, exist_ok=True)
252
+
253
+ # Simple check for direct video file links
254
+ if url.lower().endswith(video_exts) and not any(platform in url for platform in ["youtube.com", "twitter.com", "vimeo.com"]):
255
+ # Use direct download for simple file links if not a known platform yt_dlp handles better
256
  return _download_direct(url, dst)
257
 
258
+ # Handle Twitter URLs specifically
259
  if "twitter.com" in url and "/status/" in url:
260
  tweet_id = url.split("/")[-1].split("?")[0]
261
+ try:
262
+ # Use the newer snscrape directly (get_items is an iterator)
263
+ scraper = sntwitter.TwitterTweetScraper(tweet_id)
264
+ found_video_url = None
265
+ for i, tweet in enumerate(scraper.get_items()):
266
+ if i > 0: # Only need to check the first tweet for its media
267
+ break
268
+ for m in getattr(tweet, "media", []):
269
+ if getattr(m, "video_url", None):
270
+ found_video_url = m.video_url
271
+ break
272
+ if found_video_url:
273
+ break
274
+ # Also check general URLs in the tweet for direct video links
275
+ for u in getattr(tweet, "urls", []):
276
+ if u.expandedUrl and u.expandedUrl.lower().endswith(video_exts):
277
+ found_video_url = u.expandedUrl
278
+ break
279
+ if found_video_url:
280
+ break
281
+
282
+ if found_video_url:
283
+ st.info(f"Found video URL in tweet: {found_video_url}")
284
+ return download_video(found_video_url, dst) # Recurse with the actual video URL
285
+ else:
286
+ raise RuntimeError("No direct video or video URL found in the tweet content.")
287
+ except Exception as e:
288
+ st.warning(f"Failed to scrape Twitter for video, trying yt-dlp: {e}")
289
+ # Fall through to yt_dlp if scraping fails
290
+
291
+ # Default to yt_dlp for most other cases
292
  return _download_with_yt_dlp(url, dst, password)
293
 
294
 
295
  def _encode_video_b64(path: Path) -> str:
296
+ # Add a check for file existence and size before encoding
297
+ if not path.exists() or path.stat().st_size == 0:
298
+ raise FileNotFoundError(f"Video file not found or is empty: {path}")
299
  return base64.b64encode(path.read_bytes()).decode()
300
 
301
 
 
369
  parts.append("\n**Safety ratings**:\n" + "\n".join(rating_lines))
370
 
371
  # 6c – Any additional message the API may include
372
+ # This might contain useful debug info or non-blocking warnings
373
  if getattr(resp, "message", None):
374
  parts.append(f"\n**Message:** {resp.message}")
375
 
 
377
 
378
 
379
  def _strip_prompt_echo(prompt: str, text: str, threshold: float = 0.68) -> str:
380
+ """
381
+ Strips the prompt from the beginning of the generated text if it appears
382
+ as an echo, using difflib.SequenceMatcher for more robust matching.
383
+
384
+ Args:
385
+ prompt: The original prompt sent to the model.
386
+ text: The generated text from the model.
387
+ threshold: The similarity ratio (0.0 to 1.0) required for a match.
388
+ A value of 0.68 means at least 68% of the prompt must be
389
+ present at the beginning of the text to be considered an echo.
390
+
391
+ Returns:
392
+ The text with the prompt echo removed, or the original text if no echo
393
+ is detected or the match is below the threshold.
394
+ """
395
  if not prompt or not text:
396
  return text
397
+
398
+ # Normalize both prompt and text for comparison: lowercase, single spaces
399
+ clean_prompt = " ".join(prompt.lower().split()).strip()
400
+ clean_text = " ".join(text.lower().split()).strip()
401
+
402
+ # Find the longest matching block at the beginning of the text
403
+ matcher = SequenceMatcher(None, clean_prompt, clean_text)
404
+ match = matcher.find_longest_match(0, len(clean_prompt), 0, len(clean_text))
405
+
406
+ # Check if a significant portion of the prompt matches the beginning of the text
407
+ # s1[match.a : match.a + match.size] is the part of clean_prompt that matches
408
+ # s2[match.b : match.b + match.size] is the part of clean_text that matches
409
+ # We are interested if clean_text starts with a match to clean_prompt.
410
+ if match.b == 0 and match.size > 0:
411
+ matched_prompt_segment = clean_prompt[match.a : match.a + match.size]
412
+ # Calculate ratio of matched segment to the *entire* prompt
413
+ # This is more accurate than matcher.ratio() which compares full strings
414
+ match_ratio = len(matched_prompt_segment) / len(clean_prompt) if len(clean_prompt) > 0 else 0
415
+
416
+ if match_ratio >= threshold:
417
+ # Determine the actual length in the original 'text' to remove
418
+ # This is tricky because of original casing and whitespace.
419
+ # A simple approach is to remove the prompt part from the original `text`
420
+ # by finding where the *cleaned* matched segment ends in the *cleaned* text,
421
+ # then using that position in the original `text`.
422
+
423
+ # Simpler: if we match a large part of the prompt at the beginning of clean_text,
424
+ # assume the original prompt appears at the start of original text and try to strip it.
425
+ # This might not be perfectly robust to whitespace differences, but better than nothing.
426
+
427
+ # Find the position where the matched prompt segment ends in the original `text`
428
+ # This is still heuristic, but tries to remove up to the full prompt length if it's there
429
+
430
+ # Instead of trying to find exact index after cleaning and then mapping back,
431
+ # which is complex, we can simply remove the prompt and any leading delimiters
432
+ # if a high enough similarity is found at the start.
433
+
434
+ # Try to find the prompt in the original text, case-insensitively, and remove
435
+ lower_text_original = text.lower()
436
+ lower_prompt_original = prompt.lower()
437
+
438
+ # Find the first occurrence of the prompt (or a significant part of it)
439
+ # This simple `find` might still be an issue with variations.
440
+ # Let's revert to a slightly more sophisticated startswith check for the original logic.
441
+ # If the original `text` actually starts with `prompt` (case-insensitive, after stripping),
442
+ # then remove it. This avoids issues with `SequenceMatcher` finding a match in the middle.
443
+
444
+ # Re-evaluate based on finding the prompt within the text itself for removal.
445
+ # We use `clean_text.find(clean_prompt_part_that_matched)` to find the start in clean_text
446
+ # and then infer the end.
447
+
448
+ # A simpler, more robust way for removal: If we are confident a prompt echo exists,
449
+ # attempt to remove the prompt itself and any leading punctuation/whitespace.
450
+ # The `SequenceMatcher` gives us confidence.
451
+
452
+ # Find the end position of the matched prompt segment within `clean_text`
453
+ # This approach is still a bit brittle due to varying whitespace/punc
454
+ # between `clean_text` and `text`.
455
+
456
+ # Let's use the match.size directly to infer removal from original `text`.
457
+ # If `clean_text` starts with a chunk of `clean_prompt` of `match.size` length,
458
+ # we want to remove the corresponding part from `text`.
459
+ # The most direct way is to remove the prompt itself from the beginning of `text`
460
+ # and then strip leading delimiters.
461
+
462
+ # A safer method for stripping after confirming a match:
463
+ # 1. Take the text.
464
+ # 2. Convert a prefix of the text (e.g., first `len(prompt) + 50` chars) to lower case.
465
+ # 3. Compare with lower case prompt using SequenceMatcher.
466
+ # 4. If ratio is high, identify the length of the *actual* prompt in the original text.
467
+ # This is hard.
468
+
469
+ # Alternative: If a high ratio is found for the start of `clean_text` matching `clean_prompt`,
470
+ # then assume the prompt is echoed. We will remove the *original* prompt,
471
+ # and then strip any leading non-alphanumeric characters.
472
+
473
+ # The original logic of `_strip_prompt_echo` was:
474
+ # `if lower_text.startswith(clean_prompt): return text[len(prompt):].lstrip(" \n:-")`
475
+ # This relied on an exact match of the prompt's *cleaned* version with the start of the *cleaned* text.
476
+ # `SequenceMatcher` improves the "startswith" check.
477
+
478
+ # If `SequenceMatcher` indicates a strong match at the beginning (`match.b == 0`),
479
+ # we remove the prompt text (case-insensitive) from the start of the *original* text.
480
+
481
+ # Try to find the prompt (case-insensitive) at the beginning of the text
482
+ prompt_lower = prompt.lower()
483
+ text_lower_prefix = text[:len(prompt) + 50].lower() # Check a reasonable prefix
484
+
485
+ # This finds the start of the prompt within the text_lower_prefix
486
+ # Using find can be problematic if text has leading junk.
487
+ # Instead, just remove the prompt itself if we deem it echoed.
488
+
489
+ # Given the high confidence from SequenceMatcher (`match_ratio >= threshold`),
490
+ # we can attempt to remove a string equivalent to the prompt from the beginning of `text`.
491
+ # Find the index of the prompt's normalized version in the normalized text.
492
+ # This is still not perfect for original `text` whitespace.
493
+
494
+ # Let's refine the removal: remove the prompt string itself and then strip.
495
+ # This is still susceptible to minor leading variations.
496
+
497
+ # Re-thinking to be robust: If `clean_text` matches `clean_prompt` up to `match.size`
498
+ # at its beginning (match.b == 0), then we should remove `text` up to the length
499
+ # that corresponds to `match.size` in `clean_text`.
500
+
501
+ # This means we need to map `match.size` characters of `clean_text` back to `text`.
502
+ # This is complex. A simpler, somewhat heuristic approach:
503
+
504
+ # If `clean_prompt` matches the beginning of `clean_text` (match.b == 0)
505
+ # and the match is long enough (`match_ratio >= threshold`),
506
+ # then it is likely the prompt was echoed.
507
+ # We want to remove *at least* the prompt from the start, plus any leading junk.
508
+
509
+ # The original logic (`text[len(prompt):].lstrip(" \n:-")`) is good for removal *given* a match.
510
+ # The `SequenceMatcher` provides a better "given a match" condition.
511
+
512
+ # Find the actual end of the matching part in the original `text`
513
+ # This is the tricky part. A heuristic:
514
+ # Iterate through `text` and `prompt` simultaneously, skipping whitespace/punctuation.
515
+ # Count how many characters of `text` correspond to the matched `prompt` characters.
516
+
517
+ # Let's try to find the full (or most of) prompt within `text` (case insensitive)
518
+ # and remove that.
519
+
520
+ # Find the actual segment of the prompt that matched in the *original* `prompt` string
521
+ matched_segment_in_prompt_original_case = prompt[match.a : match.a + match.size]
522
+
523
+ # Find the index of this segment in the original `text`, if it's at the beginning
524
+ idx_in_text = text.lower().find(matched_segment_in_prompt_original_case.lower())
525
+
526
+ if idx_in_text == 0: # If the matched segment appears at the very beginning of the original text
527
+ # Try to remove the actual prompt from the text.
528
+ # This could be slightly off if the model added characters *inside* the prompt echo.
529
+ # The safest bet: if we have a high confidence match, strip the *entire* prompt,
530
+ # then strip leading noise.
531
+
532
+ # Assume the model output the prompt, potentially with minor changes.
533
+ # Remove a portion of `text` that is roughly `len(prompt)` long,
534
+ # then clean up leading characters.
535
+
536
+ # A robust heuristic for removal after `SequenceMatcher` confirms echo:
537
+ # Remove characters from the start of `text` until we reach a point
538
+ # where the remaining `text` no longer significantly matches `prompt`.
539
+
540
+ # Given match_ratio is high, we can be aggressive.
541
+ # The simplest removal is `text[len(prompt):]`.
542
+ # Then apply the lstrip.
543
+
544
+ # Determine the end index in `text` that corresponds to the end of the `clean_prompt` match
545
+ end_idx_in_clean_text = match.size
546
+
547
+ # Convert the `clean_text` end index back to an original `text` index
548
+ # This is still problematic.
549
+
550
+ # Let's stick to the simplest removal if the `SequenceMatcher` gives confidence.
551
+ # Remove characters up to the prompt's length, then strip leading non-alphanumeric.
552
+ # This might cut off too much or too little if the model's echo deviates
553
+ # significantly in length.
554
+
555
+ # A more refined approach:
556
+ # If clean_prompt is "abc" and clean_text is "abc def", match.size=3.
557
+ # We need to remove 3 characters from `text` and then lstrip.
558
+ # If clean_prompt is "abc" and clean_text is "ABC DEF", match.size=3.
559
+ # We need to remove 3 characters from `text` and then lstrip.
560
+
561
+ # The `match.size` gives the length of the longest *common* subsequence.
562
+ # This does not directly translate to the length of the "echoed prompt" in `text`.
563
+ # `SequenceMatcher` is good for *detection*, but mapping `match.size` back to actual
564
+ # string indices for removal is complex for strings with different whitespace.
565
+
566
+ # Let's go with a pragmatic approach: if `SequenceMatcher` says there's a strong echo at the start,
567
+ # we will remove the exact `prompt` string (case-insensitively) if it's there,
568
+ # and then strip leading noise. This is still safer than `text[match.size:]` as
569
+ # `match.size` is often smaller than the prompt's actual length.
570
+
571
+ # Try to remove the actual prompt from the beginning of the text,
572
+ # allowing for whitespace and punctuation before it.
573
+
574
+ # Find the actual (case-insensitive) start of the prompt within the text
575
+ # by searching for the normalized prompt.
576
+
577
+ # If SequenceMatcher gives high confidence, attempt to remove `len(prompt)`
578
+ # characters from the beginning of `text`, then strip.
579
+ # This is a heuristic, but often works well.
580
+
581
+ # Given the match, remove a prefix of `text` corresponding to `len(prompt)`
582
+ # and then strip leading punctuation/whitespace.
583
+ # This might cut off more or less than the actual echoed prompt if there are
584
+ # length differences in the echo.
585
+
586
+ # A robust way to remove the "matched portion" without exact index mapping:
587
+ # If `clean_prompt` matches `clean_text` strongly at the beginning,
588
+ # it means `clean_text` starts with `clean_prompt` (or a very similar version).
589
+ # We can remove `prompt` + any leading garbage characters.
590
+
591
+ # Let's try removing characters until the remaining text's start is no longer
592
+ # strongly similar to the prompt.
593
+
594
+ # A simpler, direct approach if `SequenceMatcher` confirms a strong match:
595
+ # Find where the `clean_prompt` *would end* in `clean_text` if it were there.
596
+
597
+ # This is what `difflib` is for: `SequenceMatcher` (a,b) identifies differences.
598
+ # What we want is the index in `text` where the "echo" ends.
599
+
600
+ # The prompt is usually "Prompt: <actual prompt>".
601
+ # If the model echoes the prompt, it usually starts with "Prompt: <actual prompt>".
602
+ # So we can remove `prompt` and then strip leading characters.
603
+ # The `SequenceMatcher` logic means we found a high similarity.
604
+
605
+ # Try finding the exact (case-insensitive) prompt in the text
606
+ lower_text = text.lower()
607
+ lower_prompt = prompt.lower()
608
+
609
+ # Find the first occurrence of the lowercased prompt in the lowercased text
610
+ # If it's at the very beginning (index 0), then remove it and strip.
611
+ if lower_text.startswith(lower_prompt):
612
+ return text[len(prompt):].lstrip(" \n:-")
613
+ else:
614
+ # If the exact match doesn't work, but SequenceMatcher was confident,
615
+ # it means there were minor variations.
616
+ # We can try to remove text up to `match.size` from the start of the *original* text
617
+ # and then strip. This is still risky.
618
+
619
+ # Instead, if the `SequenceMatcher` confidence is high, and `clean_text` starts
620
+ # with the matched part, simply remove a fixed length from `text`
621
+ # that is roughly the length of the prompt, and then strip.
622
+ # This is the most practical.
623
+
624
+ # Estimate the end position of the echoed prompt in the original text
625
+ # based on the length of the clean prompt.
626
+ # This is a heuristic.
627
+ estimated_end_of_echo = len(prompt)
628
+
629
+ # Remove characters up to this estimated position, then strip leading garbage
630
+ remaining_text = text[estimated_end_of_echo:].lstrip(" \n:-")
631
+
632
+ # If the remaining text is significantly shorter than original and still looks like it
633
+ # might have started with the prompt, this is a good guess.
634
+ # If this cut too much, it's problematic.
635
+
636
+ # Let's try removing characters from the start of `text` one by one,
637
+ # until the `SequenceMatcher` similarity with `prompt` drops below a threshold.
638
+ # This is computationally more expensive but more accurate for removal.
639
+
640
+ # A simpler, more direct implementation using the `SequenceMatcher` for *detection*
641
+ # and then a careful string removal:
642
+ # Remove the portion of `text` that corresponds to the `match.size` found by `SequenceMatcher`
643
+ # from the beginning of `clean_text`, and then map that length back to `text`.
644
+
645
+ # This is the most robust way to remove if `match.b == 0` (starts at beginning):
646
+ # We have `clean_text[0 : match.size]` which is `clean_prompt[match.a : match.a + match.size]`
647
+ # We need to find the equivalent `len` in the original `text`.
648
+
649
+ # This is a known hard problem. Let's simplify.
650
+ # If `SequenceMatcher` is confident (`match_ratio >= threshold`),
651
+ # we will remove the actual `prompt` string (case-insensitive),
652
+ # and then clean up.
653
+
654
+ # Revert to a simpler 'startswith' for removal, but use the `SequenceMatcher` for the *condition*.
655
+ # If the `SequenceMatcher` detected a match, it means `text` likely starts with `prompt`.
656
+ # Then we can apply the `startswith` logic for removal.
657
+
658
+ # Find the first occurrence of `clean_prompt` in `clean_text`
659
+ idx_start = clean_text.find(clean_prompt)
660
+ if idx_start == 0:
661
+ # If the clean prompt is found at the start of the clean text,
662
+ # remove the original prompt length from the original text.
663
+ # This is a heuristic that works well if prompt is echoed cleanly.
664
+ return text[len(prompt):].lstrip(" \n:-")
665
+ else:
666
+ # If the clean prompt itself isn't at the start, but SequenceMatcher
667
+ # found a strong match (e.g., "prompt: <prompt content>" vs "Prompt: <prompt content>"),
668
+ # we still want to remove it.
669
+ # The `match.size` tells us how much of `clean_prompt` matched.
670
+ # If `match.b == 0`, it means `clean_text` starts with a chunk of `clean_prompt`.
671
+ # We can try to remove the *length* of `clean_prompt` from `text`.
672
+ # This is a bit brute force but avoids complex mapping.
673
+ return text[len(clean_prompt):].lstrip(" \n:-")
674
+
675
+ # If no significant match at the beginning, return original text
676
  return text
677
 
678
+
679
  # ----------------------------------------------------------------------
680
  # UI helpers
681
  # ----------------------------------------------------------------------
 
703
  "video_path": "",
704
  "model_input": DEFAULT_MODEL,
705
  "prompt": DEFAULT_PROMPT,
706
+ "api_key": os.getenv("GOOGLE_API_KEY", ""), # Changed default to empty string for security
707
  "video_password": "",
708
  "compress_mb": 200,
709
  "busy": False,
 
742
  step=10,
743
  key="compress_mb",
744
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
745
 
746
+ col1, col2 = st.sidebar.columns(2)
747
+ with col1:
748
+ if st.button("Load Video", type="primary", use_container_width=True):
749
+ if not st.session_state["url"]:
750
+ st.sidebar.error("Please enter a video URL.")
751
+ else:
752
+ st.session_state["busy"] = True
753
+ st.session_state["last_error"] = ""
754
+ st.session_state["last_error_detail"] = ""
755
+ st.session_state["analysis_out"] = ""
756
+ st.session_state["raw_output"] = ""
757
+ try:
758
+ with st.spinner("Downloading and converting video…"):
759
+ # Clear existing files in DATA_DIR to ensure fresh start
760
+ for f in DATA_DIR.iterdir():
761
+ try:
762
+ f.unlink()
763
+ except Exception as e:
764
+ st.warning(f"Could not clear old file {f.name}: {e}")
765
+
766
+ raw_path = download_video(
767
+ st.session_state["url"], DATA_DIR, st.session_state["video_password"]
768
+ )
769
+ mp4_path = _convert_to_mp4(Path(raw_path)) # Ensure it's MP4
770
+ st.session_state["video_path"], was_compressed = _maybe_compress(mp4_path, st.session_state["compress_mb"])
771
+ if was_compressed:
772
+ st.toast("Video downloaded and compressed.")
773
+ else:
774
+ st.toast("Video downloaded.")
775
+ st.session_state["last_error"] = ""
776
+ except (
777
+ ValueError,
778
+ RuntimeError,
779
+ requests.exceptions.RequestException,
780
+ yt_dlp.utils.DownloadError,
781
+ ) as e:
782
+ st.session_state["last_error"] = f"Download failed: {e}"
783
+ st.session_state["last_error_detail"] = traceback.format_exc()
784
+ st.sidebar.error(st.session_state["last_error"])
785
+ finally:
786
+ st.session_state["busy"] = False
787
+ with col2:
788
+ if st.button("Clear Video", use_container_width=True):
789
+ for f in DATA_DIR.iterdir():
790
+ try:
791
+ f.unlink()
792
+ except Exception:
793
+ pass
794
+ st.session_state.update(
795
+ {
796
+ "video_path": "",
797
+ "analysis_out": "",
798
+ "raw_output": "",
799
+ "last_error": "",
800
+ "last_error_detail": "",
801
+ }
802
+ )
803
+ st.toast("All cached videos cleared")
804
+
805
 
806
  # ---------- Settings ----------
807
  with st.sidebar.expander("Settings", expanded=False):
 
831
 
832
  # ---------- Main panel ----------
833
  # Run Analysis button (placed after settings for visual flow)
834
+ if st.button("Run Analysis", disabled=st.session_state.get("busy", False)):
835
  if not st.session_state.get("video_path"):
836
  st.error("No video loaded – load a video first.")
837
  elif not st.session_state.get("api_key"):
 
853
  st.session_state["prompt"],
854
  st.session_state["model_input"],
855
  )
856
+ # Use the improved _strip_prompt_echo
857
  cleaned = _strip_prompt_echo(st.session_state["prompt"], raw)
858
  st.session_state["analysis_out"] = cleaned
859
  st.session_state["raw_output"] = raw
860
+ st.toast("Analysis complete!")
861
  except Exception as e:
862
  st.session_state["last_error"] = f"Analysis failed: {e}"
863
  st.session_state["last_error_detail"] = traceback.format_exc()
864
+ st.error(st.session_state["last_error"])
865
  finally:
866
  st.session_state["busy"] = False
867
 
868
  # ---- Layout: analysis first, then video, then errors ----
869
  if st.session_state.get("analysis_out"):
870
  st.subheader("📝 Analysis")
871
+ st.markdown(st.session_state["analysis_out"]) # Use markdown for rendered output
872
 
873
  with st.expander("Show raw model output"):
874
  st.code(st.session_state["raw_output"], language="text")
875
 
876
  if st.session_state.get("video_path"):
877
+ st.subheader("📺 Loaded Video")
878
  st.video(st.session_state["video_path"])
879
 
880
  if st.session_state.get("last_error"):