CB commited on
Commit
ff726a7
·
verified ·
1 Parent(s): 1866c11

Update streamlit_app.py

Browse files
Files changed (1) hide show
  1. streamlit_app.py +296 -496
streamlit_app.py CHANGED
@@ -3,17 +3,9 @@ import os
3
  import time
4
  import string
5
  import hashlib
6
- import traceback
7
- import inspect
8
- import json
9
- import re
10
  from glob import glob
11
  from pathlib import Path
12
  from difflib import SequenceMatcher
13
- from typing import Optional, Tuple, Any
14
-
15
- import requests
16
- from bs4 import BeautifulSoup
17
 
18
  import yt_dlp
19
  import ffmpeg
@@ -22,23 +14,22 @@ from dotenv import load_dotenv
22
 
23
  load_dotenv()
24
 
25
- # Try import google.generativeai (optional)
26
- HAS_GENAI = False
27
- genai = None
28
- upload_file = None
29
- get_file = None
30
  try:
31
- import google.generativeai as genai # type: ignore
32
- try:
33
- from google.generativeai import upload_file, get_file # type: ignore
34
- except Exception:
35
- upload_file = None
36
- get_file = None
 
 
 
 
 
37
  HAS_GENAI = True
38
  except Exception:
39
  genai = None
40
- upload_file = None
41
- get_file = None
42
  HAS_GENAI = False
43
 
44
  st.set_page_config(page_title="Generate the story of videos", layout="wide")
@@ -58,15 +49,12 @@ st.session_state.setdefault("file_hash", None)
58
  st.session_state.setdefault("fast_mode", False)
59
  st.session_state.setdefault("api_key", os.getenv("GOOGLE_API_KEY", ""))
60
  st.session_state.setdefault("last_model", "")
 
61
  st.session_state.setdefault("last_url_value", "")
62
- # allow disabling SSL verify for HTTP fallback (not recommended)
63
- st.session_state.setdefault("http_skip_ssl_verify", False)
64
-
65
- HEADERS = {"User-Agent": "Mozilla/5.0 (compatible)"}
66
 
67
- # ----------------- Utilities -----------------
68
- def sanitize_filename(path_str: str) -> str:
69
- return Path(path_str).name.lower().translate(str.maketrans("", "", string.punctuation)).replace(" ", "_")
70
 
71
  def file_sha256(path: str, block_size: int = 65536) -> str:
72
  h = hashlib.sha256()
@@ -86,141 +74,14 @@ def convert_video_to_mp4(video_path: str) -> str:
86
  pass
87
  return target_path
88
 
89
- def compress_video(input_path: str, target_path: str, crf: int = 28, preset: str = "fast") -> str:
90
  try:
91
  ffmpeg.input(input_path).output(target_path, vcodec="libx264", crf=crf, preset=preset).run(overwrite_output=True, quiet=True)
92
  return target_path
93
  except Exception:
94
  return input_path
95
 
96
- # ----------------- Download / Extract -----------------
97
- def expand_url(short_url: str, timeout: int = 10) -> Tuple[Optional[str], Optional[str]]:
98
- try:
99
- r = requests.get(short_url, allow_redirects=True, timeout=timeout, headers=HEADERS)
100
- r.raise_for_status()
101
- return r.url, r.text
102
- except Exception as e:
103
- return None, f"error: {e}"
104
-
105
- def extract_video_from_html(html: str, base_url: Optional[str] = None) -> Optional[str]:
106
- soup = BeautifulSoup(html, "html.parser")
107
- og = soup.find("meta", property="og:video")
108
- if og and og.get("content"):
109
- return og.get("content")
110
- vtag = soup.find("video")
111
- if vtag:
112
- src = vtag.get("src")
113
- if src:
114
- return src
115
- source = vtag.find("source")
116
- if source and source.get("src"):
117
- return source.get("src")
118
- for script in soup.find_all("script", type="application/ld+json"):
119
- try:
120
- data = json.loads(script.string or "{}")
121
- if isinstance(data, dict):
122
- video = data.get("video") or data.get("videoObject") or data.get("mainEntity")
123
- if isinstance(video, dict):
124
- for k in ("contentUrl", "url"):
125
- if video.get(k):
126
- return video.get(k)
127
- if data.get("contentUrl"):
128
- return data.get("contentUrl")
129
- except Exception:
130
- continue
131
- for mname in ("twitter:player:stream", "twitter:player"):
132
- m = soup.find("meta", attrs={"name": mname})
133
- if m and m.get("content"):
134
- return m.get("content")
135
- for a in soup.find_all("a", href=True):
136
- href = a["href"]
137
- if any(domain in href for domain in ("youtube.com", "youtu.be", "vimeo.com")):
138
- return href
139
- return None
140
-
141
- def extract_video_from_twitter_html(html: str) -> Optional[str]:
142
- soup = BeautifulSoup(html, "html.parser")
143
- og_video = soup.find("meta", property="og:video")
144
- if og_video and og_video.get("content"):
145
- return og_video["content"]
146
- scripts = soup.find_all("script")
147
- for s in scripts:
148
- txt = s.string
149
- if not txt:
150
- continue
151
- if any(k in txt for k in ("video_info", "variants", "playbackUrl", "media")):
152
- m = re.search(r"(?s)(\{.+\})", txt)
153
- if not m:
154
- continue
155
- try:
156
- blob = json.loads(m.group(1))
157
- except Exception:
158
- continue
159
- def find_media_urls(obj):
160
- if isinstance(obj, dict):
161
- for k, v in obj.items():
162
- if isinstance(v, str) and v.startswith("https://") and v.endswith(".mp4"):
163
- yield v
164
- else:
165
- yield from find_media_urls(v)
166
- elif isinstance(obj, list):
167
- for it in obj:
168
- yield from find_media_urls(it)
169
- for url in find_media_urls(blob):
170
- return url
171
- def find_variants(obj):
172
- if isinstance(obj, dict):
173
- for k, v in obj.items():
174
- if k == "variants" and isinstance(v, list):
175
- for vi in v:
176
- if isinstance(vi, dict):
177
- url = vi.get("url") or vi.get("playbackUrl")
178
- ct = vi.get("content_type", "") or vi.get("contentType", "")
179
- if url and url.startswith("http") and ("mp4" in url or "video" in ct or "video" in url):
180
- yield url
181
- else:
182
- yield from find_variants(v)
183
- elif isinstance(obj, list):
184
- for it in obj:
185
- yield from find_variants(it)
186
- for url in find_variants(blob):
187
- return url
188
- return None
189
-
190
- def extract_direct_twitter_video(url: str) -> Tuple[Optional[str], str]:
191
- final, html_or_err = expand_url(url)
192
- if final is None:
193
- return None, html_or_err or "expand failed"
194
- variants = [
195
- final,
196
- final.replace("://twitter.com/", "://mobile.twitter.com/"),
197
- final.replace("://twitter.com/", "://x.com/"),
198
- final + "?s=20",
199
- final + "?ref_src=twsrc%5Etfw",
200
- ]
201
- for u in variants:
202
- try:
203
- r = requests.get(u, allow_redirects=True, headers=HEADERS, timeout=10)
204
- r.raise_for_status()
205
- direct = extract_video_from_twitter_html(r.text)
206
- if direct:
207
- return direct, u
208
- except Exception:
209
- continue
210
- try:
211
- oembed = requests.get("https://publish.twitter.com/oembed?url=" + final, headers=HEADERS, timeout=6)
212
- if oembed.ok:
213
- j = oembed.json()
214
- html = j.get("html", "")
215
- soup = BeautifulSoup(html, "html.parser")
216
- video = soup.find("video")
217
- if video and video.get("src"):
218
- return video["src"], final
219
- except Exception:
220
- pass
221
- return None, "not found"
222
-
223
- def download_video_ytdlp(url: str, save_dir: str, video_password: Optional[str] = None) -> str:
224
  if not url:
225
  raise ValueError("No URL provided")
226
  outtmpl = str(Path(save_dir) / "%(id)s.%(ext)s")
@@ -239,29 +100,100 @@ def download_video_ytdlp(url: str, save_dir: str, video_password: Optional[str]
239
  raise FileNotFoundError("Downloaded video not found")
240
  return convert_video_to_mp4(matches[0])
241
 
242
- # ----------------- Generative AI helpers -----------------
243
- def get_effective_api_key() -> Optional[str]:
 
 
 
 
 
 
244
  return st.session_state.get("api_key") or os.getenv("GOOGLE_API_KEY")
245
 
246
- def maybe_configure_genai(key: str) -> bool:
247
- if not key or not HAS_GENAI:
 
248
  return False
249
  try:
250
  genai.configure(api_key=key)
251
- return True
252
  except Exception:
253
- return False
 
254
 
255
- def upload_video_sdk(filepath: str) -> Any:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
256
  key = get_effective_api_key()
257
  if not key:
258
  raise RuntimeError("No API key provided")
259
  if not HAS_GENAI or upload_file is None:
260
- raise RuntimeError("google.generativeai SDK upload not available")
261
  genai.configure(api_key=key)
262
  return upload_file(filepath)
263
 
264
- def wait_for_processed(file_obj: Any, timeout: int = 180) -> Any:
265
  if not HAS_GENAI or get_file is None:
266
  return file_obj
267
  start = time.time()
@@ -270,10 +202,7 @@ def wait_for_processed(file_obj: Any, timeout: int = 180) -> Any:
270
  return file_obj
271
  backoff = 1.0
272
  while True:
273
- try:
274
- obj = get_file(name)
275
- except Exception:
276
- return file_obj
277
  state = getattr(obj, "state", None)
278
  if not state or getattr(state, "name", None) != "PROCESSING":
279
  return obj
@@ -282,28 +211,13 @@ def wait_for_processed(file_obj: Any, timeout: int = 180) -> Any:
282
  time.sleep(backoff)
283
  backoff = min(backoff * 2, 8.0)
284
 
285
- def file_name_or_id(file_obj: Any) -> Optional[str]:
286
- if file_obj is None:
287
- return None
288
- if isinstance(file_obj, dict):
289
- return file_obj.get("name") or file_obj.get("id")
290
- for attr in ("name", "id", "fileId", "file_id"):
291
- if hasattr(file_obj, attr):
292
- val = getattr(file_obj, attr)
293
- if val:
294
- return val
295
- return str(file_obj)
296
-
297
- def remove_prompt_echo(prompt: str, text: str, check_len: int = 600, ratio_threshold: float = 0.68) -> str:
298
  if not prompt or not text:
299
  return text
300
  a = " ".join(prompt.strip().lower().split())
301
  b_full = text.strip()
302
  b = " ".join(b_full[:check_len].lower().split())
303
- try:
304
- ratio = SequenceMatcher(None, a, b).ratio()
305
- except Exception:
306
- ratio = 0.0
307
  if ratio >= ratio_threshold:
308
  cut = min(len(b_full), max(int(len(prompt) * 0.9), len(a)))
309
  new_text = b_full[cut:].lstrip(" \n:-")
@@ -316,266 +230,46 @@ def remove_prompt_echo(prompt: str, text: str, check_len: int = 600, ratio_thres
316
  return b_full[len(ph):].lstrip(" \n:-")
317
  return text
318
 
319
- def generative_model_call_flexible(model_name: str, messages: list, files: Optional[list] = None, max_output_tokens: int = 1024) -> Any:
320
- """
321
- Robustly call GenerativeModel with different signatures.
322
- This function anticipates generate_content taking different arg shapes.
323
- """
324
- if not HAS_GENAI or genai is None:
325
- raise RuntimeError("genai not available")
326
- GM = getattr(genai, "GenerativeModel", None)
327
- if GM is None:
328
- raise RuntimeError("GenerativeModel not available")
329
-
330
- # Construct instance robustly
331
- gm = None
332
- try:
333
- sig = inspect.signature(GM)
334
- params = sig.parameters
335
- if "model" in params:
336
- gm = GM(model=model_name)
337
- elif "model_name" in params:
338
- gm = GM(model_name=model_name)
339
- else:
340
- gm = GM()
341
- try:
342
- if hasattr(gm, "model"):
343
- setattr(gm, "model", model_name)
344
- except Exception:
345
- pass
346
- except Exception:
347
- try:
348
- gm = GM(model=model_name)
349
- except Exception:
350
- try:
351
- gm = GM(model_name=model_name)
352
- except Exception:
353
- gm = GM()
354
-
355
- # Try generate_content with multiple call shapes:
356
- # - generate_content(messages=..., files=..., max_output_tokens=...)
357
- # - generate_content(messages, files, max_output_tokens)
358
- # - generate_content(messages)
359
- if hasattr(gm, "generate_content"):
360
- # try keyword style first
361
- try:
362
- return gm.generate_content(messages=messages, files=files, max_output_tokens=max_output_tokens)
363
- except TypeError:
364
- # try positional: some versions expect (messages, max_output_tokens)
365
- try:
366
- return gm.generate_content(messages, max_output_tokens)
367
- except TypeError:
368
- # maybe (prompt_str,) shape
369
- try:
370
- prompt = messages[-1].get("content") if isinstance(messages, (list, tuple)) and messages else str(messages)
371
- return gm.generate_content(prompt)
372
- except Exception as e:
373
- raise RuntimeError(f"GenerativeModel.generate_content unusable: {e}")
374
- except Exception as e:
375
- raise RuntimeError(f"generate_content failed: {e}")
376
-
377
- # Try generate with files kw / positional
378
- if hasattr(gm, "generate"):
379
- try:
380
- return gm.generate(messages=messages, files=files, max_output_tokens=max_output_tokens)
381
- except TypeError:
382
- try:
383
- return gm.generate(messages, max_output_tokens)
384
- except Exception as e:
385
- raise RuntimeError(f"GenerativeModel.generate unusable: {e}")
386
-
387
- raise RuntimeError("No usable generate method on GenerativeModel instance")
388
-
389
- def responses_http_call(api_key: str, model: str, messages: list, file_name: Optional[str] = None, max_output_tokens: int = 1024, safety_settings: Optional[list] = None) -> dict:
390
- """
391
- HTTP fallback to Responses v1 endpoint. Attempts retries and allows optional SSL skip.
392
- """
393
- url = f"https://api.generativeai.googleapis.com/v1/models/{model}:generateMessage"
394
- headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
395
- payload = {
396
- "messages": [{"role": m.get("role", "user"), "content": [{"type": "text", "text": m.get("content", "")}]} for m in messages],
397
- "maxOutputTokens": max_output_tokens,
398
- }
399
- if safety_settings:
400
- payload["safetySettings"] = safety_settings
401
- if file_name:
402
- payload["files"] = [{"name": file_name}]
403
- # allow skipping SSL verify in special environments (not recommended)
404
- verify = not bool(st.session_state.get("http_skip_ssl_verify", False))
405
- last_err = None
406
- for attempt in range(1, 4):
407
- try:
408
- r = requests.post(url, json=payload, headers=headers, timeout=30, verify=verify)
409
- r.raise_for_status()
410
- return r.json()
411
- except requests.exceptions.SSLError as e:
412
- last_err = e
413
- # If SSL hostname mismatch or cert issues, surface helpful message once
414
- raise RuntimeError(f"SSL error when calling Responses HTTP endpoint: {e}. If you are behind a proxy intercepting TLS, set 'Skip HTTP SSL verify' in Settings (not recommended) or fix your CA bundle.")
415
- except Exception as e:
416
- last_err = e
417
- time.sleep(0.8 * attempt)
418
- continue
419
- raise RuntimeError(f"HTTP responses fallback failed after retries: {last_err}")
420
-
421
- def normalize_response_to_text(response: Any) -> str:
422
- """Extract text from SDK or HTTP responses into a single string."""
423
- if not response:
424
- return ""
425
- # dict-like
426
- if isinstance(response, dict):
427
- for key in ("output", "candidates", "items", "responses"):
428
- val = response.get(key)
429
- if isinstance(val, (list, tuple)) and val:
430
- pieces = []
431
- for el in val:
432
- if isinstance(el, dict):
433
- c = el.get("content") or el.get("message") or el.get("text")
434
- if isinstance(c, list):
435
- for part in c:
436
- if isinstance(part, dict):
437
- t = part.get("text") or part.get("content")
438
- if t:
439
- pieces.append(t)
440
- elif isinstance(part, str):
441
- pieces.append(part)
442
- elif isinstance(c, str):
443
- pieces.append(c)
444
- elif isinstance(el, str):
445
- pieces.append(el)
446
- if pieces:
447
- return "\n\n".join(pieces)
448
- # message path
449
- msg = response.get("message") or response.get("response") or response.get("output")
450
- if isinstance(msg, dict):
451
- c = msg.get("content")
452
- if isinstance(c, list):
453
- texts = []
454
- for part in c:
455
- if isinstance(part, dict) and "text" in part:
456
- texts.append(part.get("text"))
457
- elif isinstance(part, str):
458
- texts.append(part)
459
- return "\n\n".join([t for t in texts if t])
460
- # fallback join string values
461
- flat = []
462
- for v in response.values():
463
- if isinstance(v, str) and v.strip():
464
- flat.append(v.strip())
465
- return "\n\n".join(flat)
466
- # object-like SDK responses
467
- for attr in ("output", "candidates", "items", "responses", "message"):
468
- val = getattr(response, attr, None)
469
- if isinstance(val, (list, tuple)) and val:
470
- pieces = []
471
- for el in val:
472
- if hasattr(el, "text"):
473
- pieces.append(getattr(el, "text"))
474
- elif isinstance(el, dict):
475
- t = el.get("text") or el.get("content")
476
- if t:
477
- pieces.append(t)
478
- else:
479
- pieces.append(str(el))
480
- return "\n\n".join([p for p in pieces if p])
481
- text = getattr(response, "text", None) or getattr(response, "message", None)
482
- return text or ""
483
-
484
- # ----------------- UI -----------------
485
- current_url = st.session_state.get("url", "")
486
- if current_url != st.session_state.get("last_url_value"):
487
- st.session_state.update({"videos": "", "uploaded_file": None, "processed_file": None, "last_loaded_path": "", "analysis_out": "", "last_error": "", "file_hash": None})
488
- st.session_state["last_url_value"] = current_url
489
-
490
- st.sidebar.header("Video Input")
491
- st.sidebar.text_input("Video URL", key="url", placeholder="https://")
492
-
493
- settings_exp = st.sidebar.expander("Settings", expanded=False)
494
- settings_exp.text_input("Gemini Model (short name)", "gemini-2.5-flash-lite", key="model_input")
495
- settings_exp.text_input("Google API Key", key="api_key", value=os.getenv("GOOGLE_API_KEY", ""), type="password")
496
- default_prompt = (
497
- "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."
498
- )
499
- settings_exp.text_area("Enter analysis", value=default_prompt, height=140, key="analysis_prompt")
500
- settings_exp.text_input("Video Password (if needed)", key="video-password", placeholder="password", type="password")
501
- settings_exp.checkbox("Fast mode (skip compression, smaller model, fewer tokens)", key="fast_mode")
502
- settings_exp.checkbox("Skip HTTP SSL verify (only if you trust the network)", key="http_skip_ssl_verify")
503
-
504
- key_source = "session" if st.session_state.get("api_key") else ".env" if os.getenv("GOOGLE_API_KEY") else "none"
505
- settings_exp.caption(f"Using API key from: **{key_source}**")
506
-
507
- if not get_effective_api_key():
508
- settings_exp.warning("No Google API key provided; upload/generation disabled.", icon="⚠️")
509
-
510
- safety_settings = [
511
- {"category": "HARM_CATEGORY_HARASSMENT", "threshold": "OFF"},
512
- {"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "OFF"},
513
- {"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "OFF"},
514
- {"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "OFF"},
515
- ]
516
-
517
  col1, col2 = st.columns([1, 3])
518
  with col1:
519
  generate_now = st.button("Generate the story", type="primary", disabled=not bool(get_effective_api_key()))
520
  with col2:
521
  pass
522
 
523
- # Load Video
524
  if st.sidebar.button("Load Video", use_container_width=True):
525
  try:
526
  vpw = st.session_state.get("video-password", "")
527
- url_val = st.session_state.get("url", "").strip()
528
- final_url = url_val
529
- html_text = None
530
- extracted = None
531
- if url_val:
532
- if "t.co/" in url_val or ("twitter.com" in url_val or "x.com" in url_val):
533
- extracted, src_info = extract_direct_twitter_video(url_val)
534
- if extracted:
535
- final_url = extracted
536
- else:
537
- expanded, html_or_err = expand_url(url_val)
538
- if expanded:
539
- final_url = expanded
540
- html_text = html_or_err
541
- else:
542
- expanded, html_or_err = expand_url(url_val)
543
- if expanded:
544
- final_url = expanded
545
- html_text = html_or_err
546
- if html_text and not extracted:
547
- extracted = extract_video_from_html(html_text, base_url=final_url)
548
- target_url_for_ytdlp = extracted or final_url
549
- path = download_video_ytdlp(target_url_for_ytdlp, str(DATA_DIR), vpw)
550
  st.session_state["videos"] = path
551
  st.session_state["last_loaded_path"] = path
552
- st.session_state["file_hash"] = file_sha256(path) if os.path.exists(path) else None
553
- st.session_state["uploaded_file"] = None
554
- st.session_state["processed_file"] = None
 
 
 
555
  except Exception as e:
556
  st.sidebar.error(f"Failed to load video: {e}")
557
 
558
- # Sidebar preview
559
  if st.session_state["videos"]:
560
  try:
561
  st.sidebar.video(st.session_state["videos"], loop=st.session_state.get("loop_video", False))
562
  except Exception:
563
  st.sidebar.write("Couldn't preview video")
 
564
  with st.sidebar.expander("Options", expanded=False):
565
  loop_checkbox = st.checkbox("Enable Loop", value=st.session_state.get("loop_video", False))
566
  st.session_state["loop_video"] = loop_checkbox
 
567
  if st.button("Clear Video(s)"):
568
- for f in glob(str(DATA_DIR / "*")):
569
- try:
570
- os.remove(f)
571
- except Exception:
572
- pass
573
- st.session_state.update({"videos": "", "uploaded_file": None, "processed_file": None, "last_loaded_path": "", "analysis_out": "", "last_error": "", "file_hash": None})
574
  try:
575
  with open(st.session_state["videos"], "rb") as vf:
576
  st.download_button("Download Video", data=vf, file_name=sanitize_filename(st.session_state["videos"]), mime="video/mp4", use_container_width=True)
577
  except Exception:
578
  st.sidebar.error("Failed to prepare download")
 
579
  st.sidebar.write("Title:", Path(st.session_state["videos"]).name)
580
  try:
581
  file_size_mb = os.path.getsize(st.session_state["videos"]) / (1024 * 1024)
@@ -585,7 +279,7 @@ if st.session_state["videos"]:
585
  except Exception:
586
  pass
587
 
588
- # Generation flow
589
  if generate_now and not st.session_state.get("busy"):
590
  if not st.session_state.get("videos"):
591
  st.error("No video loaded. Use 'Load Video' in the sidebar.")
@@ -594,28 +288,49 @@ if generate_now and not st.session_state.get("busy"):
594
  if not key_to_use:
595
  st.error("Google API key not set.")
596
  else:
597
- st.session_state["busy"] = True
598
  try:
599
- maybe_configure_genai(key_to_use)
 
 
 
 
 
 
600
  model_id = (st.session_state.get("model_input") or "gemini-2.5-flash-lite").strip()
 
 
 
 
 
601
  current_path = st.session_state.get("videos")
602
- current_hash = file_sha256(current_path) if current_path and os.path.exists(current_path) else None
 
 
 
603
 
604
  reupload_needed = True
605
- processed = st.session_state.get("processed_file")
606
  if processed and st.session_state.get("last_loaded_path") == current_path and st.session_state.get("file_hash") == current_hash:
607
  reupload_needed = False
608
 
609
- upload_path = current_path
610
  if reupload_needed:
 
 
 
611
  fast_mode = st.session_state.get("fast_mode", False)
 
612
  try:
613
- file_size_mb = os.path.getsize(current_path) / (1024 * 1024)
614
  except Exception:
615
  file_size_mb = 0
 
616
  if not fast_mode and file_size_mb > 50:
617
- compressed_path = str(Path(current_path).with_name(Path(current_path).stem + "_compressed.mp4"))
618
- upload_path = compress_video(current_path, compressed_path, crf=28, preset="fast")
 
 
 
 
 
619
  with st.spinner("Uploading video..."):
620
  uploaded = upload_video_sdk(upload_path)
621
  processed = wait_for_processed(uploaded, timeout=180)
@@ -624,91 +339,176 @@ if generate_now and not st.session_state.get("busy"):
624
  st.session_state["last_loaded_path"] = current_path
625
  st.session_state["file_hash"] = current_hash
626
 
627
- prompt_text = (st.session_state.get("analysis_prompt", "") or default_prompt).strip()
628
- system_msg = {"role": "system", "content": prompt_text}
629
- user_msg = {"role": "user", "content": "Please summarize the attached video."}
630
-
631
- fname = file_name_or_id(processed)
632
-
633
- response = None
634
- diagnostics = {"attempts": []}
635
 
636
- # 1) genai.responses.generate (supports files)
637
- if response is None and HAS_GENAI and genai is not None and hasattr(genai, "responses") and hasattr(genai.responses, "generate"):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
638
  try:
639
- diagnostics["attempts"].append("responses.generate")
640
  response = genai.responses.generate(
641
- model=model_id,
642
  messages=[system_msg, user_msg],
643
- files=[{"name": fname}] if fname else None,
 
 
 
 
 
 
 
644
  safety_settings=safety_settings,
645
- max_output_tokens=(256 if st.session_state.get("fast_mode") else 1024),
646
  )
647
- except Exception as e:
648
- diagnostics["responses.generate_error"] = str(e)
649
- response = None
650
 
651
- # 2) GenerativeModel flexible call
652
- if response is None and HAS_GENAI and genai is not None and hasattr(genai, "GenerativeModel"):
653
- try:
654
- diagnostics["attempts"].append("GenerativeModel")
655
- response = generative_model_call_flexible(model_id, [system_msg, user_msg], files=[{"name": fname}] if fname else None, max_output_tokens=(256 if st.session_state.get("fast_mode") else 1024))
656
- except Exception as e:
657
- diagnostics["GenerativeModel_error"] = str(e)
658
- response = None
659
-
660
- # 3) top-level legacy helpers
661
- if response is None and HAS_GENAI and genai is not None:
662
- try:
663
- if hasattr(genai, "generate"):
664
- diagnostics["attempts"].append("top.generate")
665
- # don't assume exact param shapes; try best-effort
666
- try:
667
- response = genai.generate(model=model_id, input=[{"text": prompt_text, "files": [{"name": fname}]}], max_output_tokens=(256 if st.session_state.get("fast_mode") else 1024))
668
- except TypeError:
669
- response = genai.generate(model=model_id, input=prompt_text)
670
- elif hasattr(genai, "create"):
671
- diagnostics["attempts"].append("top.create")
672
- try:
673
- response = genai.create(model=model_id, input=[{"text": prompt_text, "files": [{"name": fname}]}], max_output_tokens=(256 if st.session_state.get("fast_mode") else 1024))
674
- except TypeError:
675
- response = genai.create(model=model_id, input=prompt_text)
676
- except Exception as e:
677
- diagnostics["top_level_error"] = str(e)
678
- response = None
679
-
680
- # 4) HTTP fallback
681
- if response is None:
682
- try:
683
- diagnostics["attempts"].append("http_fallback")
684
- response = responses_http_call(key_to_use, model_id, [system_msg, user_msg], file_name=fname, max_output_tokens=(256 if st.session_state.get("fast_mode") else 1024), safety_settings=safety_settings)
685
- except Exception as e:
686
- diagnostics["http_fallback_error"] = str(e)
687
- response = None
688
-
689
- if response is None:
690
- st.session_state["last_error"] = f"No supported generation method found. Diagnostics: {diagnostics}"
691
- st.error("Unable to call a supported Responses method in this runtime. See Last Error.")
692
- out = ""
693
- else:
694
- out = normalize_response_to_text(response)
695
- out = remove_prompt_echo(prompt_text, out).strip()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
696
  placeholders = ["enter analysis", "enter your analysis", "enter analysis here", "please enter analysis"]
697
  low = out.strip().lower()
698
  for ph in placeholders:
699
  if low.startswith(ph):
700
  out = out.strip()[len(ph):].lstrip(" \n:-")
701
  break
 
702
 
703
  st.session_state["analysis_out"] = out
704
- st.session_state["last_error"] = "" if out else st.session_state.get("last_error", "")
705
  st.subheader("Analysis Result")
706
  st.markdown(out if out else "No analysis returned.")
707
- st.caption(f"Est. max tokens: {256 if st.session_state.get('fast_mode') else 1024}")
708
-
709
  except Exception as e:
710
- tb = traceback.format_exc()
711
- st.session_state["last_error"] = f"{e}\n\nTraceback:\n{tb}"
712
  st.error("An error occurred while generating the story. You can try Generate again; the uploaded video will be reused.")
713
  finally:
714
  st.session_state["busy"] = False
@@ -721,4 +521,4 @@ if st.session_state.get("analysis_out"):
721
 
722
  if st.session_state.get("last_error"):
723
  with st.expander("Last Error", expanded=False):
724
- st.write(st.session_state.get("last_error"))
 
3
  import time
4
  import string
5
  import hashlib
 
 
 
 
6
  from glob import glob
7
  from pathlib import Path
8
  from difflib import SequenceMatcher
 
 
 
 
9
 
10
  import yt_dlp
11
  import ffmpeg
 
14
 
15
  load_dotenv()
16
 
 
 
 
 
 
17
  try:
18
+ from phi.agent import Agent
19
+ from phi.model.google import Gemini
20
+ from phi.tools.duckduckgo import DuckDuckGo
21
+ HAS_PHI = True
22
+ except Exception:
23
+ Agent = Gemini = DuckDuckGo = None
24
+ HAS_PHI = False
25
+
26
+ try:
27
+ import google.generativeai as genai
28
+ from google.generativeai import upload_file, get_file # type: ignore
29
  HAS_GENAI = True
30
  except Exception:
31
  genai = None
32
+ upload_file = get_file = None
 
33
  HAS_GENAI = False
34
 
35
  st.set_page_config(page_title="Generate the story of videos", layout="wide")
 
49
  st.session_state.setdefault("fast_mode", False)
50
  st.session_state.setdefault("api_key", os.getenv("GOOGLE_API_KEY", ""))
51
  st.session_state.setdefault("last_model", "")
52
+ st.session_state.setdefault("upload_progress", {"uploaded": 0, "total": 0})
53
  st.session_state.setdefault("last_url_value", "")
 
 
 
 
54
 
55
+ def sanitize_filename(path_str: str):
56
+ name = Path(path_str).name
57
+ return name.lower().translate(str.maketrans("", "", string.punctuation)).replace(" ", "_")
58
 
59
  def file_sha256(path: str, block_size: int = 65536) -> str:
60
  h = hashlib.sha256()
 
74
  pass
75
  return target_path
76
 
77
+ def compress_video(input_path: str, target_path: str, crf: int = 28, preset: str = "fast"):
78
  try:
79
  ffmpeg.input(input_path).output(target_path, vcodec="libx264", crf=crf, preset=preset).run(overwrite_output=True, quiet=True)
80
  return target_path
81
  except Exception:
82
  return input_path
83
 
84
+ def download_video_ytdlp(url: str, save_dir: str, video_password: str = None) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  if not url:
86
  raise ValueError("No URL provided")
87
  outtmpl = str(Path(save_dir) / "%(id)s.%(ext)s")
 
100
  raise FileNotFoundError("Downloaded video not found")
101
  return convert_video_to_mp4(matches[0])
102
 
103
+ def file_name_or_id(file_obj):
104
+ if file_obj is None:
105
+ return None
106
+ if isinstance(file_obj, dict):
107
+ return file_obj.get("name") or file_obj.get("id")
108
+ return getattr(file_obj, "name", None) or getattr(file_obj, "id", None) or getattr(file_obj, "fileId", None)
109
+
110
+ def get_effective_api_key():
111
  return st.session_state.get("api_key") or os.getenv("GOOGLE_API_KEY")
112
 
113
+ def configure_genai_if_needed():
114
+ key = get_effective_api_key()
115
+ if not key:
116
  return False
117
  try:
118
  genai.configure(api_key=key)
 
119
  except Exception:
120
+ pass
121
+ return True
122
 
123
+ _agent = None
124
+ def maybe_create_agent(model_id: str):
125
+ global _agent
126
+ key = get_effective_api_key()
127
+ if not (HAS_PHI and HAS_GENAI and key):
128
+ _agent = None
129
+ return None
130
+ if _agent and st.session_state.get("last_model") == model_id:
131
+ return _agent
132
+ try:
133
+ genai.configure(api_key=key)
134
+ _agent = Agent(name="Video AI summarizer", model=Gemini(id=model_id), tools=[DuckDuckGo()], markdown=True)
135
+ st.session_state["last_model"] = model_id
136
+ except Exception:
137
+ _agent = None
138
+ return _agent
139
+
140
+ def clear_all_video_state():
141
+ st.session_state.pop("uploaded_file", None)
142
+ st.session_state.pop("processed_file", None)
143
+ st.session_state["videos"] = ""
144
+ st.session_state["last_loaded_path"] = ""
145
+ st.session_state["analysis_out"] = ""
146
+ st.session_state["last_error"] = ""
147
+ st.session_state["file_hash"] = None
148
+ for f in glob(str(DATA_DIR / "*")):
149
+ try:
150
+ os.remove(f)
151
+ except Exception:
152
+ pass
153
+
154
+ # track url changes
155
+ current_url = st.session_state.get("url", "")
156
+ if current_url != st.session_state.get("last_url_value"):
157
+ clear_all_video_state()
158
+ st.session_state["last_url_value"] = current_url
159
+
160
+ st.sidebar.header("Video Input")
161
+ st.sidebar.text_input("Video URL", key="url", placeholder="https://")
162
+
163
+ settings_exp = st.sidebar.expander("Settings", expanded=False)
164
+ model_input = settings_exp.text_input("Gemini Model (short name)", "gemini-2.5-flash-lite", key="model_input")
165
+ settings_exp.text_input("Google API Key", key="api_key", value=os.getenv("GOOGLE_API_KEY", ""), type="password")
166
+ default_prompt = (
167
+ "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."
168
+ )
169
+ analysis_prompt = settings_exp.text_area("Enter analysis", value=default_prompt, height=140)
170
+ settings_exp.text_input("Video Password (if needed)", key="video-password", placeholder="password", type="password")
171
+ settings_exp.checkbox("Fast mode (skip compression, smaller model, fewer tokens)", key="fast_mode")
172
+
173
+ # Show which key is active
174
+ key_source = "session" if st.session_state.get("api_key") else ".env" if os.getenv("GOOGLE_API_KEY") else "none"
175
+ settings_exp.caption(f"Using API key from: **{key_source}**")
176
+
177
+ if not get_effective_api_key():
178
+ settings_exp.warning("No Google API key provided; upload/generation disabled.", icon="⚠️")
179
+
180
+ safety_settings = [
181
+ {"category": "HARM_CATEGORY_HARASSMENT", "threshold": "OFF"},
182
+ {"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "OFF"},
183
+ {"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "OFF"},
184
+ {"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "OFF"},
185
+ ]
186
+
187
+ def upload_video_sdk(filepath: str):
188
  key = get_effective_api_key()
189
  if not key:
190
  raise RuntimeError("No API key provided")
191
  if not HAS_GENAI or upload_file is None:
192
+ raise RuntimeError("google.generativeai SDK not available; cannot upload")
193
  genai.configure(api_key=key)
194
  return upload_file(filepath)
195
 
196
+ def wait_for_processed(file_obj, timeout=180):
197
  if not HAS_GENAI or get_file is None:
198
  return file_obj
199
  start = time.time()
 
202
  return file_obj
203
  backoff = 1.0
204
  while True:
205
+ obj = get_file(name)
 
 
 
206
  state = getattr(obj, "state", None)
207
  if not state or getattr(state, "name", None) != "PROCESSING":
208
  return obj
 
211
  time.sleep(backoff)
212
  backoff = min(backoff * 2, 8.0)
213
 
214
+ def remove_prompt_echo(prompt: str, text: str, check_len: int = 600, ratio_threshold: float = 0.68):
 
 
 
 
 
 
 
 
 
 
 
 
215
  if not prompt or not text:
216
  return text
217
  a = " ".join(prompt.strip().lower().split())
218
  b_full = text.strip()
219
  b = " ".join(b_full[:check_len].lower().split())
220
+ ratio = SequenceMatcher(None, a, b).ratio()
 
 
 
221
  if ratio >= ratio_threshold:
222
  cut = min(len(b_full), max(int(len(prompt) * 0.9), len(a)))
223
  new_text = b_full[cut:].lstrip(" \n:-")
 
230
  return b_full[len(ph):].lstrip(" \n:-")
231
  return text
232
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
233
  col1, col2 = st.columns([1, 3])
234
  with col1:
235
  generate_now = st.button("Generate the story", type="primary", disabled=not bool(get_effective_api_key()))
236
  with col2:
237
  pass
238
 
 
239
  if st.sidebar.button("Load Video", use_container_width=True):
240
  try:
241
  vpw = st.session_state.get("video-password", "")
242
+ path = download_video_ytdlp(st.session_state.get("url", ""), str(DATA_DIR), vpw)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
243
  st.session_state["videos"] = path
244
  st.session_state["last_loaded_path"] = path
245
+ st.session_state.pop("uploaded_file", None)
246
+ st.session_state.pop("processed_file", None)
247
+ try:
248
+ st.session_state["file_hash"] = file_sha256(path)
249
+ except Exception:
250
+ st.session_state["file_hash"] = None
251
  except Exception as e:
252
  st.sidebar.error(f"Failed to load video: {e}")
253
 
 
254
  if st.session_state["videos"]:
255
  try:
256
  st.sidebar.video(st.session_state["videos"], loop=st.session_state.get("loop_video", False))
257
  except Exception:
258
  st.sidebar.write("Couldn't preview video")
259
+
260
  with st.sidebar.expander("Options", expanded=False):
261
  loop_checkbox = st.checkbox("Enable Loop", value=st.session_state.get("loop_video", False))
262
  st.session_state["loop_video"] = loop_checkbox
263
+
264
  if st.button("Clear Video(s)"):
265
+ clear_all_video_state()
266
+
 
 
 
 
267
  try:
268
  with open(st.session_state["videos"], "rb") as vf:
269
  st.download_button("Download Video", data=vf, file_name=sanitize_filename(st.session_state["videos"]), mime="video/mp4", use_container_width=True)
270
  except Exception:
271
  st.sidebar.error("Failed to prepare download")
272
+
273
  st.sidebar.write("Title:", Path(st.session_state["videos"]).name)
274
  try:
275
  file_size_mb = os.path.getsize(st.session_state["videos"]) / (1024 * 1024)
 
279
  except Exception:
280
  pass
281
 
282
+ # --- Generation flow ---
283
  if generate_now and not st.session_state.get("busy"):
284
  if not st.session_state.get("videos"):
285
  st.error("No video loaded. Use 'Load Video' in the sidebar.")
 
288
  if not key_to_use:
289
  st.error("Google API key not set.")
290
  else:
 
291
  try:
292
+ st.session_state["busy"] = True
293
+ try:
294
+ if HAS_GENAI and genai is not None:
295
+ genai.configure(api_key=key_to_use)
296
+ except Exception:
297
+ pass
298
+
299
  model_id = (st.session_state.get("model_input") or "gemini-2.5-flash-lite").strip()
300
+ if st.session_state.get("last_model") != model_id:
301
+ st.session_state["last_model"] = ""
302
+ maybe_create_agent(model_id)
303
+
304
+ processed = st.session_state.get("processed_file")
305
  current_path = st.session_state.get("videos")
306
+ try:
307
+ current_hash = file_sha256(current_path) if current_path and os.path.exists(current_path) else None
308
+ except Exception:
309
+ current_hash = None
310
 
311
  reupload_needed = True
 
312
  if processed and st.session_state.get("last_loaded_path") == current_path and st.session_state.get("file_hash") == current_hash:
313
  reupload_needed = False
314
 
 
315
  if reupload_needed:
316
+ if not HAS_GENAI:
317
+ raise RuntimeError("google.generativeai SDK not available; install it.")
318
+ local_path = current_path
319
  fast_mode = st.session_state.get("fast_mode", False)
320
+ upload_path = local_path
321
  try:
322
+ file_size_mb = os.path.getsize(local_path) / (1024 * 1024)
323
  except Exception:
324
  file_size_mb = 0
325
+
326
  if not fast_mode and file_size_mb > 50:
327
+ compressed_path = str(Path(local_path).with_name(Path(local_path).stem + "_compressed.mp4"))
328
+ try:
329
+ preset = "veryfast" if fast_mode else "fast"
330
+ upload_path = compress_video(local_path, compressed_path, crf=28, preset=preset)
331
+ except Exception:
332
+ upload_path = local_path
333
+
334
  with st.spinner("Uploading video..."):
335
  uploaded = upload_video_sdk(upload_path)
336
  processed = wait_for_processed(uploaded, timeout=180)
 
339
  st.session_state["last_loaded_path"] = current_path
340
  st.session_state["file_hash"] = current_hash
341
 
342
+ prompt_text = (analysis_prompt.strip() or default_prompt).strip()
 
 
 
 
 
 
 
343
 
344
+ out = ""
345
+ if st.session_state.get("fast_mode"):
346
+ model_used = model_id if model_id else "gemini-2.5-flash-lite"
347
+ max_tokens = 512
348
+ else:
349
+ model_used = model_id
350
+ max_tokens = 1024
351
+
352
+ est_tokens = max_tokens
353
+ est_cost_caption = f"Est. max tokens: {est_tokens}"
354
+
355
+ agent = maybe_create_agent(model_used)
356
+ if agent:
357
+ with st.spinner("Generating description via Agent..."):
358
+ if not processed:
359
+ raise RuntimeError("Processed file missing for agent generation")
360
+ response = agent.run(prompt_text, videos=[processed], safety_settings=safety_settings)
361
+ out = getattr(response, "content", None) or getattr(response, "outputText", None) or str(response)
362
+ else:
363
+ if not HAS_GENAI or genai is None:
364
+ raise RuntimeError("Responses API not available; install google.generativeai SDK.")
365
+ genai.configure(api_key=key_to_use)
366
+ fname = file_name_or_id(processed)
367
+ if not fname:
368
+ raise RuntimeError("Uploaded file missing name/id")
369
+ system_msg = {"role": "system", "content": prompt_text}
370
+ user_msg = {"role": "user", "content": "Please summarize the attached video."}
371
+
372
+ # Try the modern and legacy signatures; fail clearly if both fail
373
  try:
 
374
  response = genai.responses.generate(
375
+ model=model_used,
376
  messages=[system_msg, user_msg],
377
+ files=[{"name": fname}],
378
+ safety_settings=safety_settings,
379
+ max_output_tokens=max_tokens,
380
+ )
381
+ except TypeError:
382
+ response = genai.responses.generate(
383
+ model=model_used,
384
+ input=[{"text": prompt_text, "files": [{"name": fname}]}],
385
  safety_settings=safety_settings,
386
+ max_output_tokens=max_tokens,
387
  )
 
 
 
388
 
389
+ # Normalize response into iterable items safely
390
+ outputs = []
391
+ if response is None:
392
+ outputs = []
393
+ else:
394
+ # response might be object or dict; try known attributes/keys
395
+ if isinstance(response, dict):
396
+ # common dict keys
397
+ if isinstance(response.get("output"), list):
398
+ outputs = response.get("output") or []
399
+ elif isinstance(response.get("candidates"), list):
400
+ outputs = response.get("candidates") or []
401
+ elif isinstance(response.get("items"), list):
402
+ outputs = response.get("items") or []
403
+ elif isinstance(response.get("responses"), list):
404
+ outputs = response.get("responses") or []
405
+ else:
406
+ # fallback: try to find list-valued entries
407
+ for v in response.values():
408
+ if isinstance(v, list):
409
+ outputs = v
410
+ break
411
+ else:
412
+ # try attribute access
413
+ attr_candidates = []
414
+ for attr in ("output", "candidates", "items", "responses"):
415
+ val = getattr(response, attr, None)
416
+ if isinstance(val, list):
417
+ attr_candidates = val
418
+ break
419
+ outputs = attr_candidates or []
420
+
421
+ # Ensure we have a list
422
+ if not isinstance(outputs, list):
423
+ outputs = list(outputs) if outputs else []
424
+
425
+ text_pieces = []
426
+ # Iterate safely through outputs (may be dicts or objects)
427
+ for item in outputs:
428
+ if item is None:
429
+ continue
430
+ # attempt to extract a 'content' bag
431
+ contents = None
432
+ if isinstance(item, dict):
433
+ contents = item.get("content") or item.get("text") or item.get("message") or item.get("output")
434
+ else:
435
+ contents = getattr(item, "content", None) or getattr(item, "text", None) or getattr(item, "message", None) or getattr(item, "output", None)
436
+
437
+ # If contents is a single string, take it
438
+ if isinstance(contents, str):
439
+ if contents.strip():
440
+ text_pieces.append(contents.strip())
441
+ continue
442
+
443
+ # If contents is list-like, iterate
444
+ if isinstance(contents, (list, tuple)):
445
+ for c in contents:
446
+ if c is None:
447
+ continue
448
+ if isinstance(c, str):
449
+ if c.strip():
450
+ text_pieces.append(c.strip())
451
+ continue
452
+ c_text = None
453
+ if isinstance(c, dict):
454
+ c_text = c.get("text") or c.get("content") or None
455
+ else:
456
+ c_text = getattr(c, "text", None) or getattr(c, "content", None)
457
+ if c_text:
458
+ text_pieces.append(str(c_text).strip())
459
+ continue
460
+
461
+ # If the item itself contains direct text fields
462
+ direct_txt = None
463
+ if isinstance(item, dict):
464
+ direct_txt = item.get("text") or item.get("output_text") or item.get("message")
465
+ else:
466
+ direct_txt = getattr(item, "text", None) or getattr(item, "output_text", None) or getattr(item, "message", None)
467
+ if direct_txt:
468
+ text_pieces.append(str(direct_txt).strip())
469
+
470
+ # final fallback: top-level text on response
471
+ if not text_pieces:
472
+ top_text = None
473
+ if isinstance(response, dict):
474
+ top_text = response.get("text") or response.get("message") or None
475
+ else:
476
+ top_text = getattr(response, "text", None) or getattr(response, "message", None)
477
+ if top_text:
478
+ text_pieces.append(str(top_text).strip())
479
+
480
+ # dedupe preserving order
481
+ seen = set()
482
+ filtered = []
483
+ for t in text_pieces:
484
+ if not isinstance(t, str):
485
+ continue
486
+ if t and t not in seen:
487
+ filtered.append(t)
488
+ seen.add(t)
489
+ out = "\n\n".join(filtered)
490
+
491
+ # post-process output to remove prompt echo or placeholders
492
+ if out:
493
+ out = remove_prompt_echo(prompt_text, out)
494
+ p = prompt_text
495
+ if p and out.strip().lower().startswith(p.lower()):
496
+ out = out.strip()[len(p):].lstrip(" \n:-")
497
  placeholders = ["enter analysis", "enter your analysis", "enter analysis here", "please enter analysis"]
498
  low = out.strip().lower()
499
  for ph in placeholders:
500
  if low.startswith(ph):
501
  out = out.strip()[len(ph):].lstrip(" \n:-")
502
  break
503
+ out = out.strip()
504
 
505
  st.session_state["analysis_out"] = out
506
+ st.session_state["last_error"] = ""
507
  st.subheader("Analysis Result")
508
  st.markdown(out if out else "No analysis returned.")
509
+ st.caption(est_cost_caption)
 
510
  except Exception as e:
511
+ st.session_state["last_error"] = str(e)
 
512
  st.error("An error occurred while generating the story. You can try Generate again; the uploaded video will be reused.")
513
  finally:
514
  st.session_state["busy"] = False
 
521
 
522
  if st.session_state.get("last_error"):
523
  with st.expander("Last Error", expanded=False):
524
+ st.write(st.session_state.get("last_error