Hug0endob commited on
Commit
1644976
·
verified ·
1 Parent(s): 85c9529

Update streamlit_app.py

Browse files
Files changed (1) hide show
  1. streamlit_app.py +267 -149
streamlit_app.py CHANGED
@@ -55,13 +55,14 @@ DEFAULT_PROMPT = (
55
 
56
  # ---- Session defaults ----
57
  st.session_state.setdefault("url", "")
58
- st.session_state.setdefault("videos", "")
 
 
59
  st.session_state.setdefault("loop_video", False)
60
- st.session_state.setdefault("uploaded_file", None)
61
- st.session_state.setdefault("processed_file", None)
62
  st.session_state.setdefault("busy", False)
63
  st.session_state.setdefault("last_url", "")
64
- st.session_state.setdefault("last_local_path", "")
65
  st.session_state.setdefault("analysis_out", "")
66
  st.session_state.setdefault("last_error", "")
67
  st.session_state.setdefault("file_hash", None)
@@ -74,7 +75,10 @@ st.session_state.setdefault("compress_threshold_mb", 200)
74
  # ---- Helpers ----
75
  def sanitize_filename(path_str: str):
76
  name = Path(path_str).name
77
- return name.lower().translate(str.maketrans("", "", string.punctuation)).replace(" ", "_")
 
 
 
78
 
79
  def file_sha256(path: str, block_size: int = 65536) -> str:
80
  try:
@@ -88,62 +92,115 @@ def file_sha256(path: str, block_size: int = 65536) -> str:
88
 
89
  def convert_video_to_mp4(video_path: str) -> str:
90
  target_path = str(Path(video_path).with_suffix(".mp4"))
91
- if os.path.exists(target_path):
92
- return target_path
93
- try:
94
- ffmpeg.input(video_path).output(target_path).run(overwrite_output=True, quiet=True)
95
- except Exception:
96
- raise
97
  if os.path.exists(target_path) and os.path.getsize(target_path) > 0:
 
 
 
 
 
 
 
 
 
98
  try:
99
- if str(Path(video_path).resolve()) != str(Path(target_path).resolve()):
100
- os.remove(video_path)
101
- except Exception:
102
- pass
103
- return target_path
 
 
 
 
 
 
 
 
 
 
 
 
 
104
 
105
  def compress_video(input_path: str, target_path: str, crf: int = 28, preset: str = "fast"):
106
- try:
107
- ffmpeg.input(input_path).output(
108
- target_path, vcodec="libx264", crf=crf, preset=preset
109
- ).run(overwrite_output=True, quiet=True)
110
- if os.path.exists(target_path) and os.path.getsize(target_path) > 0:
111
- return target_path
112
- return input_path
113
- except Exception:
114
- return input_path
 
 
 
 
 
 
 
 
 
 
 
115
 
116
  def download_video_ytdlp(url: str, save_dir: str, video_password: str = None) -> str:
117
  if not url:
118
  raise ValueError("No URL provided")
119
- outtmpl = str(Path(save_dir) / "%(id)s.%(ext)s")
120
- ydl_opts = {"outtmpl": outtmpl, "format": "best"}
121
- if video_password:
122
- ydl_opts["videopassword"] = video_password
123
- with yt_dlp.YoutubeDL(ydl_opts) as ydl:
124
- info = ydl.extract_info(url, download=True)
125
- candidates = []
126
- if isinstance(info, dict):
127
- entries = info.get("entries")
128
- if entries:
129
- for e in entries:
130
- if isinstance(e, dict) and e.get("id"):
131
- candidates.append(str(Path(save_dir) / f"{e['id']}.mp4"))
132
- else:
133
- vid = info.get("id")
134
- ext = info.get("ext") or "mp4"
135
- if vid:
136
- candidates.append(str(Path(save_dir) / f"{vid}.{ext}"))
137
- if not candidates:
138
- all_files = glob(os.path.join(save_dir, "*"))
139
- if not all_files:
140
- raise FileNotFoundError("Downloaded video not found")
141
- chosen = sorted(all_files, key=os.path.getmtime, reverse=True)[0]
142
- else:
143
- existing = [p for p in candidates if os.path.exists(p)]
144
- chosen = existing[0] if existing else sorted(glob(os.path.join(save_dir, "*")), key=os.path.getmtime, reverse=True)[0]
145
- final = convert_video_to_mp4(chosen)
146
- return final
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
 
148
  def remove_prompt_echo(prompt: str, text: str, check_len: int = 600, ratio_threshold: float = 0.68):
149
  if not prompt or not text:
@@ -157,9 +214,10 @@ def remove_prompt_echo(prompt: str, text: str, check_len: int = 600, ratio_thres
157
  except Exception:
158
  ratio = 0.0
159
  if ratio >= ratio_threshold:
160
- cut = min(len(b_full), max(int(len(prompt) * 0.9), len(a)))
161
- new_text = b_full[cut:].lstrip(" \n:-")
162
- if len(new_text) >= 3:
 
163
  return new_text
164
  placeholders = ["enter analysis", "enter your analysis", "enter analysis here", "please enter analysis"]
165
  low = b_full.strip().lower()
@@ -172,7 +230,7 @@ def compress_video_if_large(local_path: str, threshold_mb: int = 200):
172
  try:
173
  file_size_mb = os.path.getsize(local_path) / (1024 * 1024)
174
  except Exception:
175
- st.session_state["last_error"] = "Failed to stat file before compression"
176
  return local_path, False
177
 
178
  if file_size_mb <= threshold_mb:
@@ -182,17 +240,29 @@ def compress_video_if_large(local_path: str, threshold_mb: int = 200):
182
  compressed_name = f"{p.stem}_compressed.mp4"
183
  compressed_path = str(p.with_name(compressed_name))
184
 
185
- try:
186
- result = compress_video(local_path, compressed_path, crf=28, preset="fast")
187
- if result and os.path.exists(result) and os.path.getsize(result) > 0:
 
 
 
 
 
 
 
188
  return result, True
189
- return local_path, False
190
- except Exception:
191
- st.session_state["last_error"] = "Video compression failed"
192
- return local_path, False
193
 
194
  # ---- Inline-video generation via base64 (bypass upload) ----
195
  def generate_with_inline_video(local_path: str, prompt: str, model_used: str, timeout: int = 300):
 
 
 
 
 
196
  # Read the video bytes
197
  with open(local_path, "rb") as f:
198
  video_bytes = f.read()
@@ -205,45 +275,72 @@ def generate_with_inline_video(local_path: str, prompt: str, model_used: str, ti
205
  }
206
  contents = [prompt, video_part]
207
 
208
- # Use new client API if present
209
  try:
210
- client = genai.Client()
211
- resp = client.models.generate_content(
212
- model=model_used,
213
- contents=contents,
214
- generation_config={"max_output_tokens": 1024}
 
215
  )
216
- except Exception:
217
- # Fallback older style
218
- resp = genai.GenerativeModel(model_used).generate_content(contents)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
219
 
220
- text = getattr(resp, "text", None) or getattr(resp, "output_text", None) or str(resp)
221
- return text
222
 
223
  # ---- Main UI & logic ----
224
 
225
- # Reset when URL changes (compare against last_url only)
226
  current_url = st.session_state.get("url", "")
227
  if current_url != st.session_state.get("last_url"):
228
- if st.session_state.get("last_url"):
229
- # clear old video state
230
- st.session_state.pop("uploaded_file", None)
231
- st.session_state.pop("processed_file", None)
232
- st.session_state["videos"] = ""
233
- st.session_state["last_local_path"] = ""
234
- st.session_state["analysis_out"] = ""
235
- st.session_state["last_error"] = ""
236
- st.session_state["file_hash"] = None
237
- for f in glob(str(DATA_DIR / "*")):
238
- try:
239
- os.remove(f)
240
- except Exception:
241
- pass
242
  st.session_state["last_url"] = current_url
243
 
 
244
  # Sidebar UI
245
  st.sidebar.header("Video Input")
246
- st.sidebar.text_input("Video URL", key="url", placeholder="https://")
247
 
248
  settings_exp = st.sidebar.expander("Settings", expanded=False)
249
  model_choice = settings_exp.selectbox("Select model", options=MODEL_OPTIONS,
@@ -252,10 +349,10 @@ if model_choice == "custom":
252
  model_input = settings_exp.text_input("Custom model id", value=DEFAULT_MODEL, key="model_input")
253
  model_selected = model_input.strip() or DEFAULT_MODEL
254
  else:
255
- st.session_state["model_input"] = model_choice
256
  model_selected = model_choice
257
 
258
- settings_exp.text_input("Google API Key", key="api_key", value=os.getenv("GOOGLE_API_KEY", ""), type="password")
259
  analysis_prompt = settings_exp.text_area("Analysis prompt", value=DEFAULT_PROMPT, height=140)
260
  settings_exp.text_input("Video Password (if needed)", key="video-password", placeholder="password", type="password")
261
 
@@ -283,102 +380,123 @@ if not st.session_state.get("api_key") and not os.getenv("GOOGLE_API_KEY"):
283
  # Buttons & layout
284
  col1, col2 = st.columns([1, 3])
285
  with col1:
286
- generate_now = st.button("Generate the story", type="primary")
 
287
  with col2:
288
- if not st.session_state.get("videos"):
289
  st.info("Load a video first (sidebar) to enable generation.", icon="ℹ️")
 
 
290
  else:
291
- st.write("")
292
 
293
- if st.sidebar.button("Load Video", use_container_width=True):
 
 
294
  try:
 
 
 
 
295
  vpw = st.session_state.get("video-password", "")
296
- path = download_video_ytdlp(st.session_state.get("url", ""), str(DATA_DIR), vpw)
297
- st.session_state["videos"] = path
298
- st.session_state["last_local_path"] = path
299
- st.session_state.pop("uploaded_file", None)
300
- st.session_state.pop("processed_file", None)
301
- st.session_state["file_hash"] = file_sha256(path)
302
- except Exception as e:
303
- st.sidebar.error(f"Failed to load video: {e}")
304
 
305
- if st.session_state["videos"]:
306
- path = st.session_state["videos"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
307
  try:
308
- mp4_path = convert_video_to_mp4(path)
309
- with open(mp4_path, "rb") as vf:
310
  video_bytes = vf.read()
311
- st.sidebar.video(video_bytes, format="video/mp4", start_time=0)
312
  except Exception:
313
- st.sidebar.write("Couldn't preview video")
 
314
 
315
  with st.sidebar.expander("Options", expanded=False):
316
- loop_checkbox = st.checkbox("Enable Loop", value=st.session_state.get("loop_video", False))
317
- st.session_state["loop_video"] = loop_checkbox
318
-
319
- if st.button("Clear Video(s)"):
320
- # Clear video state
321
- st.session_state.pop("uploaded_file", None)
322
- st.session_state.pop("processed_file", None)
323
- st.session_state["videos"] = ""
324
- st.session_state["last_local_path"] = ""
325
- st.session_state["analysis_out"] = ""
326
- st.session_state["last_error"] = ""
327
- st.session_state["file_hash"] = None
328
- for f in glob(str(DATA_DIR / "*")):
329
- try:
330
- os.remove(f)
331
- except Exception:
332
- pass
333
 
334
  try:
335
- with open(st.session_state["videos"], "rb") as vf:
336
- st.download_button("Download Video", data=vf,
337
- file_name=sanitize_filename(st.session_state["videos"]),
338
- mime="video/mp4", use_container_width=True)
 
 
 
339
  except Exception:
340
- st.sidebar.error("Failed to prepare download")
341
 
342
- st.sidebar.write("Title:", Path(st.session_state["videos"]).name)
343
  try:
344
- file_size_mb = os.path.getsize(st.session_state["videos"]) / (1024 * 1024)
345
  st.sidebar.caption(f"File size: {file_size_mb:.1f} MB")
346
- if file_size_mb > st.session_state.get("compress_threshold_mb", 200):
347
  st.sidebar.warning(
348
- f"Large file detected — it may exceed inline size limits (>{st.session_state.get('compress_threshold_mb')} MB).",
349
  icon="⚠️"
350
  )
351
  except Exception:
352
- pass
353
 
354
  # Generation / analysis
355
  if generate_now and not st.session_state.get("busy"):
356
- if not st.session_state.get("videos"):
 
357
  st.error("No video loaded. Use 'Load Video' in the sidebar.")
358
  else:
359
- key = st.session_state.get("api_key") or os.getenv("GOOGLE_API_KEY")
360
- if not key:
361
- st.error("Google API key not set.")
362
  else:
363
  st.session_state["busy"] = True
364
  try:
365
- genai.configure(api_key=key)
366
  model_id = (st.session_state.get("model_input") or model_selected or DEFAULT_MODEL).strip()
367
  prompt_text = (analysis_prompt.strip() or DEFAULT_PROMPT).strip()
368
 
369
- with st.spinner("Generating analysis (inline video)…"):
 
370
  out = generate_with_inline_video(
371
- st.session_state["videos"], prompt_text, model_id,
372
  timeout=st.session_state.get("generation_timeout", 300)
373
  )
 
374
 
375
  out = remove_prompt_echo(prompt_text, out)
376
  st.session_state["analysis_out"] = out
377
- st.session_state["last_error"] = ""
378
- st.subheader("Analysis Result")
379
  st.markdown(out or "No analysis returned.")
 
380
  except Exception as e:
381
- st.session_state["last_error"] = f"Inline generation error: {e}\n{traceback.format_exc()}"
382
- st.error("An error occurred while generating the story using inline video. Check the error log.")
383
  finally:
384
  st.session_state["busy"] = False
 
 
 
 
 
 
 
 
 
 
 
55
 
56
  # ---- Session defaults ----
57
  st.session_state.setdefault("url", "")
58
+ # 'current_video_path' will store the path to the video file currently being displayed/analyzed.
59
+ # This could be a downloaded file, a converted file, or a compressed file.
60
+ st.session_state.setdefault("current_video_path", "")
61
  st.session_state.setdefault("loop_video", False)
62
+ st.session_state.setdefault("uploaded_file", None) # Kept for consistency if file upload is added later
63
+ st.session_state.setdefault("processed_file", None) # Kept for consistency if file upload is added later
64
  st.session_state.setdefault("busy", False)
65
  st.session_state.setdefault("last_url", "")
 
66
  st.session_state.setdefault("analysis_out", "")
67
  st.session_state.setdefault("last_error", "")
68
  st.session_state.setdefault("file_hash", None)
 
75
  # ---- Helpers ----
76
  def sanitize_filename(path_str: str):
77
  name = Path(path_str).name
78
+ # Remove file extension before sanitizing
79
+ name_without_ext = Path(name).stem
80
+ sanitized = name_without_ext.lower().translate(str.maketrans("", "", string.punctuation)).replace(" ", "_")
81
+ return f"{sanitized}.mp4" # Ensure it's an mp4 extension for download consistency
82
 
83
  def file_sha256(path: str, block_size: int = 65536) -> str:
84
  try:
 
92
 
93
  def convert_video_to_mp4(video_path: str) -> str:
94
  target_path = str(Path(video_path).with_suffix(".mp4"))
 
 
 
 
 
 
95
  if os.path.exists(target_path) and os.path.getsize(target_path) > 0:
96
+ if str(Path(video_path).resolve()) != str(Path(target_path).resolve()):
97
+ try:
98
+ os.remove(video_path) # Clean up original if different and conversion was successful
99
+ except Exception:
100
+ pass
101
+ return target_path
102
+
103
+ # Only convert if the target doesn't exist or is empty
104
+ with st.status(f"Converting video to MP4: {Path(video_path).name}...", expanded=True) as status:
105
  try:
106
+ ffmpeg.input(video_path).output(target_path).run(overwrite_output=True, quiet=True)
107
+ if os.path.exists(target_path) and os.path.getsize(target_path) > 0:
108
+ status.update(label=f"Conversion successful: {Path(target_path).name}", state="complete", expanded=False)
109
+ if str(Path(video_path).resolve()) != str(Path(target_path).resolve()):
110
+ try:
111
+ os.remove(video_path)
112
+ except Exception:
113
+ pass
114
+ return target_path
115
+ else:
116
+ status.update(label=f"Conversion failed, target file empty: {Path(target_path).name}", state="error", expanded=True)
117
+ raise RuntimeError("Converted MP4 file is empty.")
118
+ except ffmpeg.Error as e:
119
+ status.update(label=f"FFmpeg conversion failed: {e.stderr.decode()}", state="error", expanded=True)
120
+ raise
121
+ except Exception as e:
122
+ status.update(label=f"Video conversion failed: {e}", state="error", expanded=True)
123
+ raise
124
 
125
  def compress_video(input_path: str, target_path: str, crf: int = 28, preset: str = "fast"):
126
+ with st.status(f"Compressing video: {Path(input_path).name}...", expanded=True) as status:
127
+ try:
128
+ ffmpeg.input(input_path).output(
129
+ target_path, vcodec="libx264", crf=crf, preset=preset,
130
+ movflags="+faststart" # Optimize for web streaming
131
+ ).run(overwrite_output=True, quiet=True)
132
+ if os.path.exists(target_path) and os.path.getsize(target_path) > 0:
133
+ status.update(label=f"Compression successful: {Path(target_path).name}", state="complete", expanded=False)
134
+ return target_path
135
+ else:
136
+ status.update(label=f"Compression failed, target file empty: {Path(target_path).name}", state="error", expanded=True)
137
+ return input_path # Return original if compressed is empty
138
+ except ffmpeg.Error as e:
139
+ status.update(label=f"FFmpeg compression failed: {e.stderr.decode()}", state="error", expanded=True)
140
+ st.session_state["last_error"] = f"FFmpeg compression failed: {e.stderr.decode()}"
141
+ return input_path
142
+ except Exception as e:
143
+ status.update(label=f"Video compression failed: {e}", state="error", expanded=True)
144
+ st.session_state["last_error"] = f"Video compression failed: {e}"
145
+ return input_path
146
 
147
  def download_video_ytdlp(url: str, save_dir: str, video_password: str = None) -> str:
148
  if not url:
149
  raise ValueError("No URL provided")
150
+
151
+ with st.status(f"Downloading video from {url}...", expanded=True) as status:
152
+ try:
153
+ # Use %(title)s for a more descriptive filename, but ensure it's safe
154
+ outtmpl_base = Path(save_dir) / "%(title)s.%(ext)s"
155
+ # yt_dlp handles sanitization for filenames, so directly use %(title)s
156
+ ydl_opts = {
157
+ "outtmpl": str(outtmpl_base),
158
+ "format": "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best", # Prefer mp4 if possible
159
+ "noplaylist": True, # Ensure only single video is downloaded for direct URLs
160
+ "writethumbnail": False,
161
+ "quiet": True,
162
+ "noprogress": True,
163
+ "geo_bypass": True,
164
+ "retries": 5
165
+ }
166
+ if video_password:
167
+ ydl_opts["videopassword"] = video_password
168
+
169
+ with yt_dlp.YoutubeDL(ydl_opts) as ydl:
170
+ info = ydl.extract_info(url, download=True)
171
+
172
+ # Find the actual downloaded file. yt_dlp typically gives the full path in 'filepath' or 'requested_downloads'
173
+ downloaded_filepath = None
174
+ if info:
175
+ if 'filepath' in info:
176
+ downloaded_filepath = info['filepath']
177
+ elif 'requested_downloads' in info and isinstance(info['requested_downloads'], list):
178
+ for dl in info['requested_downloads']:
179
+ if 'filepath' in dl:
180
+ downloaded_filepath = dl['filepath']
181
+ break
182
+ elif 'id' in info and 'ext' in info:
183
+ # Fallback if no specific filepath, based on id and ext
184
+ filename_pattern = Path(save_dir) / f"{info['id']}.*"
185
+ candidates = glob(str(filename_pattern))
186
+ if candidates:
187
+ downloaded_filepath = sorted(candidates, key=os.path.getmtime, reverse=True)[0]
188
+
189
+ if not downloaded_filepath or not os.path.exists(downloaded_filepath):
190
+ # Final fallback: scan directory for recently created files
191
+ all_files = glob(os.path.join(save_dir, "*"))
192
+ if not all_files:
193
+ raise FileNotFoundError("Downloaded video not found in expected location.")
194
+ downloaded_filepath = sorted(all_files, key=os.path.getmtime, reverse=True)[0]
195
+
196
+ status.update(label=f"Download complete: {Path(downloaded_filepath).name}", state="complete", expanded=False)
197
+ return downloaded_filepath
198
+ except yt_dlp.DownloadError as e:
199
+ status.update(label=f"Download failed: {e}", state="error", expanded=True)
200
+ raise ValueError(f"Failed to download video: {e}")
201
+ except Exception as e:
202
+ status.update(label=f"An unexpected error occurred during download: {e}", state="error", expanded=True)
203
+ raise
204
 
205
  def remove_prompt_echo(prompt: str, text: str, check_len: int = 600, ratio_threshold: float = 0.68):
206
  if not prompt or not text:
 
214
  except Exception:
215
  ratio = 0.0
216
  if ratio >= ratio_threshold:
217
+ # Cut based on prompt length, ensuring we don't cut too much
218
+ cut_point = min(len(b_full), len(b_full) - len(b) + len(prompt))
219
+ new_text = b_full[cut_point:].lstrip(" \n:-")
220
+ if len(new_text) >= 3: # Ensure we don't return an empty or almost empty string
221
  return new_text
222
  placeholders = ["enter analysis", "enter your analysis", "enter analysis here", "please enter analysis"]
223
  low = b_full.strip().lower()
 
230
  try:
231
  file_size_mb = os.path.getsize(local_path) / (1024 * 1024)
232
  except Exception:
233
+ st.session_state["last_error"] = "Failed to stat file before compression."
234
  return local_path, False
235
 
236
  if file_size_mb <= threshold_mb:
 
240
  compressed_name = f"{p.stem}_compressed.mp4"
241
  compressed_path = str(p.with_name(compressed_name))
242
 
243
+ st.toast(f"Compressing video {p.name} (size: {file_size_mb:.1f}MB)...", icon="🗜️")
244
+ result = compress_video(local_path, compressed_path, crf=28, preset="fast")
245
+
246
+ if result and os.path.exists(result) and os.path.getsize(result) > 0:
247
+ if result != local_path: # Compression was successful and produced a new file
248
+ try:
249
+ os.remove(local_path) # Remove original uncompressed file
250
+ except Exception as e:
251
+ st.session_state["last_error"] = f"Failed to remove original video after compression: {e}"
252
+ st.toast(f"Video compressed to {os.path.getsize(result) / (1024 * 1024):.1f}MB.", icon="✅")
253
  return result, True
254
+ else: # Result is the same as input_path, meaning compression failed or didn't reduce size
255
+ st.session_state["last_error"] = "Video compression did not produce a smaller file or failed."
256
+ return local_path, False
257
+ return local_path, False # Fallback
258
 
259
  # ---- Inline-video generation via base64 (bypass upload) ----
260
  def generate_with_inline_video(local_path: str, prompt: str, model_used: str, timeout: int = 300):
261
+ if not HAS_GENAI:
262
+ raise RuntimeError("Google Generative AI SDK not available.")
263
+ if not Path(local_path).exists():
264
+ raise FileNotFoundError(f"Video file not found: {local_path}")
265
+
266
  # Read the video bytes
267
  with open(local_path, "rb") as f:
268
  video_bytes = f.read()
 
275
  }
276
  contents = [prompt, video_part]
277
 
 
278
  try:
279
+ # Use genai.GenerativeModel directly for consistent behavior across SDK versions
280
+ model = genai.GenerativeModel(model_used)
281
+ resp = model.generate_content(
282
+ contents,
283
+ generation_config={"max_output_tokens": 2048}, # Increased max_output_tokens
284
+ request_options={"timeout": timeout},
285
  )
286
+ text = getattr(resp, "text", None) or getattr(resp, "output_text", None) or str(resp)
287
+ return text
288
+ except Exception as e:
289
+ st.session_state["last_error"] = f"Generation failed: {e}\n{traceback.format_exc()}"
290
+ raise
291
+
292
+ def _init_genai():
293
+ """Initializes genai with the API key if not already configured or if key changes."""
294
+ current_key = st.session_state.get("api_key") or os.getenv("GOOGLE_API_KEY")
295
+ if not current_key:
296
+ return False
297
+
298
+ # Check if genai is already configured with this key
299
+ # (This is a heuristic, actual SDK doesn't expose configured key easily)
300
+ if hasattr(st.session_state, '_genai_configured_key') and st.session_state._genai_configured_key == current_key:
301
+ return True
302
+
303
+ try:
304
+ genai.configure(api_key=current_key)
305
+ st.session_state._genai_configured_key = current_key
306
+ return True
307
+ except Exception as e:
308
+ st.session_state["last_error"] = f"Failed to configure Google Generative AI: {e}"
309
+ return False
310
+
311
+ def _clear_video_state():
312
+ """Clears all video-related session state and deletes local video files."""
313
+ st.session_state.pop("url", None)
314
+ st.session_state.pop("current_video_path", None)
315
+ st.session_state.pop("uploaded_file", None)
316
+ st.session_state.pop("processed_file", None)
317
+ st.session_state["analysis_out"] = ""
318
+ st.session_state["last_error"] = ""
319
+ st.session_state["file_hash"] = None
320
+ st.session_state["last_url"] = "" # Clear last_url as well to prevent re-triggering
321
+
322
+ for f in glob(str(DATA_DIR / "*")):
323
+ try:
324
+ os.remove(f)
325
+ except Exception:
326
+ pass
327
+ st.toast("Video cleared and local files removed.", icon="🗑️")
328
 
 
 
329
 
330
  # ---- Main UI & logic ----
331
 
332
+ # Check for URL change and clear state if it's a new URL
333
  current_url = st.session_state.get("url", "")
334
  if current_url != st.session_state.get("last_url"):
335
+ if st.session_state.get("last_url"): # Only clear if a previous URL was set
336
+ _clear_video_state()
337
+ st.session_state["url"] = current_url # Re-set the new URL after clearing
 
 
 
 
 
 
 
 
 
 
 
338
  st.session_state["last_url"] = current_url
339
 
340
+
341
  # Sidebar UI
342
  st.sidebar.header("Video Input")
343
+ st.sidebar.text_input("Video URL", key="url", placeholder="https://", on_change=_clear_video_state if st.session_state.get("url") else None)
344
 
345
  settings_exp = st.sidebar.expander("Settings", expanded=False)
346
  model_choice = settings_exp.selectbox("Select model", options=MODEL_OPTIONS,
 
349
  model_input = settings_exp.text_input("Custom model id", value=DEFAULT_MODEL, key="model_input")
350
  model_selected = model_input.strip() or DEFAULT_MODEL
351
  else:
352
+ st.session_state["model_input"] = model_choice # Update for custom model name
353
  model_selected = model_choice
354
 
355
+ settings_exp.text_input("Google API Key", key="api_key", value=st.session_state.get("api_key"), type="password")
356
  analysis_prompt = settings_exp.text_area("Analysis prompt", value=DEFAULT_PROMPT, height=140)
357
  settings_exp.text_input("Video Password (if needed)", key="video-password", placeholder="password", type="password")
358
 
 
380
  # Buttons & layout
381
  col1, col2 = st.columns([1, 3])
382
  with col1:
383
+ generate_now = st.button("Generate the story", type="primary",
384
+ disabled=st.session_state.get("busy") or not st.session_state.get("current_video_path"))
385
  with col2:
386
+ if not st.session_state.get("current_video_path"):
387
  st.info("Load a video first (sidebar) to enable generation.", icon="ℹ️")
388
+ elif st.session_state.get("busy"):
389
+ st.info("Processing in progress...", icon="⏳")
390
  else:
391
+ st.write("") # Placeholder for alignment
392
 
393
+ if st.sidebar.button("Load Video", use_container_width=True, disabled=st.session_state.get("busy")):
394
+ st.session_state["last_error"] = "" # Clear previous error
395
+ st.session_state["busy"] = True
396
  try:
397
+ url = st.session_state.get("url", "").strip()
398
+ if not url:
399
+ raise ValueError("Please enter a video URL.")
400
+
401
  vpw = st.session_state.get("video-password", "")
 
 
 
 
 
 
 
 
402
 
403
+ downloaded_path = download_video_ytdlp(url, str(DATA_DIR), vpw)
404
+
405
+ # Ensure it's an MP4 and compress if needed
406
+ converted_path = convert_video_to_mp4(downloaded_path)
407
+ final_path, was_compressed = compress_video_if_large(
408
+ converted_path, st.session_state.get("compress_threshold_mb", 200)
409
+ )
410
+
411
+ st.session_state["current_video_path"] = final_path
412
+ st.session_state["file_hash"] = file_sha256(final_path)
413
+ st.toast("Video loaded and ready!", icon="✅")
414
+
415
+ except Exception as e:
416
+ st.session_state["last_error"] = f"Failed to load video: {e}\n{traceback.format_exc()}"
417
+ st.sidebar.error("Failed to load video. Check the error log in the main area.")
418
+ finally:
419
+ st.session_state["busy"] = False
420
+
421
+ # Display video and related info if a video is loaded
422
+ if st.session_state["current_video_path"]:
423
+ path_to_display = st.session_state["current_video_path"]
424
  try:
425
+ with open(path_to_display, "rb") as vf:
 
426
  video_bytes = vf.read()
427
+ st.sidebar.video(video_bytes, format="video/mp4", start_time=0, loop=st.session_state.get("loop_video", False))
428
  except Exception:
429
+ st.sidebar.write("Couldn't preview video.")
430
+ st.session_state["last_error"] = f"Couldn't preview video from {path_to_display}. File might be corrupt or inaccessible."
431
 
432
  with st.sidebar.expander("Options", expanded=False):
433
+ st.session_state["loop_video"] = st.checkbox("Enable Loop", value=st.session_state.get("loop_video", False), key="sidebar_loop_checkbox")
434
+
435
+ if st.button("Clear Video(s)", key="clear_video_button", disabled=st.session_state.get("busy")):
436
+ _clear_video_state()
437
+ st.rerun() # Rerun to clear video display immediately
 
 
 
 
 
 
 
 
 
 
 
 
438
 
439
  try:
440
+ with open(path_to_display, "rb") as vf:
441
+ st.download_button(
442
+ "Download Video", data=vf,
443
+ file_name=sanitize_filename(path_to_display), # Use sanitized name
444
+ mime="video/mp4", use_container_width=True,
445
+ key="download_button"
446
+ )
447
  except Exception:
448
+ st.sidebar.error("Failed to prepare download.")
449
 
450
+ st.sidebar.write("Title:", Path(path_to_display).name)
451
  try:
452
+ file_size_mb = os.path.getsize(path_to_display) / (1024 * 1024)
453
  st.sidebar.caption(f"File size: {file_size_mb:.1f} MB")
454
+ if file_size_mb > st.session_state.get("compress_threshold_mb", 200) * 1.5: # Warn if significantly larger than threshold
455
  st.sidebar.warning(
456
+ f"Large file (>{st.session_state.get('compress_threshold_mb')} MB) might exceed inline limits.",
457
  icon="⚠️"
458
  )
459
  except Exception:
460
+ pass # File might not exist or be accessible for size check
461
 
462
  # Generation / analysis
463
  if generate_now and not st.session_state.get("busy"):
464
+ st.session_state["last_error"] = "" # Clear previous error
465
+ if not st.session_state.get("current_video_path"):
466
  st.error("No video loaded. Use 'Load Video' in the sidebar.")
467
  else:
468
+ if not _init_genai():
469
+ st.error("Google API key not set or failed to configure.")
 
470
  else:
471
  st.session_state["busy"] = True
472
  try:
 
473
  model_id = (st.session_state.get("model_input") or model_selected or DEFAULT_MODEL).strip()
474
  prompt_text = (analysis_prompt.strip() or DEFAULT_PROMPT).strip()
475
 
476
+ st.subheader("Analysis Result")
477
+ with st.status("Generating analysis (inline video)...", expanded=True, state="running") as status:
478
  out = generate_with_inline_video(
479
+ st.session_state["current_video_path"], prompt_text, model_id,
480
  timeout=st.session_state.get("generation_timeout", 300)
481
  )
482
+ status.update(label="Analysis generation complete.", state="complete", expanded=False)
483
 
484
  out = remove_prompt_echo(prompt_text, out)
485
  st.session_state["analysis_out"] = out
 
 
486
  st.markdown(out or "No analysis returned.")
487
+ st.toast("Analysis complete!", icon="✨")
488
  except Exception as e:
489
+ st.session_state["last_error"] = f"An error occurred during analysis: {e}\n{traceback.format_exc()}"
490
+ st.error("An error occurred while generating the story. Please check the error log below.")
491
  finally:
492
  st.session_state["busy"] = False
493
+
494
+ # Display last error if any
495
+ if st.session_state.get("last_error"):
496
+ st.error("An error occurred:")
497
+ st.code(st.session_state["last_error"], language="text")
498
+
499
+ # Display previous analysis if available and no new error
500
+ elif st.session_state.get("analysis_out") and not st.session_state.get("busy"):
501
+ st.subheader("Analysis Result (Previous)")
502
+ st.markdown(st.session_state["analysis_out"])