CB commited on
Commit
7831f28
·
verified ·
1 Parent(s): d955274

Update streamlit_app.py

Browse files
Files changed (1) hide show
  1. streamlit_app.py +214 -193
streamlit_app.py CHANGED
@@ -1,4 +1,17 @@
1
  # streamlit_app.py
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  import os
3
  import time
4
  import string
@@ -6,7 +19,6 @@ import hashlib
6
  import traceback
7
  from glob import glob
8
  from pathlib import Path
9
- from difflib import SequenceMatcher
10
  import json
11
  import logging
12
 
@@ -15,19 +27,7 @@ import ffmpeg
15
  import streamlit as st
16
  from dotenv import load_dotenv
17
 
18
- load_dotenv()
19
-
20
- # Optional phi integration (Agent wrapper)
21
- try:
22
- from phi.agent import Agent
23
- from phi.model.google import Gemini
24
- from phi.tools.duckduckgo import DuckDuckGo
25
- HAS_PHI = True
26
- except Exception:
27
- Agent = Gemini = DuckDuckGo = None
28
- HAS_PHI = False
29
-
30
- # google.generativeai SDK (try both legacy and newer patterns)
31
  try:
32
  import google.generativeai as genai
33
  genai_responses = getattr(genai, "responses", None) or getattr(genai, "Responses", None)
@@ -41,13 +41,18 @@ except Exception:
41
  get_file = None
42
  HAS_GENAI = False
43
 
 
 
 
44
  logging.basicConfig(level=logging.INFO)
45
  logger = logging.getLogger("video_ai")
46
 
 
47
  st.set_page_config(page_title="Generate the story of videos", layout="wide")
48
  DATA_DIR = Path("./data")
49
  DATA_DIR.mkdir(exist_ok=True)
50
 
 
51
  st.session_state.setdefault("videos", "")
52
  st.session_state.setdefault("loop_video", False)
53
  st.session_state.setdefault("uploaded_file", None)
@@ -64,6 +69,7 @@ st.session_state.setdefault("last_url_value", "")
64
  st.session_state.setdefault("processing_timeout", 900)
65
  st.session_state.setdefault("generation_timeout", 300)
66
  st.session_state.setdefault("preferred_model", "gemini-2.5-flash-lite")
 
67
 
68
  MODEL_OPTIONS = [
69
  "gemini-2.5-flash",
@@ -73,6 +79,7 @@ MODEL_OPTIONS = [
73
  "custom",
74
  ]
75
 
 
76
  def sanitize_filename(path_str: str):
77
  name = Path(path_str).name
78
  return name.lower().translate(str.maketrans("", "", string.punctuation)).replace(" ", "_")
@@ -95,13 +102,26 @@ def convert_video_to_mp4(video_path: str) -> str:
95
  pass
96
  return target_path
97
 
98
- def compress_video(input_path: str, target_path: str, crf: int = 28, preset: str = "fast"):
 
 
 
 
99
  try:
100
- ffmpeg.input(input_path).output(
101
- target_path, vcodec="libx264", crf=crf, preset=preset
102
- ).run(overwrite_output=True, quiet=True)
103
- return target_path
 
 
 
 
 
 
 
 
104
  except Exception:
 
105
  return input_path
106
 
107
  def download_video_ytdlp(url: str, save_dir: str, video_password: str = None) -> str:
@@ -141,96 +161,38 @@ def configure_genai_if_needed():
141
  if genai is not None and hasattr(genai, "configure"):
142
  genai.configure(api_key=key)
143
  except Exception:
144
- pass
145
  return True
146
 
147
- _agent = None
148
- def maybe_create_agent(model_id: str):
149
- global _agent
 
 
 
150
  key = get_effective_api_key()
151
- if not (HAS_PHI and HAS_GENAI and key):
152
- _agent = None
153
- return None
154
- if _agent and st.session_state.get("last_model") == model_id:
155
- return _agent
156
  try:
157
  if genai is not None and hasattr(genai, "configure"):
158
  genai.configure(api_key=key)
159
- _agent = Agent(name="Video AI summarizer", model=Gemini(id=model_id), tools=[DuckDuckGo()], markdown=True)
160
- st.session_state["last_model"] = model_id
161
  except Exception:
162
- _agent = None
163
- return _agent
164
-
165
- def clear_all_video_state():
166
- st.session_state.pop("uploaded_file", None)
167
- st.session_state.pop("processed_file", None)
168
- st.session_state["videos"] = ""
169
- st.session_state["last_loaded_path"] = ""
170
- st.session_state["analysis_out"] = ""
171
- st.session_state["last_error"] = ""
172
- st.session_state["file_hash"] = None
173
- for f in glob(str(DATA_DIR / "*")):
174
- try:
175
- os.remove(f)
176
- except Exception:
177
- pass
178
-
179
- current_url = st.session_state.get("url", "")
180
- if current_url != st.session_state.get("last_url_value"):
181
- clear_all_video_state()
182
- st.session_state["last_url_value"] = current_url
183
-
184
- st.sidebar.header("Video Input")
185
- st.sidebar.text_input("Video URL", key="url", placeholder="https://")
186
-
187
- settings_exp = st.sidebar.expander("Settings", expanded=False)
188
- chosen = settings_exp.selectbox("Gemini model", MODEL_OPTIONS, index=MODEL_OPTIONS.index("gemini-2.5-flash-lite"))
189
- custom_model = ""
190
- if chosen == "custom":
191
- custom_model = settings_exp.text_input("Custom model name", value=st.session_state.get("preferred_model", "gemini-2.5-flash-lite"))
192
- model_input_value = (custom_model.strip() if chosen == "custom" else chosen).strip()
193
- settings_exp.text_input("Google API Key", key="api_key", value=os.getenv("GOOGLE_API_KEY", ""), type="password")
194
- default_prompt = (
195
- "Watch the video and provide a detailed behavioral report focusing on human actions, interactions, posture, movement, and apparent intent. Keep language professional. Include a list of observations for notable events."
196
- )
197
- analysis_prompt = settings_exp.text_area("Enter analysis", value=default_prompt, height=140)
198
- settings_exp.text_input("Video Password (if needed)", key="video-password", placeholder="password", type="password")
199
-
200
- settings_exp.number_input(
201
- "Processing timeout (s)", min_value=60, max_value=3600,
202
- value=st.session_state.get("processing_timeout", 900), step=30,
203
- key="processing_timeout",
204
- )
205
- settings_exp.number_input(
206
- "Generation timeout (s)", min_value=30, max_value=1800,
207
- value=st.session_state.get("generation_timeout", 300), step=10,
208
- key="generation_timeout",
209
- )
210
-
211
- key_source = "session" if st.session_state.get("api_key") else ".env" if os.getenv("GOOGLE_API_KEY") else "none"
212
- settings_exp.caption(f"Using API key from: **{key_source}**")
213
- if not get_effective_api_key():
214
- settings_exp.warning("No Google API key provided; upload/generation disabled.", icon="⚠️")
215
-
216
- safety_settings = [
217
- {"category": "HARM_CATEGORY_HARASSMENT", "threshold": "OFF"},
218
- {"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "OFF"},
219
- {"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "OFF"},
220
- {"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "OFF"},
221
- ]
222
 
223
- def upload_video_sdk(filepath: str):
224
- key = get_effective_api_key()
225
- if not key:
226
- raise RuntimeError("No API key provided")
227
- if not HAS_GENAI or upload_file is None:
228
- raise RuntimeError("google.generativeai SDK not available; cannot upload")
229
- if genai is not None and hasattr(genai, "configure"):
230
- genai.configure(api_key=key)
231
- return upload_file(filepath)
232
 
233
  def wait_for_processed(file_obj, timeout: int = None, progress_callback=None):
 
 
 
234
  if timeout is None:
235
  timeout = st.session_state.get("processing_timeout", 900)
236
  if not HAS_GENAI or get_file is None:
@@ -268,45 +230,7 @@ def wait_for_processed(file_obj, timeout: int = None, progress_callback=None):
268
  time.sleep(backoff)
269
  backoff = min(backoff * 2, 8.0)
270
 
271
- def remove_prompt_echo(prompt: str, text: str, check_len: int = 600, ratio_threshold: float = 0.68):
272
- if not prompt or not text:
273
- return text
274
- a = " ".join(prompt.strip().lower().split())
275
- b_full = text.strip()
276
- b = " ".join(b_full[:check_len].lower().split())
277
- ratio = SequenceMatcher(None, a, b).ratio()
278
- if ratio >= ratio_threshold:
279
- cut = min(len(b_full), max(int(len(prompt) * 0.9), len(a)))
280
- new_text = b_full[cut:].lstrip(" \n:-")
281
- if len(new_text) >= 3:
282
- return new_text
283
- placeholders = ["enter analysis", "enter your analysis", "enter analysis here", "please enter analysis"]
284
- low = b_full.strip().lower()
285
- for ph in placeholders:
286
- if low.startswith(ph):
287
- return b_full[len(ph):].lstrip(" \n:-")
288
- return text
289
-
290
- def compress_video_if_large(local_path: str, threshold_mb: int = 50):
291
- try:
292
- file_size_mb = os.path.getsize(local_path) / (1024 * 1024)
293
- except Exception as e:
294
- st.session_state["last_error"] = f"Failed to stat file before compression: {e}"
295
- return local_path, False
296
-
297
- if file_size_mb <= threshold_mb:
298
- return local_path, False
299
-
300
- compressed_path = str(Path(local_path).with_name(Path(local_path).stem + "_compressed.mp4"))
301
- try:
302
- result = compress_video(local_path, compressed_path, crf=28, preset="fast")
303
- if result and os.path.exists(result):
304
- return result, True
305
- return local_path, False
306
- except Exception as e:
307
- st.session_state["last_error"] = f"Video compression failed: {e}\n{traceback.format_exc()}"
308
- return local_path, False
309
-
310
  def _normalize_genai_response(response):
311
  if response is None:
312
  return ""
@@ -371,6 +295,7 @@ def _normalize_genai_response(response):
371
  seen.add(t)
372
  return "\n\n".join(filtered).strip()
373
 
 
374
  def generate_via_responses_api(prompt_text: str, processed, model_used: str, max_tokens: int = 1024, timeout: int = 300, progress_callback=None):
375
  key = get_effective_api_key()
376
  if not key:
@@ -387,8 +312,11 @@ def generate_via_responses_api(prompt_text: str, processed, model_used: str, max
387
  user_msg = {"role": "user", "content": "Please summarize the attached video."}
388
  call_variants = []
389
 
390
- call_variants.append({"method": "responses.generate", "payload": {"model": model_used, "messages": [system_msg, user_msg], "files": [{"name": fname}], "safety_settings": safety_settings, "max_output_tokens": max_tokens}})
391
- call_variants.append({"method": "responses.generate_alt", "payload": {"model": model_used, "input": [{"text": prompt_text, "files": [{"name": fname}]}], "safety_settings": safety_settings, "max_output_tokens": max_tokens}})
 
 
 
392
  call_variants.append({"method": "legacy_responses_create", "payload": {"model": model_used, "input": prompt_text, "file": fname, "max_output_tokens": max_tokens}})
393
 
394
  def is_transient_error(e_text: str):
@@ -450,7 +378,96 @@ def generate_via_responses_api(prompt_text: str, processed, model_used: str, max
450
  time.sleep(backoff)
451
  backoff = min(backoff * 2, 8.0)
452
 
453
- # UI layout
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
454
  col1, col2 = st.columns([1, 3])
455
  with col1:
456
  generate_now = st.button("Generate the story", type="primary", disabled=not bool(get_effective_api_key()))
@@ -483,7 +500,19 @@ if st.session_state["videos"]:
483
  st.session_state["loop_video"] = loop_checkbox
484
 
485
  if st.button("Clear Video(s)"):
486
- clear_all_video_state()
 
 
 
 
 
 
 
 
 
 
 
 
487
 
488
  try:
489
  with open(st.session_state["videos"], "rb") as vf:
@@ -495,11 +524,14 @@ if st.session_state["videos"]:
495
  try:
496
  file_size_mb = os.path.getsize(st.session_state["videos"]) / (1024 * 1024)
497
  st.sidebar.caption(f"File size: {file_size_mb:.1f} MB")
498
- if file_size_mb > 50:
499
  st.sidebar.warning("Large file detected — it will be compressed automatically before upload.", icon="⚠️")
 
 
500
  except Exception:
501
  pass
502
 
 
503
  if generate_now and not st.session_state.get("busy"):
504
  if not st.session_state.get("videos"):
505
  st.error("No video loaded. Use 'Load Video' in the sidebar.")
@@ -514,12 +546,12 @@ if generate_now and not st.session_state.get("busy"):
514
  if HAS_GENAI and genai is not None:
515
  genai.configure(api_key=key_to_use)
516
  except Exception:
517
- pass
518
 
519
  model_id = model_input_value or st.session_state.get("preferred_model") or "gemini-2.5-flash-lite"
520
  if st.session_state.get("last_model") != model_id:
521
  st.session_state["last_model"] = ""
522
- maybe_create_agent(model_id)
523
 
524
  processed = st.session_state.get("processed_file")
525
  current_path = st.session_state.get("videos")
@@ -536,7 +568,23 @@ if generate_now and not st.session_state.get("busy"):
536
  if not HAS_GENAI:
537
  raise RuntimeError("google.generativeai SDK not available; install it.")
538
  local_path = current_path
539
- upload_path, compressed = compress_video_if_large(local_path)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
540
 
541
  with st.spinner(f"Uploading video{' (compressed)' if compressed else ''}..."):
542
  try:
@@ -574,52 +622,23 @@ if generate_now and not st.session_state.get("busy"):
574
  max_tokens = 2048 if "2.5" in model_used else 1024
575
  est_tokens = max_tokens
576
 
577
- agent = maybe_create_agent(model_used)
578
- debug_info = {"agent_attempted": False, "agent_ok": False, "agent_error": None, "agent_response_has_text": False}
579
- if agent:
580
- debug_info["agent_attempted"] = True
581
- try:
582
- with st.spinner("Generating description via Agent..."):
583
- if not processed:
584
- raise RuntimeError("Processed file missing for agent generation")
585
- agent_response = agent.run(prompt_text, videos=[processed], safety_settings=safety_settings)
586
- agent_text = getattr(agent_response, "content", None) or getattr(agent_response, "outputText", None) or None
587
- if not agent_text:
588
- try:
589
- if isinstance(agent_response, dict):
590
- for k in ("content", "outputText", "text", "message"):
591
- if k in agent_response and agent_response[k]:
592
- agent_text = agent_response[k]
593
- break
594
- except Exception:
595
- pass
596
- if agent_text and str(agent_text).strip():
597
- out = str(agent_text).strip()
598
- debug_info["agent_ok"] = True
599
- debug_info["agent_response_has_text"] = True
600
- else:
601
- debug_info["agent_ok"] = False
602
- except Exception as ae:
603
- debug_info["agent_error"] = f"{ae}"
604
- debug_info["agent_traceback"] = traceback.format_exc()
605
-
606
- if not out:
607
- try:
608
- gen_progress_placeholder = st.empty()
609
- gen_status = gen_progress_placeholder.text("Starting generation...")
610
- start_gen = time.time()
611
- def gen_progress_cb(stage, elapsed, info):
612
- try:
613
- gen_status.text(f"Stage: {stage} — elapsed: {elapsed}s — {info}")
614
- except Exception:
615
- pass
616
- out = generate_via_responses_api(prompt_text, processed, model_used, max_tokens=max_tokens, timeout=st.session_state.get("generation_timeout", 300), progress_callback=gen_progress_cb)
617
- gen_progress_placeholder.text(f"Generation complete in {int(time.time()-start_gen)}s")
618
- except Exception as e:
619
- tb = traceback.format_exc()
620
- st.session_state["last_error"] = f"Responses API error: {e}\n\nDebug: {debug_info}\n\nTraceback:\n{tb}"
621
- st.error("An error occurred while generating the story. You can try Generate again; the uploaded video will be reused.")
622
- out = ""
623
 
624
  if out:
625
  out = remove_prompt_echo(prompt_text, out)
@@ -642,17 +661,19 @@ if generate_now and not st.session_state.get("busy"):
642
 
643
  except Exception as e:
644
  tb = traceback.format_exc()
645
- st.session_state["last_error"] = f"{e}\n\nDebug: {locals().get('debug_info', {})}\n\nTraceback:\n{tb}"
646
  st.error("An error occurred while generating the story. You can try Generate again; the uploaded video will be reused.")
647
  finally:
648
  st.session_state["busy"] = False
649
 
 
650
  if st.session_state.get("analysis_out"):
651
  just_loaded_same = (st.session_state.get("last_loaded_path") == st.session_state.get("videos"))
652
  if not just_loaded_same:
653
  st.subheader("Analysis Result")
654
  st.markdown(st.session_state.get("analysis_out"))
655
 
 
656
  if st.session_state.get("last_error"):
657
  with st.expander("Last Error", expanded=False):
658
  st.write(st.session_state.get("last_error"))
 
1
  # streamlit_app.py
2
+ """
3
+ Streamlit app for video captioning / analysis using Google GenAI Responses API.
4
+ Removed phi-agent support. Uses google.generativeai SDK (Responses).
5
+ Requires GOOGLE_API_KEY in environment or entered in UI.
6
+
7
+ Features:
8
+ - Download video via yt-dlp
9
+ - Optional compression for files > 200 MB (configurable)
10
+ - Upload video via google.generativeai.upload_file and wait for processing via get_file
11
+ - Generate analysis via Responses.generate (or Responses.create legacy compatibility)
12
+ - Basic UI for model selection, prompts, timeouts, and status/progress reporting
13
+ """
14
+
15
  import os
16
  import time
17
  import string
 
19
  import traceback
20
  from glob import glob
21
  from pathlib import Path
 
22
  import json
23
  import logging
24
 
 
27
  import streamlit as st
28
  from dotenv import load_dotenv
29
 
30
+ # Google GenAI SDK
 
 
 
 
 
 
 
 
 
 
 
 
31
  try:
32
  import google.generativeai as genai
33
  genai_responses = getattr(genai, "responses", None) or getattr(genai, "Responses", None)
 
41
  get_file = None
42
  HAS_GENAI = False
43
 
44
+ load_dotenv()
45
+
46
+ # Logging
47
  logging.basicConfig(level=logging.INFO)
48
  logger = logging.getLogger("video_ai")
49
 
50
+ # App config
51
  st.set_page_config(page_title="Generate the story of videos", layout="wide")
52
  DATA_DIR = Path("./data")
53
  DATA_DIR.mkdir(exist_ok=True)
54
 
55
+ # Session defaults
56
  st.session_state.setdefault("videos", "")
57
  st.session_state.setdefault("loop_video", False)
58
  st.session_state.setdefault("uploaded_file", None)
 
69
  st.session_state.setdefault("processing_timeout", 900)
70
  st.session_state.setdefault("generation_timeout", 300)
71
  st.session_state.setdefault("preferred_model", "gemini-2.5-flash-lite")
72
+ st.session_state.setdefault("compression_threshold_mb", 200) # new threshold per plan
73
 
74
  MODEL_OPTIONS = [
75
  "gemini-2.5-flash",
 
79
  "custom",
80
  ]
81
 
82
+ # Utilities
83
  def sanitize_filename(path_str: str):
84
  name = Path(path_str).name
85
  return name.lower().translate(str.maketrans("", "", string.punctuation)).replace(" ", "_")
 
102
  pass
103
  return target_path
104
 
105
+ def compress_video(input_path: str, target_path: str, crf: int = 28, preset: str = "fast", bitrate: str = None):
106
+ """
107
+ Compress video using ffmpeg; tune via crf or bitrate.
108
+ Returns target_path on success, else original input_path.
109
+ """
110
  try:
111
+ out = ffmpeg.input(input_path)
112
+ params = {"vcodec": "libx264", "crf": crf, "preset": preset}
113
+ if bitrate:
114
+ params["video_bitrate"] = bitrate
115
+ # ffmpeg-python uses keyword 'b' for bitrate if passed via output string; using bitrate via args below
116
+ stream = out.output(target_path, **{"vcodec": "libx264", "preset": preset}, video_bitrate=bitrate)
117
+ else:
118
+ stream = out.output(target_path, **params)
119
+ stream.run(overwrite_output=True, quiet=True)
120
+ if os.path.exists(target_path):
121
+ return target_path
122
+ return input_path
123
  except Exception:
124
+ logger.exception("Compression failed")
125
  return input_path
126
 
127
  def download_video_ytdlp(url: str, save_dir: str, video_password: str = None) -> str:
 
161
  if genai is not None and hasattr(genai, "configure"):
162
  genai.configure(api_key=key)
163
  except Exception:
164
+ logger.exception("Failed to configure genai")
165
  return True
166
 
167
+ # Upload & processing helpers (using google.generativeai SDK functions upload_file/get_file)
168
+ def upload_video_sdk(filepath: str, progress_callback=None):
169
+ """
170
+ Upload a local file using google.generativeai.upload_file.
171
+ Assumes genai.configure(api_key=...) was called.
172
+ """
173
  key = get_effective_api_key()
174
+ if not key:
175
+ raise RuntimeError("No API key provided")
176
+ if not HAS_GENAI or upload_file is None:
177
+ raise RuntimeError("google.generativeai SDK not available; cannot upload")
178
+ # SDK upload_file typically takes path and returns file object
179
  try:
180
  if genai is not None and hasattr(genai, "configure"):
181
  genai.configure(api_key=key)
 
 
182
  except Exception:
183
+ pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
184
 
185
+ # call upload_file and return its result
186
+ try:
187
+ return upload_file(filepath)
188
+ except Exception as e:
189
+ logger.exception("Upload failed")
190
+ raise
 
 
 
191
 
192
  def wait_for_processed(file_obj, timeout: int = None, progress_callback=None):
193
+ """
194
+ Poll get_file(name_or_id) until processing state changes away from 'PROCESSING' or timeout.
195
+ """
196
  if timeout is None:
197
  timeout = st.session_state.get("processing_timeout", 900)
198
  if not HAS_GENAI or get_file is None:
 
230
  time.sleep(backoff)
231
  backoff = min(backoff * 2, 8.0)
232
 
233
+ # Response normalization
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
234
  def _normalize_genai_response(response):
235
  if response is None:
236
  return ""
 
295
  seen.add(t)
296
  return "\n\n".join(filtered).strip()
297
 
298
+ # Generation via Responses API (supports modern and legacy patterns)
299
  def generate_via_responses_api(prompt_text: str, processed, model_used: str, max_tokens: int = 1024, timeout: int = 300, progress_callback=None):
300
  key = get_effective_api_key()
301
  if not key:
 
312
  user_msg = {"role": "user", "content": "Please summarize the attached video."}
313
  call_variants = []
314
 
315
+ # preferred modern call
316
+ call_variants.append({"method": "responses.generate", "payload": {"model": model_used, "messages": [system_msg, user_msg], "files": [{"name": fname}], "max_output_tokens": max_tokens}})
317
+ # alternate modern payload shape
318
+ call_variants.append({"method": "responses.generate_alt", "payload": {"model": model_used, "input": [{"text": prompt_text, "files": [{"name": fname}]}], "max_output_tokens": max_tokens}})
319
+ # legacy
320
  call_variants.append({"method": "legacy_responses_create", "payload": {"model": model_used, "input": prompt_text, "file": fname, "max_output_tokens": max_tokens}})
321
 
322
  def is_transient_error(e_text: str):
 
378
  time.sleep(backoff)
379
  backoff = min(backoff * 2, 8.0)
380
 
381
+ # Prompt echo removal
382
+ from difflib import SequenceMatcher
383
+ def remove_prompt_echo(prompt: str, text: str, check_len: int = 600, ratio_threshold: float = 0.68):
384
+ if not prompt or not text:
385
+ return text
386
+ a = " ".join(prompt.strip().lower().split())
387
+ b_full = text.strip()
388
+ b = " ".join(b_full[:check_len].lower().split())
389
+ ratio = SequenceMatcher(None, a, b).ratio()
390
+ if ratio >= ratio_threshold:
391
+ cut = min(len(b_full), max(int(len(prompt) * 0.9), len(a)))
392
+ new_text = b_full[cut:].lstrip(" \n:-")
393
+ if len(new_text) >= 3:
394
+ return new_text
395
+ placeholders = ["enter analysis", "enter your analysis", "enter analysis here", "please enter analysis"]
396
+ low = b_full.strip().lower()
397
+ for ph in placeholders:
398
+ if low.startswith(ph):
399
+ return b_full[len(ph):].lstrip(" \n:-")
400
+ return text
401
+
402
+ # UI
403
+ current_url = st.session_state.get("url", "")
404
+ if current_url != st.session_state.get("last_url_value"):
405
+ # clear per-plan
406
+ st.session_state["videos"] = ""
407
+ st.session_state["last_loaded_path"] = ""
408
+ st.session_state["uploaded_file"] = None
409
+ st.session_state["processed_file"] = None
410
+ st.session_state["analysis_out"] = ""
411
+ st.session_state["last_error"] = ""
412
+ st.session_state["file_hash"] = None
413
+ for f in glob(str(DATA_DIR / "*")):
414
+ try:
415
+ os.remove(f)
416
+ except Exception:
417
+ pass
418
+ st.session_state["last_url_value"] = current_url
419
+
420
+ st.sidebar.header("Video Input")
421
+ st.sidebar.text_input("Video URL", key="url", placeholder="https://")
422
+
423
+ settings_exp = st.sidebar.expander("Settings", expanded=False)
424
+ chosen = settings_exp.selectbox("Gemini model", MODEL_OPTIONS, index=MODEL_OPTIONS.index(st.session_state.get("preferred_model", "gemini-2.5-flash-lite")))
425
+ custom_model = ""
426
+ if chosen == "custom":
427
+ custom_model = settings_exp.text_input("Custom model name", value=st.session_state.get("preferred_model", "gemini-2.5-flash-lite"))
428
+ model_input_value = (custom_model.strip() if chosen == "custom" else chosen).strip()
429
+
430
+ settings_exp.text_input("Google API Key", key="api_key", value=os.getenv("GOOGLE_API_KEY", ""), type="password")
431
+
432
+ default_prompt = (
433
+ "Watch the video and provide a detailed behavioral report focusing on human actions, interactions, posture, movement, and apparent intent. Keep language professional. Include a list of observations for notable events."
434
+ )
435
+ analysis_prompt = settings_exp.text_area("Enter analysis prompt", value=default_prompt, height=140)
436
+ settings_exp.text_input("Video Password (if needed)", key="video-password", placeholder="password", type="password")
437
+
438
+ settings_exp.number_input(
439
+ "Processing timeout (s)", min_value=60, max_value=3600,
440
+ value=st.session_state.get("processing_timeout", 900), step=30,
441
+ key="processing_timeout",
442
+ )
443
+ settings_exp.number_input(
444
+ "Generation timeout (s)", min_value=30, max_value=1800,
445
+ value=st.session_state.get("generation_timeout", 300), step=10,
446
+ key="generation_timeout",
447
+ )
448
+
449
+ # Compression threshold control (per plan: 200 MB)
450
+ settings_exp.number_input(
451
+ "Compression threshold (MB)", min_value=10, max_value=2000,
452
+ value=st.session_state.get("compression_threshold_mb", 200), step=10,
453
+ key="compression_threshold_mb",
454
+ )
455
+ settings_exp.caption("Files ≤ threshold are uploaded unchanged. Files > threshold are compressed before upload (tunable).")
456
+
457
+ key_source = "session" if st.session_state.get("api_key") else ".env" if os.getenv("GOOGLE_API_KEY") else "none"
458
+ settings_exp.caption(f"Using API key from: **{key_source}**")
459
+ if not get_effective_api_key():
460
+ settings_exp.warning("No Google API key provided; upload/generation disabled.", icon="⚠️")
461
+
462
+ # Safety settings placeholder (kept minimal)
463
+ safety_settings = [
464
+ {"category": "HARM_CATEGORY_HARASSMENT", "threshold": "OFF"},
465
+ {"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "OFF"},
466
+ {"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "OFF"},
467
+ {"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "OFF"},
468
+ ]
469
+
470
+ # Buttons / UI layout
471
  col1, col2 = st.columns([1, 3])
472
  with col1:
473
  generate_now = st.button("Generate the story", type="primary", disabled=not bool(get_effective_api_key()))
 
500
  st.session_state["loop_video"] = loop_checkbox
501
 
502
  if st.button("Clear Video(s)"):
503
+ # minimal clear
504
+ st.session_state["videos"] = ""
505
+ st.session_state["last_loaded_path"] = ""
506
+ st.session_state["uploaded_file"] = None
507
+ st.session_state["processed_file"] = None
508
+ st.session_state["analysis_out"] = ""
509
+ st.session_state["last_error"] = ""
510
+ st.session_state["file_hash"] = None
511
+ for f in glob(str(DATA_DIR / "*")):
512
+ try:
513
+ os.remove(f)
514
+ except Exception:
515
+ pass
516
 
517
  try:
518
  with open(st.session_state["videos"], "rb") as vf:
 
524
  try:
525
  file_size_mb = os.path.getsize(st.session_state["videos"]) / (1024 * 1024)
526
  st.sidebar.caption(f"File size: {file_size_mb:.1f} MB")
527
+ if file_size_mb > st.session_state.get("compression_threshold_mb", 200):
528
  st.sidebar.warning("Large file detected — it will be compressed automatically before upload.", icon="⚠️")
529
+ else:
530
+ st.sidebar.info("File ≤ threshold — will be uploaded unchanged.")
531
  except Exception:
532
  pass
533
 
534
+ # Generation flow
535
  if generate_now and not st.session_state.get("busy"):
536
  if not st.session_state.get("videos"):
537
  st.error("No video loaded. Use 'Load Video' in the sidebar.")
 
546
  if HAS_GENAI and genai is not None:
547
  genai.configure(api_key=key_to_use)
548
  except Exception:
549
+ logger.exception("genai configure failed")
550
 
551
  model_id = model_input_value or st.session_state.get("preferred_model") or "gemini-2.5-flash-lite"
552
  if st.session_state.get("last_model") != model_id:
553
  st.session_state["last_model"] = ""
554
+ # no phi agent creation per plan
555
 
556
  processed = st.session_state.get("processed_file")
557
  current_path = st.session_state.get("videos")
 
568
  if not HAS_GENAI:
569
  raise RuntimeError("google.generativeai SDK not available; install it.")
570
  local_path = current_path
571
+
572
+ # Decide whether to compress based on threshold (per plan ≤ threshold upload unchanged)
573
+ try:
574
+ file_size_mb = os.path.getsize(local_path) / (1024 * 1024)
575
+ except Exception:
576
+ file_size_mb = None
577
+
578
+ compressed = False
579
+ upload_path = local_path
580
+ threshold_mb = st.session_state.get("compression_threshold_mb", 200)
581
+ if file_size_mb is not None and file_size_mb > threshold_mb:
582
+ # compress with conservative settings; allow user to tune via constants if desired
583
+ compressed_path = str(Path(local_path).with_name(Path(local_path).stem + "_compressed.mp4"))
584
+ with st.spinner("Compressing video before upload..."):
585
+ upload_path = compress_video(local_path, compressed_path, crf=28, preset="fast")
586
+ if upload_path != local_path:
587
+ compressed = True
588
 
589
  with st.spinner(f"Uploading video{' (compressed)' if compressed else ''}..."):
590
  try:
 
622
  max_tokens = 2048 if "2.5" in model_used else 1024
623
  est_tokens = max_tokens
624
 
625
+ # Generate via Responses API
626
+ try:
627
+ gen_progress_placeholder = st.empty()
628
+ gen_status = gen_progress_placeholder.text("Starting generation...")
629
+ start_gen = time.time()
630
+ def gen_progress_cb(stage, elapsed, info):
631
+ try:
632
+ gen_status.text(f"Stage: {stage} elapsed: {elapsed}s — {info}")
633
+ except Exception:
634
+ pass
635
+ out = generate_via_responses_api(prompt_text, processed, model_used, max_tokens=max_tokens, timeout=st.session_state.get("generation_timeout", 300), progress_callback=gen_progress_cb)
636
+ gen_progress_placeholder.text(f"Generation complete in {int(time.time()-start_gen)}s")
637
+ except Exception as e:
638
+ tb = traceback.format_exc()
639
+ st.session_state["last_error"] = f"Responses API error: {e}\n\nTraceback:\n{tb}"
640
+ st.error("An error occurred while generating the story. You can try Generate again; the uploaded video will be reused.")
641
+ out = ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
642
 
643
  if out:
644
  out = remove_prompt_echo(prompt_text, out)
 
661
 
662
  except Exception as e:
663
  tb = traceback.format_exc()
664
+ st.session_state["last_error"] = f"{e}\n\nTraceback:\n{tb}"
665
  st.error("An error occurred while generating the story. You can try Generate again; the uploaded video will be reused.")
666
  finally:
667
  st.session_state["busy"] = False
668
 
669
+ # Display existing analysis
670
  if st.session_state.get("analysis_out"):
671
  just_loaded_same = (st.session_state.get("last_loaded_path") == st.session_state.get("videos"))
672
  if not just_loaded_same:
673
  st.subheader("Analysis Result")
674
  st.markdown(st.session_state.get("analysis_out"))
675
 
676
+ # Last error expander
677
  if st.session_state.get("last_error"):
678
  with st.expander("Last Error", expanded=False):
679
  st.write(st.session_state.get("last_error"))