Hug0endob commited on
Commit
cf672fe
·
verified ·
1 Parent(s): c04d70f

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +203 -211
app.py CHANGED
@@ -9,13 +9,12 @@ from typing import List, Tuple, Optional
9
  import requests
10
  from PIL import Image, ImageFile, UnidentifiedImageError
11
  import gradio as gr
12
- import asyncio
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", "")
21
  PIXTRAL_MODEL = "pixtral-12b-2409"
@@ -37,10 +36,23 @@ SYSTEM_INSTRUCTION = (
37
  ImageFile.LOAD_TRUNCATED_IMAGES = True
38
  Image.MAX_IMAGE_PIXELS = 10000 * 10000
39
 
 
 
 
40
  try:
41
  from mistralai import Mistral
42
- except Exception:
 
43
  Mistral = None
 
 
 
 
 
 
 
 
 
44
 
45
  # --- Temporary File Cleanup ---
46
  _temp_preview_files_to_delete = []
@@ -84,13 +96,13 @@ def ext_from_src(src: str) -> str:
84
 
85
  def safe_head(url: str, timeout: int = 6):
86
  try:
87
- r = requests.head(url, timeout=timeout, allow_redirects=True)
88
  return None if r.status_code >= 400 else r
89
  except Exception:
90
  return None
91
 
92
  def safe_get(url: str, timeout: int = 15):
93
- r = requests.get(url, timeout=timeout)
94
  r.raise_for_status()
95
  return r
96
 
@@ -111,6 +123,10 @@ def _temp_file(data: bytes, suffix: str) -> str:
111
  return path
112
 
113
  def fetch_bytes(src: str, stream_threshold: int = STREAM_THRESHOLD, timeout: int = 60, progress=None) -> bytes:
 
 
 
 
114
  if progress is not None:
115
  progress(0.05, desc="Checking remote/local source...")
116
  if is_remote(src):
@@ -121,24 +137,26 @@ def fetch_bytes(src: str, stream_threshold: int = STREAM_THRESHOLD, timeout: int
121
  if cl and int(cl) > stream_threshold:
122
  if progress is not None:
123
  progress(0.1, desc="Streaming large remote file...")
124
- with requests.get(src, timeout=timeout, stream=True) as r:
125
- r.raise_for_status()
126
- fd, p = tempfile.mkstemp()
127
- os.close(fd)
128
- try:
 
 
129
  with open(p, "wb") as fh:
130
  for chunk in r.iter_content(8192):
131
  if chunk:
132
  fh.write(chunk)
133
- with open(p, "rb") as fh:
134
- return fh.read()
135
- finally:
136
- # This temp file is only for streaming download, not for final preview
137
- try: os.remove(p)
138
- except Exception: pass
139
- except Exception:
140
  pass # Fallback to non-streaming download if streaming fails
141
- r = safe_get(src, timeout=timeout)
142
  if progress is not None:
143
  progress(0.25, desc="Downloaded remote content")
144
  return r.content
@@ -213,31 +231,42 @@ def _get_video_info_and_timestamps(media_path: str, sample_count: int) -> Tuple[
213
 
214
  timestamps: List[float] = []
215
  if duration > 0 and sample_count > 0:
216
- step = duration / (sample_count + 1)
217
- timestamps = [step * (i + 1) for i in range(sample_count)]
218
- else:
219
- timestamps = [0.5, 1.0, 2.0, 3.0, 4.0][:sample_count] # Fallback to fixed times
 
 
 
 
 
 
220
  return info, timestamps
221
 
222
- 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]]:
223
  """
224
  Extracts frames from a video, processes them for both model input (high-res JPEG bytes)
225
  and gallery display (smaller JPEG temp file paths), in a single pass.
 
226
  """
227
  frames_for_model: List[bytes] = [] # List of JPEG bytes for model input
228
  frame_paths_for_gallery: List[str] = [] # List of temp JPEG file paths for gallery
229
 
230
  if not FFMPEG_BIN or not os.path.exists(media_path):
 
231
  return frames_for_model, frame_paths_for_gallery
232
 
233
  if progress is not None:
234
  progress(0.05, desc="Preparing frame extraction...")
235
 
236
  _, timestamps = _get_video_info_and_timestamps(media_path, sample_count)
 
 
 
237
 
238
  for i, t in enumerate(timestamps):
239
  if progress is not None:
240
- progress(0.1 + (i / max(1, sample_count)) * 0.2, desc=f"Extracting frame {i+1}/{sample_count}...")
241
 
242
  # Extract to a temp PNG first for best quality, then process with PIL
243
  fd_raw, tmp_png_path = tempfile.mkstemp(suffix=f"_frame_{i}.png")
@@ -261,7 +290,7 @@ def extract_frames_for_model_and_gallery(media_path: str, sample_count: int = 5,
261
  frames_for_model.append(jpeg_model_bytes)
262
 
263
  # For gallery: convert to smaller JPEG bytes and save as new temp file
264
- jpeg_gallery_bytes = convert_to_jpeg_bytes(raw_frame_bytes, base_h=gallery_base_h)
265
  if jpeg_gallery_bytes: # Only create temp file if conversion was successful
266
  temp_jpeg_path = _temp_file(jpeg_gallery_bytes, suffix=f"_gallery_{i}.jpg") # _temp_file tracks this for cleanup
267
  if temp_jpeg_path: # Only add to gallery if temp file was successfully created
@@ -292,7 +321,7 @@ def chat_complete(client, model: str, messages, timeout: int = 120, progress=Non
292
 
293
  # Prefer using the Mistral client if available and functional
294
  if hasattr(client, "chat") and hasattr(client.chat, "complete"):
295
- res = client.chat.complete(model=model, messages=messages, stream=False, timeout_ms=timeout * 1000) # FIX: use timeout_ms
296
  else:
297
  api_key = getattr(client, "api_key", "") or DEFAULT_KEY
298
  if not api_key:
@@ -315,14 +344,21 @@ def chat_complete(client, model: str, messages, timeout: int = 120, progress=Non
315
  content = (msg.get("content") if isinstance(msg, dict) else getattr(msg, "content", None))
316
  return content.strip() if isinstance(content, str) else str(content)
317
 
318
- except HTTPError as e:
 
 
 
 
 
 
 
319
  if e.response.status_code == 429 and attempt < max_retries - 1:
320
  delay = initial_delay * (2 ** attempt)
321
  print(f"Rate limit exceeded (429). Retrying in {delay:.2f}s...")
322
  time.sleep(delay)
323
  else:
324
  return f"Error: API request failed with status {e.response.status_code}: {e.response.text}"
325
- except RequestException as e:
326
  if attempt < max_retries - 1:
327
  delay = initial_delay * (2 ** attempt)
328
  print(f"Network/API request failed: {e}. Retrying in {delay:.2f}s...")
@@ -332,7 +368,7 @@ def chat_complete(client, model: str, messages, timeout: int = 120, progress=Non
332
  except Exception as e:
333
  return f"Error during model call: {e}"
334
 
335
- return "Error: Maximum retries reached for API call." # Should ideally not be reached if handled gracefully
336
 
337
  def upload_file_to_mistral(client, path: str, filename: str | None = None, purpose: str = "batch", timeout: int = 120, progress=None) -> str:
338
  fname = filename or os.path.basename(path)
@@ -347,8 +383,10 @@ def upload_file_to_mistral(client, path: str, filename: str | None = None, purpo
347
  with open(path, "rb") as fh:
348
  res = client.files.upload(file={"file_name": fname, "content": fh}, purpose=purpose)
349
  fid = getattr(res, "id", None) or (res.get("id") if isinstance(res, dict) else None)
350
- if not fid: # Older API responses might nest id in 'data'
351
  fid = res["data"][0]["id"]
 
 
352
  if progress is not None:
353
  progress(0.6, desc="Upload complete")
354
  return fid
@@ -367,6 +405,13 @@ def upload_file_to_mistral(client, path: str, filename: str | None = None, purpo
367
  if progress is not None:
368
  progress(0.65, desc="Upload complete (REST)")
369
  return jr.get("id") or jr.get("data", [{}])[0].get("id")
 
 
 
 
 
 
 
370
  except HTTPError as e:
371
  if e.response.status_code == 429 and attempt < max_retries - 1:
372
  delay = initial_delay * (2 ** attempt)
@@ -392,7 +437,7 @@ def determine_media_type(src: str, progress=None) -> Tuple[bool, bool]:
392
 
393
  if ext in IMAGE_EXTS:
394
  is_image = True
395
- elif ext in VIDEO_EXTS: # Use elif to prioritize video if both extensions are possible (unlikely but safe)
396
  is_video = True
397
 
398
  if is_remote(src):
@@ -405,7 +450,7 @@ def determine_media_type(src: str, progress=None) -> Tuple[bool, bool]:
405
  is_video, is_image = True, False
406
 
407
  if progress is not None:
408
- progress(0.02, desc="Determined media type")
409
  return is_image, is_video
410
 
411
  def analyze_image_structured(client, img_bytes: bytes, prompt: str, progress=None) -> str:
@@ -451,7 +496,7 @@ def analyze_video_cohesive(client, video_path: str, prompt: str, progress=None)
451
  # If successful upload, still extract frames for gallery display
452
  # Use the combined function for gallery frames
453
  _, gallery_frame_paths = extract_frames_for_model_and_gallery(
454
- video_path, sample_count=6, gallery_base_h=128, progress=progress
455
  )
456
  return result, gallery_frame_paths
457
  except Exception as e:
@@ -460,7 +505,7 @@ def analyze_video_cohesive(client, video_path: str, prompt: str, progress=None)
460
 
461
  # Use the combined extraction function for both model input and gallery display
462
  frames_for_model_bytes, gallery_frame_paths = extract_frames_for_model_and_gallery(
463
- video_path, sample_count=6, gallery_base_h=128, progress=progress
464
  )
465
 
466
  if not frames_for_model_bytes:
@@ -469,7 +514,6 @@ def analyze_video_cohesive(client, video_path: str, prompt: str, progress=None)
469
  image_entries = []
470
  for i, fb in enumerate(frames_for_model_bytes, start=1):
471
  if progress is not None:
472
- # Update progress description to reflect that frames are already prepared as JPEGs
473
  progress(0.4 + (i / len(frames_for_model_bytes)) * 0.2, desc=f"Adding frame {i}/{len(frames_for_model_bytes)} to model input...")
474
  image_entries.append(
475
  {
@@ -505,7 +549,7 @@ def _convert_video_for_preview_if_needed(path: str) -> str:
505
  if video_streams and any(s.get("codec_name") in ("h264", "h265", "avc1") for s in video_streams):
506
  return path # Already playable
507
 
508
- out_path = _temp_file(b"", suffix=".mp4") # Create an empty temp file and add to cleanup list (will return "" if data is b"")
509
  if not out_path: # If _temp_file returned empty path
510
  print(f"Error: Could not create temporary file for video conversion from {path}.")
511
  return path # Fallback to original path
@@ -540,26 +584,34 @@ def _get_playable_preview_path_from_raw(src_url: str, raw_bytes: bytes, is_image
540
  return ""
541
 
542
  # Determine media type (prioritizing hints, then byte analysis)
 
543
  is_actually_image = False
544
  is_actually_video = False
545
 
546
- if is_image_hint:
 
 
547
  is_actually_image = True
548
- elif is_video_hint:
549
- is_actually_video = True
550
- else: # If hints are not definitive, try to determine from bytes
551
- try:
552
- # Attempt to open as image
553
- # We don't need to load the whole image, just verify format.
554
- Image.open(BytesIO(raw_bytes)).verify()
555
- is_actually_image = True
556
- except UnidentifiedImageError:
557
- # Not an identifiable image by PIL, assume video
558
- is_actually_video = True
559
- except Exception as e:
560
- # Other PIL errors, assume video as a fallback for preview
561
- print(f"Warning: Generic error during image check for {src_url}: {e}. Falling back to video preview attempt.")
562
  is_actually_video = True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
563
 
564
  # --- Attempt Image Preview ---
565
  if is_actually_image:
@@ -597,69 +649,6 @@ def _get_playable_preview_path_from_raw(src_url: str, raw_bytes: bytes, is_image
597
  return ""
598
 
599
 
600
- def _fetch_with_retries_bytes(src: str, timeout: int = 15, max_retries: int = 3) -> bytes:
601
- attempt = 0
602
- delay = 1.0
603
- while True:
604
- attempt += 1
605
- try:
606
- if is_remote(src):
607
- # Using requests.get without stream and directly returning content for simplicity here,
608
- # as stream logic is primarily handled in fetch_bytes for large files.
609
- # For preview, we often need the full file quickly.
610
- r = requests.get(src, timeout=timeout)
611
- r.raise_for_status()
612
- return r.content
613
- else:
614
- with open(src, "rb") as fh:
615
- return fh.read()
616
- except requests.exceptions.RequestException as e:
617
- if attempt >= max_retries:
618
- raise RuntimeError(f"Failed to fetch {src} after {max_retries} attempts: {e}")
619
- print(f"Retrying fetch for {src} ({attempt}/{max_retries}). Delaying {delay:.1f}s...")
620
- time.sleep(delay)
621
- delay *= 2
622
- except FileNotFoundError:
623
- raise FileNotFoundError(f"Local path not found: {src}")
624
- except Exception as e:
625
- if attempt >= max_retries:
626
- raise RuntimeError(f"Failed to fetch {src} after {max_retries} attempts due to unexpected error: {e}")
627
- print(f"Retrying fetch for {src} ({attempt}/{max_retries}). Delaying {delay:.1f}s...")
628
- time.sleep(delay)
629
- delay *= 2
630
-
631
- def _save_local_playable_preview(src: str, is_image_hint: bool, is_video_hint: bool) -> Optional[str]:
632
- """
633
- Fetches remote content or reads local, then ensures it's in a playable format
634
- for Gradio preview components, using media type hints.
635
- Returns None if no playable preview could be generated.
636
- """
637
- if not src:
638
- return None
639
-
640
- if not is_remote(src):
641
- if os.path.exists(src):
642
- if is_video_hint:
643
- return _convert_video_for_preview_if_needed(src)
644
- return src # For local images, return the path directly.
645
- return None
646
-
647
- # Remote source
648
- try:
649
- raw_bytes = _fetch_with_retries_bytes(src, timeout=15, max_retries=3)
650
- if not raw_bytes: # Handle case where fetch_bytes returns empty
651
- print(f"Error: Failed to fetch any bytes for {src}.")
652
- return None
653
-
654
- playable_path = _get_playable_preview_path_from_raw(src, raw_bytes, is_image_hint, is_video_hint)
655
- if not playable_path: # Handle case where _get_playable_preview_path_from_raw couldn't create a path
656
- print(f"Error: No playable preview path generated for {src}.")
657
- return None
658
- return playable_path
659
- except Exception as e:
660
- print(f"Error creating local playable preview from {src}: {e}")
661
- return None
662
-
663
  # --- Gradio Interface Logic ---
664
  css = ".preview_media img, .preview_media video { max-width: 100%; height: auto; border-radius:6px; }"
665
 
@@ -694,13 +683,16 @@ def create_demo():
694
  main_preview_path_state = gr.State("")
695
  # State to hold the list of screenshot paths for the gallery
696
  screenshot_paths_state = gr.State([])
 
 
 
697
 
698
  def clear_all_files_and_ui():
699
  """
700
  Cleans up all tracked temporary files and resets all relevant UI components.
701
  This function is meant to be called at the start of any new processing
702
  or when the user explicitly clicks "Clear".
703
- Returns 10 values for the 10 output components.
704
  """
705
  for f_path in list(_temp_preview_files_to_delete):
706
  if os.path.exists(f_path):
@@ -708,9 +700,9 @@ def create_demo():
708
  os.remove(f_path)
709
  except Exception as e:
710
  print(f"Error during proactive cleanup of {f_path}: {e}")
711
- _temp_preview_files_to_delete.clear()
712
 
713
- # Return exactly 10 values to match the outputs list
714
  return "", \
715
  gr.update(value=None, visible=False), \
716
  gr.update(value=None, visible=False), \
@@ -720,7 +712,9 @@ def create_demo():
720
  "", \
721
  "", \
722
  [], \
723
- gr.update(value="", visible=True)
 
 
724
 
725
  clear_btn.click(
726
  fn=clear_all_files_and_ui,
@@ -735,39 +729,62 @@ def create_demo():
735
  output_md,
736
  main_preview_path_state,
737
  screenshot_paths_state,
738
- preview_status
 
739
  ]
740
  )
741
 
742
  # Function to handle URL input change and update main preview
743
- def load_main_preview_and_clear_old(url: str):
744
  # First, clear all existing temporary files and reset UI components
745
- # This ensures a clean slate before loading new content
746
- # The unpacking now expects 10 values, correctly.
747
  _, img_update_clear, video_update_clear, gallery_update_clear, _, _, _, \
748
- main_path_clear, screenshot_paths_clear, status_update_clear = clear_all_files_and_ui() # Call the cleanup function
749
 
750
  if not url:
751
  return img_update_clear, video_update_clear, gallery_update_clear, \
752
- gr.update(value="", visible=True), main_path_clear, screenshot_paths_clear
753
-
754
- # Determine media type once for preview loading
755
- is_img_initial, is_vid_initial = determine_media_type(url)
756
 
 
 
757
  try:
758
- # Pass determined types to _save_local_playable_preview
759
- local_playable_path = _save_local_playable_preview(url, is_img_initial, is_vid_initial)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
760
  if not local_playable_path:
 
 
 
 
 
761
  return img_update_clear, video_update_clear, gallery_update_clear, \
762
- gr.update(value="Preview load failed: could not fetch resource or make playable.", visible=True), \
763
- "", []
764
 
765
- # Re-evaluate media type from the local_playable_path if it's different from the original URL
766
- # This handles cases where _save_local_playable_preview might have converted a generic file.
767
  is_img_preview = False
768
  is_vid_preview = False
769
 
770
- # Check actual file extension
771
  ext = ext_from_src(local_playable_path)
772
  if ext in IMAGE_EXTS:
773
  is_img_preview = True
@@ -780,132 +797,108 @@ def create_demo():
780
  Image.open(local_playable_path).verify()
781
  is_img_preview = True
782
  except Exception:
783
- # If not an image, assume it might be a video (or non-playable)
784
  is_vid_preview = True # Flag as video for Gradio component decision
785
 
786
  if is_img_preview:
787
  return gr.update(value=local_playable_path, visible=True), gr.update(value=None, visible=False), \
788
  gr.update(value=[], visible=False), gr.update(value="Image preview loaded.", visible=True), \
789
- local_playable_path, []
790
  elif is_vid_preview: # Assume video if not image
791
  return gr.update(value=None, visible=False), gr.update(value=local_playable_path, visible=True), \
792
  gr.update(value=[], visible=False), gr.update(value="Video preview loaded.", visible=True), \
793
- local_playable_path, []
794
  else:
795
- return gr.update(value=None, visible=False), gr.update(value=None, visible=False), \
796
- gr.update(value=[], visible=False), gr.update(value="Preview load failed: unknown playable format.", visible=True), \
797
- "", []
798
 
799
 
800
  except Exception as e:
801
- return gr.update(value=None, visible=False), gr.update(value=None, visible=False), \
802
- gr.update(value=[], visible=False), gr.update(value=f"Preview load failed: {e}", visible=True), \
803
- "", []
 
 
 
 
 
 
804
 
805
  url_input.change(
806
  fn=load_main_preview_and_clear_old,
807
  inputs=[url_input],
808
- outputs=[preview_image, preview_video, screenshot_gallery, preview_status, main_preview_path_state, screenshot_paths_state]
809
  )
810
 
811
- def worker(url: str, prompt: str, key: str, progress=gr.Progress()):
812
  """
813
- Performs the media analysis.
814
  Returns (status, markdown_output, main_preview_path_for_state, screenshot_paths_for_state).
815
  """
816
- temp_media_file_for_analysis = None
817
- generated_main_preview_path = ""
818
- generated_screenshot_paths: List[str] = []
819
  result_text = ""
820
 
821
  try:
822
- if not url:
823
- return "error", "**Error:** No URL provided.", "", []
824
 
825
- progress(0.01, desc="Starting media processing")
826
- progress(0.02, desc="Checking URL / content‑type")
827
- # Determine type once at the start of worker
828
- is_img_worker, is_vid_worker = determine_media_type(url, progress=progress)
829
 
830
- client = get_client(key)
831
-
832
- raw_bytes = None
833
 
834
- # Fetch bytes regardless of type to enable fallback
835
- progress(0.05, desc="Downloading media for analysis")
836
- raw_bytes = fetch_bytes(url, timeout=120, progress=progress)
837
- if not raw_bytes:
838
- return "error", "Failed to download media bytes.", "", []
839
 
840
- # Determine type more definitively using raw bytes
841
- # This step is now more robustly handled inside _get_playable_preview_path_from_raw itself for preview,
842
- # but we need it here for deciding analysis path (image model vs. video model)
843
- is_actually_image_for_analysis = is_img_worker
844
- is_actually_video_for_analysis = is_vid_worker
845
-
846
- if not is_actually_image_for_analysis and not is_actually_video_for_analysis:
847
- try:
848
- # Attempt to open as image
849
- Image.open(BytesIO(raw_bytes)).verify()
850
- is_actually_image_for_analysis = True
851
- except UnidentifiedImageError:
852
- is_actually_video_for_analysis = True # Not an image, assume video
853
- except Exception as e:
854
- print(f"Warning: Could not definitively determine media type for {url} based on bytes: {e}. Attempting video analysis.")
855
- is_actually_video_for_analysis = True # Generic error, fallback to video
856
 
857
- # Get playable preview path (this internally handles image/video decision and conversion for preview)
858
- generated_main_preview_path = _get_playable_preview_path_from_raw(url, raw_bytes, is_actually_image_for_analysis, is_actually_video_for_analysis)
859
- if not generated_main_preview_path:
860
- print(f"Error: Could not generate main preview for analysis: {url}") # Log this specific failure point
861
- return "error", "Could not generate a playable preview for display.", "", []
 
 
 
 
862
 
 
863
 
864
  # --- Video Processing Path ---
865
  if is_actually_video_for_analysis:
866
- temp_media_file_for_analysis = _temp_file(raw_bytes, suffix=ext_from_src(url) or ".mp4")
867
- if not temp_media_file_for_analysis: # Handle if _temp_file failed
868
- return "error", "Failed to create temporary video file for analysis.", "", []
869
  progress(0.25, desc="Running full‑video analysis")
870
- result_text, generated_screenshot_paths = analyze_video_cohesive(client, temp_media_file_for_analysis, prompt, progress=progress)
871
 
872
  # --- Image Processing Path ---
873
  elif is_actually_image_for_analysis:
874
  progress(0.20, desc="Running image analysis")
875
- result_text = analyze_image_structured(client, raw_bytes, prompt, progress=progress)
876
  # No screenshots for images
877
 
878
- # --- Fallback if still no clear analysis path (should be rare with refined logic) ---
879
  else:
880
- # This block should ideally not be reached if previous type determination is robust.
881
- # As a final fallback, treat as video for analysis.
882
- print(f"Warning: No definitive analysis type determined for {url} after all checks. Attempting video analysis fallback.")
883
- temp_media_file_for_analysis = _temp_file(raw_bytes, suffix=ext_from_src(url) or ".mp4")
884
- if not temp_media_file_for_analysis:
885
- return "error", "Failed to create temporary video file for final analysis fallback.", "", []
886
- progress(0.25, desc="Running video analysis (final fallback for unknown type)")
887
- result_text, generated_screenshot_paths = analyze_video_cohesive(client, temp_media_file_for_analysis, prompt, progress=progress)
888
 
889
 
890
  status = "done" if not (isinstance(result_text, str) and result_text.lower().startswith("error")) else "error"
891
 
892
- return status, result_text, generated_main_preview_path, generated_screenshot_paths
 
893
 
894
  except Exception as exc:
895
- return "error", f"Unexpected worker error: {exc}", "", []
896
  finally:
897
- # Cleanup temporary file used for video analysis if it was created.
898
- # Files for previews and gallery are tracked by _temp_file and cleaned up by atexit.
899
- if temp_media_file_for_analysis and os.path.exists(temp_media_file_for_analysis):
900
- if temp_media_file_for_analysis in _temp_preview_files_to_delete:
901
- _temp_preview_files_to_delete.remove(temp_media_file_for_analysis)
902
- try: os.remove(temp_media_file_for_analysis)
903
- except Exception as e: print(f"Error cleaning up analysis temp file {temp_media_file_for_analysis}: {e}")
904
 
905
  # Worker output changed to include screenshot_paths_state
906
  submit_btn.click(
907
  fn=worker,
908
- inputs=[url_input, custom_prompt, api_key],
909
  outputs=[status_state, output_md, main_preview_path_state, screenshot_paths_state],
910
  show_progress="full",
911
  show_progress_on=progress_md,
@@ -920,7 +913,6 @@ def create_demo():
920
  status_state.change(fn=status_to_progress_text, inputs=[status_state], outputs=[progress_md])
921
 
922
  # This function updates the UI components based on the state values.
923
- # It should *not* perform cleanup, as that's handled by clear_all_files_and_ui or load_main_preview_and_clear_old.
924
  def _update_preview_components(current_main_preview_path: str, current_screenshot_paths: List[str]):
925
  img_update = gr.update(value=None, visible=False)
926
  video_update = gr.update(value=None, visible=False)
@@ -937,7 +929,7 @@ def create_demo():
937
  elif ext in VIDEO_EXTS:
938
  is_vid_preview = True
939
 
940
- # Fallback to PIL check if extension is ambiguous or unknown
941
  if not is_img_preview and not is_vid_preview and os.path.exists(current_main_preview_path):
942
  try:
943
  Image.open(current_main_preview_path).verify()
 
9
  import requests
10
  from PIL import Image, ImageFile, UnidentifiedImageError
11
  import gradio as gr
12
+ # import asyncio # Not used
13
+ # import threading # Not directly used for core logic, implicit in Gradio's max_threads
14
  import time
15
  import atexit
16
  from requests.exceptions import RequestException, HTTPError # Import for rate limiting
17
 
 
18
  # --- Configuration and Globals ---
19
  DEFAULT_KEY = os.getenv("MISTRAL_API_KEY", "")
20
  PIXTRAL_MODEL = "pixtral-12b-2409"
 
36
  ImageFile.LOAD_TRUNCATED_IMAGES = True
37
  Image.MAX_IMAGE_PIXELS = 10000 * 10000
38
 
39
+ # Add a default User-Agent header for external requests to improve compatibility
40
+ DEFAULT_HEADERS = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"}
41
+
42
  try:
43
  from mistralai import Mistral
44
+ from mistralai.exceptions import MistralAPIException # Import for better error handling
45
+ except ImportError: # Use ImportError for module import issues
46
  Mistral = None
47
+ # Define a mock MistralAPIException for type hinting and to avoid NameError
48
+ class MistralAPIException(Exception):
49
+ def __init__(self, message, status_code=None, *args, **kwargs):
50
+ super().__init__(message, *args)
51
+ self.status_code = status_code
52
+ @property
53
+ def message(self):
54
+ return self.args[0] if self.args else "An unknown Mistral API error occurred (mistralai library not installed)."
55
+
56
 
57
  # --- Temporary File Cleanup ---
58
  _temp_preview_files_to_delete = []
 
96
 
97
  def safe_head(url: str, timeout: int = 6):
98
  try:
99
+ r = requests.head(url, timeout=timeout, allow_redirects=True, headers=DEFAULT_HEADERS)
100
  return None if r.status_code >= 400 else r
101
  except Exception:
102
  return None
103
 
104
  def safe_get(url: str, timeout: int = 15):
105
+ r = requests.get(url, timeout=timeout, headers=DEFAULT_HEADERS)
106
  r.raise_for_status()
107
  return r
108
 
 
123
  return path
124
 
125
  def fetch_bytes(src: str, stream_threshold: int = STREAM_THRESHOLD, timeout: int = 60, progress=None) -> bytes:
126
+ """
127
+ Fetches bytes from a local path or remote URL, handling large files by streaming to a temp file.
128
+ Returns the content as bytes.
129
+ """
130
  if progress is not None:
131
  progress(0.05, desc="Checking remote/local source...")
132
  if is_remote(src):
 
137
  if cl and int(cl) > stream_threshold:
138
  if progress is not None:
139
  progress(0.1, desc="Streaming large remote file...")
140
+ fd, p = tempfile.mkstemp(suffix=ext_from_src(src) or ".tmp")
141
+ os.close(fd)
142
+ # This temp file is only for streaming download, and will be read back.
143
+ # It's not intended for final preview display or analysis directly.
144
+ try:
145
+ with requests.get(src, timeout=timeout, stream=True, headers=DEFAULT_HEADERS) as r:
146
+ r.raise_for_status()
147
  with open(p, "wb") as fh:
148
  for chunk in r.iter_content(8192):
149
  if chunk:
150
  fh.write(chunk)
151
+ with open(p, "rb") as fh:
152
+ return fh.read()
153
+ finally:
154
+ try: os.remove(p)
155
+ except Exception as e: print(f"Error during streaming temp file cleanup {p}: {e}")
156
+ except Exception as e:
157
+ print(f"Warning: Streaming download failed for {src}: {e}. Falling back to non-streaming.")
158
  pass # Fallback to non-streaming download if streaming fails
159
+ r = safe_get(src, timeout=timeout) # safe_get uses DEFAULT_HEADERS
160
  if progress is not None:
161
  progress(0.25, desc="Downloaded remote content")
162
  return r.content
 
231
 
232
  timestamps: List[float] = []
233
  if duration > 0 and sample_count > 0:
234
+ # Ensure we don't go past the video duration, or request too many samples
235
+ actual_sample_count = min(sample_count, int(duration)) # Limit samples to integer seconds
236
+ if actual_sample_count > 0:
237
+ step = duration / (actual_sample_count + 1)
238
+ timestamps = [step * (i + 1) for i in range(actual_sample_count)]
239
+
240
+ # If no timestamps generated or duration is 0, fall back to fixed times
241
+ if not timestamps:
242
+ timestamps = [0.5, 1.0, 2.0, 3.0, 4.0][:sample_count]
243
+
244
  return info, timestamps
245
 
246
+ def extract_frames_for_model_and_gallery(media_path: str, sample_count: int = 5, timeout_extract: int = 15, gallery_base_h: int = 256, progress=None) -> Tuple[List[bytes], List[str]]:
247
  """
248
  Extracts frames from a video, processes them for both model input (high-res JPEG bytes)
249
  and gallery display (smaller JPEG temp file paths), in a single pass.
250
+ Increased gallery_base_h for better quality.
251
  """
252
  frames_for_model: List[bytes] = [] # List of JPEG bytes for model input
253
  frame_paths_for_gallery: List[str] = [] # List of temp JPEG file paths for gallery
254
 
255
  if not FFMPEG_BIN or not os.path.exists(media_path):
256
+ print(f"Warning: FFMPEG not found or media path does not exist: {media_path}. Cannot extract frames.")
257
  return frames_for_model, frame_paths_for_gallery
258
 
259
  if progress is not None:
260
  progress(0.05, desc="Preparing frame extraction...")
261
 
262
  _, timestamps = _get_video_info_and_timestamps(media_path, sample_count)
263
+ if not timestamps:
264
+ print(f"Warning: No valid timestamps generated for {media_path}. Cannot extract frames.")
265
+ return frames_for_model, frame_paths_for_gallery
266
 
267
  for i, t in enumerate(timestamps):
268
  if progress is not None:
269
+ progress(0.1 + (i / max(1, sample_count)) * 0.2, desc=f"Extracting frame {i+1}/{sample_count} at {t:.1f}s...")
270
 
271
  # Extract to a temp PNG first for best quality, then process with PIL
272
  fd_raw, tmp_png_path = tempfile.mkstemp(suffix=f"_frame_{i}.png")
 
290
  frames_for_model.append(jpeg_model_bytes)
291
 
292
  # For gallery: convert to smaller JPEG bytes and save as new temp file
293
+ jpeg_gallery_bytes = convert_to_jpeg_bytes(raw_frame_bytes, base_h=gallery_base_h) # Higher base_h
294
  if jpeg_gallery_bytes: # Only create temp file if conversion was successful
295
  temp_jpeg_path = _temp_file(jpeg_gallery_bytes, suffix=f"_gallery_{i}.jpg") # _temp_file tracks this for cleanup
296
  if temp_jpeg_path: # Only add to gallery if temp file was successfully created
 
321
 
322
  # Prefer using the Mistral client if available and functional
323
  if hasattr(client, "chat") and hasattr(client.chat, "complete"):
324
+ res = client.chat.complete(model=model, messages=messages, stream=False, timeout_ms=timeout * 1000)
325
  else:
326
  api_key = getattr(client, "api_key", "") or DEFAULT_KEY
327
  if not api_key:
 
344
  content = (msg.get("content") if isinstance(msg, dict) else getattr(msg, "content", None))
345
  return content.strip() if isinstance(content, str) else str(content)
346
 
347
+ except MistralAPIException as e: # Catch Mistral client's specific API exceptions
348
+ if e.status_code == 429 and attempt < max_retries - 1:
349
+ delay = initial_delay * (2 ** attempt)
350
+ print(f"MistralAPIException: Rate limit exceeded (429). Retrying in {delay:.2f}s...")
351
+ time.sleep(delay)
352
+ else:
353
+ return f"Error: Mistral API error occurred with status {e.status_code}: {e.message}"
354
+ except HTTPError as e: # For direct requests calls
355
  if e.response.status_code == 429 and attempt < max_retries - 1:
356
  delay = initial_delay * (2 ** attempt)
357
  print(f"Rate limit exceeded (429). Retrying in {delay:.2f}s...")
358
  time.sleep(delay)
359
  else:
360
  return f"Error: API request failed with status {e.response.status_code}: {e.response.text}"
361
+ except RequestException as e: # For direct requests calls
362
  if attempt < max_retries - 1:
363
  delay = initial_delay * (2 ** attempt)
364
  print(f"Network/API request failed: {e}. Retrying in {delay:.2f}s...")
 
368
  except Exception as e:
369
  return f"Error during model call: {e}"
370
 
371
+ return "Error: Maximum retries reached for API call."
372
 
373
  def upload_file_to_mistral(client, path: str, filename: str | None = None, purpose: str = "batch", timeout: int = 120, progress=None) -> str:
374
  fname = filename or os.path.basename(path)
 
383
  with open(path, "rb") as fh:
384
  res = client.files.upload(file={"file_name": fname, "content": fh}, purpose=purpose)
385
  fid = getattr(res, "id", None) or (res.get("id") if isinstance(res, dict) else None)
386
+ if not fid and isinstance(res, dict) and "data" in res and res["data"]: # Older API responses might nest id in 'data'
387
  fid = res["data"][0]["id"]
388
+ if not fid:
389
+ raise RuntimeError(f"Mistral API upload response missing file ID: {res}")
390
  if progress is not None:
391
  progress(0.6, desc="Upload complete")
392
  return fid
 
405
  if progress is not None:
406
  progress(0.65, desc="Upload complete (REST)")
407
  return jr.get("id") or jr.get("data", [{}])[0].get("id")
408
+ except MistralAPIException as e: # Catch Mistral client's specific API exceptions
409
+ if e.status_code == 429 and attempt < max_retries - 1:
410
+ delay = initial_delay * (2 ** attempt)
411
+ print(f"MistralAPIException: Upload rate limit exceeded (429). Retrying in {delay:.2f}s...")
412
+ time.sleep(delay)
413
+ else:
414
+ raise RuntimeError(f"Mistral API file upload failed with status {e.status_code}: {e.message}") from e
415
  except HTTPError as e:
416
  if e.response.status_code == 429 and attempt < max_retries - 1:
417
  delay = initial_delay * (2 ** attempt)
 
437
 
438
  if ext in IMAGE_EXTS:
439
  is_image = True
440
+ elif ext in VIDEO_EXTS:
441
  is_video = True
442
 
443
  if is_remote(src):
 
450
  is_video, is_image = True, False
451
 
452
  if progress is not None:
453
+ progress(0.02, desc="Determined media type (initial hint)")
454
  return is_image, is_video
455
 
456
  def analyze_image_structured(client, img_bytes: bytes, prompt: str, progress=None) -> str:
 
496
  # If successful upload, still extract frames for gallery display
497
  # Use the combined function for gallery frames
498
  _, gallery_frame_paths = extract_frames_for_model_and_gallery(
499
+ video_path, sample_count=6, gallery_base_h=256, progress=progress # Increased gallery_base_h
500
  )
501
  return result, gallery_frame_paths
502
  except Exception as e:
 
505
 
506
  # Use the combined extraction function for both model input and gallery display
507
  frames_for_model_bytes, gallery_frame_paths = extract_frames_for_model_and_gallery(
508
+ video_path, sample_count=6, gallery_base_h=256, progress=progress # Increased gallery_base_h
509
  )
510
 
511
  if not frames_for_model_bytes:
 
514
  image_entries = []
515
  for i, fb in enumerate(frames_for_model_bytes, start=1):
516
  if progress is not None:
 
517
  progress(0.4 + (i / len(frames_for_model_bytes)) * 0.2, desc=f"Adding frame {i}/{len(frames_for_model_bytes)} to model input...")
518
  image_entries.append(
519
  {
 
549
  if video_streams and any(s.get("codec_name") in ("h264", "h265", "avc1") for s in video_streams):
550
  return path # Already playable
551
 
552
+ out_path = _temp_file(b"", suffix=".mp4") # Create an empty temp file and add to cleanup list
553
  if not out_path: # If _temp_file returned empty path
554
  print(f"Error: Could not create temporary file for video conversion from {path}.")
555
  return path # Fallback to original path
 
584
  return ""
585
 
586
  # Determine media type (prioritizing hints, then byte analysis)
587
+ # This logic aims to be somewhat lenient for preview display
588
  is_actually_image = False
589
  is_actually_video = False
590
 
591
+ # Try to determine from bytes first, if hints are ambiguous or absent
592
+ try:
593
+ Image.open(BytesIO(raw_bytes)).verify()
594
  is_actually_image = True
595
+ except UnidentifiedImageError:
596
+ # Not an identifiable image by PIL. Now consider hints or default to video.
597
+ is_actually_image = False
598
+ if is_video_hint:
 
 
 
 
 
 
 
 
 
 
599
  is_actually_video = True
600
+ elif is_image_hint: # If hinted as image but PIL failed, still prefer image for error clarity
601
+ print(f"Warning: Hinted as image but PIL failed for {src_url}. Assuming image for preview attempt.")
602
+ is_actually_image = True
603
+ else:
604
+ is_actually_video = True # No strong hint, not an image, assume video
605
+ except Exception as e:
606
+ print(f"Warning: Generic error during image check for {src_url}: {e}. Falling back to video preview attempt.")
607
+ is_actually_image = False # Clear image flag
608
+ is_actually_video = True # Assume video as a fallback for preview
609
+
610
+ # If still neither, use original hints as last resort
611
+ if not is_actually_image and not is_actually_video:
612
+ is_actually_image = is_image_hint
613
+ is_actually_video = is_video_hint
614
+
615
 
616
  # --- Attempt Image Preview ---
617
  if is_actually_image:
 
649
  return ""
650
 
651
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
652
  # --- Gradio Interface Logic ---
653
  css = ".preview_media img, .preview_media video { max-width: 100%; height: auto; border-radius:6px; }"
654
 
 
683
  main_preview_path_state = gr.State("")
684
  # State to hold the list of screenshot paths for the gallery
685
  screenshot_paths_state = gr.State([])
686
+ # New state to hold the path to the raw downloaded media file for analysis
687
+ raw_media_path_state = gr.State("")
688
+
689
 
690
  def clear_all_files_and_ui():
691
  """
692
  Cleans up all tracked temporary files and resets all relevant UI components.
693
  This function is meant to be called at the start of any new processing
694
  or when the user explicitly clicks "Clear".
695
+ Returns values for all 11 output components.
696
  """
697
  for f_path in list(_temp_preview_files_to_delete):
698
  if os.path.exists(f_path):
 
700
  os.remove(f_path)
701
  except Exception as e:
702
  print(f"Error during proactive cleanup of {f_path}: {e}")
703
+ _temp_preview_files_to_delete.clear()
704
 
705
+ # Return exactly 11 values to match the outputs list
706
  return "", \
707
  gr.update(value=None, visible=False), \
708
  gr.update(value=None, visible=False), \
 
712
  "", \
713
  "", \
714
  [], \
715
+ gr.update(value="", visible=True), \
716
+ "" # For raw_media_path_state
717
+
718
 
719
  clear_btn.click(
720
  fn=clear_all_files_and_ui,
 
729
  output_md,
730
  main_preview_path_state,
731
  screenshot_paths_state,
732
+ preview_status,
733
+ raw_media_path_state # Clear the raw media path state as well
734
  ]
735
  )
736
 
737
  # Function to handle URL input change and update main preview
738
+ def load_main_preview_and_clear_old(url: str, progress=gr.Progress()):
739
  # First, clear all existing temporary files and reset UI components
 
 
740
  _, img_update_clear, video_update_clear, gallery_update_clear, _, _, _, \
741
+ main_path_clear, screenshot_paths_clear, status_update_clear, raw_media_path_clear = clear_all_files_and_ui()
742
 
743
  if not url:
744
  return img_update_clear, video_update_clear, gallery_update_clear, \
745
+ gr.update(value="", visible=True), main_path_clear, screenshot_paths_clear, raw_media_path_clear
 
 
 
746
 
747
+ raw_bytes_for_analysis = b""
748
+ temp_raw_path_for_analysis = ""
749
  try:
750
+ # 1. Fetch raw bytes once for both preview and analysis
751
+ progress(0.01, desc="Downloading media for preview and analysis...")
752
+ raw_bytes_for_analysis = fetch_bytes(url, timeout=60, progress=progress)
753
+ if not raw_bytes_for_analysis:
754
+ return img_update_clear, video_update_clear, gallery_update_clear, \
755
+ gr.update(value="Preview load failed: No media bytes fetched.", visible=True), \
756
+ main_path_clear, screenshot_paths_clear, raw_media_path_clear
757
+
758
+ # Store raw bytes in a temp file for potential analysis (especially video uploads or large image processing)
759
+ # This file is tracked for cleanup by _temp_file
760
+ temp_raw_path_for_analysis = _temp_file(raw_bytes_for_analysis, suffix=ext_from_src(url) or ".tmp")
761
+ if not temp_raw_path_for_analysis:
762
+ return img_update_clear, video_update_clear, gallery_update_clear, \
763
+ gr.update(value="Preview load failed: Could not save raw media to temp file.", visible=True), \
764
+ main_path_clear, screenshot_paths_clear, raw_media_path_clear
765
+
766
+ progress(0.05, desc="Generating playable preview...")
767
+ # 2. Determine initial media type based on URL/headers (hints for preview generation)
768
+ is_img_initial, is_vid_initial = determine_media_type(url)
769
+
770
+ # 3. Generate playable path from the fetched raw bytes and hints
771
+ local_playable_path = _get_playable_preview_path_from_raw(url, raw_bytes_for_analysis, is_img_initial, is_vid_initial)
772
+
773
  if not local_playable_path:
774
+ # If preview failed, cleanup the temp_raw_path_for_analysis as well
775
+ if temp_raw_path_for_analysis in _temp_preview_files_to_delete:
776
+ _temp_preview_files_to_delete.remove(temp_raw_path_for_analysis)
777
+ try: os.remove(temp_raw_path_for_analysis)
778
+ except Exception as e: print(f"Error during cleanup of raw temp file {temp_raw_path_for_analysis}: {e}")
779
  return img_update_clear, video_update_clear, gallery_update_clear, \
780
+ gr.update(value="Preview load failed: could not make content playable.", visible=True), \
781
+ main_path_clear, screenshot_paths_clear, raw_media_path_clear
782
 
783
+ # 4. Re-evaluate actual type for Gradio display from the *playable path*
784
+ # This is important as _get_playable_preview_path_from_raw might have converted the format
785
  is_img_preview = False
786
  is_vid_preview = False
787
 
 
788
  ext = ext_from_src(local_playable_path)
789
  if ext in IMAGE_EXTS:
790
  is_img_preview = True
 
797
  Image.open(local_playable_path).verify()
798
  is_img_preview = True
799
  except Exception:
800
+ # If not an image, assume it might be a video (or non-playable for Gradio)
801
  is_vid_preview = True # Flag as video for Gradio component decision
802
 
803
  if is_img_preview:
804
  return gr.update(value=local_playable_path, visible=True), gr.update(value=None, visible=False), \
805
  gr.update(value=[], visible=False), gr.update(value="Image preview loaded.", visible=True), \
806
+ local_playable_path, [], temp_raw_path_for_analysis # Update raw_media_path_state
807
  elif is_vid_preview: # Assume video if not image
808
  return gr.update(value=None, visible=False), gr.update(value=local_playable_path, visible=True), \
809
  gr.update(value=[], visible=False), gr.update(value="Video preview loaded.", visible=True), \
810
+ local_playable_path, [], temp_raw_path_for_analysis # Update raw_media_path_state
811
  else:
812
+ return img_update_clear, video_update_clear, gallery_update_clear, \
813
+ gr.update(value="Preview load failed: unknown playable format.", visible=True), \
814
+ main_path_clear, screenshot_paths_clear, raw_media_path_clear
815
 
816
 
817
  except Exception as e:
818
+ # Cleanup temp_raw_path_for_analysis on error
819
+ if temp_raw_path_for_analysis in _temp_preview_files_to_delete:
820
+ _temp_preview_files_to_delete.remove(temp_raw_path_for_analysis)
821
+ try: os.remove(temp_raw_path_for_analysis)
822
+ except Exception as ex: print(f"Error during cleanup of raw temp file {temp_raw_path_for_analysis} on error: {ex}")
823
+ return img_update_clear, video_update_clear, gallery_update_clear, \
824
+ gr.update(value=f"Preview load failed: {e}", visible=True), \
825
+ main_path_clear, screenshot_paths_clear, raw_media_path_clear
826
+
827
 
828
  url_input.change(
829
  fn=load_main_preview_and_clear_old,
830
  inputs=[url_input],
831
+ outputs=[preview_image, preview_video, screenshot_gallery, preview_status, main_preview_path_state, screenshot_paths_state, raw_media_path_state]
832
  )
833
 
834
+ def worker(url: str, prompt: str, key: str, current_main_preview_path: str, raw_media_path: str, progress=gr.Progress()):
835
  """
836
+ Performs the media analysis using the pre-downloaded raw media.
837
  Returns (status, markdown_output, main_preview_path_for_state, screenshot_paths_for_state).
838
  """
839
+ generated_screenshot_paths: List[str] = []
 
 
840
  result_text = ""
841
 
842
  try:
843
+ if not raw_media_path or not os.path.exists(raw_media_path):
844
+ return "error", "**Error:** No raw media file available for analysis. Please load a URL first.", current_main_preview_path, []
845
 
846
+ # Read raw bytes from the stored temp file
847
+ with open(raw_media_path, "rb") as f:
848
+ raw_bytes_for_analysis = f.read()
 
849
 
850
+ if not raw_bytes_for_analysis:
851
+ return "error", "**Error:** Raw media file is empty for analysis.", current_main_preview_path, []
 
852
 
853
+ progress(0.01, desc="Starting media analysis...")
 
 
 
 
854
 
855
+ # Determine actual media type for analysis using the raw bytes
856
+ is_actually_image_for_analysis = False
857
+ is_actually_video_for_analysis = False
 
 
 
 
 
 
 
 
 
 
 
 
 
858
 
859
+ try:
860
+ # Try as image first, using actual bytes
861
+ Image.open(BytesIO(raw_bytes_for_analysis)).verify()
862
+ is_actually_image_for_analysis = True
863
+ except UnidentifiedImageError:
864
+ is_actually_video_for_analysis = True # Not an identifiable image
865
+ except Exception as e:
866
+ print(f"Warning: PIL error during image verification for raw analysis media ({raw_media_path}): {e}. Falling back to video.")
867
+ is_actually_video_for_analysis = True # Other PIL error, treat as video
868
 
869
+ client = get_client(key)
870
 
871
  # --- Video Processing Path ---
872
  if is_actually_video_for_analysis:
 
 
 
873
  progress(0.25, desc="Running full‑video analysis")
874
+ result_text, generated_screenshot_paths = analyze_video_cohesive(client, raw_media_path, prompt, progress=progress)
875
 
876
  # --- Image Processing Path ---
877
  elif is_actually_image_for_analysis:
878
  progress(0.20, desc="Running image analysis")
879
+ result_text = analyze_image_structured(client, raw_bytes_for_analysis, prompt, progress=progress)
880
  # No screenshots for images
881
 
 
882
  else:
883
+ return "error", "Error: Could not definitively determine media type for analysis after byte inspection.", current_main_preview_path, []
 
 
 
 
 
 
 
884
 
885
 
886
  status = "done" if not (isinstance(result_text, str) and result_text.lower().startswith("error")) else "error"
887
 
888
+ # The main_preview_path_state should already hold the path to the main preview from load_main_preview_and_clear_old
889
+ return status, result_text, current_main_preview_path, generated_screenshot_paths
890
 
891
  except Exception as exc:
892
+ return "error", f"Unexpected worker error: {exc}", current_main_preview_path, []
893
  finally:
894
+ # raw_media_path is a temp file tracked by _temp_file and cleaned up by atexit/clear_all_files_and_ui
895
+ # No specific cleanup needed here.
896
+ pass
 
 
 
 
897
 
898
  # Worker output changed to include screenshot_paths_state
899
  submit_btn.click(
900
  fn=worker,
901
+ inputs=[url_input, custom_prompt, api_key, main_preview_path_state, raw_media_path_state],
902
  outputs=[status_state, output_md, main_preview_path_state, screenshot_paths_state],
903
  show_progress="full",
904
  show_progress_on=progress_md,
 
913
  status_state.change(fn=status_to_progress_text, inputs=[status_state], outputs=[progress_md])
914
 
915
  # This function updates the UI components based on the state values.
 
916
  def _update_preview_components(current_main_preview_path: str, current_screenshot_paths: List[str]):
917
  img_update = gr.update(value=None, visible=False)
918
  video_update = gr.update(value=None, visible=False)
 
929
  elif ext in VIDEO_EXTS:
930
  is_vid_preview = True
931
 
932
+ # Fallback to PIL check if extension is ambiguous or unknown, and if it's an actual file
933
  if not is_img_preview and not is_vid_preview and os.path.exists(current_main_preview_path):
934
  try:
935
  Image.open(current_main_preview_path).verify()