Spaces:
Sleeping
Sleeping
Update streamlit_app.py
Browse files- 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 |
-
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 67 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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
|
| 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 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
|
|
|
| 163 |
|
| 164 |
# ---------- Fallback: direct HTTP download ----------
|
|
|
|
| 165 |
try:
|
| 166 |
r = requests.get(url, stream=True, timeout=30)
|
| 167 |
r.raise_for_status()
|
| 168 |
-
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
for
|
| 197 |
-
if
|
| 198 |
-
|
| 199 |
-
|
| 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
|
| 289 |
-
clean_prompt = " ".join(prompt.lower().split())
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
#
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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", "
|
| 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 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 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"):
|