Hug0endob commited on
Commit
a29bc89
·
verified ·
1 Parent(s): a999681

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +83 -196
app.py CHANGED
@@ -4,6 +4,7 @@ import subprocess
4
  import tempfile
5
  import base64
6
  import json
 
7
  from io import BytesIO
8
  from typing import List, Tuple, Optional, Set
9
  import requests
@@ -11,60 +12,11 @@ from PIL import Image, ImageFile, UnidentifiedImageError
11
  import gradio as gr
12
  import time
13
  import atexit
14
- from requests.exceptions import RequestException, HTTPError # Import HTTPError for requests fallback
15
-
16
- # --- Mistral Client Import & Placeholder for graceful degradation ---
17
- _MISTRAL_CLIENT_INSTALLED = False
18
- try:
19
- from mistralai import Mistral
20
- from mistralai.exceptions import MistralAPIException
21
- _MISTRAL_CLIENT_INSTALLED = True
22
- except ImportError:
23
- print(
24
- "Warning: Mistral AI client library ('mistralai') not found. "
25
- "Please install it with 'pip install mistralai' to enable full AI analysis features. " # Updated message
26
- "The application will launch, but API calls will fall back to direct HTTP requests "
27
- "if an API key is provided." # Updated message to reflect fallback
28
- )
29
- # Define placeholder classes to prevent NameErrors and provide clear messages
30
- class MistralAPIException(Exception):
31
- """A placeholder for mistralai.exceptions.MistralAPIException."""
32
- def __init__(self, message: str, status_code: Optional[int] = None):
33
- super().__init__(message)
34
- self.message = message
35
- self.status_code = status_code or 500
36
- def __str__(self):
37
- return f"MistralAPIException (Status: {self.status_code}): {self.message}"
38
-
39
- class _DummyMistralChatClient:
40
- """Placeholder for Mistral client's chat interface."""
41
- def complete(self, *args, **kwargs):
42
- # This method will typically not be called if _MISTRAL_CLIENT_INSTALLED is False,
43
- # as the `chat_complete` function will use the requests fallback instead.
44
- raise MistralAPIException(
45
- "Mistral AI chat client is unavailable. "
46
- "Please install 'mistralai' with 'pip install mistralai'.",
47
- status_code=500
48
- )
49
- class _DummyMistralFilesClient:
50
- """Placeholder for Mistral client's files interface."""
51
- def upload(self, *args, **kwargs):
52
- # This method will typically not be called if _MISTRAL_CLIENT_INSTALLED is False.
53
- raise MistralAPIException(
54
- "Mistral AI files client is unavailable. "
55
- "Please install 'mistralai' with 'pip install mistralai'.",
56
- status_code=500
57
- )
58
- class Mistral:
59
- """A placeholder for the Mistral client if the library is not installed."""
60
- def __init__(self, api_key: str = "", *args, **kwargs): # Added api_key to store it for fallback
61
- self.api_key = api_key # Store the API key
62
- @property
63
- def chat(self):
64
- return _DummyMistralChatClient()
65
- @property
66
- def files(self):
67
- return _DummyMistralFilesClient()
68
 
69
  # --- Configuration and Globals ---
70
  DEFAULT_MISTRAL_KEY = os.getenv("MISTRAL_API_KEY", "")
@@ -89,13 +41,12 @@ Image.MAX_IMAGE_PIXELS = 10000 * 10000
89
 
90
  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"}
91
 
92
- # --- Temporary File Cleanup (Changed to use a set) ---
93
  _temp_files_to_delete: Set[str] = set() # Use a set for better management
94
 
95
  def _cleanup_all_temp_files():
96
  """Removes all temporary files created upon application exit."""
97
- # Create a copy to iterate while modifying the original set
98
- for f_path in _temp_files_to_delete.copy():
99
  if os.path.exists(f_path):
100
  try:
101
  os.remove(f_path)
@@ -107,11 +58,10 @@ def _cleanup_all_temp_files():
107
  atexit.register(_cleanup_all_temp_files)
108
 
109
  # --- Mistral Client and API Helpers ---
110
- def get_client(api_key: Optional[str] = None):
111
  """
112
  Returns a Mistral client instance. If the API key is missing, a MistralAPIException is raised.
113
- If the client library is not installed, a placeholder client is returned, and API calls
114
- will fall back to direct HTTP requests.
115
  """
116
  key_to_use = (api_key or "").strip() or DEFAULT_MISTRAL_KEY
117
  if not key_to_use:
@@ -119,10 +69,6 @@ def get_client(api_key: Optional[str] = None):
119
  "Mistral API key is not set. Please provide it in the UI or as MISTRAL_API_KEY environment variable.",
120
  status_code=401 # Unauthorized
121
  )
122
-
123
- # Always return a Mistral client instance.
124
- # If _MISTRAL_CLIENT_INSTALLED is True, this will be the real Mistral client.
125
- # Otherwise, it's the placeholder that has the api_key stored.
126
  return Mistral(api_key=key_to_use)
127
 
128
  def is_remote(src: str) -> bool:
@@ -176,7 +122,6 @@ def fetch_bytes(src: str, stream_threshold: int = STREAM_THRESHOLD_BYTES, timeou
176
  fd, p = tempfile.mkstemp(suffix=ext_from_src(src) or ".tmp")
177
  os.close(fd)
178
  try:
179
- # FIX: Open file for writing before the streaming loop
180
  with open(p, "wb") as fh_write:
181
  with requests.get(src, timeout=timeout, stream=True, headers=DEFAULT_HEADERS) as r:
182
  r.raise_for_status()
@@ -187,12 +132,11 @@ def fetch_bytes(src: str, stream_threshold: int = STREAM_THRESHOLD_BYTES, timeou
187
  fh_write.write(chunk)
188
  downloaded_size += len(chunk)
189
  if progress is not None and total_size > 0:
190
- # Scale progress from 0.1 to 0.25 for streaming phase
191
  progress(0.1 + (downloaded_size / total_size) * 0.15)
192
- with open(p, "rb") as fh_read: # Open again to read full content
193
  return fh_read.read()
194
  finally:
195
- try: os.remove(p)
196
  except Exception as e: print(f"Error during streaming temp file cleanup {p}: {e}")
197
  except Exception as e:
198
  print(f"Warning: Streaming download failed for {src}: {e}. Falling back to non-streaming.")
@@ -223,7 +167,7 @@ def convert_to_jpeg_bytes(img_bytes: bytes, base_h: int = 480) -> bytes:
223
  return b""
224
 
225
  try:
226
- if getattr(img, "is_animated", False): # Handle animated images (e.g., GIFs) by taking the first frame
227
  img.seek(0)
228
  except Exception:
229
  pass
@@ -253,11 +197,11 @@ def _ffprobe_streams(path: str) -> Optional[dict]:
253
  if os.path.exists(potential_ffprobe_in_dir) and os.access(potential_ffprobe_in_dir, os.X_OK):
254
  ffprobe_path = potential_ffprobe_in_dir
255
 
256
- if not ffprobe_path: # Fallback to checking PATH if not found next to ffmpeg
257
  ffprobe_path = shutil.which("ffprobe")
258
 
259
  if not ffprobe_path:
260
- return None # ffprobe is not available
261
 
262
  cmd = [
263
  ffprobe_path, "-v", "error", "-print_format", "json", "-show_streams", "-show_format", path
@@ -281,13 +225,13 @@ def _get_video_info_and_timestamps(media_path: str, sample_count: int) -> Tuple[
281
 
282
  timestamps: List[float] = []
283
  if duration > 0 and sample_count > 0:
284
- actual_sample_count = min(sample_count, max(1, int(duration))) # Ensure sample_count doesn't exceed duration
285
  if actual_sample_count > 0:
286
  step = duration / (actual_sample_count + 1)
287
  timestamps = [step * (i + 1) for i in range(actual_sample_count)]
288
 
289
- if not timestamps: # Fallback for very short videos or if duration couldn't be determined
290
- timestamps = [0.5, 1.0, 2.0, 3.0, 4.0][:sample_count] # Try fixed early timestamps
291
 
292
  return info, timestamps
293
 
@@ -299,7 +243,7 @@ def extract_frames_for_model_and_gallery(media_path: str, sample_count: int = 5,
299
  frames_for_model: List[bytes] = []
300
  frame_paths_for_gallery: List[str] = []
301
 
302
- if not FFMPEG_BIN: # Check FFMPEG_BIN here
303
  print(f"Warning: FFMPEG not found. Cannot extract frames for {media_path}.")
304
  return frames_for_model, frame_paths_for_gallery
305
  if not os.path.exists(media_path):
@@ -361,7 +305,7 @@ def extract_frames_for_model_and_gallery(media_path: str, sample_count: int = 5,
361
  progress(0.45, desc=f"Extracted {len(frames_for_model)} frames for analysis and gallery")
362
  return frames_for_model, frame_paths_for_gallery
363
 
364
- def chat_complete(client, model: str, messages, timeout: int = 120, progress=None) -> str:
365
  """Sends messages to the Mistral chat completion API with retry logic."""
366
  max_retries = 5
367
  initial_delay = 1.0
@@ -370,39 +314,25 @@ def chat_complete(client, model: str, messages, timeout: int = 120, progress=Non
370
  if progress is not None:
371
  progress(0.6 + 0.01 * attempt, desc=f"Sending request to model (attempt {attempt+1}/{max_retries})...")
372
 
373
- res = None
374
- if _MISTRAL_CLIENT_INSTALLED:
375
- # Use the real Mistral client's chat.complete method
376
- res = client.chat.complete(model=model, messages=messages, stream=False, timeout_ms=timeout * 1000)
377
- else:
378
- # Fallback to direct HTTP request if client library not installed
379
- api_key = getattr(client, "api_key", "") # Get key from client, should always be present now
380
- if not api_key: # Double check, though get_client already ensures this
381
- return "Error: Mistral API key is not set for fallback."
382
-
383
- url = "https://api.mistral.ai/v1/chat/completions"
384
- headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
385
- payload = {"model": model, "messages": messages, "stream": False} # Removed timeout_ms for requests
386
- r = requests.post(url, json=payload, headers=headers, timeout=timeout) # requests timeout in seconds
387
- r.raise_for_status() # Raises HTTPError for bad responses (4xx or 5xx)
388
- res = r.json()
389
 
390
  if progress is not None:
391
  progress(0.8, desc="Model responded, parsing...")
392
 
393
- # Handle both object-style (from mistralai client) and dict-style (from requests.json()) responses
394
- choices = getattr(res, "choices", None) or (res.get("choices") if isinstance(res, dict) else [])
395
 
396
  if not choices:
397
  return f"Empty response from model: {res}"
398
 
399
  first = choices[0]
400
- msg = getattr(first, "message", None) or (first.get("message") if isinstance(first, dict) else first)
401
- content = getattr(msg, "content", None) or (msg.get("content") if isinstance(msg, dict) else None)
402
  return content.strip() if isinstance(content, str) else str(content)
403
 
404
- except (MistralAPIException, HTTPError) as e: # Catch both client lib and requests HTTP errors
405
- status_code = getattr(e, "status_code", None) or (e.response.status_code if isinstance(e, HTTPError) else None)
406
  message = getattr(e, "message", str(e))
407
 
408
  if status_code == 429 and attempt < max_retries - 1:
@@ -411,7 +341,7 @@ def chat_complete(client, model: str, messages, timeout: int = 120, progress=Non
411
  time.sleep(delay)
412
  else:
413
  return f"Error: Mistral API error occurred ({status_code if status_code else 'unknown'}): {message}"
414
- except RequestException as e: # Catch other requests errors (e.g., connection issues, timeout)
415
  if attempt < max_retries - 1:
416
  delay = initial_delay * (2 ** attempt)
417
  print(f"Network/API request failed: {e}. Retrying in {delay:.2f}s...")
@@ -423,7 +353,7 @@ def chat_complete(client, model: str, messages, timeout: int = 120, progress=Non
423
 
424
  return "Error: Maximum retries reached for API call."
425
 
426
- def upload_file_to_mistral(client, path: str, filename: str | None = None, purpose: str = "batch", timeout: int = 120, progress=None) -> str:
427
  """Uploads a file to the Mistral API, returning its file ID."""
428
  fname = filename or os.path.basename(path)
429
  max_retries = 3
@@ -433,38 +363,23 @@ def upload_file_to_mistral(client, path: str, filename: str | None = None, purpo
433
  if progress is not None:
434
  progress(0.5 + 0.01 * attempt, desc=f"Uploading file to model service (attempt {attempt+1}/{max_retries})...")
435
 
436
- fid = None
437
- if _MISTRAL_CLIENT_INSTALLED:
438
- with open(path, "rb") as fh:
439
- # Mistral client's file upload expects (filename, file_like_object) for 'file' param
440
- res = client.files.upload(file=(fname, fh), purpose=purpose)
441
- fid = getattr(res, "id", None) or (res.get("id") if isinstance(res, dict) else None)
442
- if not fid:
443
- raise RuntimeError(f"Mistral API upload response missing file ID from client: {res}")
444
- else:
445
- # Fallback to direct HTTP request
446
- api_key = getattr(client, "api_key", "")
447
- if not api_key:
448
- raise RuntimeError("Mistral API key is not set for file upload fallback.")
449
-
450
- url = "https://api.mistral.ai/v1/files"
451
- headers = {"Authorization": f"Bearer {api_key}"}
452
- with open(path, "rb") as fh:
453
- files = {"file": (fname, fh)}
454
- data = {"purpose": purpose}
455
- r = requests.post(url, headers=headers, files=files, data=data, timeout=timeout)
456
- r.raise_for_status()
457
- jr = r.json()
458
- fid = jr.get("id") or jr.get("data", [{}])[0].get("id") # Handle potential nested 'data' from older API
459
- if not fid:
460
- raise RuntimeError(f"Mistral API upload response missing file ID from direct request: {jr}")
461
 
462
  if progress is not None:
463
  progress(0.6, desc="Upload complete")
464
  return fid
465
 
466
- except (MistralAPIException, HTTPError) as e:
467
- status_code = getattr(e, "status_code", None) or (e.response.status_code if isinstance(e, HTTPError) else None)
468
  message = getattr(e, "message", str(e))
469
  if status_code == 429 and attempt < max_retries - 1:
470
  delay = initial_delay * (2 ** attempt)
@@ -507,7 +422,7 @@ def determine_media_type(src: str, progress=None) -> Tuple[bool, bool]:
507
  progress(0.02, desc="Determined media type (initial hint)")
508
  return is_image, is_video
509
 
510
- def analyze_image_structured(client, img_bytes: bytes, prompt: str, progress=None) -> str:
511
  """Analyzes an image using the PixTRAL model."""
512
  try:
513
  if progress is not None:
@@ -529,14 +444,13 @@ def analyze_image_structured(client, img_bytes: bytes, prompt: str, progress=Non
529
  except Exception as e:
530
  return f"Error analyzing image: {e}"
531
 
532
- def analyze_video_cohesive(client, video_path: str, prompt: str, progress=None) -> Tuple[str, List[str]]:
533
  """
534
  Analyzes a video using the VoxTRAL model (if available) or by extracting frames
535
  and using PixTRAL as a fallback.
536
  Returns: (analysis result text, list of paths to gallery frames)
537
  """
538
  gallery_frame_paths: List[str] = []
539
- # If FFmpeg is not available, we can't do video analysis at all
540
  if not FFMPEG_BIN:
541
  return "Error: FFmpeg is not found in your system PATH. Video analysis and preview are unavailable.", []
542
 
@@ -548,12 +462,12 @@ def analyze_video_cohesive(client, video_path: str, prompt: str, progress=None)
548
  messages = [
549
  {"role": "system", "content": SYSTEM_INSTRUCTION},
550
  {"role": "user", "content": [
551
- {"type": "text", "text": f"Uploaded video file id: {file_id}\n\nInstruction: Analyze the entire video and produce a single cohesive narrative describing consistent observations.\n\n{prompt}"},
 
552
  ]},
553
  ]
554
  result = chat_complete(client, VIDEO_MODEL, messages, progress=progress)
555
 
556
- # Still extract frames for gallery even if full video upload was successful
557
  _, gallery_frame_paths = extract_frames_for_model_and_gallery(
558
  video_path, sample_count=6, gallery_base_h=1080, model_base_h=1024, progress=progress
559
  )
@@ -581,7 +495,6 @@ def analyze_video_cohesive(client, video_path: str, prompt: str, progress=None)
581
  "meta": {"frame_index": i},
582
  }
583
  )
584
- # Consolidate frames for a cohesive narrative, as per requirement
585
  content = [{"type": "text", "text": prompt + "\n\nPlease consolidate observations across these frames into a single cohesive narrative."}] + image_entries
586
  messages = [
587
  {"role": "system", "content": SYSTEM_INSTRUCTION},
@@ -599,13 +512,12 @@ def _convert_video_for_preview_if_needed(path: str) -> str:
599
  if not FFMPEG_BIN or not os.path.exists(path):
600
  return path
601
 
602
- # Check if it's already a web-friendly MP4 (H.264/AVC1, H.265)
603
  if path.lower().endswith((".mp4", ".m4v")):
604
  info = _ffprobe_streams(path)
605
  if info:
606
  video_streams = [s for s in info.get("streams", []) if s.get("codec_type") == "video"]
607
  if video_streams and any(s.get("codec_name") in ("h264", "h265", "avc1") for s in video_streams):
608
- return path # Already compatible, no conversion needed
609
 
610
  out_path = _temp_file(b"", suffix=".mp4")
611
  if not out_path:
@@ -614,18 +526,17 @@ def _convert_video_for_preview_if_needed(path: str) -> str:
614
 
615
  cmd = [
616
  FFMPEG_BIN, "-y", "-i", path,
617
- "-c:v", "libx264", "-preset", "veryfast", "-crf", "28", # H.264 codec
618
- "-c:a", "aac", "-b:a", "128k", # AAC audio
619
- "-movflags", "+faststart", out_path, # Optimize for web streaming
620
- "-map_metadata", "-1" # Remove metadata
621
  ]
622
  try:
623
  subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=60)
624
  return out_path
625
  except Exception as e:
626
  print(f"Error converting video for preview: {e}")
627
- # If conversion fails, remove the failed temp file and return original path
628
- _temp_files_to_delete.discard(out_path) # Remove from set
629
  try: os.remove(out_path)
630
  except Exception: pass
631
  return path
@@ -645,14 +556,14 @@ def _get_playable_preview_path_from_raw(src_url: str, raw_bytes: bytes, is_image
645
  Image.open(BytesIO(raw_bytes)).verify()
646
  is_actually_image = True
647
  except (UnidentifiedImageError, Exception):
648
- pass # Not a verifiable image, proceed to video or fallback logic
649
 
650
  if is_actually_image:
651
  jpeg_bytes = convert_to_jpeg_bytes(raw_bytes, base_h=1024)
652
  if jpeg_bytes:
653
  return _temp_file(jpeg_bytes, suffix=".jpg")
654
- return "" # Failed image conversion
655
- elif is_video_hint: # If it's not an image, and was hinted as video
656
  temp_raw_video_path = _temp_file(raw_bytes, suffix=ext_from_src(src_url) or ".mp4")
657
  if not temp_raw_video_path:
658
  print(f"Error: Failed to create temporary raw video file for {src_url}.")
@@ -660,7 +571,7 @@ def _get_playable_preview_path_from_raw(src_url: str, raw_bytes: bytes, is_image
660
 
661
  playable_path = _convert_video_for_preview_if_needed(temp_raw_video_path)
662
  return playable_path
663
- elif is_image_hint: # Fallback: if hinted image but not verifiable, still try conversion
664
  jpeg_bytes = convert_to_jpeg_bytes(raw_bytes, base_h=1024)
665
  if jpeg_bytes:
666
  return _temp_file(jpeg_bytes, suffix=".jpg")
@@ -679,22 +590,15 @@ def _get_button_label_for_status(status: str) -> str:
679
 
680
  def create_demo():
681
  """Creates the Gradio interface for Flux Multimodal analysis."""
682
- # Determine FFMPEG status once for UI message
683
  ffmpeg_status_message = ""
684
  if not FFMPEG_BIN:
685
  ffmpeg_status_message = "🔴 FFmpeg not found! Video analysis and preview will be limited/unavailable."
686
  else:
687
  ffmpeg_status_message = "🟢 FFmpeg found. Video features enabled."
688
 
689
- mistral_client_status_message = ""
690
- if not _MISTRAL_CLIENT_INSTALLED:
691
- mistral_client_status_message = "🟡 Mistral AI client ('mistralai') not installed. AI analysis will fall back to direct HTTP requests. Run `pip install mistralai` for full features." # Updated message
692
- else:
693
- mistral_client_status_message = "🟢 Mistral AI client found."
694
-
695
  with gr.Blocks(title="Flux Multimodal", css=GRADIO_CSS) as demo:
696
  gr.Markdown("# Flux Multimodal AI Assistant")
697
- gr.Markdown(f"{mistral_client_status_message}<br>{ffmpeg_status_message}") # Display dependency status
698
 
699
  with gr.Row():
700
  with gr.Column(scale=1):
@@ -715,23 +619,22 @@ def create_demo():
715
  output_markdown = gr.Markdown("")
716
 
717
  status_state = gr.State("idle")
718
- main_preview_path_state = gr.State("") # Path to the playable preview file (image or video)
719
- screenshot_paths_state = gr.State([]) # List of paths to extracted frames for gallery
720
- raw_media_path_state = gr.State("") # Path to the raw downloaded media file for analysis
721
 
722
  def clear_all_ui_and_files_handler():
723
  """
724
  Cleans up all tracked temporary files and resets all relevant UI components and states.
725
  """
726
- # Iterate a copy of the set while potentially modifying the original
727
- for f_path in _temp_files_to_delete.copy():
728
  if os.path.exists(f_path):
729
  try:
730
  os.remove(f_path)
731
- _temp_files_to_delete.discard(f_path) # Remove from set
732
  except Exception as e:
733
  print(f"Error during proactive cleanup of {f_path}: {e}")
734
- _temp_files_to_delete.clear() # Ensure set is empty
735
 
736
  return "", \
737
  gr.update(value=None, visible=False), \
@@ -775,22 +678,20 @@ def create_demo():
775
  Loads media from URL, generates a preview, and sets up temporary files for analysis.
776
  Also handles cleanup of previously loaded media.
777
  """
778
- # --- Proactive cleanup of old files related to previous load ---
779
  if current_main_preview_path and os.path.exists(current_main_preview_path):
780
- _temp_files_to_delete.discard(current_main_preview_path) # Remove from set
781
  try: os.remove(current_main_preview_path)
782
  except Exception as e: print(f"Error cleaning up old temp file {current_main_preview_path}: {e}")
783
  if current_raw_media_path and os.path.exists(current_raw_media_path):
784
- _temp_files_to_delete.discard(current_raw_media_path) # Remove from set
785
  try: os.remove(current_raw_media_path)
786
  except Exception as e: print(f"Error cleaning up old temp file {current_raw_media_path}: {e}")
787
  for path in current_screenshot_paths:
788
  if path and os.path.exists(path):
789
- _temp_files_to_delete.discard(path) # Remove from set
790
  try: os.remove(path)
791
  except Exception as e: print(f"Error cleaning up old temp file {path}: {e}")
792
 
793
- # Default cleared states for UI and backend values to be returned on empty URL or error
794
  img_update_clear = gr.update(value=None, visible=False)
795
  video_update_clear = gr.update(value=None, visible=False)
796
  gallery_update_clear = gr.update(value=[], visible=False)
@@ -806,26 +707,24 @@ def create_demo():
806
  temp_raw_path_for_analysis = ""
807
  try:
808
  progress(0.01, desc="Downloading media for preview and analysis...")
809
- raw_bytes_for_analysis = fetch_bytes(url, timeout=60, progress=progress) # Pass progress here
810
  if not raw_bytes_for_analysis:
811
  return img_update_clear, video_update_clear, gallery_update_clear, \
812
  gr.update(value="Preview load failed: No media bytes fetched.", visible=True), \
813
  main_path_clear, raw_media_path_clear, screenshot_paths_clear
814
 
815
- # Store raw bytes in a temp file for potential analysis (especially video uploads or large image processing)
816
  temp_raw_path_for_analysis = _temp_file(raw_bytes_for_analysis, suffix=ext_from_src(url) or ".tmp")
817
  if not temp_raw_path_for_analysis:
818
  return img_update_clear, video_update_clear, gallery_update_clear, \
819
  gr.update(value="Preview load failed: Could not save raw media to temp file.", visible=True), \
820
  main_path_clear, raw_media_path_clear, screenshot_paths_clear
821
 
822
- progress(0.25, desc="Generating playable preview...") # Adjusted progress start
823
  is_img_initial, is_vid_initial = determine_media_type(url)
824
  local_playable_path = _get_playable_preview_path_from_raw(url, raw_bytes_for_analysis, is_img_initial, is_vid_initial)
825
 
826
  if not local_playable_path:
827
- # If preview failed, cleanup the temp_raw_path_for_analysis as well
828
- _temp_files_to_delete.discard(temp_raw_path_for_analysis) # Remove from set
829
  try: os.remove(temp_raw_path_for_analysis)
830
  except Exception as e: print(f"Error during cleanup of raw temp file {temp_raw_path_for_analysis}: {e}")
831
 
@@ -846,12 +745,10 @@ def create_demo():
846
  gallery_update_clear, gr.update(value="Video preview loaded.", visible=True), \
847
  local_playable_path, temp_raw_path_for_analysis, screenshot_paths_clear
848
  else:
849
- # If local_playable_path exists but is not image/video, clean it up
850
- _temp_files_to_delete.discard(local_playable_path) # Remove from set
851
  try: os.remove(local_playable_path)
852
  except Exception as e: print(f"Error during cleanup of unplayable temp file {local_playable_path}: {e}")
853
- # Also clean up raw_media_path if the playable path was not generated successfully
854
- _temp_files_to_delete.discard(temp_raw_path_for_analysis) # Remove from set
855
  try: os.remove(temp_raw_path_for_analysis)
856
  except Exception as e: print(f"Error during cleanup of raw temp file {temp_raw_path_for_analysis}: {e}")
857
 
@@ -860,9 +757,8 @@ def create_demo():
860
  main_path_clear, raw_media_path_clear, screenshot_paths_clear
861
 
862
  except Exception as e:
863
- # If an error occurred during loading, clear all relevant paths.
864
  if os.path.exists(temp_raw_path_for_analysis):
865
- _temp_files_to_delete.discard(temp_raw_path_for_analysis) # Remove from set
866
  try: os.remove(temp_raw_path_for_analysis)
867
  except Exception as ex: print(f"Error during cleanup of raw temp file {temp_raw_path_for_analysis} on error: {ex}")
868
 
@@ -876,7 +772,7 @@ def create_demo():
876
  outputs=[preview_image, preview_video, screenshot_gallery, preview_status_text, main_preview_path_state, raw_media_path_state, screenshot_paths_state]
877
  )
878
 
879
- def worker(url: str, prompt: str, key: str, current_main_preview_path: str, raw_media_path: str, progress=gr.Progress()):
880
  """
881
  The main worker function that performs media analysis using Mistral models.
882
  """
@@ -885,43 +781,36 @@ def create_demo():
885
 
886
  try:
887
  if not raw_media_path or not os.path.exists(raw_media_path):
888
- return "error", "**Error:** No raw media file available for analysis. Please load a URL first.", current_main_preview_path, []
889
 
890
- # Initial check for FFmpeg for video processing
891
  if not FFMPEG_BIN:
892
- # If this is a video, and ffmpeg is missing, return early.
893
- # Otherwise, proceed for image analysis.
894
- # Determine media type by extension from raw_media_path
895
  ext = ext_from_src(raw_media_path)
896
  if ext in VIDEO_EXTENSIONS:
897
- return "error", "**Error:** FFmpeg is not found in your system PATH. Video analysis is unavailable. Please install FFmpeg.", current_main_preview_path, []
898
 
899
  with open(raw_media_path, "rb") as f:
900
  raw_bytes_for_analysis = f.read()
901
 
902
  if not raw_bytes_for_analysis:
903
- return "error", "**Error:** Raw media file is empty for analysis.", current_main_preview_path, []
904
 
905
  progress(0.01, desc="Starting media analysis...")
906
 
907
  is_actually_image_for_analysis = False
908
  is_actually_video_for_analysis = False
909
 
910
- # Determine media type for analysis robustly
911
  try:
912
  Image.open(BytesIO(raw_bytes_for_analysis)).verify()
913
  is_actually_image_for_analysis = True
914
  except UnidentifiedImageError:
915
- # If PIL can't identify it as an image, check if it has a video extension.
916
  if ext_from_src(raw_media_path) in VIDEO_EXTENSIONS:
917
  is_actually_video_for_analysis = True
918
  except Exception as e:
919
- # Catch other PIL errors (e.g., truncated, memory, etc.).
920
  print(f"Warning: PIL error during image verification for raw analysis media ({raw_media_path}): {e}. Checking for video extension.")
921
  if ext_from_src(raw_media_path) in VIDEO_EXTENSIONS:
922
  is_actually_video_for_analysis = True
923
 
924
- client = get_client(key) # This will raise MistralAPIException if key missing, but NOT if lib not installed
925
 
926
  if is_actually_video_for_analysis:
927
  progress(0.25, desc="Running full-video analysis")
@@ -930,21 +819,20 @@ def create_demo():
930
  progress(0.20, desc="Running image analysis")
931
  result_text = analyze_image_structured(client, raw_bytes_for_analysis, prompt, progress=progress)
932
  else:
933
- return "error", "Error: Could not definitively determine media type for analysis after byte inspection and extension check. Please check the URL/file content.", current_main_preview_path, []
934
 
935
  status = "done" if not (isinstance(result_text, str) and result_text.lower().startswith("error")) else "error"
936
- return status, result_text, current_main_preview_path, generated_screenshot_paths
937
 
938
  except MistralAPIException as e:
939
- # Catch API key missing or client not installed errors from get_client or client method calls
940
- return "error", f"**Mistral API Error:** {e.message}", current_main_preview_path, []
941
  except Exception as exc:
942
- return "error", f"**Unexpected worker error:** {type(exc).__name__}: {exc}", current_main_preview_path, []
943
 
944
  submit_btn.click(
945
  fn=worker,
946
- inputs=[url_input, custom_prompt, api_key_input, main_preview_path_state, raw_media_path_state],
947
- outputs=[status_state, output_markdown, main_preview_path_state, screenshot_paths_state],
948
  show_progress="full",
949
  show_progress_on=progress_markdown,
950
  )
@@ -973,7 +861,6 @@ def create_demo():
973
  gallery_update = gr.update(value=current_screenshot_paths, visible=bool(current_screenshot_paths))
974
  return img_update, video_update, gallery_update
975
 
976
- # These change events use queue=False to ensure UI updates are immediate and don't block
977
  main_preview_path_state.change(
978
  fn=_update_preview_components,
979
  inputs=[main_preview_path_state, screenshot_paths_state],
 
4
  import tempfile
5
  import base64
6
  import json
7
+ import mimetypes # New import for mimetype guessing
8
  from io import BytesIO
9
  from typing import List, Tuple, Optional, Set
10
  import requests
 
12
  import gradio as gr
13
  import time
14
  import atexit
15
+ from requests.exceptions import RequestException # Keep for general network errors in fetch_bytes
16
+
17
+ # --- Mistral Client Import (Assume installed as requested) ---
18
+ from mistralai import Mistral
19
+ from mistralai.exceptions import MistralAPIException
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
 
21
  # --- Configuration and Globals ---
22
  DEFAULT_MISTRAL_KEY = os.getenv("MISTRAL_API_KEY", "")
 
41
 
42
  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"}
43
 
44
+ # --- Temporary File Cleanup ---
45
  _temp_files_to_delete: Set[str] = set() # Use a set for better management
46
 
47
  def _cleanup_all_temp_files():
48
  """Removes all temporary files created upon application exit."""
49
+ for f_path in list(_temp_files_to_delete): # Iterate over a copy to allow modification
 
50
  if os.path.exists(f_path):
51
  try:
52
  os.remove(f_path)
 
58
  atexit.register(_cleanup_all_temp_files)
59
 
60
  # --- Mistral Client and API Helpers ---
61
+ def get_client(api_key: Optional[str] = None) -> Mistral:
62
  """
63
  Returns a Mistral client instance. If the API key is missing, a MistralAPIException is raised.
64
+ Assumes mistralai client library is installed.
 
65
  """
66
  key_to_use = (api_key or "").strip() or DEFAULT_MISTRAL_KEY
67
  if not key_to_use:
 
69
  "Mistral API key is not set. Please provide it in the UI or as MISTRAL_API_KEY environment variable.",
70
  status_code=401 # Unauthorized
71
  )
 
 
 
 
72
  return Mistral(api_key=key_to_use)
73
 
74
  def is_remote(src: str) -> bool:
 
122
  fd, p = tempfile.mkstemp(suffix=ext_from_src(src) or ".tmp")
123
  os.close(fd)
124
  try:
 
125
  with open(p, "wb") as fh_write:
126
  with requests.get(src, timeout=timeout, stream=True, headers=DEFAULT_HEADERS) as r:
127
  r.raise_for_status()
 
132
  fh_write.write(chunk)
133
  downloaded_size += len(chunk)
134
  if progress is not None and total_size > 0:
 
135
  progress(0.1 + (downloaded_size / total_size) * 0.15)
136
+ with open(p, "rb") as fh_read:
137
  return fh_read.read()
138
  finally:
139
+ try: _temp_files_to_delete.discard(p); os.remove(p)
140
  except Exception as e: print(f"Error during streaming temp file cleanup {p}: {e}")
141
  except Exception as e:
142
  print(f"Warning: Streaming download failed for {src}: {e}. Falling back to non-streaming.")
 
167
  return b""
168
 
169
  try:
170
+ if getattr(img, "is_animated", False):
171
  img.seek(0)
172
  except Exception:
173
  pass
 
197
  if os.path.exists(potential_ffprobe_in_dir) and os.access(potential_ffprobe_in_dir, os.X_OK):
198
  ffprobe_path = potential_ffprobe_in_dir
199
 
200
+ if not ffprobe_path:
201
  ffprobe_path = shutil.which("ffprobe")
202
 
203
  if not ffprobe_path:
204
+ return None
205
 
206
  cmd = [
207
  ffprobe_path, "-v", "error", "-print_format", "json", "-show_streams", "-show_format", path
 
225
 
226
  timestamps: List[float] = []
227
  if duration > 0 and sample_count > 0:
228
+ actual_sample_count = min(sample_count, max(1, int(duration)))
229
  if actual_sample_count > 0:
230
  step = duration / (actual_sample_count + 1)
231
  timestamps = [step * (i + 1) for i in range(actual_sample_count)]
232
 
233
+ if not timestamps:
234
+ timestamps = [0.5, 1.0, 2.0, 3.0, 4.0][:sample_count]
235
 
236
  return info, timestamps
237
 
 
243
  frames_for_model: List[bytes] = []
244
  frame_paths_for_gallery: List[str] = []
245
 
246
+ if not FFMPEG_BIN:
247
  print(f"Warning: FFMPEG not found. Cannot extract frames for {media_path}.")
248
  return frames_for_model, frame_paths_for_gallery
249
  if not os.path.exists(media_path):
 
305
  progress(0.45, desc=f"Extracted {len(frames_for_model)} frames for analysis and gallery")
306
  return frames_for_model, frame_paths_for_gallery
307
 
308
+ def chat_complete(client: Mistral, model: str, messages, timeout: int = 120, progress=None) -> str:
309
  """Sends messages to the Mistral chat completion API with retry logic."""
310
  max_retries = 5
311
  initial_delay = 1.0
 
314
  if progress is not None:
315
  progress(0.6 + 0.01 * attempt, desc=f"Sending request to model (attempt {attempt+1}/{max_retries})...")
316
 
317
+ # Always use the real Mistral client's chat.complete method
318
+ res = client.chat.complete(model=model, messages=messages, stream=False, timeout_ms=timeout * 1000)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
319
 
320
  if progress is not None:
321
  progress(0.8, desc="Model responded, parsing...")
322
 
323
+ # Access attributes directly from the client's response object
324
+ choices = getattr(res, "choices", [])
325
 
326
  if not choices:
327
  return f"Empty response from model: {res}"
328
 
329
  first = choices[0]
330
+ msg = getattr(first, "message", None)
331
+ content = getattr(msg, "content", None)
332
  return content.strip() if isinstance(content, str) else str(content)
333
 
334
+ except MistralAPIException as e:
335
+ status_code = getattr(e, "status_code", None)
336
  message = getattr(e, "message", str(e))
337
 
338
  if status_code == 429 and attempt < max_retries - 1:
 
341
  time.sleep(delay)
342
  else:
343
  return f"Error: Mistral API error occurred ({status_code if status_code else 'unknown'}): {message}"
344
+ except RequestException as e: # Catch network issues that MistralAPIException might not fully wrap
345
  if attempt < max_retries - 1:
346
  delay = initial_delay * (2 ** attempt)
347
  print(f"Network/API request failed: {e}. Retrying in {delay:.2f}s...")
 
353
 
354
  return "Error: Maximum retries reached for API call."
355
 
356
+ def upload_file_to_mistral(client: Mistral, path: str, filename: str | None = None, purpose: str = "batch", timeout: int = 120, progress=None) -> str:
357
  """Uploads a file to the Mistral API, returning its file ID."""
358
  fname = filename or os.path.basename(path)
359
  max_retries = 3
 
363
  if progress is not None:
364
  progress(0.5 + 0.01 * attempt, desc=f"Uploading file to model service (attempt {attempt+1}/{max_retries})...")
365
 
366
+ # Guess mimetype for robust upload
367
+ mimetype, _ = mimetypes.guess_type(fname)
368
+ mimetype = mimetype or "application/octet-stream"
369
+
370
+ with open(path, "rb") as fh:
371
+ # Mistral client's file upload expects (filename, file_like_object, mimetype) for 'file' param
372
+ res = client.files.upload(file=(fname, fh, mimetype), purpose=purpose)
373
+ fid = getattr(res, "id", None)
374
+ if not fid:
375
+ raise RuntimeError(f"Mistral API upload response missing file ID: {res}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
376
 
377
  if progress is not None:
378
  progress(0.6, desc="Upload complete")
379
  return fid
380
 
381
+ except MistralAPIException as e:
382
+ status_code = getattr(e, "status_code", None)
383
  message = getattr(e, "message", str(e))
384
  if status_code == 429 and attempt < max_retries - 1:
385
  delay = initial_delay * (2 ** attempt)
 
422
  progress(0.02, desc="Determined media type (initial hint)")
423
  return is_image, is_video
424
 
425
+ def analyze_image_structured(client: Mistral, img_bytes: bytes, prompt: str, progress=None) -> str:
426
  """Analyzes an image using the PixTRAL model."""
427
  try:
428
  if progress is not None:
 
444
  except Exception as e:
445
  return f"Error analyzing image: {e}"
446
 
447
+ def analyze_video_cohesive(client: Mistral, video_path: str, prompt: str, progress=None) -> Tuple[str, List[str]]:
448
  """
449
  Analyzes a video using the VoxTRAL model (if available) or by extracting frames
450
  and using PixTRAL as a fallback.
451
  Returns: (analysis result text, list of paths to gallery frames)
452
  """
453
  gallery_frame_paths: List[str] = []
 
454
  if not FFMPEG_BIN:
455
  return "Error: FFmpeg is not found in your system PATH. Video analysis and preview are unavailable.", []
456
 
 
462
  messages = [
463
  {"role": "system", "content": SYSTEM_INSTRUCTION},
464
  {"role": "user", "content": [
465
+ {"type": "video", "id": file_id}, # Correct format for video input
466
+ {"type": "text", "text": f"Instruction: Analyze the entire video and produce a single cohesive narrative describing consistent observations.\n\n{prompt}"},
467
  ]},
468
  ]
469
  result = chat_complete(client, VIDEO_MODEL, messages, progress=progress)
470
 
 
471
  _, gallery_frame_paths = extract_frames_for_model_and_gallery(
472
  video_path, sample_count=6, gallery_base_h=1080, model_base_h=1024, progress=progress
473
  )
 
495
  "meta": {"frame_index": i},
496
  }
497
  )
 
498
  content = [{"type": "text", "text": prompt + "\n\nPlease consolidate observations across these frames into a single cohesive narrative."}] + image_entries
499
  messages = [
500
  {"role": "system", "content": SYSTEM_INSTRUCTION},
 
512
  if not FFMPEG_BIN or not os.path.exists(path):
513
  return path
514
 
 
515
  if path.lower().endswith((".mp4", ".m4v")):
516
  info = _ffprobe_streams(path)
517
  if info:
518
  video_streams = [s for s in info.get("streams", []) if s.get("codec_type") == "video"]
519
  if video_streams and any(s.get("codec_name") in ("h264", "h265", "avc1") for s in video_streams):
520
+ return path
521
 
522
  out_path = _temp_file(b"", suffix=".mp4")
523
  if not out_path:
 
526
 
527
  cmd = [
528
  FFMPEG_BIN, "-y", "-i", path,
529
+ "-c:v", "libx264", "-preset", "veryfast", "-crf", "28",
530
+ "-c:a", "aac", "-b:a", "128k",
531
+ "-movflags", "+faststart", out_path,
532
+ "-map_metadata", "-1"
533
  ]
534
  try:
535
  subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=60)
536
  return out_path
537
  except Exception as e:
538
  print(f"Error converting video for preview: {e}")
539
+ _temp_files_to_delete.discard(out_path)
 
540
  try: os.remove(out_path)
541
  except Exception: pass
542
  return path
 
556
  Image.open(BytesIO(raw_bytes)).verify()
557
  is_actually_image = True
558
  except (UnidentifiedImageError, Exception):
559
+ pass
560
 
561
  if is_actually_image:
562
  jpeg_bytes = convert_to_jpeg_bytes(raw_bytes, base_h=1024)
563
  if jpeg_bytes:
564
  return _temp_file(jpeg_bytes, suffix=".jpg")
565
+ return ""
566
+ elif is_video_hint:
567
  temp_raw_video_path = _temp_file(raw_bytes, suffix=ext_from_src(src_url) or ".mp4")
568
  if not temp_raw_video_path:
569
  print(f"Error: Failed to create temporary raw video file for {src_url}.")
 
571
 
572
  playable_path = _convert_video_for_preview_if_needed(temp_raw_video_path)
573
  return playable_path
574
+ elif is_image_hint:
575
  jpeg_bytes = convert_to_jpeg_bytes(raw_bytes, base_h=1024)
576
  if jpeg_bytes:
577
  return _temp_file(jpeg_bytes, suffix=".jpg")
 
590
 
591
  def create_demo():
592
  """Creates the Gradio interface for Flux Multimodal analysis."""
 
593
  ffmpeg_status_message = ""
594
  if not FFMPEG_BIN:
595
  ffmpeg_status_message = "🔴 FFmpeg not found! Video analysis and preview will be limited/unavailable."
596
  else:
597
  ffmpeg_status_message = "🟢 FFmpeg found. Video features enabled."
598
 
 
 
 
 
 
 
599
  with gr.Blocks(title="Flux Multimodal", css=GRADIO_CSS) as demo:
600
  gr.Markdown("# Flux Multimodal AI Assistant")
601
+ gr.Markdown(f"🟢 Mistral AI client found.<br>{ffmpeg_status_message}") # Simplified status
602
 
603
  with gr.Row():
604
  with gr.Column(scale=1):
 
619
  output_markdown = gr.Markdown("")
620
 
621
  status_state = gr.State("idle")
622
+ main_preview_path_state = gr.State("")
623
+ screenshot_paths_state = gr.State([])
624
+ raw_media_path_state = gr.State("")
625
 
626
  def clear_all_ui_and_files_handler():
627
  """
628
  Cleans up all tracked temporary files and resets all relevant UI components and states.
629
  """
630
+ for f_path in list(_temp_files_to_delete):
 
631
  if os.path.exists(f_path):
632
  try:
633
  os.remove(f_path)
634
+ _temp_files_to_delete.discard(f_path)
635
  except Exception as e:
636
  print(f"Error during proactive cleanup of {f_path}: {e}")
637
+ _temp_files_to_delete.clear()
638
 
639
  return "", \
640
  gr.update(value=None, visible=False), \
 
678
  Loads media from URL, generates a preview, and sets up temporary files for analysis.
679
  Also handles cleanup of previously loaded media.
680
  """
 
681
  if current_main_preview_path and os.path.exists(current_main_preview_path):
682
+ _temp_files_to_delete.discard(current_main_preview_path)
683
  try: os.remove(current_main_preview_path)
684
  except Exception as e: print(f"Error cleaning up old temp file {current_main_preview_path}: {e}")
685
  if current_raw_media_path and os.path.exists(current_raw_media_path):
686
+ _temp_files_to_delete.discard(current_raw_media_path)
687
  try: os.remove(current_raw_media_path)
688
  except Exception as e: print(f"Error cleaning up old temp file {current_raw_media_path}: {e}")
689
  for path in current_screenshot_paths:
690
  if path and os.path.exists(path):
691
+ _temp_files_to_delete.discard(path)
692
  try: os.remove(path)
693
  except Exception as e: print(f"Error cleaning up old temp file {path}: {e}")
694
 
 
695
  img_update_clear = gr.update(value=None, visible=False)
696
  video_update_clear = gr.update(value=None, visible=False)
697
  gallery_update_clear = gr.update(value=[], visible=False)
 
707
  temp_raw_path_for_analysis = ""
708
  try:
709
  progress(0.01, desc="Downloading media for preview and analysis...")
710
+ raw_bytes_for_analysis = fetch_bytes(url, timeout=60, progress=progress)
711
  if not raw_bytes_for_analysis:
712
  return img_update_clear, video_update_clear, gallery_update_clear, \
713
  gr.update(value="Preview load failed: No media bytes fetched.", visible=True), \
714
  main_path_clear, raw_media_path_clear, screenshot_paths_clear
715
 
 
716
  temp_raw_path_for_analysis = _temp_file(raw_bytes_for_analysis, suffix=ext_from_src(url) or ".tmp")
717
  if not temp_raw_path_for_analysis:
718
  return img_update_clear, video_update_clear, gallery_update_clear, \
719
  gr.update(value="Preview load failed: Could not save raw media to temp file.", visible=True), \
720
  main_path_clear, raw_media_path_clear, screenshot_paths_clear
721
 
722
+ progress(0.25, desc="Generating playable preview...")
723
  is_img_initial, is_vid_initial = determine_media_type(url)
724
  local_playable_path = _get_playable_preview_path_from_raw(url, raw_bytes_for_analysis, is_img_initial, is_vid_initial)
725
 
726
  if not local_playable_path:
727
+ _temp_files_to_delete.discard(temp_raw_path_for_analysis)
 
728
  try: os.remove(temp_raw_path_for_analysis)
729
  except Exception as e: print(f"Error during cleanup of raw temp file {temp_raw_path_for_analysis}: {e}")
730
 
 
745
  gallery_update_clear, gr.update(value="Video preview loaded.", visible=True), \
746
  local_playable_path, temp_raw_path_for_analysis, screenshot_paths_clear
747
  else:
748
+ _temp_files_to_delete.discard(local_playable_path)
 
749
  try: os.remove(local_playable_path)
750
  except Exception as e: print(f"Error during cleanup of unplayable temp file {local_playable_path}: {e}")
751
+ _temp_files_to_delete.discard(temp_raw_path_for_analysis)
 
752
  try: os.remove(temp_raw_path_for_analysis)
753
  except Exception as e: print(f"Error during cleanup of raw temp file {temp_raw_path_for_analysis}: {e}")
754
 
 
757
  main_path_clear, raw_media_path_clear, screenshot_paths_clear
758
 
759
  except Exception as e:
 
760
  if os.path.exists(temp_raw_path_for_analysis):
761
+ _temp_files_to_delete.discard(temp_raw_path_for_analysis)
762
  try: os.remove(temp_raw_path_for_analysis)
763
  except Exception as ex: print(f"Error during cleanup of raw temp file {temp_raw_path_for_analysis} on error: {ex}")
764
 
 
772
  outputs=[preview_image, preview_video, screenshot_gallery, preview_status_text, main_preview_path_state, raw_media_path_state, screenshot_paths_state]
773
  )
774
 
775
+ def worker(url: str, prompt: str, key: str, raw_media_path: str, progress=gr.Progress()):
776
  """
777
  The main worker function that performs media analysis using Mistral models.
778
  """
 
781
 
782
  try:
783
  if not raw_media_path or not os.path.exists(raw_media_path):
784
+ return "error", "**Error:** No raw media file available for analysis. Please load a URL first.", [], []
785
 
 
786
  if not FFMPEG_BIN:
 
 
 
787
  ext = ext_from_src(raw_media_path)
788
  if ext in VIDEO_EXTENSIONS:
789
+ return "error", "**Error:** FFmpeg is not found in your system PATH. Video analysis is unavailable. Please install FFmpeg.", [], []
790
 
791
  with open(raw_media_path, "rb") as f:
792
  raw_bytes_for_analysis = f.read()
793
 
794
  if not raw_bytes_for_analysis:
795
+ return "error", "**Error:** Raw media file is empty for analysis.", [], []
796
 
797
  progress(0.01, desc="Starting media analysis...")
798
 
799
  is_actually_image_for_analysis = False
800
  is_actually_video_for_analysis = False
801
 
 
802
  try:
803
  Image.open(BytesIO(raw_bytes_for_analysis)).verify()
804
  is_actually_image_for_analysis = True
805
  except UnidentifiedImageError:
 
806
  if ext_from_src(raw_media_path) in VIDEO_EXTENSIONS:
807
  is_actually_video_for_analysis = True
808
  except Exception as e:
 
809
  print(f"Warning: PIL error during image verification for raw analysis media ({raw_media_path}): {e}. Checking for video extension.")
810
  if ext_from_src(raw_media_path) in VIDEO_EXTENSIONS:
811
  is_actually_video_for_analysis = True
812
 
813
+ client = get_client(key)
814
 
815
  if is_actually_video_for_analysis:
816
  progress(0.25, desc="Running full-video analysis")
 
819
  progress(0.20, desc="Running image analysis")
820
  result_text = analyze_image_structured(client, raw_bytes_for_analysis, prompt, progress=progress)
821
  else:
822
+ return "error", "Error: Could not definitively determine media type for analysis after byte inspection and extension check. Please check the URL/file content.", [], []
823
 
824
  status = "done" if not (isinstance(result_text, str) and result_text.lower().startswith("error")) else "error"
825
+ return status, result_text, generated_screenshot_paths, [] # Ensure the main_preview_path is reset or handled if it should not change after analysis
826
 
827
  except MistralAPIException as e:
828
+ return "error", f"**Mistral API Error:** {e.message}", [], []
 
829
  except Exception as exc:
830
+ return "error", f"**Unexpected worker error:** {type(exc).__name__}: {exc}", [], []
831
 
832
  submit_btn.click(
833
  fn=worker,
834
+ inputs=[url_input, custom_prompt, api_key_input, raw_media_path_state],
835
+ outputs=[status_state, output_markdown, screenshot_paths_state, main_preview_path_state], # main_preview_path_state should remain unchanged or be updated from worker if needed.
836
  show_progress="full",
837
  show_progress_on=progress_markdown,
838
  )
 
861
  gallery_update = gr.update(value=current_screenshot_paths, visible=bool(current_screenshot_paths))
862
  return img_update, video_update, gallery_update
863
 
 
864
  main_preview_path_state.change(
865
  fn=_update_preview_components,
866
  inputs=[main_preview_path_state, screenshot_paths_state],