Hug0endob commited on
Commit
2c71427
·
verified ·
1 Parent(s): 9c7140f

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +310 -231
app.py CHANGED
@@ -13,6 +13,8 @@ import asyncio
13
  import threading
14
  import time
15
  import atexit
 
 
16
 
17
  # --- Configuration and Globals ---
18
  DEFAULT_KEY = os.getenv("MISTRAL_API_KEY", "")
@@ -59,8 +61,16 @@ atexit.register(_cleanup_all_temp_preview_files)
59
  def get_client(key: Optional[str] = None):
60
  api_key = (key or "").strip() or DEFAULT_KEY
61
  if Mistral is None:
62
- class Dummy:
63
- def __init__(self, k): self.api_key = k # Mock client for no mistralai library
 
 
 
 
 
 
 
 
64
  return Dummy(api_key)
65
  return Mistral(api_key=api_key)
66
 
@@ -123,7 +133,7 @@ def fetch_bytes(src: str, stream_threshold: int = STREAM_THRESHOLD, timeout: int
123
  try: os.remove(p)
124
  except Exception: pass
125
  except Exception:
126
- pass
127
  r = safe_get(src, timeout=timeout)
128
  if progress is not None:
129
  progress(0.25, desc="Downloaded remote content")
@@ -142,15 +152,17 @@ def fetch_bytes(src: str, stream_threshold: int = STREAM_THRESHOLD, timeout: int
142
  def convert_to_jpeg_bytes(img_bytes: bytes, base_h: int = 480) -> bytes:
143
  img = Image.open(BytesIO(img_bytes))
144
  try:
145
- if getattr(img, "is_animated", False):
146
  img.seek(0)
147
  except Exception:
148
- pass
 
149
  if img.mode != "RGB":
150
  img = img.convert("RGB")
151
- h = base_h
152
- w = max(1, int(img.width * (h / img.height)))
153
- img = img.resize((w, h), Image.LANCZOS)
 
154
  buf = BytesIO()
155
  img.save(buf, format="JPEG", quality=85)
156
  return buf.getvalue()
@@ -162,11 +174,14 @@ def _ffprobe_streams(path: str) -> Optional[dict]:
162
  """Probes video codecs via ffprobe; returns dict with streams info or None on failure."""
163
  if not FFMPEG_BIN:
164
  return None
165
- ffprobe = FFMPEG_BIN.replace("ffmpeg", "ffprobe") if "ffmpeg" in FFMPEG_BIN else "ffprobe"
166
- if not shutil.which(ffprobe):
167
- ffprobe = "ffprobe" # Try system PATH
 
 
 
168
  cmd = [
169
- ffprobe, "-v", "error", "-print_format", "json", "-show_streams", "-show_format", path
170
  ]
171
  try:
172
  out = subprocess.check_output(cmd, stderr=subprocess.DEVNULL)
@@ -174,18 +189,10 @@ def _ffprobe_streams(path: str) -> Optional[dict]:
174
  except Exception:
175
  return None
176
 
177
- def extract_best_frames_bytes(media_path: str, sample_count: int = 5, timeout_extract: int = 15, progress=None) -> List[bytes]:
178
- """
179
- Extracts frames as bytes for model input. These temp files are immediately deleted.
180
- """
181
- frames: List[bytes] = []
182
- if not FFMPEG_BIN or not os.path.exists(media_path):
183
- return frames
184
- if progress is not None:
185
- progress(0.05, desc="Preparing frame extraction for model...")
186
-
187
- duration = 0.0
188
  info = _ffprobe_streams(media_path)
 
189
  if info and "format" in info and "duration" in info["format"]:
190
  try:
191
  duration = float(info["format"]["duration"])
@@ -194,81 +201,36 @@ def extract_best_frames_bytes(media_path: str, sample_count: int = 5, timeout_ex
194
 
195
  timestamps: List[float] = []
196
  if duration > 0 and sample_count > 0:
197
- # Sample evenly across the video
198
  step = duration / (sample_count + 1)
199
  timestamps = [step * (i + 1) for i in range(sample_count)]
200
  else:
201
- # Fallback to fixed timestamps
202
- timestamps = [0.5, 1.0, 2.0, 3.0, 4.0][:sample_count]
203
-
204
- for i, t in enumerate(timestamps):
205
- fd, tmp = tempfile.mkstemp(suffix=f"_{i}_model.jpg")
206
- os.close(fd)
207
- # This temp file is for immediate read and deletion, not persistent tracking
208
-
209
- cmd = [
210
- FFMPEG_BIN,
211
- "-nostdin",
212
- "-y",
213
- "-ss",
214
- str(t),
215
- "-i",
216
- media_path,
217
- "-frames:v",
218
- "1",
219
- "-q:v",
220
- "2", # High quality JPEG
221
- tmp,
222
- ]
223
- try:
224
- if progress is not None:
225
- progress(0.1 + (i / max(1, sample_count)) * 0.2, desc=f"Extracting frame {i+1}/{sample_count} for model...")
226
- subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=timeout_extract)
227
- if os.path.exists(tmp) and os.path.getsize(tmp) > 0:
228
- with open(tmp, "rb") as f:
229
- frames.append(f.read())
230
- except Exception:
231
- pass
232
- finally:
233
- try: os.remove(tmp)
234
- except Exception: pass
235
- if progress is not None:
236
- progress(0.45, desc=f"Extracted {len(frames)} frames for model")
237
- return frames
238
 
239
- def extract_and_save_frames_for_gallery(media_path: str, sample_count: int = 5, timeout_extract: int = 15, base_h: int = 128, progress=None) -> List[str]:
240
  """
241
- Extracts frames from a video, converts them to small JPEGs, saves them as temp files
242
- (tracked for cleanup), and returns a list of paths to these temporary files for gallery display.
243
  """
244
- frame_paths: List[str] = []
 
 
245
  if not FFMPEG_BIN or not os.path.exists(media_path):
246
- return frame_paths
247
 
248
- duration = 0.0
249
- info = _ffprobe_streams(media_path)
250
- if info and "format" in info and "duration" in info["format"]:
251
- try:
252
- duration = float(info["format"]["duration"])
253
- except ValueError:
254
- pass
255
 
256
- timestamps: List[float] = []
257
- if duration > 0 and sample_count > 0:
258
- step = duration / (sample_count + 1)
259
- timestamps = [step * (i + 1) for i in range(sample_count)]
260
- else:
261
- timestamps = [0.5, 1.0, 2.0, 3.0, 4.0][:sample_count] # Fallback to fixed times
262
 
263
  for i, t in enumerate(timestamps):
264
  if progress is not None:
265
- progress(0.1 + (i / max(1, sample_count)) * 0.2, desc=f"Extracting frame {i+1}/{sample_count} for gallery...")
266
 
267
  # Extract to a temp PNG first for best quality, then process with PIL
268
- fd_raw, tmp_png_path = tempfile.mkstemp(suffix=".png")
269
  os.close(fd_raw)
270
 
271
- # Command to extract frame to PNG
272
  cmd_extract = [
273
  FFMPEG_BIN, "-nostdin", "-y", "-ss", str(t), "-i", media_path,
274
  "-frames:v", "1", "-pix_fmt", "rgb24", tmp_png_path,
@@ -278,90 +240,142 @@ def extract_and_save_frames_for_gallery(media_path: str, sample_count: int = 5,
278
  subprocess.run(cmd_extract, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=timeout_extract)
279
 
280
  if os.path.exists(tmp_png_path) and os.path.getsize(tmp_png_path) > 0:
281
- # Convert extracted PNG to a smaller JPEG and save as new temp file
282
- jpeg_bytes = convert_to_jpeg_bytes(open(tmp_png_path, "rb").read(), base_h=base_h)
283
- temp_jpeg_path = _temp_file(jpeg_bytes, suffix=f"_gallery_{i}.jpg") # _temp_file tracks this for cleanup
284
- frame_paths.append(temp_jpeg_path)
 
 
 
 
 
 
 
285
 
286
  except Exception as e:
287
- print(f"Error processing frame {i+1} for gallery: {e}")
288
  finally:
289
  if os.path.exists(tmp_png_path):
290
  try: os.remove(tmp_png_path)
291
  except Exception: pass
292
 
293
  if progress is not None:
294
- progress(0.45, desc=f"Extracted {len(frame_paths)} frames for gallery")
295
- return frame_paths
296
 
297
  def chat_complete(client, model: str, messages, timeout: int = 120, progress=None) -> str:
298
- try:
299
- if progress is not None:
300
- progress(0.6, desc="Sending request to model...")
301
- if hasattr(client, "chat") and hasattr(client.chat, "complete"):
302
- res = client.chat.complete(model=model, messages=messages, stream=False)
303
- else:
304
- api_key = getattr(client, "api_key", "") or DEFAULT_KEY
305
- url = "https://api.mistral.ai/v1/chat/completions"
306
- headers = ({"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"} if api_key else {"Content-Type": "application/json"})
307
- r = requests.post(url, json={"model": model, "messages": messages}, headers=headers, timeout=timeout)
308
- r.raise_for_status()
309
- res = r.json()
310
- if progress is not None:
311
- progress(0.8, desc="Model responded, parsing...")
312
- choices = getattr(res, "choices", None) or (res.get("choices") if isinstance(res, dict) else [])
313
- if not choices:
314
- return f"Empty response from model: {res}"
315
- first = choices[0]
316
- msg = (first.message if hasattr(first, "message") else (first.get("message") if isinstance(first, dict) else first))
317
- content = (msg.get("content") if isinstance(msg, dict) else getattr(msg, "content", None))
318
- return content.strip() if isinstance(content, str) else str(content)
319
- except requests.exceptions.RequestException as e:
320
- return f"Error: network/API request failed: {e}"
321
- except Exception as e:
322
- return f"Error during model call: {e}"
323
 
324
- def upload_file_to_mistral(client, path: str, filename: str | None = None, purpose: str = "batch", timeout: int = 120, progress=None) -> str:
325
- fname = filename or os.path.basename(path)
326
- try:
327
- if progress is not None:
328
- progress(0.5, desc="Uploading file to model service...")
329
- if hasattr(client, "files") and hasattr(client.files, "upload"):
330
- with open(path, "rb") as fh:
331
- res = client.files.upload(file={"file_name": fname, "content": fh}, purpose=purpose)
332
- fid = getattr(res, "id", None) or (res.get("id") if isinstance(res, dict) else None)
333
- if not fid: # Older API responses might nest id in 'data'
334
- fid = res["data"][0]["id"]
335
  if progress is not None:
336
- progress(0.6, desc="Upload complete")
337
- return fid
338
- except Exception:
339
- pass # Fallback to manual requests if client.files.upload fails
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
340
 
341
- api_key = getattr(client, "api_key", "") or DEFAULT_KEY
342
- url = "https://api.mistral.ai/v1/files"
343
- headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
344
- try:
345
- with open(path, "rb") as fh:
346
- files = {"file": (fname, fh)}
347
- data = {"purpose": purpose}
348
- r = requests.post(url, headers=headers, files=files, data=data, timeout=timeout)
349
- r.raise_for_status()
350
- jr = r.json()
351
  if progress is not None:
352
- progress(0.65, desc="Upload complete (REST)")
353
- return jr.get("id") or jr.get("data", [{}])[0].get("id")
354
- except requests.exceptions.RequestException as e:
355
- raise RuntimeError(f"File upload failed: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
356
 
357
  def determine_media_type(src: str, progress=None) -> Tuple[bool, bool]:
358
  is_image = False
359
  is_video = False
360
  ext = ext_from_src(src)
 
361
  if ext in IMAGE_EXTS:
362
  is_image = True
363
- if ext in VIDEO_EXTS:
364
  is_video = True
 
365
  if is_remote(src):
366
  head = safe_head(src)
367
  if head:
@@ -370,6 +384,7 @@ def determine_media_type(src: str, progress=None) -> Tuple[bool, bool]:
370
  is_image, is_video = True, False
371
  elif ctype.startswith("video/"):
372
  is_video, is_image = True, False
 
373
  if progress is not None:
374
  progress(0.02, desc="Determined media type")
375
  return is_image, is_video
@@ -413,36 +428,35 @@ def analyze_video_cohesive(client, video_path: str, prompt: str, progress=None)
413
  ]
414
  result = chat_complete(client, VIDEO_MODEL, messages, progress=progress)
415
  # If successful upload, still extract frames for gallery display
416
- gallery_frame_paths = extract_and_save_frames_for_gallery(video_path, sample_count=6, base_h=128, progress=progress)
 
 
 
417
  return result, gallery_frame_paths
418
  except Exception as e:
419
  if progress is not None:
420
- progress(0.35, desc="Upload failed, extracting frames as fallback...")
421
-
422
- # Extract frames for model input (bytes)
423
- frames_for_model_bytes = extract_best_frames_bytes(video_path, sample_count=6, progress=progress)
424
 
425
- # Extract and save frames for gallery display (paths)
426
- gallery_frame_paths = extract_and_save_frames_for_gallery(video_path, sample_count=6, base_h=128, progress=progress)
 
 
427
 
428
  if not frames_for_model_bytes:
429
  return f"Error: could not upload video and no frames could be extracted. ({e})", []
430
 
431
  image_entries = []
432
  for i, fb in enumerate(frames_for_model_bytes, start=1):
433
- try:
434
- if progress is not None:
435
- progress(0.4 + (i / len(frames_for_model_bytes)) * 0.2, desc=f"Preparing frame {i}/{len(frames_for_model_bytes)} for model...")
436
- j = convert_to_jpeg_bytes(fb, base_h=720)
437
- image_entries.append(
438
- {
439
- "type": "image_url",
440
- "image_url": b64_bytes(j, mime="image/jpeg"),
441
- "meta": {"frame_index": i},
442
- }
443
- )
444
- except Exception:
445
- continue
446
  content = [{"type": "text", "text": prompt + "\n\nPlease consolidate observations across these frames into a single cohesive narrative."}] + image_entries
447
  messages = [
448
  {"role": "system", "content": SYSTEM_INSTRUCTION},
@@ -462,6 +476,7 @@ def _convert_video_for_preview_if_needed(path: str) -> str:
462
  if not FFMPEG_BIN or not os.path.exists(path):
463
  return path # Cannot convert, return original
464
 
 
465
  if path.lower().endswith((".mp4", ".m4v", ".mov")):
466
  info = _ffprobe_streams(path)
467
  if info:
@@ -472,14 +487,16 @@ def _convert_video_for_preview_if_needed(path: str) -> str:
472
  out_path = _temp_file(b"", suffix=".mp4") # Create an empty temp file and add to cleanup list
473
  cmd = [
474
  FFMPEG_BIN, "-y", "-i", path,
475
- "-c:v", "libx264", "-preset", "veryfast", "-crf", "28",
476
- "-c:a", "aac", "-movflags", "+faststart", out_path,
 
477
  "-map_metadata", "-1" # Remove metadata
478
  ]
479
  try:
480
  subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=60)
481
  return out_path
482
- except Exception:
 
483
  # If conversion fails, remove the created temp file and fall back to the original
484
  if out_path in _temp_preview_files_to_delete:
485
  _temp_preview_files_to_delete.remove(out_path)
@@ -488,14 +505,12 @@ def _convert_video_for_preview_if_needed(path: str) -> str:
488
  return path
489
 
490
  # --- Preview Generation Logic ---
491
- def _get_playable_preview_path_from_raw(src_url: str, raw_bytes: bytes) -> str:
492
  """
493
- Generates a playable preview file from raw bytes.
494
  Creates and tracks a new temporary file.
495
  """
496
- is_img, is_vid = determine_media_type(src_url)
497
-
498
- if is_vid:
499
  temp_raw_video_path = _temp_file(raw_bytes, suffix=ext_from_src(src_url) or ".mp4")
500
  playable_path = _convert_video_for_preview_if_needed(temp_raw_video_path)
501
 
@@ -506,8 +521,22 @@ def _get_playable_preview_path_from_raw(src_url: str, raw_bytes: bytes) -> str:
506
  try: os.remove(temp_raw_video_path)
507
  except Exception: pass
508
  return playable_path
509
- else:
510
  return _temp_file(convert_to_jpeg_bytes(raw_bytes, base_h=1024), suffix=".jpg")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
511
 
512
  def _fetch_with_retries_bytes(src: str, timeout: int = 15, max_retries: int = 3):
513
  attempt = 0
@@ -529,7 +558,7 @@ def _fetch_with_retries_bytes(src: str, timeout: int = 15, max_retries: int = 3)
529
  time.sleep(delay)
530
  delay *= 2
531
  except FileNotFoundError:
532
- raise
533
  except Exception as e:
534
  if attempt >= max_retries:
535
  raise RuntimeError(f"Failed to fetch {src} after {max_retries} attempts due to unexpected error: {e}")
@@ -537,27 +566,28 @@ def _fetch_with_retries_bytes(src: str, timeout: int = 15, max_retries: int = 3)
537
  time.sleep(delay)
538
  delay *= 2
539
 
540
- def _save_local_playable_preview(src: str) -> Optional[str]:
541
  """
542
  Fetches remote content or reads local, then ensures it's in a playable format
543
- for Gradio preview components.
544
  """
545
  if not src:
546
  return None
547
 
548
  if not is_remote(src):
549
  if os.path.exists(src):
550
- is_img, is_vid = determine_media_type(src)
551
- if is_vid:
552
  return _convert_video_for_preview_if_needed(src)
553
- return src # Local image, return as is (assuming Gradio can display it)
 
 
554
  return None
555
 
556
  # Remote source
557
  try:
558
  raw_bytes = _fetch_with_retries_bytes(src, timeout=15, max_retries=3)
559
  if not raw_bytes: return None
560
- return _get_playable_preview_path_from_raw(src, raw_bytes)
561
  except Exception as e:
562
  print(f"Error creating local playable preview from {src}: {e}")
563
  return None
@@ -602,6 +632,7 @@ def create_demo():
602
  Cleans up all tracked temporary files and resets all relevant UI components.
603
  This function is meant to be called at the start of any new processing
604
  or when the user explicitly clicks "Clear".
 
605
  """
606
  for f_path in list(_temp_preview_files_to_delete):
607
  if os.path.exists(f_path):
@@ -611,22 +642,40 @@ def create_demo():
611
  print(f"Error during proactive cleanup of {f_path}: {e}")
612
  _temp_preview_files_to_delete.clear()
613
 
 
614
  return "", \
615
  gr.update(value=None, visible=False), \
616
  gr.update(value=None, visible=False), \
617
  gr.update(value=[], visible=False), \
618
- "idle", "Idle", "", "", "", [], gr.update(value="", visible=True) # url_input, preview_image, preview_video, screenshot_gallery, status_state, progress_md, output_md, main_preview_path_state, screenshot_paths_state, preview_status
 
 
 
 
 
619
 
620
  clear_btn.click(
621
  fn=clear_all_files_and_ui,
622
  inputs=[],
623
- outputs=[url_input, preview_image, preview_video, screenshot_gallery, status_state, progress_md, output_md, main_preview_path_state, screenshot_paths_state, preview_status]
 
 
 
 
 
 
 
 
 
 
 
624
  )
625
 
626
  # Function to handle URL input change and update main preview
627
  def load_main_preview_and_clear_old(url: str):
628
  # First, clear all existing temporary files and reset UI components
629
  # This ensures a clean slate before loading new content
 
630
  _, img_update_clear, video_update_clear, gallery_update_clear, _, _, _, \
631
  main_path_clear, screenshot_paths_clear, status_update_clear = clear_all_files_and_ui() # Call the cleanup function
632
 
@@ -634,28 +683,51 @@ def create_demo():
634
  return img_update_clear, video_update_clear, gallery_update_clear, \
635
  gr.update(value="", visible=True), main_path_clear, screenshot_paths_clear
636
 
 
 
 
637
  try:
638
- local_playable_path = _save_local_playable_preview(url) # This adds to _temp_preview_files_to_delete
 
639
  if not local_playable_path:
640
  return img_update_clear, video_update_clear, gallery_update_clear, \
641
  gr.update(value="Preview load failed: could not fetch resource or make playable.", visible=True), \
642
  "", []
643
 
 
 
644
  is_img_preview = False
645
- try:
646
- Image.open(local_playable_path).verify()
 
 
 
647
  is_img_preview = True
648
- except Exception:
649
- pass
 
 
 
 
 
 
 
 
 
650
 
651
  if is_img_preview:
652
  return gr.update(value=local_playable_path, visible=True), gr.update(value=None, visible=False), \
653
  gr.update(value=[], visible=False), gr.update(value="Image preview loaded.", visible=True), \
654
  local_playable_path, []
655
- else: # Assume video
656
  return gr.update(value=None, visible=False), gr.update(value=local_playable_path, visible=True), \
657
  gr.update(value=[], visible=False), gr.update(value="Video preview loaded.", visible=True), \
658
  local_playable_path, []
 
 
 
 
 
659
 
660
  except Exception as e:
661
  return gr.update(value=None, visible=False), gr.update(value=None, visible=False), \
@@ -674,8 +746,8 @@ def create_demo():
674
  Returns (status, markdown_output, main_preview_path_for_state, screenshot_paths_for_state).
675
  """
676
  temp_media_file_for_analysis = None
677
- generated_main_preview_path = "" # This should reflect the preview that was loaded by load_main_preview_and_clear_old
678
- generated_screenshot_paths: List[str] = [] # List of paths for gallery
679
  result_text = ""
680
 
681
  try:
@@ -684,60 +756,54 @@ def create_demo():
684
 
685
  progress(0.01, desc="Starting media processing")
686
  progress(0.02, desc="Checking URL / content‑type")
687
- is_img, is_vid = determine_media_type(url, progress=progress)
 
688
 
689
  client = get_client(key)
690
 
691
  raw_bytes = None
692
 
693
- # --- Video Processing Path ---
694
- if is_vid:
695
- progress(0.05, desc="Downloading video for analysis")
696
- raw_bytes = fetch_bytes(url, timeout=120, progress=progress)
697
- if not raw_bytes:
698
- return "error", "Failed to download video bytes.", "", []
 
 
 
 
 
 
 
699
 
 
 
700
  temp_media_file_for_analysis = _temp_file(raw_bytes, suffix=ext_from_src(url) or ".mp4")
701
- generated_main_preview_path = _get_playable_preview_path_from_raw(url, raw_bytes) # This generates the main video preview path
 
702
 
703
  progress(0.25, desc="Running full‑video analysis")
704
  result_text, generated_screenshot_paths = analyze_video_cohesive(client, temp_media_file_for_analysis, prompt, progress=progress)
705
 
706
  # --- Image Processing Path ---
707
- elif is_img:
708
- progress(0.05, desc="Downloading image for analysis")
709
- raw_bytes = fetch_bytes(url, progress=progress)
710
-
711
- progress(0.15, desc="Preparing image preview")
712
- generated_main_preview_path = _get_playable_preview_path_from_raw(url, raw_bytes) # This generates the main image preview path
713
 
714
  progress(0.20, desc="Running image analysis")
715
  result_text = analyze_image_structured(client, raw_bytes, prompt, progress=progress)
716
  # No screenshots for images
717
 
718
- # --- Unknown Media Type (Fallback) ---
719
  else:
720
- progress(0.07, desc="Downloading unknown media for type determination")
721
- raw_bytes = fetch_bytes(url, timeout=120, progress=progress)
722
-
723
- is_definitely_img = False
724
- try:
725
- Image.open(BytesIO(raw_bytes)).verify()
726
- is_definitely_img = True
727
- except Exception:
728
- pass
729
-
730
- if is_definitely_img:
731
- progress(0.15, desc="Preparing image preview (fallback)")
732
- generated_main_preview_path = _get_playable_preview_path_from_raw(url, raw_bytes)
733
- progress(0.20, desc="Running image analysis (fallback)")
734
- result_text = analyze_image_structured(client, raw_bytes, prompt, progress=progress)
735
- else: # Treat as video fallback
736
- progress(0.15, desc="Preparing video preview (fallback)")
737
- temp_media_file_for_analysis = _temp_file(raw_bytes, suffix=ext_from_src(url) or ".mp4")
738
- generated_main_preview_path = _get_playable_preview_path_from_raw(url, raw_bytes)
739
- progress(0.25, desc="Running video analysis (fallback)")
740
- result_text, generated_screenshot_paths = analyze_video_cohesive(client, temp_media_file_for_analysis, prompt, progress=progress)
741
 
742
  status = "done" if not (isinstance(result_text, str) and result_text.lower().startswith("error")) else "error"
743
 
@@ -746,6 +812,8 @@ def create_demo():
746
  except Exception as exc:
747
  return "error", f"Unexpected worker error: {exc}", "", []
748
  finally:
 
 
749
  if temp_media_file_for_analysis and os.path.exists(temp_media_file_for_analysis):
750
  if temp_media_file_for_analysis in _temp_preview_files_to_delete:
751
  _temp_preview_files_to_delete.remove(temp_media_file_for_analysis)
@@ -778,15 +846,26 @@ def create_demo():
778
  if current_main_preview_path:
779
  try:
780
  is_img_preview = False
781
- try:
782
- Image.open(current_main_preview_path).verify()
 
 
 
783
  is_img_preview = True
784
- except Exception:
785
- pass # Not an image, treat as video
 
 
 
 
 
 
 
 
786
 
787
  if is_img_preview:
788
  img_update = gr.update(value=current_main_preview_path, visible=True)
789
- else:
790
  video_update = gr.update(value=current_main_preview_path, visible=True)
791
  except Exception as e:
792
  print(f"Error setting main preview from path {current_main_preview_path}: {e}")
 
13
  import threading
14
  import time
15
  import atexit
16
+ from requests.exceptions import RequestException, HTTPError # Import for rate limiting
17
+
18
 
19
  # --- Configuration and Globals ---
20
  DEFAULT_KEY = os.getenv("MISTRAL_API_KEY", "")
 
61
  def get_client(key: Optional[str] = None):
62
  api_key = (key or "").strip() or DEFAULT_KEY
63
  if Mistral is None:
64
+ class Dummy: # Mock client for no mistralai library or no key
65
+ def __init__(self, k): self.api_key = k
66
+ class Chat:
67
+ def complete(self, **kwargs):
68
+ raise RuntimeError("Mistral client library not installed or API key missing.")
69
+ chat = Chat()
70
+ class Files:
71
+ def upload(self, **kwargs):
72
+ raise RuntimeError("Mistral client library not installed or API key missing.")
73
+ files = Files()
74
  return Dummy(api_key)
75
  return Mistral(api_key=api_key)
76
 
 
133
  try: os.remove(p)
134
  except Exception: pass
135
  except Exception:
136
+ pass # Fallback to non-streaming download if streaming fails
137
  r = safe_get(src, timeout=timeout)
138
  if progress is not None:
139
  progress(0.25, desc="Downloaded remote content")
 
152
  def convert_to_jpeg_bytes(img_bytes: bytes, base_h: int = 480) -> bytes:
153
  img = Image.open(BytesIO(img_bytes))
154
  try:
155
+ if getattr(img, "is_animated", False): # Handles animated GIFs by taking first frame
156
  img.seek(0)
157
  except Exception:
158
+ pass # Not an animated image
159
+
160
  if img.mode != "RGB":
161
  img = img.convert("RGB")
162
+
163
+ # Calculate new width preserving aspect ratio
164
+ w = max(1, int(img.width * (base_h / img.height)))
165
+ img = img.resize((w, base_h), Image.LANCZOS)
166
  buf = BytesIO()
167
  img.save(buf, format="JPEG", quality=85)
168
  return buf.getvalue()
 
174
  """Probes video codecs via ffprobe; returns dict with streams info or None on failure."""
175
  if not FFMPEG_BIN:
176
  return None
177
+ ffprobe_path = FFMPEG_BIN.replace("ffmpeg", "ffprobe") if "ffmpeg" in FFMPEG_BIN else "ffprobe"
178
+ if not shutil.which(ffprobe_path):
179
+ ffprobe_path = "ffprobe" # Try system PATH
180
+ if not shutil.which(ffprobe_path):
181
+ return None # ffprobe not found
182
+
183
  cmd = [
184
+ ffprobe_path, "-v", "error", "-print_format", "json", "-show_streams", "-show_format", path
185
  ]
186
  try:
187
  out = subprocess.check_output(cmd, stderr=subprocess.DEVNULL)
 
189
  except Exception:
190
  return None
191
 
192
+ def _get_video_info_and_timestamps(media_path: str, sample_count: int) -> Tuple[Optional[dict], List[float]]:
193
+ """Helper to get video info and calculate timestamps once."""
 
 
 
 
 
 
 
 
 
194
  info = _ffprobe_streams(media_path)
195
+ duration = 0.0
196
  if info and "format" in info and "duration" in info["format"]:
197
  try:
198
  duration = float(info["format"]["duration"])
 
201
 
202
  timestamps: List[float] = []
203
  if duration > 0 and sample_count > 0:
 
204
  step = duration / (sample_count + 1)
205
  timestamps = [step * (i + 1) for i in range(sample_count)]
206
  else:
207
+ timestamps = [0.5, 1.0, 2.0, 3.0, 4.0][:sample_count] # Fallback to fixed times
208
+ return info, timestamps
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
209
 
210
+ def extract_frames_for_model_and_gallery(media_path: str, sample_count: int = 5, timeout_extract: int = 15, gallery_base_h: int = 128, progress=None) -> Tuple[List[bytes], List[str]]:
211
  """
212
+ Extracts frames from a video, processes them for both model input (high-res JPEG bytes)
213
+ and gallery display (smaller JPEG temp file paths), in a single pass.
214
  """
215
+ frames_for_model: List[bytes] = [] # List of JPEG bytes for model input
216
+ frame_paths_for_gallery: List[str] = [] # List of temp JPEG file paths for gallery
217
+
218
  if not FFMPEG_BIN or not os.path.exists(media_path):
219
+ return frames_for_model, frame_paths_for_gallery
220
 
221
+ if progress is not None:
222
+ progress(0.05, desc="Preparing frame extraction...")
 
 
 
 
 
223
 
224
+ _, timestamps = _get_video_info_and_timestamps(media_path, sample_count)
 
 
 
 
 
225
 
226
  for i, t in enumerate(timestamps):
227
  if progress is not None:
228
+ progress(0.1 + (i / max(1, sample_count)) * 0.2, desc=f"Extracting frame {i+1}/{sample_count}...")
229
 
230
  # Extract to a temp PNG first for best quality, then process with PIL
231
+ fd_raw, tmp_png_path = tempfile.mkstemp(suffix=f"_frame_{i}.png")
232
  os.close(fd_raw)
233
 
 
234
  cmd_extract = [
235
  FFMPEG_BIN, "-nostdin", "-y", "-ss", str(t), "-i", media_path,
236
  "-frames:v", "1", "-pix_fmt", "rgb24", tmp_png_path,
 
240
  subprocess.run(cmd_extract, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=timeout_extract)
241
 
242
  if os.path.exists(tmp_png_path) and os.path.getsize(tmp_png_path) > 0:
243
+ with open(tmp_png_path, "rb") as f:
244
+ raw_frame_bytes = f.read()
245
+
246
+ # For model: convert to high-res JPEG bytes (model expects this)
247
+ jpeg_model_bytes = convert_to_jpeg_bytes(raw_frame_bytes, base_h=720) # Keep higher res for model
248
+ frames_for_model.append(jpeg_model_bytes)
249
+
250
+ # For gallery: convert to smaller JPEG bytes and save as new temp file
251
+ jpeg_gallery_bytes = convert_to_jpeg_bytes(raw_frame_bytes, base_h=gallery_base_h)
252
+ temp_jpeg_path = _temp_file(jpeg_gallery_bytes, suffix=f"_gallery_{i}.jpg") # _temp_file tracks this for cleanup
253
+ frame_paths_for_gallery.append(temp_jpeg_path)
254
 
255
  except Exception as e:
256
+ print(f"Error processing frame {i+1} for model/gallery: {e}")
257
  finally:
258
  if os.path.exists(tmp_png_path):
259
  try: os.remove(tmp_png_path)
260
  except Exception: pass
261
 
262
  if progress is not None:
263
+ progress(0.45, desc=f"Extracted {len(frames_for_model)} frames for analysis and gallery")
264
+ return frames_for_model, frame_paths_for_gallery
265
 
266
  def chat_complete(client, model: str, messages, timeout: int = 120, progress=None) -> str:
267
+ max_retries = 5
268
+ initial_delay = 1.0 # seconds
269
+ for attempt in range(max_retries):
270
+ try:
271
+ if progress is not None:
272
+ progress(0.6 + 0.01 * attempt, desc=f"Sending request to model (attempt {attempt+1}/{max_retries})...")
273
+
274
+ # Prefer using the Mistral client if available and functional
275
+ if hasattr(client, "chat") and hasattr(client.chat, "complete"):
276
+ res = client.chat.complete(model=model, messages=messages, stream=False, timeout=timeout)
277
+ else:
278
+ api_key = getattr(client, "api_key", "") or DEFAULT_KEY
279
+ if not api_key:
280
+ return "Error: Mistral API key is not set."
281
+ url = "https://api.mistral.ai/v1/chat/completions"
282
+ headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
283
+ r = requests.post(url, json={"model": model, "messages": messages}, headers=headers, timeout=timeout)
284
+ r.raise_for_status() # Raises HTTPError for bad responses (4xx or 5xx)
285
+ res = r.json()
 
 
 
 
 
 
286
 
 
 
 
 
 
 
 
 
 
 
 
287
  if progress is not None:
288
+ progress(0.8, desc="Model responded, parsing...")
289
+
290
+ choices = getattr(res, "choices", None) or (res.get("choices") if isinstance(res, dict) else [])
291
+ if not choices:
292
+ return f"Empty response from model: {res}"
293
+
294
+ first = choices[0]
295
+ msg = (first.message if hasattr(first, "message") else (first.get("message") if isinstance(first, dict) else first))
296
+ content = (msg.get("content") if isinstance(msg, dict) else getattr(msg, "content", None))
297
+ return content.strip() if isinstance(content, str) else str(content)
298
+
299
+ except HTTPError as e:
300
+ if e.response.status_code == 429 and attempt < max_retries - 1:
301
+ delay = initial_delay * (2 ** attempt)
302
+ print(f"Rate limit exceeded (429). Retrying in {delay:.2f}s...")
303
+ time.sleep(delay)
304
+ else:
305
+ return f"Error: API request failed with status {e.response.status_code}: {e.response.text}"
306
+ except RequestException as e:
307
+ if attempt < max_retries - 1:
308
+ delay = initial_delay * (2 ** attempt)
309
+ print(f"Network/API request failed: {e}. Retrying in {delay:.2f}s...")
310
+ time.sleep(delay)
311
+ else:
312
+ return f"Error: network/API request failed after {max_retries} attempts: {e}"
313
+ except Exception as e:
314
+ return f"Error during model call: {e}"
315
 
316
+ return "Error: Maximum retries reached for API call." # Should ideally not be reached if handled gracefully
317
+
318
+ def upload_file_to_mistral(client, path: str, filename: str | None = None, purpose: str = "batch", timeout: int = 120, progress=None) -> str:
319
+ fname = filename or os.path.basename(path)
320
+ max_retries = 3
321
+ initial_delay = 1.0
322
+ for attempt in range(max_retries):
323
+ try:
 
 
324
  if progress is not None:
325
+ progress(0.5 + 0.01 * attempt, desc=f"Uploading file to model service (attempt {attempt+1}/{max_retries})...")
326
+
327
+ if hasattr(client, "files") and hasattr(client.files, "upload"):
328
+ with open(path, "rb") as fh:
329
+ res = client.files.upload(file={"file_name": fname, "content": fh}, purpose=purpose)
330
+ fid = getattr(res, "id", None) or (res.get("id") if isinstance(res, dict) else None)
331
+ if not fid: # Older API responses might nest id in 'data'
332
+ fid = res["data"][0]["id"]
333
+ if progress is not None:
334
+ progress(0.6, desc="Upload complete")
335
+ return fid
336
+ else: # Fallback to manual requests if client.files.upload fails or Mistral is mocked
337
+ api_key = getattr(client, "api_key", "") or DEFAULT_KEY
338
+ if not api_key:
339
+ raise RuntimeError("Mistral API key is not set for file upload.")
340
+ url = "https://api.mistral.ai/v1/files"
341
+ headers = {"Authorization": f"Bearer {api_key}"}
342
+ with open(path, "rb") as fh:
343
+ files = {"file": (fname, fh)}
344
+ data = {"purpose": purpose}
345
+ r = requests.post(url, headers=headers, files=files, data=data, timeout=timeout)
346
+ r.raise_for_status()
347
+ jr = r.json()
348
+ if progress is not None:
349
+ progress(0.65, desc="Upload complete (REST)")
350
+ return jr.get("id") or jr.get("data", [{}])[0].get("id")
351
+ except HTTPError as e:
352
+ if e.response.status_code == 429 and attempt < max_retries - 1:
353
+ delay = initial_delay * (2 ** attempt)
354
+ print(f"Upload rate limit exceeded (429). Retrying in {delay:.2f}s...")
355
+ time.sleep(delay)
356
+ else:
357
+ raise RuntimeError(f"File upload failed with status {e.response.status_code}: {e.response.text}") from e
358
+ except RequestException as e:
359
+ if attempt < max_retries - 1:
360
+ delay = initial_delay * (2 ** attempt)
361
+ print(f"Upload network/API request failed: {e}. Retrying in {delay:.2f}s...")
362
+ time.sleep(delay)
363
+ else:
364
+ raise RuntimeError(f"File upload failed after {max_retries} attempts: {e}") from e
365
+ except Exception as e:
366
+ raise RuntimeError(f"File upload failed unexpectedly: {e}") from e
367
+ raise RuntimeError("File upload failed: Maximum retries reached.")
368
 
369
  def determine_media_type(src: str, progress=None) -> Tuple[bool, bool]:
370
  is_image = False
371
  is_video = False
372
  ext = ext_from_src(src)
373
+
374
  if ext in IMAGE_EXTS:
375
  is_image = True
376
+ elif ext in VIDEO_EXTS: # Use elif to prioritize video if both extensions are possible (unlikely but safe)
377
  is_video = True
378
+
379
  if is_remote(src):
380
  head = safe_head(src)
381
  if head:
 
384
  is_image, is_video = True, False
385
  elif ctype.startswith("video/"):
386
  is_video, is_image = True, False
387
+
388
  if progress is not None:
389
  progress(0.02, desc="Determined media type")
390
  return is_image, is_video
 
428
  ]
429
  result = chat_complete(client, VIDEO_MODEL, messages, progress=progress)
430
  # If successful upload, still extract frames for gallery display
431
+ # Use the combined function for gallery frames
432
+ _, gallery_frame_paths = extract_frames_for_model_and_gallery(
433
+ video_path, sample_count=6, gallery_base_h=128, progress=progress
434
+ )
435
  return result, gallery_frame_paths
436
  except Exception as e:
437
  if progress is not None:
438
+ progress(0.35, desc=f"Upload failed for video ({e}). Extracting frames as fallback...")
 
 
 
439
 
440
+ # Use the combined extraction function for both model input and gallery display
441
+ frames_for_model_bytes, gallery_frame_paths = extract_frames_for_model_and_gallery(
442
+ video_path, sample_count=6, gallery_base_h=128, progress=progress
443
+ )
444
 
445
  if not frames_for_model_bytes:
446
  return f"Error: could not upload video and no frames could be extracted. ({e})", []
447
 
448
  image_entries = []
449
  for i, fb in enumerate(frames_for_model_bytes, start=1):
450
+ if progress is not None:
451
+ # Update progress description to reflect that frames are already prepared as JPEGs
452
+ progress(0.4 + (i / len(frames_for_model_bytes)) * 0.2, desc=f"Adding frame {i}/{len(frames_for_model_bytes)} to model input...")
453
+ image_entries.append(
454
+ {
455
+ "type": "image_url",
456
+ "image_url": b64_bytes(fb, mime="image/jpeg"), # `fb` is already JPEG bytes
457
+ "meta": {"frame_index": i},
458
+ }
459
+ )
 
 
 
460
  content = [{"type": "text", "text": prompt + "\n\nPlease consolidate observations across these frames into a single cohesive narrative."}] + image_entries
461
  messages = [
462
  {"role": "system", "content": SYSTEM_INSTRUCTION},
 
476
  if not FFMPEG_BIN or not os.path.exists(path):
477
  return path # Cannot convert, return original
478
 
479
+ # Check if the video is already likely browser-compatible (MP4 with H.264/H.265)
480
  if path.lower().endswith((".mp4", ".m4v", ".mov")):
481
  info = _ffprobe_streams(path)
482
  if info:
 
487
  out_path = _temp_file(b"", suffix=".mp4") # Create an empty temp file and add to cleanup list
488
  cmd = [
489
  FFMPEG_BIN, "-y", "-i", path,
490
+ "-c:v", "libx264", "-preset", "veryfast", "-crf", "28", # H.264 video codec
491
+ "-c:a", "aac", "-b:a", "128k", # AAC audio codec
492
+ "-movflags", "+faststart", out_path, # Optimize for web streaming
493
  "-map_metadata", "-1" # Remove metadata
494
  ]
495
  try:
496
  subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=60)
497
  return out_path
498
+ except Exception as e:
499
+ print(f"Error converting video for preview: {e}")
500
  # If conversion fails, remove the created temp file and fall back to the original
501
  if out_path in _temp_preview_files_to_delete:
502
  _temp_preview_files_to_delete.remove(out_path)
 
505
  return path
506
 
507
  # --- Preview Generation Logic ---
508
+ def _get_playable_preview_path_from_raw(src_url: str, raw_bytes: bytes, is_image_hint: bool, is_video_hint: bool) -> str:
509
  """
510
+ Generates a playable preview file from raw bytes, using pre-determined media type hints.
511
  Creates and tracks a new temporary file.
512
  """
513
+ if is_video_hint: # Use the passed hint
 
 
514
  temp_raw_video_path = _temp_file(raw_bytes, suffix=ext_from_src(src_url) or ".mp4")
515
  playable_path = _convert_video_for_preview_if_needed(temp_raw_video_path)
516
 
 
521
  try: os.remove(temp_raw_video_path)
522
  except Exception: pass
523
  return playable_path
524
+ elif is_image_hint: # Use the passed hint
525
  return _temp_file(convert_to_jpeg_bytes(raw_bytes, base_h=1024), suffix=".jpg")
526
+ else:
527
+ # Fallback if hints are unclear, try image first, then video
528
+ try:
529
+ # Attempt to open as image
530
+ img_bytes_io = BytesIO(raw_bytes)
531
+ Image.open(img_bytes_io).verify()
532
+ img_bytes_io.seek(0) # Reset stream position after verify
533
+ return _temp_file(convert_to_jpeg_bytes(img_bytes_io.read(), base_h=1024), suffix=".jpg")
534
+ except UnidentifiedImageError:
535
+ # If not an image, assume it's a video for preview purposes
536
+ print(f"Warning: Unknown media type for {src_url}, falling back to video preview attempt.")
537
+ temp_raw_video_path = _temp_file(raw_bytes, suffix=ext_from_src(src_url) or ".mp4")
538
+ playable_path = _convert_video_for_preview_if_needed(temp_raw_video_path)
539
+ return playable_path # This may return original path if conversion fails
540
 
541
  def _fetch_with_retries_bytes(src: str, timeout: int = 15, max_retries: int = 3):
542
  attempt = 0
 
558
  time.sleep(delay)
559
  delay *= 2
560
  except FileNotFoundError:
561
+ raise FileNotFoundError(f"Local path not found: {src}")
562
  except Exception as e:
563
  if attempt >= max_retries:
564
  raise RuntimeError(f"Failed to fetch {src} after {max_retries} attempts due to unexpected error: {e}")
 
566
  time.sleep(delay)
567
  delay *= 2
568
 
569
+ def _save_local_playable_preview(src: str, is_image_hint: bool, is_video_hint: bool) -> Optional[str]:
570
  """
571
  Fetches remote content or reads local, then ensures it's in a playable format
572
+ for Gradio preview components, using media type hints.
573
  """
574
  if not src:
575
  return None
576
 
577
  if not is_remote(src):
578
  if os.path.exists(src):
579
+ if is_video_hint:
 
580
  return _convert_video_for_preview_if_needed(src)
581
+ # For local images, return the path directly. If it was mis-hinted,
582
+ # _get_playable_preview_path_from_raw's internal checks would handle it.
583
+ return src
584
  return None
585
 
586
  # Remote source
587
  try:
588
  raw_bytes = _fetch_with_retries_bytes(src, timeout=15, max_retries=3)
589
  if not raw_bytes: return None
590
+ return _get_playable_preview_path_from_raw(src, raw_bytes, is_image_hint, is_video_hint)
591
  except Exception as e:
592
  print(f"Error creating local playable preview from {src}: {e}")
593
  return None
 
632
  Cleans up all tracked temporary files and resets all relevant UI components.
633
  This function is meant to be called at the start of any new processing
634
  or when the user explicitly clicks "Clear".
635
+ Returns 10 values for the 10 output components.
636
  """
637
  for f_path in list(_temp_preview_files_to_delete):
638
  if os.path.exists(f_path):
 
642
  print(f"Error during proactive cleanup of {f_path}: {e}")
643
  _temp_preview_files_to_delete.clear()
644
 
645
+ # Return exactly 10 values to match the outputs list
646
  return "", \
647
  gr.update(value=None, visible=False), \
648
  gr.update(value=None, visible=False), \
649
  gr.update(value=[], visible=False), \
650
+ "idle", \
651
+ "Idle", \
652
+ "", \
653
+ "", \
654
+ [], \
655
+ gr.update(value="", visible=True)
656
 
657
  clear_btn.click(
658
  fn=clear_all_files_and_ui,
659
  inputs=[],
660
+ outputs=[
661
+ url_input,
662
+ preview_image,
663
+ preview_video,
664
+ screenshot_gallery,
665
+ status_state,
666
+ progress_md,
667
+ output_md,
668
+ main_preview_path_state,
669
+ screenshot_paths_state,
670
+ preview_status
671
+ ]
672
  )
673
 
674
  # Function to handle URL input change and update main preview
675
  def load_main_preview_and_clear_old(url: str):
676
  # First, clear all existing temporary files and reset UI components
677
  # This ensures a clean slate before loading new content
678
+ # The unpacking now expects 10 values, correctly.
679
  _, img_update_clear, video_update_clear, gallery_update_clear, _, _, _, \
680
  main_path_clear, screenshot_paths_clear, status_update_clear = clear_all_files_and_ui() # Call the cleanup function
681
 
 
683
  return img_update_clear, video_update_clear, gallery_update_clear, \
684
  gr.update(value="", visible=True), main_path_clear, screenshot_paths_clear
685
 
686
+ # Determine media type once for preview loading
687
+ is_img_initial, is_vid_initial = determine_media_type(url)
688
+
689
  try:
690
+ # Pass determined types to _save_local_playable_preview
691
+ local_playable_path = _save_local_playable_preview(url, is_img_initial, is_vid_initial)
692
  if not local_playable_path:
693
  return img_update_clear, video_update_clear, gallery_update_clear, \
694
  gr.update(value="Preview load failed: could not fetch resource or make playable.", visible=True), \
695
  "", []
696
 
697
+ # Re-evaluate media type from the local_playable_path if it's different from the original URL
698
+ # This handles cases where _save_local_playable_preview might have converted a generic file.
699
  is_img_preview = False
700
+ is_vid_preview = False
701
+
702
+ # Check actual file extension
703
+ ext = ext_from_src(local_playable_path)
704
+ if ext in IMAGE_EXTS:
705
  is_img_preview = True
706
+ elif ext in VIDEO_EXTS:
707
+ is_vid_preview = True
708
+
709
+ # Fallback to PIL check if extension is ambiguous or unknown
710
+ if not is_img_preview and not is_vid_preview and os.path.exists(local_playable_path):
711
+ try:
712
+ Image.open(local_playable_path).verify()
713
+ is_img_preview = True
714
+ except Exception:
715
+ # If not an image, assume it might be a video (or non-playable)
716
+ is_vid_preview = True # Flag as video for Gradio component decision
717
 
718
  if is_img_preview:
719
  return gr.update(value=local_playable_path, visible=True), gr.update(value=None, visible=False), \
720
  gr.update(value=[], visible=False), gr.update(value="Image preview loaded.", visible=True), \
721
  local_playable_path, []
722
+ elif is_vid_preview: # Assume video if not image
723
  return gr.update(value=None, visible=False), gr.update(value=local_playable_path, visible=True), \
724
  gr.update(value=[], visible=False), gr.update(value="Video preview loaded.", visible=True), \
725
  local_playable_path, []
726
+ else:
727
+ return gr.update(value=None, visible=False), gr.update(value=None, visible=False), \
728
+ gr.update(value=[], visible=False), gr.update(value="Preview load failed: unknown playable format.", visible=True), \
729
+ "", []
730
+
731
 
732
  except Exception as e:
733
  return gr.update(value=None, visible=False), gr.update(value=None, visible=False), \
 
746
  Returns (status, markdown_output, main_preview_path_for_state, screenshot_paths_for_state).
747
  """
748
  temp_media_file_for_analysis = None
749
+ generated_main_preview_path = ""
750
+ generated_screenshot_paths: List[str] = []
751
  result_text = ""
752
 
753
  try:
 
756
 
757
  progress(0.01, desc="Starting media processing")
758
  progress(0.02, desc="Checking URL / content‑type")
759
+ # Determine type once at the start of worker
760
+ is_img_worker, is_vid_worker = determine_media_type(url, progress=progress)
761
 
762
  client = get_client(key)
763
 
764
  raw_bytes = None
765
 
766
+ # Fetch bytes regardless of type to enable fallback
767
+ progress(0.05, desc="Downloading media for analysis")
768
+ raw_bytes = fetch_bytes(url, timeout=120, progress=progress)
769
+ if not raw_bytes:
770
+ return "error", "Failed to download media bytes.", "", []
771
+
772
+ # Check for image type with PIL if initial determination was uncertain
773
+ if not is_img_worker and not is_vid_worker:
774
+ try:
775
+ Image.open(BytesIO(raw_bytes)).verify()
776
+ is_img_worker = True
777
+ except Exception:
778
+ pass # Not an image, proceed to video or generic fallback
779
 
780
+ # --- Video Processing Path ---
781
+ if is_vid_worker:
782
  temp_media_file_for_analysis = _temp_file(raw_bytes, suffix=ext_from_src(url) or ".mp4")
783
+ # Pass determined types to _get_playable_preview_path_from_raw
784
+ generated_main_preview_path = _get_playable_preview_path_from_raw(url, raw_bytes, is_img_worker, is_vid_worker)
785
 
786
  progress(0.25, desc="Running full‑video analysis")
787
  result_text, generated_screenshot_paths = analyze_video_cohesive(client, temp_media_file_for_analysis, prompt, progress=progress)
788
 
789
  # --- Image Processing Path ---
790
+ elif is_img_worker:
791
+ # Pass determined types to _get_playable_preview_path_from_raw
792
+ generated_main_preview_path = _get_playable_preview_path_from_raw(url, raw_bytes, is_img_worker, is_vid_worker)
 
 
 
793
 
794
  progress(0.20, desc="Running image analysis")
795
  result_text = analyze_image_structured(client, raw_bytes, prompt, progress=progress)
796
  # No screenshots for images
797
 
798
+ # --- Unknown Media Type (Fallback to Video) ---
799
  else:
800
+ # If after all checks, it's still unknown, treat as video by default for analysis.
801
+ print(f"Warning: Could not definitively determine media type for {url}. Attempting video analysis.")
802
+ temp_media_file_for_analysis = _temp_file(raw_bytes, suffix=ext_from_src(url) or ".mp4")
803
+ # Even though type is 'unknown', we'll hint as video for preview creation if it's not an image
804
+ generated_main_preview_path = _get_playable_preview_path_from_raw(url, raw_bytes, False, True)
805
+ progress(0.25, desc="Running video analysis (fallback for unknown type)")
806
+ result_text, generated_screenshot_paths = analyze_video_cohesive(client, temp_media_file_for_analysis, prompt, progress=progress)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
807
 
808
  status = "done" if not (isinstance(result_text, str) and result_text.lower().startswith("error")) else "error"
809
 
 
812
  except Exception as exc:
813
  return "error", f"Unexpected worker error: {exc}", "", []
814
  finally:
815
+ # Cleanup temporary file used for video analysis if it was created.
816
+ # Files for previews and gallery are tracked by _temp_file and cleaned up by atexit.
817
  if temp_media_file_for_analysis and os.path.exists(temp_media_file_for_analysis):
818
  if temp_media_file_for_analysis in _temp_preview_files_to_delete:
819
  _temp_preview_files_to_delete.remove(temp_media_file_for_analysis)
 
846
  if current_main_preview_path:
847
  try:
848
  is_img_preview = False
849
+ is_vid_preview = False
850
+
851
+ # Determine type based on file extension
852
+ ext = ext_from_src(current_main_preview_path)
853
+ if ext in IMAGE_EXTS:
854
  is_img_preview = True
855
+ elif ext in VIDEO_EXTS:
856
+ is_vid_preview = True
857
+
858
+ # Fallback to PIL check if extension is ambiguous or unknown
859
+ if not is_img_preview and not is_vid_preview and os.path.exists(current_main_preview_path):
860
+ try:
861
+ Image.open(current_main_preview_path).verify()
862
+ is_img_preview = True
863
+ except Exception:
864
+ is_vid_preview = True # If not image, assume video for display purposes
865
 
866
  if is_img_preview:
867
  img_update = gr.update(value=current_main_preview_path, visible=True)
868
+ elif is_vid_preview:
869
  video_update = gr.update(value=current_main_preview_path, visible=True)
870
  except Exception as e:
871
  print(f"Error setting main preview from path {current_main_preview_path}: {e}")