Merge diese skripte zusammen mti den besten inhalten kombiniert.



Skript 1:
# Standard Library Imports
import sys
import os
import re
import io
import time
import json
import requests
import logging
import traceback
import tempfile
import shutil
import math
import weakref # Added for potential weak references if needed
from pathlib import Path # Use pathlib for better path handling
from collections import deque # For the pending lanes queue
import base64

# Third-party imports
import numpy as np
from PIL import Image, ImageEnhance, ImageOps
import cv2
import face_recognition # Core face recognition library
from bs4 import BeautifulSoup
from selenium import webdriver
from selenium.webdriver.chrome.service import Service as ChromeService
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.common.exceptions import (NoSuchElementException, TimeoutException,
                                        WebDriverException, StaleElementReferenceException,
                                        ElementClickInterceptedException, # Added common exceptions
                                        NoSuchWindowException)
from webdriver_manager.chrome import ChromeDriverManager

# PyQt5 Imports
from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QGroupBox,
                             QPushButton, QLabel, QLineEdit, QSpinBox, QDoubleSpinBox, QComboBox,
                             QTextEdit, QCheckBox, QFileDialog, QScrollArea, QProgressBar, QFrame,
                             QGridLayout, QMessageBox, QGraphicsOpacityEffect, QSizePolicy, QToolButton,
                             QFormLayout, QMenu, QSpacerItem)
from PyQt5.QtGui import (QPixmap, QFont, QCursor, QImage, QMovie, QColor, QPalette, QIcon,
                         QDesktopServices, QFontMetrics, QPainter)
from PyQt5.QtCore import (Qt, QThread, pyqtSignal, QRunnable, QObject, QThreadPool, QTimer, QBuffer,
                          QSettings, QPropertyAnimation, QSize, pyqtSlot, QMetaObject, QPoint,
                          QParallelAnimationGroup, QEasingCurve, QEvent, QMargins, Q_ARG, QByteArray,
                          QMutex, QMutexLocker, QWaitCondition, QUrl, QSemaphore, QCoreApplication) # Added QCoreApplication


# --- Application Start Logging ---
# Logging configured below

# --- Globale Custom Exception ---
class TaskStopException(Exception):
    """Custom exception to signal controlled task stopping."""
    pass

# --- Global Exception Hook ---
def global_exception_handler(exctype, value, tb):
    """Logs unhandled exceptions and attempts to show a message box."""
    logging.critical("Unhandled exception caught by global handler:", exc_info=(exctype, value, tb))
    traceback_str = "".join(traceback.format_exception(exctype, value, tb))
    logging.critical(f"Traceback:\n{traceback_str}")

    # Use QCoreApplication.applicationName() if available for context
    app_name = QCoreApplication.applicationName() if QCoreApplication.instance() else "Application"

    try:
        # Attempt to show message box, create QApplication if needed (last resort)
        app = QApplication.instance()
        if not app:
            logging.warning("QApplication not running in global exception handler. Creating temporary one for message box.")
            # Create a temporary app instance JUST for the message box.
            # This is risky and might not always work depending on the context of the error.
            try:
                 app = QApplication(sys.argv)
                 created_temp_app = True
                 logging.info("Temporary QApplication created in global exception handler.")
            except RuntimeError as qapp_err: # e.g., "Cannot create a QCoreApplication instance"
                 logging.error(f"Failed to create temporary QApplication: {qapp_err}")
                 created_temp_app = False

        # Only show message box if an app instance (original or temporary) exists
        if app:
            msgBox = QMessageBox()
            msgBox.setIcon(QMessageBox.Critical)
            msgBox.setWindowTitle(f"{app_name} - Unhandled Exception")
            # Keep main text concise
            msgBox.setText(f"A critical error occurred:\n\n{value}\n\nSee 'webvidscrap.log' for details.")
            # Add detailed traceback if possible and not excessively long
            if len(traceback_str) < 5000: # Increased limit slightly
                 msgBox.setDetailedText(traceback_str)
            else:
                 msgBox.setDetailedText("Traceback too long to display here. Please check the log file.")
            msgBox.setStandardButtons(QMessageBox.Ok)
            msgBox.exec_()
        else:
             # If no app instance, fall back to stderr
             print(f"CRITICAL ERROR ({app_name}): {value}\nCheck 'webvidscrap.log'", file=sys.stderr)
             print(f"Traceback:\n{traceback_str}", file=sys.stderr)

    except Exception as mb_err:
        # Log error if showing the message box itself fails
        logging.error(f"Error showing critical error message box: {mb_err}")
        # Fallback to printing directly to stderr
        print(f"CRITICAL ERROR ({app_name}): {value}\nCheck 'webvidscrap.log'", file=sys.stderr)
        print(f"Traceback:\n{traceback_str}", file=sys.stderr)

    # Call the default excepthook to ensure Python handles it as usual (e.g., exits)
    # Do not wrap this in a try/except, let it propagate if it fails.
    sys.__excepthook__(exctype, value, tb)
    # Consider exiting explicitly after logging/showing message for fatal errors
    # sys.exit(1) # Uncomment if you want definite exit on unhandled exceptions


# Set the global exception hook
sys.excepthook = global_exception_handler


# --- Global Mutex for Face Recognition ---
FACE_RECOGNITION_MUTEX = QMutex()

# --- Constants ---
APP_DIR = Path(__file__).parent.resolve() # Get the directory where the script is located
THUMBNAILS_DIR = APP_DIR / "thumbnails"
RESULTS_FILE = APP_DIR / "results.json"
ENCODING_CACHE_FILE = APP_DIR / "encoding_cache.json"
SETTINGS_ORG = "WebVidScrapProOrg"
SETTINGS_APP = "WebVidScrapProApp"
APP_VERSION = "1.6.1" # Incremented version for fixes

# Settings Keys (Keep as strings for QSettings compatibility)
SETTING_SEARCH_TEXT = "searchText"; SETTING_START_PAGE = "startPage"; SETTING_END_PAGE = "endPage"
SETTING_THRESHOLD = "threshold"; SETTING_HEADLESS = "headless"
SETTING_REC_FRAMES = "recognitionFrames"; SETTING_ANIM_FRAMES = "animationFrames"
SETTING_REF_PATHS = "referenceImagePaths"; SETTING_LAST_DIR = "last_dir"
SETTING_WIN_SIZE = "mainWindowSize"; SETTING_WIN_GEOM = "mainWindowGeometry"
SETTING_REF_LOAD_THREADS = "refLoadThreads"; SETTING_ANALYZER_THREADS = "analyzerThreads"
SETTING_DETECTION_PRIORITY = "detectionPriority"; SETTING_LIVE_VIEW_PERF_MODE = "liveViewPerformanceMode"
SETTING_ENCODING_MODEL = "encodingModel"; SETTING_ENCODING_JITTERS = "encodingJitters"
SETTING_MAX_DETECTION_UPSAMPLE = "maxDetectionUpsample"
SETTING_FORCE_ALL_DETECTION_ATTEMPTS = "forceAllDetectionAttempts"
SETTING_USE_FACE_MUTEX = "useFaceRecognitionMutex"

# Detection Priority Enum
DETECT_PRIO_ACCURACY = 0; DETECT_PRIO_SPEED = 1

# Image Processing Constants
DEFAULT_MAX_IMAGE_SIZE = 1024 # Max dimension for processing reference images

# Website Specific Selectors (Motherless) - Keep specific if needed
MOTHERLESS_VIDEO_CONTAINER_SELECTOR = "div.thumb-container.video"
MOTHERLESS_VIDEO_LINK_SELECTOR = "a.img-container" # Link within the container
MOTHERLESS_VIDEO_TITLE_SELECTOR = ".caption.title" # Title within the container
MOTHERLESS_THUMBNAIL_IMG_SELECTOR = "img.static" # Thumbnail image within the container

# UI Constants
ANIMATION_FRAME_INTERVAL_MS = 250 # Slower animation interval
PLACEHOLDER_REF_ICON_SIZE = QSize(80, 80)
PLACEHOLDER_LANE_THUMB_SIZE = QSize(96, 54)
PLACEHOLDER_RESULT_THUMB_SIZE = QSize(160, 90)
LANE_FRAME_WIDGET_SIZE = QSize(96, 54) # Standard size for frame widgets in lane
PLACEHOLDER_COLOR = QColor("#282828")
# Frame Widget State Colors
COLOR_PENDING = QColor("#555555")
COLOR_DOWNLOADING = QColor("#4682B4") # SteelBlue
COLOR_DOWNLOAD_FAILED = QColor("#8B0000") # DarkRed
COLOR_ANALYZING = QColor("#DAA520") # GoldenRod
COLOR_COMPARING = QColor("#9370DB") # MediumPurple
COLOR_NO_FACE = QColor("#A52A2A") # Brown
COLOR_FACE_FOUND = QColor("#FFD700") # Gold
COLOR_BEST = QColor("#32CD32") # LimeGreen
COLOR_THRESHOLD_MET_FLASH = QColor("#00FF7F") # SpringGreen

# Placeholder Pixmap Cache (defined globally, initialized after QApplication)
PLACEHOLDER_REF_PIXMAP = None
PLACEHOLDER_LANE_THUMB_PIXMAP = None
PLACEHOLDER_RESULT_THUMB_PIXMAP = None
PLACEHOLDER_FRAME_PIXMAP = None

# Placeholder Pixmap Generation Function
def create_placeholder_pixmap(size, text=""):
    """Creates a QPixmap filled with a solid color and optional centered text."""
    pixmap = QPixmap(size)
    pixmap.fill(PLACEHOLDER_COLOR)
    if text:
        painter = QPainter(pixmap) # QPainter is defined here
        painter.setPen(QColor("#888888"))
        # Adjust font size based on pixmap size
        font_size = 8 if size.width() < 100 else 10
        font = QFont("Arial", font_size)
        painter.setFont(font)
        # Draw text centered and wrapped
        rect = pixmap.rect().adjusted(2, 2, -2, -2) # Small margin
        flags = Qt.AlignCenter | Qt.TextWordWrap
        painter.drawText(rect, flags, text)
        painter.end()
    return pixmap

# =============================================================================
# ProcessingStats - Class for tracking processing statistics
# =============================================================================
class ProcessingStats:
    """Tracks statistics during the scraping and analysis process."""
    def __init__(self):
        self.mutex = QMutex() # Mutex to protect updates from multiple threads
        self.current_page = 0
        self.total_pages = 0
        self.processed_videos = 0 # Videos for which processing *completed* (success or fail)
        self.queued_videos = 0    # Videos currently being processed or waiting in queue/lanes
        self.frame_processing_times = deque() # Use deque for potentially faster appends
        self.video_processing_times = deque()
        self.process_start_time = time.monotonic() # Use monotonic for elapsed time
        # Total counts across the session
        self.total_video_count_session = 0 # Total videos added to lanes/queue
        self.total_frame_count_session = 0 # Total frames analyzed (incremented directly)

    def update_page_progress(self, current, total):
        with QMutexLocker(self.mutex):
            self.current_page = current
            self.total_pages = total

    def update_video_queue(self, processed, queued):
        """ Updates counts, ensuring thread safety. """
        with QMutexLocker(self.mutex):
            self.processed_videos = processed
            self.queued_videos = queued
            # Total count is tracked separately when videos are found
            # self.total_video_count_session = processed + queued # This might be inaccurate if items are pending

    def increment_total_videos_found(self):
         """ Increments the total count when a video is initially found. """
         with QMutexLocker(self.mutex):
              self.total_video_count_session += 1

    def add_frame_processing_time(self, milliseconds):
        """Adds frame processing time in milliseconds, thread-safe."""
        if milliseconds >= 0: # Ignore potential negative times
            with QMutexLocker(self.mutex):
                self.frame_processing_times.append(milliseconds)
                self.total_frame_count_session += 1 # Increment total frame count here
        else:
            logging.warning(f"Ignored invalid frame processing time: {milliseconds}")

    def add_video_processing_time(self, milliseconds):
        """Adds video processing time in milliseconds, thread-safe."""
        if milliseconds >= 0:
            with QMutexLocker(self.mutex):
                self.video_processing_times.append(milliseconds)
            # Processed videos count is handled separately by update_video_queue
        else:
             logging.warning(f"Ignored invalid video processing time: {milliseconds}")

    def get_stats(self):
        """Calculates and returns current statistics, thread-safe read."""
        with QMutexLocker(self.mutex):
            # Local copies for calculation to minimize lock time
            frame_times = list(self.frame_processing_times)
            video_times = list(self.video_times)
            current_page = self.current_page
            total_pages = self.total_pages
            processed_vids = self.processed_videos
            # Queued includes active lanes + pending queue
            queued_vids = self.queued_videos
            total_vids_session = self.total_video_count_session
            total_frames_session = self.total_frame_count_session

        # Calculations outside the lock
        avg_frame = self._calculate_average(frame_times)
        avg_video = self._calculate_average(video_times)
        avg_frame_50 = self._calculate_average_per_50(frame_times)
        avg_video_50 = self._calculate_average_per_50(video_times)

        # Format total pages to show ∞ if end_page is very large
        total_pages_display = "∞" if total_pages > 9998 else str(total_pages)

        # Runtime calculation
        runtime_seconds = time.monotonic() - self.process_start_time
        runtime_str = self._format_runtime(int(runtime_seconds))

        return {
            'page_progress': f"{current_page}/{total_pages_display}",
            # Show processed / total found in session
            'video_progress': f"{processed_vids}/{total_vids_session}",
            'avg_frame_time': avg_frame,
            'avg_frame_time_50': avg_frame_50,
            'avg_video_time': avg_video,
            'avg_video_time_50': avg_video_50,
            'total_runtime': runtime_str,
            'total_frames_processed': total_frames_session, # Use session total
            'total_videos_processed': len(video_times) # Videos fully processed with timing
        }

    def _calculate_average(self, values):
        """Calculates the average of a list of values, returns 0 if empty."""
        return sum(values) / len(values) if values else 0

    def _calculate_average_per_50(self, values):
        """Calculates the average time for 50 units based on total data, returns 0 if empty."""
        if not values: return 0
        total_time = sum(values)
        total_count = len(values)
        return (total_time / total_count) * 50 if total_count > 0 else 0

    def _format_runtime(self, total_seconds):
        """Formats seconds into Hh Mm Ss or Mm Ss string."""
        if total_seconds < 0: total_seconds = 0
        hours = total_seconds // 3600
        minutes = (total_seconds % 3600) // 60
        seconds = total_seconds % 60
        if hours > 0:
            return f"{hours}h {minutes}m {seconds}s"
        else:
            return f"{minutes}m {seconds}s"

# =============================================================================
# ImageProcessor - Robust image loading and processing
# =============================================================================
class ImageProcessor:
    """
    Utility class for processing and optimizing images for face recognition.
    Handles various formats and potential errors during loading.
    Uses Path objects for file paths.
    """
    @staticmethod
    def process_image_file(file_path: Path, max_size=DEFAULT_MAX_IMAGE_SIZE):
        """Loads/processes image file robustly (PIL, OpenCV), ensures RGB, resizes."""
        pil_image = None
        file_path_str = str(file_path) # For logging compatibility if needed
        try:
            # Attempt 1: PIL (Handles more formats, EXIF)
            try:
                with Image.open(file_path) as img:
                    pil_image = img.copy() # Work with a copy
                pil_image = ImageOps.exif_transpose(pil_image) # Handle EXIF orientation
                pil_image = ImageProcessor._ensure_rgb(pil_image, file_path)
                image_np = np.array(pil_image)
                return ImageProcessor.resize_if_needed(image_np, pil_image, max_size, file_path)
            except Exception as e:
                logging.warning(f"PIL failed for {file_path_str}: {e}. Trying OpenCV.")
                pil_image = None # Reset pil_image if PIL fails

            # Attempt 2: OpenCV (Good fallback)
            image_cv = cv2.imread(str(file_path), cv2.IMREAD_COLOR) # Force 3 channels
            if image_cv is None:
                raise ValueError("cv2.imread returned None")
            image_rgb = cv2.cvtColor(image_cv, cv2.COLOR_BGR2RGB) # OpenCV loads as BGR
            pil_image = Image.fromarray(image_rgb)
            image_np = np.array(pil_image) # Already RGB
            return ImageProcessor.resize_if_needed(image_np, pil_image, max_size, file_path)

        except Exception as e:
            logging.error(f"Error processing image file {file_path_str}: {e}", exc_info=True)
            if pil_image: logging.error(f"Image info at error: Mode: {pil_image.mode}, Size: {pil_image.size}")
            raise ValueError(f"Image processing failed for {file_path.name}: {e}") from e
    
    @staticmethod
    def process_image_file_async(file_path: Path, callback, max_size=DEFAULT_MAX_IMAGE_SIZE):
        """Processes an image file asynchronously by calling process_image_file."""
        def task():
            try:
                result = ImageProcessor.process_image_file(file_path, max_size)
                callback(result)
            except Exception as e:
                logging.error(f"Error in async image processing for {file_path}: {e}", exc_info=True)
                callback(None)

        worker = QRunnable()
        worker.run = task
        QThreadPool.globalInstance().start(worker)
        """Processes an image file asynchronously."""
        worker = QRunnable()
        worker.run = lambda: callback(ImageProcessor.process_image_file(file_path))
        QThreadPool.globalInstance().start(worker)
        """Loads/processes image file robustly (PIL, OpenCV), ensures RGB, resizes."""
        pil_image = None
        file_path_str = str(file_path) # For logging compatibility if needed
        try:
            # Attempt 1: PIL (Handles more formats, EXIF)
            try:
                logging.debug(f"Trying PIL.Image.open: {file_path_str}")
                with Image.open(file_path) as img:
                    pil_image = img.copy() # Work with a copy
                pil_image = ImageOps.exif_transpose(pil_image) # Handle EXIF orientation
                logging.debug(f"PIL loaded: {file_path_str}, Mode: {pil_image.mode}, Size: {pil_image.size}")
                pil_image = ImageProcessor._ensure_rgb(pil_image, file_path)
                image_np = np.array(pil_image)
                logging.debug(f"PIL converted to numpy: {file_path_str}")
                # Pass Path object to resize
                return ImageProcessor.resize_if_needed(image_np, pil_image, max_size, file_path)
            except Exception as e:
                logging.warning(f"PIL failed for {file_path_str}: {e}. Trying OpenCV.")
                pil_image = None # Reset pil_image if PIL fails

            # Attempt 2: OpenCV (Good fallback)
            try:
                logging.debug(f"Trying cv2.imread: {file_path_str}")
                image_cv = cv2.imread(str(file_path), cv2.IMREAD_COLOR) # Force 3 channels
                if image_cv is None: raise ValueError("cv2.imread returned None")
                image_rgb = cv2.cvtColor(image_np_cv, cv2.COLOR_BGR2RGB)
                pil_image = Image.fromarray(image_rgb)
                logging.debug(f"OpenCV loaded: {file_path_str}, Mode: {pil_image.mode}, Size: {pil_image.size}")
                image_np = np.array(pil_image) # Already RGB
                logging.debug(f"OpenCV converted to numpy: {file_path_str}")
                return ImageProcessor.resize_if_needed(image_np, pil_image, max_size, file_path)
            except Exception as e:
                logging.error(f"OpenCV also failed for {file_path_str}: {e}")
                pil_image = None

            raise ValueError(f"All image loading methods failed for: {file_path.name}")

        except Exception as e:
            logging.error(f"Error processing image file {file_path_str}: {e}", exc_info=True)
            if pil_image: logging.error(f"Image info at error: Mode: {pil_image.mode}, Size: {pil_image.size}")
            raise ValueError(f"Image processing failed for {file_path.name}: {e}") from e

    @staticmethod
    def process_image_bytes(image_bytes, file_path_hint="memory", max_size=DEFAULT_MAX_IMAGE_SIZE):
        """Loads/processes image from bytes robustly (PIL, OpenCV), ensures RGB, resizes."""
        pil_image = None
        try:
            # Attempt 1: PIL
            try:
                logging.debug(f"Trying PIL from bytes: {file_path_hint}")
                with Image.open(io.BytesIO(image_bytes)) as img:
                    pil_image = img.copy()
                pil_image = ImageOps.exif_transpose(pil_image)
                logging.debug(f"PIL from bytes: {file_path_hint}, Mode: {pil_image.mode}, Size: {pil_image.size}")
                pil_image = ImageProcessor._ensure_rgb(pil_image, Path(file_path_hint))
                image_np = np.array(pil_image)
                logging.debug(f"PIL from bytes converted to numpy: {file_path_hint}")
                return ImageProcessor.resize_if_needed(image_np, pil_image, max_size, Path(file_path_hint))
            except Exception as e:
                logging.warning(f"PIL from bytes failed for {file_path_hint}: {e}")
                if pil_image: logging.warning(f"PIL Info: Mode: {pil_image.mode}, Size: {pil_image.size}")
                pil_image = None

            # Attempt 2: OpenCV
            try:
                logging.debug(f"Trying cv2.imdecode: {file_path_hint}")
                image_np_cv = cv2.imdecode(np.frombuffer(image_bytes, np.uint8), cv2.IMREAD_COLOR)
                if image_np_cv is None: raise ValueError("cv2.imdecode returned None")
                image_rgb = cv2.cvtColor(image_np_cv, cv2.COLOR_BGR2RGB)
                pil_image = Image.fromarray(image_rgb)
                logging.debug(f"OpenCV from bytes: {file_path_hint}, Mode: {pil_image.mode}, Size: {pil_image.size}")
                image_np = np.array(pil_image)
                logging.debug(f"OpenCV from bytes converted to numpy: {file_path_hint}")
                return ImageProcessor.resize_if_needed(image_np, pil_image, max_size, Path(file_path_hint))
            except Exception as e:
                logging.error(f"OpenCV from bytes failed for {file_path_hint}: {e}")
                pil_image = None

            logging.error(f"All image loading methods failed for bytes: {file_path_hint}")
            return None, None

        except Exception as e:
            logging.error(f"Error processing image bytes {file_path_hint}: {e}", exc_info=True)
            if pil_image: logging.error(f"Image info at error: Mode: {pil_image.mode}, Size: {pil_image.size}")
            return None, None

    @staticmethod
    def _ensure_rgb(pil_image, file_path: Path):
        """Converts a PIL image to RGB format if it isn't already."""
        file_name = file_path.name # Get filename for logging
        logging.debug(f"Ensuring RGB for: {file_name}, Current Mode: {pil_image.mode}")
        if pil_image.mode == 'RGB': return pil_image
        try:
            if pil_image.mode in ('RGBA', 'LA'):
                has_transparency = False
                alpha_channel_index = 3 if pil_image.mode == 'RGBA' else 1
                try:
                    # Check if alpha channel has actual transparency
                    alpha_min = pil_image.getextrema()[alpha_channel_index][0]
                    if alpha_min < 255: has_transparency = True
                except (IndexError, TypeError): # TypeError can occur for some modes
                    logging.warning(f"Could not reliably get alpha channel extrema for mode {pil_image.mode} on {file_name}. Assuming opaque.")

                if has_transparency:
                    logging.debug(f"Detected transparency in {file_name} (mode {pil_image.mode}). Pasting onto white background.")
                    background = Image.new("RGB", pil_image.size, (255, 255, 255))
                    try:
                        background.paste(pil_image, mask=pil_image.split()[alpha_channel_index])
                    except IndexError: # Fallback if split fails
                        background.paste(pil_image) # Paste without mask might lose transparency
                    logging.debug(f"Converted {pil_image.mode}->RGB (pasted on white) for '{file_name}'.")
                    return background
                else:
                    logging.debug(f"Converting {pil_image.mode}->RGB (opaque alpha) for '{file_name}'.")
                    return pil_image.convert('RGB')

            elif pil_image.mode in ('P', 'L'):
                 logging.debug(f"Converting {pil_image.mode}->RGB for '{file_name}'.")
                 if pil_image.mode == 'P' and 'transparency' in pil_image.info:
                     try:
                         logging.debug(f"Palette image '{file_name}' has transparency info. Converting via RGBA first.")
                         rgba_image = pil_image.convert('RGBA')
                         return ImageProcessor._ensure_rgb(rgba_image, file_path) # Recurse with RGBA
                     except Exception as p_conv_err:
                         logging.warning(f"Failed to convert P with transparency via RGBA for {file_name}: {p_conv_err}. Trying direct RGB.")
                 return pil_image.convert('RGB')
            else: # CMYK, YCbCr, etc.
                 logging.debug(f"Converting {pil_image.mode}->RGB for '{file_name}'.")
                 return pil_image.convert('RGB')

        except Exception as e:
            logging.error(f"Failed to convert image '{file_name}' from mode '{pil_image.mode}' to RGB: {e}", exc_info=True)
            raise ValueError(f"Cannot convert image '{file_name}' (mode: {pil_image.mode}) to RGB.") from e

    @staticmethod
    def resize_if_needed(image_np, pil_image, max_size, file_path: Path):
        """Resizes the image using LANCZOS if its width or height exceeds max_size."""
        file_name = file_path.name
        try:
            height, width = image_np.shape[:2]
            logging.debug(f"Resize check for {file_name}: {width}x{height}, max_size={max_size}")
            if max_size is not None and max_size > 0 and (width > max_size or height > max_size):
                scale = min(max_size / width, max_size / height)
                new_width = int(width * scale)
                new_height = int(height * scale)
                # Prevent zero dimensions
                if new_width <= 0 or new_height <= 0:
                    logging.warning(f"Resize calculation for {file_name} resulted in zero dimension ({new_width}x{new_height}). Using original size.")
                    return image_np, pil_image
                # Prevent upscaling
                if new_width > width or new_height > height:
                    logging.warning(f"Resize calculation for {file_name} resulted in upscaling ({width}x{height} -> {new_width}x{new_height}). Using original size.")
                    return image_np, pil_image

                logging.debug(f"Resizing {file_name} from {width}x{height} to {new_width}x{new_height} using LANCZOS")
                resized_pil = pil_image.resize((new_width, new_height), Image.Resampling.LANCZOS)
                resized_np = np.array(resized_pil)
                logging.debug(f"Resize successful for {file_name}.")
                return resized_np, resized_pil
            else:
                logging.debug(f"No resize needed for {file_name} ({width}x{height}, max: {max_size}).")
                return image_np, pil_image
        except Exception as e:
            logging.error(f"Error during image resizing for {file_name}: {e}", exc_info=True)
            return image_np, pil_image # Return original on error

    @staticmethod
    def enhance_image_for_face_detection(pil_image):
        """Applies minor contrast/brightness enhancements. Expects RGB PIL image."""
        try:
            # Subtle contrast increase
            enhancer = ImageEnhance.Contrast(pil_image)
            enhanced = enhancer.enhance(1.2)
            # Subtle brightness increase
            enhancer = ImageEnhance.Brightness(enhanced)
            enhanced = enhancer.enhance(1.1)
            logging.debug("Applied subtle contrast/brightness enhancement.")
            return enhanced
        except Exception as e:
            logging.warning(f"Image enhancement failed: {e}")
            return pil_image # Return original if enhancement fails

    @staticmethod
    def detect_faces_with_multiple_attempts(image_np,
                                            detection_priority=DETECT_PRIO_ACCURACY,
                                            encoding_model='small', num_jitters=1, max_upsample=1,
                                            force_all_attempts=False, use_mutex=False,
                                            context_hint=""):
        """
        Detects faces using face_recognition with fallbacks, priority, and mutex.
        Returns locations, encodings, and processing time in ms.
        """
        start_time = time.monotonic()
        face_locations = []
        face_encodings = []
        detection_methods_tried = []
        log_prefix = f"[FaceDetect {context_hint}]" if context_hint else "[FaceDetect]"
        logging.debug(f"{log_prefix} Starting detection. Prio: {'Speed' if detection_priority==DETECT_PRIO_SPEED else 'Accuracy'}, "
                      f"Model: {encoding_model}, Jitters: {num_jitters}, Upsample: {max_upsample}, ForceAll: {force_all_attempts}, Mutex: {use_mutex}")

        # Validate input image
        if not isinstance(image_np, np.ndarray):
            logging.error(f"{log_prefix} Invalid input: image_np is not a numpy array.")
            return [], [], 0.0

        if image_np.ndim != 3 or image_np.shape[2] != 3:
            logging.error(f"{log_prefix} Invalid input: image_np must be a 3-channel RGB image.")
            logging.error(f"{log_prefix} Image shape: {image_np.shape}")
            return [], [], 0.0

        # Determine detection models based on priority
        models_to_try = ['hog', 'cnn'] if detection_priority == DETECT_PRIO_SPEED else ['cnn', 'hog']
        upsample_levels = range(max_upsample + 1)  # Try levels 0, 1, ..., max_upsample

        detection_successful = False

        for upsample_level in upsample_levels:
            if detection_successful and not force_all_attempts:

    # Stats Signal
    frame_processing_time_signal = pyqtSignal(str, int, float) # video_url, frame_number, time_ms (forwarded from analysis task)


    def __init__(self, search_text, start_page, end_page, frames_for_recognition, frames_for_animation,
                 headless, reference_encodings_all, threshold, reference_pixmaps, known_results_urls,
                 analyzer_max_threads, detection_priority, encoding_model, num_jitters, max_upsample,
                 force_all_attempts, use_mutex, processing_stats_ref=None, parent=None):
        super().__init__(parent)
        self.search_text = search_text
        self.start_page = start_page
        self.end_page = end_page # This can be 99999 for "infinity"
        self.frames_for_recognition = frames_for_recognition
        self.frames_for_animation = frames_for_animation
        self.headless = headless
        self.reference_encodings_all = reference_encodings_all # Direct reference
        self.threshold = threshold
        self.reference_pixmaps = reference_pixmaps # Direct reference
        self.known_results_urls = known_results_urls # Copy received, use internally
        self.analyzer_max_threads = analyzer_max_threads
        self.detection_priority = detection_priority
        self.encoding_model = encoding_model
        self.num_jitters = num_jitters
        self.max_upsample = max_upsample
        self.force_all_attempts = force_all_attempts
        self.use_mutex = use_mutex # Store the mutex usage setting
        self.processing_stats = processing_stats_ref # Reference to MainWindow's stats object

        # Internal state
        self.driver = None
        self.temp_dir = None
        self.mutex = QMutex() # Mutex for internal state (_is_running, _stop_requested, _is_paused)
        self.pause_cond = QWaitCondition()
        self._is_running = False
        self._stop_requested = False
        self._is_paused = False
        # Tracking active analysis tasks
        self.active_tasks_lock = QMutex() # Lock for accessing the dictionary below
        self.active_video_processing = {} # {video_url: {info, frames_to_analyze, frame_encodings, analyzed_count, ...}}
        self.last_page_reached = False # Flag to indicate last page has been reached
        # Thread pool for face analysis tasks (created within this thread)
        self.face_analyzer_threadpool = QThreadPool(self)
        self.face_analyzer_threadpool.setMaxThreadCount(self.analyzer_max_threads)
        self._consecutive_analysis_errors = 0 # Track consecutive errors
        self._max_consecutive_analysis_errors = 10 # Stop after this many consecutive errors

    def _handle_analysis_task_error(self, error_message):
        """Receives error signal from analysis task, tracks errors, and stops if threshold exceeded."""
        logging.warning(f"ScraperThread received task error: {error_message}") # Log internally too
        self.task_error_signal.emit(f"Analysis Task Error: {error_message}")

        self._consecutive_analysis_errors += 1
        logging.warning(f"Consecutive analysis errors: {self._consecutive_analysis_errors}/{self._max_consecutive_analysis_errors}")

        if self._consecutive_analysis_errors >= self._max_consecutive_analysis_errors:
            error_msg = f"Stopping scrape due to {self._consecutive_analysis_errors} consecutive analysis errors."
            logging.error(error_msg)
            self.error_signal.emit(error_msg) # Signal critical error to main window
            self.stop() # Request the scraper thread to stop

        self.current_page_num = 0

        logging.info(f"ScraperThread: Analyzer ThreadPool max threads set to {self.analyzer_max_threads}")

    def _extract_video_data_bs4(self, page_url):
        """Extracts video data from a page using requests and BeautifulSoup."""
        video_data_list = []
        try:
            response = requests.get(page_url, timeout=20, headers={'User-Agent': 'Mozilla/5.0'})
            response.raise_for_status() # Raise HTTPError for bad responses (4xx or 5xx)
            soup = BeautifulSoup(response.content, 'html.parser')
            video_containers = soup.select(MOTHERLESS_VIDEO_CONTAINER_SELECTOR)

            for container in video_containers:
                link_element = container.select_one(MOTHERLESS_VIDEO_LINK_SELECTOR)
                title_element = container.select_one(MOTHERLESS_VIDEO_TITLE_SELECTOR)
                thumbnail_element = container.select_one(MOTHERLESS_THUMBNAIL_IMG_SELECTOR)

                video_url = link_element['href'] if link_element else None
                video_title = title_element.text.strip() if title_element else 'Unknown Title'
                thumbnail_url = thumbnail_element['src'] if thumbnail_element else None

                if video_url and video_url.startswith('http'):
                    video_data_list.append({
                        'href': video_url,
                        'title': video_title,
                        'thumbnailSrc': thumbnail_url
                    })
        except requests.exceptions.RequestException as e:
            logging.warning(f"[BS4 Scraper] Request error for {page_url}: {e}")
        except Exception as e:
            logging.error(f"[BS4 Scraper] Error parsing page {page_url}: {e}", exc_info=True)
        return video_data_list

    def _process_extracted_video_data(self, video_data):
            """Processes raw video data extracted from the page."""
            # 1. Grundlegende Strukturprüfung der Eingabedaten
            if not isinstance(video_data, dict):
                logging.warning("Invalid video_data structure received.")
                return None # Direkt hier abbrechen, wenn die Struktur ungültig ist

            # 2. Daten extrahieren (mit .get() für Sicherheit bei fehlenden Schlüsseln)
            video_url = video_data.get('href')
            video_title = video_data.get('title', 'Unknown Title') # Standardwert falls Titel fehlt
            thumbnail_url = video_data.get('thumbnailSrc')

            # 3. *** KRITISCHE PRÜFUNG: Ist die Video-URL gültig? ***
            #    Diese Prüfung MUSS nach dem .get('href') erfolgen.
            if not video_url or not video_url.startswith("http"):
                # Logge eine Warnung mit den verfügbaren Informationen
                logging.warning(f"Skipping data processing, invalid/missing video URL. Data received: href='{video_data.get('href', 'N/A')}', title='{video_title}'")
                return None # Funktion verlassen, wenn URL ungültig ist

            # 4. Wenn die URL gültig ist, fahre mit der Verarbeitung fort (in try/except)
            all_preview_frame_urls = [] # Initialisiere die Liste hier
            try:
                # --- Generiere Vorschau-URLs (basierend auf der extrahierten thumbnail_url) ---
                if thumbnail_url:
                    try:
                        # Dein Code ab ca. Zeile 1540 beginnt hier logisch
                        logging.debug(f"Base Thumb URL found: {thumbnail_url} for Vid: ...{video_url[-20:]}") # video_url ist hier sicher verfügbar
                        base_url_part, ext = os.path.splitext(thumbnail_url)
                        # Sicherstellen, dass die Erweiterung sinnvoll ist
                        if not ext or ext.lower() not in ['.jpg', '.jpeg', '.png', '.webp']:
                            raise ValueError(f"Unexpected thumbnail extension: {ext}")

                        base_for_previews = base_url_part
                        # Robusteres Pattern Matching für die Generierung der Basis-URL
                        match = re.search(r'(-\d+)$', base_for_previews)
                        if match:
                            base_for_previews = base_for_previews[:match.start()] + '-'
                        # Pattern 2: Endet mit -small
                        elif base_for_previews.endswith('-small'):
                            base_for_previews = base_for_previews[:-len('-small')] + '-small-'
                        # Pattern 3: Endet mit _thumb oder _preview
                        elif base_for_previews.endswith('_thumb') or base_for_previews.endswith('_preview'):
                            base_for_previews = re.sub(r'(_thumb|_preview)$', '_', base_for_previews)
                        # Standard: Annahme, dass '-' angehängt werden muss, wenn kein anderes Muster passt
                        elif not base_for_previews.endswith('-'):
                            base_for_previews = base_for_previews + '-'
                        # else: base endet bereits mit '-', so verwenden

                        # Generiere URLs für Frames 1 bis 15 (Standardbereich für Motherless-Vorschauen)
                        all_preview_frame_urls = [f"{base_for_previews}{i}{ext}" for i in range(1, 16)]
                        logging.debug(f"Generated {len(all_preview_frame_urls)} potential preview URLs for ...{video_url[-20:]}")

                    except ValueError as path_err: # Fange spezifische Fehler ab (z.B. schlechte Erweiterung)
                        logging.error(f"Cannot generate preview URLs from thumbnail URL '{thumbnail_url}' for ...{video_url[-20:]}: {path_err}")
                        all_preview_frame_urls = [] # Setze generierte Liste zurück
                    except Exception as e:
                        logging.error(f"Error generating preview URLs from '{thumbnail_url}' for ...{video_url[-20:]}: {e}", exc_info=True)
                        all_preview_frame_urls = []
                else:
                    # Warnung, wenn keine Thumbnail-URL vorhanden war
                    logging.warning(f"Thumbnail URL was missing in extracted data for ...{video_url[-20:]}. Cannot generate preview URLs.")
                    all_preview_frame_urls = []

                # --- Bereite das Ergebnis-Dictionary vor ---
                # Filtere generierte URLs basierend auf ausgewählten Animationsframes
                preview_frames_for_animation_urls = []
                if all_preview_frame_urls: # Nur filtern, wenn URLs generiert wurden
                    preview_frames_for_animation_urls = [
                        all_preview_frame_urls[i-1] for i in self.frames_for_animation # Nimmt an, dass self.frames_for_animation existiert
                        if 1 <= i <= len(all_preview_frame_urls) # Stelle sicher, dass der Index gültig ist
                    ]

                # Erstelle das finale video_info Dictionary
                video_info = {
                    "videoTitle": video_title,
                    "videoURL": video_url, # video_url ist hier garantiert gültig
                    "thumbnailURL": thumbnail_url, # Kann None sein
                    "previewFramesAnimation": preview_frames_for_animation_urls,
                    "allPreviewFrameURLs": all_preview_frame_urls # Kann leer sein
                }
                return video_info # Gib das erfolgreiche Ergebnis zurück

            except Exception as e:
                # Fange alle anderen unerwarteten Fehler während der Verarbeitung *nach* der URL-Validierung
                logging.error(f"Unexpected error processing data for element (URL: ...{video_url[-20:]}): {e}", exc_info=True) # video_url existiert hier
                return None # Gib None im Fehlerfall zurück


    # --- State Control Methods ---
    def stop(self):
        """Requests the thread to stop gracefully."""
        with QMutexLocker(self.mutex):
            if self._is_running:
                logging.info("ScraperThread: Stop requested.")
                self._stop_requested = True
                # Wake up if paused
                if self._is_paused:
                    self.pause_cond.wakeAll()
            else:
                logging.debug("ScraperThread: Stop requested but already not running.")

    def pause(self):
        """Requests the thread to pause."""
        with QMutexLocker(self.mutex):
            if self._is_running and not self._is_paused:
                logging.info("ScraperThread: Pausing.")
                self._is_paused = True
                self.status_signal.emit("Pausing...") # Immediate feedback
            elif self._is_paused:
                logging.debug("ScraperThread: Pause requested but already paused.")
            else:
                logging.debug("ScraperThread: Pause requested but not running.")

    def resume(self):
        """Requests the thread to resume from pause."""
        with QMutexLocker(self.mutex):
            if self._is_running and self._is_paused:
                logging.info("ScraperThread: Resuming.")
                self._is_paused = False
                self.status_signal.emit("Resuming...") # Immediate feedback
                self.pause_cond.wakeAll() # Wake the run loop if waiting
            elif not self._is_paused:
                 logging.debug("ScraperThread: Resume requested but not paused.")
            else:
                 logging.debug("ScraperThread: Resume requested but not running.")

    # --- Main Execution Logic ---
    def run(self):
        """Main execution method: Scrapes pages, downloads frames via Selenium, queues analysis."""
        with QMutexLocker(self.mutex):
            self._is_running = True
            self._stop_requested = False
            self._is_paused = False
        self.log_signal.emit("ScraperThread: Starting run method.")
        self.driver = None # Ensure driver is reset at start
        total_videos_scraped_session = 0
        # total_videos_processed_session = 0 # Track processed videos for stats -> Handled by ProcessingStats now

        try:
            # --- Setup ---
            self.temp_dir = Path(tempfile.mkdtemp(prefix="webvidscrap_"))
            logging.info(f"ScraperThread: Temporary directory created: {self.temp_dir}")

            self.status_signal.emit("Initializing browser...")
            if self._check_stop_request(): return
            self.driver = self._initialize_webdriver() # Assign to self.driver
            if self.driver is None:
                self.log_signal.emit("ScraperThread: WebDriver initialization failed. Stopping.")
                # No need to emit finished_signal here, finally block handles it
                return

            # --- Main Scraping Loop ---
            search_term_encoded = self.search_text.strip().replace(" ", "+")
            self.current_page_num = self.start_page
            # Handle "infinity" case for display
            end_page_display = "∞" if self.end_page > 9998 else str(self.end_page)
            actual_end_page = self.end_page # The actual limit for the loop

            # Update total pages in stats
            if self.processing_stats:
                self.processing_stats.update_page_progress(self.current_page_num -1, actual_end_page)

            # Loop from start_page up to and including actual_end_page
            while not self._check_stop_request() and (self._get_active_analysis_tasks() > 0 or not self.last_page_reached):
                page_num = self.current_page_num
                if self.processing_stats:
                     self.processing_stats.update_page_progress(page_num, actual_end_page)
                if not self._check_running_or_handle_pause(): break # Check stop/pause

                self.status_signal.emit(f"Loading Page {page_num}/{end_page_display}...")
                page_url = f"https://motherless.com/term/videos/{search_term_encoded}?page={page_num}"
                self.log_signal.emit(f"ScraperThread: Navigating to: {page_url}")

                # --- Load Page ---
                if not self._load_page_and_wait(self.driver, page_url):
                    # _load_page_and_wait logs reasons for failure (timeout, error, etc.)
                    # If it returns False, assume end of results or critical error, stop looping.
                    self.log_signal.emit(f"ScraperThread: Failed to load or find content on page {page_num}. Stopping scrape.")
                    self.last_page_reached = True # Set flag to indicate last page reached

                # --- Extract video data using JavaScript ---
                # --- Extract video data using BeautifulSoup ---
                extracted_data_list = self._extract_video_data_bs4(page_url)
                if not extracted_data_list:
                    self.log_signal.emit(f"ScraperThread: No video data extracted via BeautifulSoup on page {page_num}. Assuming end of results.")
                    self.last_page_reached = True # Set flag, loop condition will handle exit
                    # break # REMOVED: Let the main while loop condition handle termination based on last_page_reached and active tasks

                self.log_signal.emit(f"ScraperThread: Extracted data for {len(extracted_data_list)} potential videos on page {page_num} via JS.")
                self.progress_signal.emit(0, len(extracted_data_list)) # Progress for items on this page
                page_video_index = 0
                INTER_VIDEO_DELAY_MS = 10 # Reduced delay

                # --- Process Videos on Page (Iterate through extracted data) ---
                for video_data in extracted_data_list:
                    if not self._check_running_or_handle_pause(): break

                    page_video_index += 1
                    self.progress_signal.emit(page_video_index, len(extracted_data_list))

                    try:
                        # Process the extracted data (no more Selenium interaction here)
                        video_info = self._process_extracted_video_data(video_data)
                        if video_info:
                            video_url = video_info.get('videoURL')
                            if not video_url: continue # Skip if URL missing after processing

                            # Check if known/active using a lock
                            with QMutexLocker(self.active_tasks_lock):
                                is_known = video_url in self.known_results_urls
                                is_active = video_url in self.active_video_processing
                            if is_known or is_active:
                                logging.debug(f"Skipping known/active URL: ...{video_url[-20:]}")
                                continue # Skip this video element

                            # If not known/active, emit signal for MainWindow to handle queuing/lane creation
                            total_videos_scraped_session += 1
                            if self.processing_stats:
                                self.processing_stats.increment_total_videos_found() # Update total count
                            # Emit signal to MainWindow
                            self.video_found_signal.emit(video_url, video_info)
                        else:
                            # Error logged within _process_extracted_video_data
                            pass

                    except Exception as video_proc_err:
                        # Log error during data processing (e.g., URL generation)
                        video_url_for_log = video_data.get('href', 'Unknown URL')
                        self.log_signal.emit(f"Error processing extracted data for video item {page_video_index} (URL: ...{video_url_for_log[-20:]}) on page {page_num}: {video_proc_err}")
                        logging.exception(f"Error processing extracted data on page {page_num} for URL: {video_url_for_log}")
                        continue # Continue to the next data item

                    self.msleep(INTER_VIDEO_DELAY_MS) # Small delay between processing elements

                if not self._check_running_or_handle_pause(): break # Check if stopped during page processing

                # Optional delay between pages
                if self._is_running and page_num < actual_end_page:
                    self.log_signal.emit(f"ScraperThread: Finished page {page_num}. Waiting briefly...")
                    self.msleep(1200)
                self.current_page_num += 1

            # --- End of Page Loop ---
            if self._check_stop_request():
                 self.log_signal.emit("ScraperThread: Stop requested during page loop.")
            elif self._is_running:
                 self.log_signal.emit("ScraperThread: Finished scraping all requested pages or no more results found.")

        except TaskStopException as stop_ex:
             # Custom exception for controlled stops (e.g., during Selenium download)
             self.log_signal.emit(f"ScraperThread: Task stopped internally: {stop_ex}")
             logging.info(f"ScraperThread caught TaskStopException: {stop_ex}")
        except WebDriverException as wd_err:
            # Catch critical WebDriver errors that might occur outside specific operations
             err_msg = f"ScraperThread: Critical WebDriver error: {wd_err}"
             self.log_signal.emit(err_msg)
             self.error_signal.emit(err_msg) # Signal main thread about critical error
             logging.critical(f"Unhandled WebDriverException in ScraperThread run loop: {wd_err}", exc_info=True)
             self._stop_requested = True # Ensure stop flag is set
        except Exception as e: # Catch any other unexpected errors
            err_msg = f"ScraperThread: Unhandled error during scraping: {e}"
            self.log_signal.emit(err_msg)
            self.error_signal.emit(err_msg) # Signal main thread
            logging.critical("Unhandled error in ScraperThread run loop", exc_info=True)
            self._stop_requested = True # Ensure stop flag is set

        finally:
            # --- Cleanup ---
            self.status_signal.emit("Finishing process...")
            self.log_signal.emit("ScraperThread: Waiting for remaining analysis tasks...")

            # Wait for analysis pool to finish tasks
            wait_start_time_proc = time.monotonic()
            max_wait_seconds_proc = 60 # Time to wait for tasks to finish
            check_interval_ms_proc = 500
            try:
                active_count = self.face_analyzer_threadpool.activeThreadCount()
                while active_count > 0:
                    if self._check_stop_request():
                        self.log_signal.emit("ScraperThread: Stop requested during final task wait.")
                        break
                    self.status_signal.emit(f"Finishing analysis ({active_count} tasks)...")
                    if time.monotonic() - wait_start_time_proc > max_wait_seconds_proc:
                        self.log_signal.emit(f"Warning: Timed out ({max_wait_seconds_proc}s) waiting for final analysis tasks. Active: {active_count}")
                        break
                    # Use QThread's sleep, process events to keep responsive
                    self.msleep(check_interval_ms_proc)
                    # QApplication.processEvents() # Avoid calling this directly in thread
                    active_count = self.face_analyzer_threadpool.activeThreadCount()

                # Force clear pool if tasks remain after wait or stop request
                if self.face_analyzer_threadpool.activeThreadCount() > 0:
                     logging.warning(f"ScraperThread: Forcing clear of analysis pool ({self.face_analyzer_threadpool.activeThreadCount()} tasks remaining).")
                     self.face_analyzer_threadpool.clear() # Request stop
                     # Give tasks a moment to respond to cancellation request
                     self.face_analyzer_threadpool.waitForDone(2000)
                     # If still not done, they might be stuck, log warning
                     if self.face_analyzer_threadpool.activeThreadCount() > 0:
                          logging.warning(f"ScraperThread: Analysis pool tasks did not fully stop after clear().")

                self.log_signal.emit("ScraperThread: Analysis tasks finished or stopped.")

            except Exception as pool_wait_err:
                 logging.error(f"Error waiting for analysis pool: {pool_wait_err}", exc_info=True)

            # Quit WebDriver
            if self.driver:
                try:
                    self.log_signal.emit("ScraperThread: Quitting browser...")
                    self.driver.quit()
                    self.log_signal.emit("ScraperThread: Browser quit successfully.")
                except (WebDriverException, NoSuchWindowException) as quit_wd_err:
                    # Ignore errors if browser already closed
                    if "no such window" not in str(quit_wd_err).lower():
                        self.log_signal.emit(f"ScraperThread: Error quitting browser: {quit_wd_err}")
                        logging.error(f"Error quitting browser: {quit_wd_err}", exc_info=False)
                except Exception as quit_err:
                    self.log_signal.emit(f"ScraperThread: Unexpected error quitting browser: {quit_err}")
                    logging.error("Unexpected error quitting browser", exc_info=True)
                finally:
                    self.driver = None # Ensure reference is cleared

            # Cleanup temporary directory
            self.cleanup_temp_dir()

            # Final status update and signal
            final_status = "Finished" # Default status
            try:
                with QMutexLocker(self.mutex):
                     is_stopped = self._stop_requested
                     self._is_running = False # Mark as not running
                     self._is_paused = False
                final_status = "Scraping stopped." if is_stopped else "Scraping finished."
            except Exception as mutex_err:
                logging.error(f"Error accessing mutex in finally block: {mutex_err}")
                final_status = "Finished with errors"

            self.log_signal.emit(f"ScraperThread: Run method finished. Status: {final_status}")
            self.status_signal.emit(final_status)

            # Signal completion to the parent (MainWindow)
            # Ensure parent still exists before signaling
            parent_obj = self.parent()
            if parent_obj:
                 try:
                      # Use invokeMethod for safety, ensures slot runs in parent's thread (GUI)
                      QMetaObject.invokeMethod(parent_obj, "scraping_finished", Qt.QueuedConnection,
                                               Q_ARG(object, self), Q_ARG(str, "ScraperThread"))
                 except Exception as signal_err:
                      logging.error(f"Error invoking scraping_finished signal to parent: {signal_err}")
            else:
                 logging.warning("ScraperThread parent missing on finish, cannot emit finished signal.")


    def _check_stop_request(self):
        """Checks if stop has been requested, thread-safe."""
        with QMutexLocker(self.mutex):
            stopped = self._stop_requested
        # if stopped: logging.debug("Stop request detected.") # Optional debug log
        return stopped

    def _check_running_or_handle_pause(self):
        """Checks if running, handles pause state, returns False if should stop."""
        self.mutex.lock()
        try:
            while self._is_running and self._is_paused and not self._stop_requested:
                self.status_signal.emit(f"Paused on page {self.current_page_num}...")
                # Wait with timeout, allows periodic checks even if not woken
                self.pause_cond.wait(self.mutex, 500) # milliseconds

            # Check running/stop status *after* potential wait
            if not self._is_running or self._stop_requested:
                return False # Stop execution
            # If not stopped/paused, continue
            return True
        finally:
            self.mutex.unlock()

    def cleanup_temp_dir(self):
        """Removes the temporary directory created by the thread."""
        if self.temp_dir and self.temp_dir.exists():
            try:
                shutil.rmtree(self.temp_dir)
                logging.info(f"ScraperThread: Deleted temporary directory: {self.temp_dir}")
            except OSError as e: # Catch specific OS errors
                logging.warning(f"ScraperThread: Error deleting temp dir {self.temp_dir}: {e}")
                self.log_signal.emit(f"Warning: Error cleaning up temp files: {e}")
            except Exception as e: # Catch other unexpected errors
                logging.warning(f"ScraperThread: Unexpected error deleting temp dir {self.temp_dir}: {e}", exc_info=True)
                self.log_signal.emit(f"Warning: Unexpected error cleaning up temp files: {e}")
        self.temp_dir = None

    def _initialize_webdriver(self):
        """Initializes the Selenium Chrome WebDriver."""
        try:
            self.log_signal.emit("ScraperThread: Configuring Chrome options...")
            options = webdriver.ChromeOptions()
            if self.headless:
                options.add_argument("--headless=new") # Use the new headless mode
                logging.info("Headless mode enabled.")
            else:
                logging.info("Headless mode disabled.")

            # Common options
            options.add_argument("--log-level=3") # Suppress console logs from Chrome/ChromeDriver
            options.add_argument("--disable-gpu") # Often needed in headless/server environments
            options.add_argument("--no-sandbox") # Often needed on Linux
            options.add_argument("--disable-dev-shm-usage") # Overcomes resource limits in Docker/Linux
            options.add_argument("--window-size=1920,1080") # Standard size
            options.add_argument("--start-maximized") # Start maximized if not headless
            options.add_argument("--disable-extensions")
            options.add_argument("--disable-infobars")
            # User agent (keep updated or allow customization)
            options.add_argument("user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36") # Updated example

            # Anti-detection options
            options.add_experimental_option('excludeSwitches', ['enable-logging', "enable-automation"])
            options.add_experimental_option('useAutomationExtension', False)
            options.add_argument("--disable-blink-features=AutomationControlled")
            # Remove problematic/obsolete arguments
            # options.add_argument('--ignore-certificate-errors') # Can be a security risk
            # options.add_argument('--allow-running-insecure-content') # Security risk

            # Driver setup using webdriver-manager
            try:
                self.log_signal.emit("ScraperThread: Setting up ChromeDriver via webdriver-manager...")
                # Explicitly define service to potentially capture logs if needed
                service = ChromeService(executable_path=ChromeDriverManager().install())
                self.log_signal.emit(f"ScraperThread: ChromeDriver installed/located at: {service.path}")
            except Exception as driver_manager_err:
                # Provide more specific error guidance
                error_msg = (f"Fatal Error: Failed setup ChromeDriver via webdriver-manager: {driver_manager_err}\n\n"
                             "- Check internet connection & firewall rules.\n"
                             "- Ensure Chrome browser is installed and compatible with driver.\n"
                             "- Verify write permissions for webdriver-manager cache directory.\n"
                             "- Try running 'webdriver-manager chrome' in terminal manually.")
                logging.critical(error_msg, exc_info=True)
                self.error_signal.emit(error_msg) # Signal main thread
                return None

            self.log_signal.emit("ScraperThread: Initializing Chrome WebDriver...")
            driver = webdriver.Chrome(service=service, options=options)
            driver.set_page_load_timeout(60) # Increased timeout
            driver.implicitly_wait(5) # Small implicit wait, rely more on explicit waits
            self.log_signal.emit("ScraperThread: WebDriver initialized.")

            # Inject anti-detection scripts using CDP
            try:
                # Use a more robust script injection
                script_source = """
                    Object.defineProperty(navigator, 'webdriver', {get: () => undefined});
                    Object.defineProperty(navigator, 'languages', {get: () => ['en-US', 'en']});
                    Object.defineProperty(navigator, 'plugins', {get: () => [1, 2, 3]});
                    // Add more overrides if needed
                """
                driver.execute_cdp_cmd('Page.addScriptToEvaluateOnNewDocument', {'source': script_source})
                self.log_signal.emit("ScraperThread: Anti-detection script injected via CDP.")
            except Exception as cdp_err:
                # Log as warning, might not be critical depending on website
                self.log_signal.emit(f"ScraperThread: Warning - Failed to inject anti-detection script: {cdp_err}")
                logging.warning(f"Failed to inject CDP script: {cdp_err}")

            self.log_signal.emit("ScraperThread: Browser initialized successfully.")
            return driver

        except WebDriverException as browser_wd_error:
             # Handle cases like browser crashing on start
             error_msg = (f"Fatal Error: Failed to initialize Chrome WebDriver: {browser_wd_error}\n\n"
                          "- Ensure Google Chrome is installed, updated, and accessible.\n"
                          "- Check if ChromeDriver version is compatible with your Chrome.\n"
                          "- Look for Chrome crash logs if applicable.")
             self.error_signal.emit(error_msg)
             logging.critical("WebDriver Initialization Failed (WebDriverException)", exc_info=True)
             return None
        except Exception as browser_error:
             # Catch other unexpected errors during initialization
             error_msg = f"Fatal Error: An unexpected error occurred initializing Chrome: {browser_error}"
             self.error_signal.emit(error_msg)
             logging.critical("WebDriver Initialization Failed (Unexpected)", exc_info=True)
             return None

    def _load_page_and_wait(self, driver, page_url):
        """Loads a page and waits for the main video container element."""
        if self._check_stop_request(): return False
        try:
            driver.get(page_url)
            # Wait for at least one video container using WebDriverWait
            WebDriverWait(driver, 25).until( # Reduced wait time slightly
                EC.presence_of_element_located((By.CSS_SELECTOR, MOTHERLESS_VIDEO_CONTAINER_SELECTOR))
            )
            # Optional: Add a small delay for dynamic content if needed
            # self.msleep(250)
            self.log_signal.emit(f"ScraperThread: Page loaded and main element found: {page_url}")
            return True
        except TimeoutException:
             if self._check_stop_request(): return False
             # Check page source or title to differentiate timeout from actual end-of-results/error
             page_title = driver.title.lower()
             page_source_sample = driver.page_source[:500].lower() # Check beginning of source
             # Look for common error indicators or lack of expected content
             if "error" in page_title or "not found" in page_title or "access denied" in page_title:
                 self.log_signal.emit(f"Warning: Page load timeout OR potential error/empty page detected: {page_url}. Assuming end.")
                 logging.warning(f"Timeout and potential error/empty page detected for {page_url}. Stopping page iteration.")
                 return False # Treat as end of results or block
             else:
                 # Timeout occurred, but page seems to have *some* content, just not the specific selector waited for.
                 # This might happen on pages with different layouts or if the selector is too specific.
                 self.log_signal.emit(f"Warning: Timeout waiting for specific video containers ({MOTHERLESS_VIDEO_CONTAINER_SELECTOR}) on: {page_url}. Page might have loaded but content structure changed. Skipping page.")
                 logging.warning(f"Timeout waiting for elements on {page_url}, but page seems to have other content.")
                 # Consider this non-fatal for the whole scrape, but skip this page.
                 return False # Treat as skippable page, but don't stop the whole scrape automatically
        except WebDriverException as page_load_wd_err:
            if self._check_stop_request(): return False
            # Handle specific critical WebDriver exceptions (like browser closed)
            if "disconnected" in str(page_load_wd_err) or "unable to connect" in str(page_load_wd_err) or "no such window" in str(page_load_wd_err).lower():
                 self.log_signal.emit(f"ScraperThread: Browser disconnected or closed during page load: {page_url}. Stopping.")
                 self.error_signal.emit(f"WebDriver disconnected: {page_load_wd_err}")
                 self._stop_requested = True # Set stop flag
                 return False # Stop the process
            else:
                 # Log other WebDriver exceptions as warnings and skip the page
                 self.log_signal.emit(f"Warning: WebDriver error loading page {page_url}: {page_load_wd_err}. Skipping page.")
                 logging.error(f"WebDriver error loading page {page_url}: {page_load_wd_err}", exc_info=False)
                 return False # Skip this page
        except Exception as page_load_err:
            if self._check_stop_request(): return False
            # Catch any other unexpected errors during page load
            self.log_signal.emit(f"Warning: Unexpected error loading page {page_url}: {page_load_err}. Skipping page.")
            logging.error(f"Unexpected error loading page {page_url}", exc_info=True)
            return False # Skip this page

        



    # --- Frame Download via Selenium ---
    def _download_frame_via_selenium(self, frame_url):
        """ Attempts to download a frame using requests. Returns bytes or None. """
        if not self._is_running or self._check_stop_request():
            logging.warning(f"[RequestsDL] Stop requested. Skipping: ...{Path(frame_url).name}")
            return None

        log_prefix_dl = f"[RequestsDL F:{Path(frame_url).name}]"
        logging.info(f"{log_prefix_dl} Attempting download via requests: {frame_url}")
        image_bytes = None
        headers = {'User-Agent': 'Mozilla/5.0'}

        try:
            resp = requests.get(frame_url, timeout=10, stream=False, headers=headers)
            logging.debug(f"{log_prefix_dl} Status: {resp.status_code}")
            resp.raise_for_status()
            image_bytes = resp.content

            if image_bytes:
                logging.info(f"{log_prefix_dl} Successfully downloaded {len(image_bytes)} bytes via requests.")
            else:
                logging.warning(f"{log_prefix_dl} Could not download image data via requests from {frame_url}.")

        except TaskStopException:
             logging.info(f"{log_prefix_dl} TaskStopException caught during download.")
        except requests.exceptions.Timeout:
            logging.warning(f"{log_prefix_dl} Timeout loading frame URL in requests: {frame_url}")
        except requests.exceptions.RequestException as e:
             logging.error(f"{log_prefix_dl} requests exception downloading frame: {e}", exc_info=False)
        except Exception as e:
            logging.error(f"{log_prefix_dl} Unexpected error downloading frame via requests: {e}", exc_info=True)

        # Return the downloaded bytes (or None if failed/stopped)
        return image_bytes


    # --- Analysis Orchestration ---
    @pyqtSlot(str, dict)
    def queue_video_for_analysis(self, video_url, video_info):
        """Downloads frames via Selenium and queues FaceAnalysisOnlyTasks."""
        if self._check_stop_request(): return # Check before starting

        all_preview_urls = video_info.get('allPreviewFrameURLs', [])
        max_available_frame_idx = len(all_preview_urls)

        # Filter and get unique frame numbers to analyze based on settings
        unique_frames_to_analyze = sorted(list(set(
            f for f in self.frames_for_recognition if 1 <= f <= max_available_frame_idx
        )))

        if not unique_frames_to_analyze:
            self.log_signal.emit(f"ScraperThread: No valid frames ({self.frames_for_recognition}) within available range (1-{max_available_frame_idx}) for ...{video_url[-10:]}. Skipping analysis.")
            # Ensure completion check is triggered even if no frames analyzed
            # Use invokeMethod to ensure it runs in this thread's event loop
            QMetaObject.invokeMethod(self, "_check_video_analysis_completion_invoked", Qt.QueuedConnection, Q_ARG(str, video_url))
            return

        num_frames = len(unique_frames_to_analyze)
        log_prefix = f"[Queue Vid:{video_url[-10:]}]"
        logging.info(f"{log_prefix} Preparing {num_frames} frames for analysis (Selenium DL)...")

        # Store video info and start time for tracking
        with QMutexLocker(self.active_tasks_lock):
            if video_url in self.active_video_processing:
                logging.warning(f"{log_prefix} Video already in processing queue. Skipping add.")
                return # Avoid duplicate processing entries

            self.active_video_processing[video_url] = {
                "info": video_info,
                "frames_to_analyze": set(unique_frames_to_analyze),
                "frame_encodings": {}, # {frame_num: [encodings_list] or None on failure}
                "frame_image_data": {}, # {frame_num: QByteArray or None}
                "analyzed_frames_count": 0, # Incremented when analysis finishes or fails
                "max_score": 0.0,
                "best_frame": 0,
                "best_ref_idx": -1,
                "comparison_complete": False, # Flag set when all frames accounted for
                "start_time": time.monotonic() # Record start time for video processing duration
            }

        tasks_queued_count = 0
        for frame_num in unique_frames_to_analyze:
            if self._check_stop_request():
                logging.info(f"{log_prefix} Stop requested while preparing analysis tasks.")
                break

            # Get the correct frame URL (adjusting for 0-based index)
            frame_url = all_preview_urls[frame_num - 1]
            # Signal UI: Pending Download/Downloading
            self.frame_status_update_signal.emit(video_url, frame_num, FrameWidget.STATE_DOWNLOADING)

            
                        # Download the frame using Requests
            image_bytes = self._download_frame_via_selenium(frame_url)
            if self._check_stop_request(): break # Check again after potentially long download

            if image_bytes:
                # Emit the loaded image data for the lane widget
                self.frame_image_loaded_signal.emit(video_url, frame_num, QByteArray(image_bytes))
                # Update status to analyzing
                self.frame_status_update_signal.emit(video_url, frame_num, FrameWidget.STATE_ANALYZING)
                try:
                    # Create the analysis task
                    analysis_task = FaceAnalysisOnlyTask(
                        video_url=video_url, frame_number=frame_num, image_bytes=image_bytes,
                        detection_priority=self.detection_priority, encoding_model=self.encoding_model,
                        num_jitters=self.num_jitters, max_upsample=self.max_upsample,
                        force_all_attempts=self.force_all_attempts,
                        use_mutex=self.use_mutex # Pass mutex setting from thread init
                    )






                    # Connect signals from the task
                    analysis_task.signals.frame_analysis_finished_signal.connect(self.handle_face_analysis_finished) # Corrected slot name

                    analysis_task.signals.log_signal.connect(self.log_signal)
                    analysis_task.signals.error.connect(self._handle_analysis_task_error) # Route error through ScraperThread handler

                    analysis_task.signals.frame_status_update_signal.connect(self.frame_status_update_signal)
                    analysis_task.signals.frame_processing_time_signal.connect(self.frame_processing_time_signal) # Forward stats

                    logging.debug(f"{log_prefix} Submitting FaceAnalysisOnlyTask F{frame_num} to pool.")
                    # Submit task to the thread pool
                    self.face_analyzer_threadpool.start(analysis_task)
                    tasks_queued_count += 1

                except Exception as task_create_err:
                    logging.error(f"{log_prefix} Failed to create/start FaceAnalysisOnlyTask for F{frame_num}: {task_create_err}", exc_info=True)
                    self.error_signal.emit(f"Error starting analysis task for ...{video_url[-20:]} F{frame_num}: {task_create_err}") # Emit critical error
                    self.error_signal.emit(f"Error starting analysis task for ...{video_url[-10:]} F{frame_num}")
                    # Mark frame as failed if task creation fails
                    self._mark_frame_as_failed(video_url, frame_num)
            else:
                # Download failed (reason logged in _download_frame_via_selenium)
                self.frame_status_update_signal.emit(video_url, frame_num, FrameWidget.STATE_DOWNLOAD_FAILED)
                # Mark frame as failed in tracking
                self._mark_frame_as_failed(video_url, frame_num)

        # After attempting all frames for this video:
        if not self._check_stop_request():
            if tasks_queued_count == 0 and unique_frames_to_analyze:
                logging.warning(f"{log_prefix} No analysis tasks were successfully queued for this video (Downloads failed?).")
            # Always check completion, even if no tasks queued, to potentially finalize the video processing state
            QMetaObject.invokeMethod(self, "_check_video_analysis_completion_invoked", Qt.QueuedConnection,
                                     Q_ARG(str, video_url))

    @pyqtSlot(str, int, list, QByteArray)
    def handle_frame_analysis_finished(self, video_url, frame_number, encodings, image_bytes):
        """Slot to receive analysis results and emit signal for frame image update."""
        self.frame_image_loaded_signal.emit(video_url, frame_number, image_bytes)
    def _mark_frame_as_failed(self, video_url, frame_num):
        """Updates tracking info when a frame analysis cannot even start (e.g., download failed)."""
        log_prefix = f"[Queue Vid:{video_url[-10:]}]"
        logging.debug(f"{log_prefix} Marking frame F{frame_num} as analysis start failed.")
        completion_check_needed = False
        with QMutexLocker(self.active_tasks_lock):
            if video_url in self.active_video_processing:
                video_data = self.active_video_processing[video_url]
                # Check if the frame was actually expected to be analyzed
                if frame_num in video_data.get("frames_to_analyze", set()):
                    # Ensure we only increment the count once per expected frame
                    # Check if results for this frame are NOT already stored
                    if frame_num not in video_data["frame_encodings"]:
                        video_data["frame_encodings"][frame_num] = None # Mark as processed (failed), value is None
                        video_data["frame_image_data"][frame_num] = None # No image data either
                        video_data["analyzed_frames_count"] += 1
                        logging.debug(f"{log_prefix} Increased analyzed count for failed F{frame_num}. New count: {video_data['analyzed_frames_count']}")
                        completion_check_needed = True
                    else:
                         # This case means the frame was already processed (success or fail). Log a warning.
                         logging.warning(f"{log_prefix} Tried to mark frame F{frame_num} as failed, but it was already processed/marked.")
                         completion_check_needed = True # Trigger check anyway, state might be inconsistent

                else:
                     logging.warning(f"{log_prefix} Tried to mark unexpected frame F{frame_num} as failed for video ...{video_url[-10:]}.")
            else:
                logging.warning(f"{log_prefix} Video not found in tracking when trying to mark F{frame_num} as failed.")

        # Trigger completion check outside the lock using invokeMethod
        if completion_check_needed:
            QMetaObject.invokeMethod(self, "_check_video_analysis_completion_invoked", Qt.QueuedConnection,
                                     Q_ARG(str, video_url))

    @pyqtSlot(str, int, list, QByteArray) # Slot to receive analysis results
    def handle_face_analysis_finished(self, video_url, frame_number, frame_encodings, image_byte_array):
        """Handles the result from a FaceAnalysisOnlyTask. Runs in this thread."""
        if self._check_stop_request(): return # Ignore results if stopping

        log_prefix = f"[AnalyzeResult Vid:{video_url[-10:]} F:{frame_number}]"
        logging.debug(f"{log_prefix} Received analysis result. Encodings: {len(frame_encodings)}, Image bytes: {image_byte_array.size()}.")
        self._consecutive_analysis_errors = 0 # Reset error count on successful analysis completion signal

        completion_check_needed = False
        valid_encodings = [enc for enc in frame_encodings if isinstance(enc, np.ndarray) and enc.size > 0]

        # --- Update Tracking Info (Inside Lock) ---
        self.active_tasks_lock.lock()
        try:
            if video_url not in self.active_video_processing:
                logging.debug(f"{log_prefix} Video {video_url} no longer in active processing. Ignoring result.")
                return # Video processing likely cancelled or finished

            video_data = self.active_video_processing[video_url]

            # Check if this frame result has already been processed/stored
            if frame_number in video_data["frame_encodings"]:
                logging.warning(f"{log_prefix} Received duplicate analysis result for frame {frame_number}. Ignoring.")
                completion_check_needed = True # Check completion anyway
                return

            # Store results and increment analyzed count
            video_data["frame_encodings"][frame_number] = valid_encodings # Store list (empty if no faces)
            # Store QByteArray if valid, otherwise None
            video_data["frame_image_data"][frame_number] = image_byte_array if not image_byte_array.isNull() else None
            video_data["analyzed_frames_count"] += 1

            analysis_progress = f"{video_data['analyzed_frames_count']}/{len(video_data['frames_to_analyze'])}"
            logging.debug(f"{log_prefix} Stored {len(valid_encodings)} encodings. Analysis progress: {analysis_progress}")

            # Signal UI state change to Comparing (will happen briefly before scores arrive)
            self.frame_status_update_signal.emit(video_url, frame_number, FrameWidget.STATE_COMPARING)
            completion_check_needed = True

        finally:
            self.active_tasks_lock.unlock()

        # --- Comparisons (Outside Lock) ---
        comparison_results = {} # {ref_idx: max_similarity_for_ref_in_this_frame}
        if valid_encodings:
             logging.debug(f"{log_prefix} Comparing frame faces against {len(self.reference_encodings_all)} reference image(s)...")
             for ref_idx, ref_encodings_list in enumerate(self.reference_encodings_all):
                 # Skip references that had no faces detected
                 if not ref_encodings_list: continue
                 max_similarity_for_this_ref = 0.0
                 try:
                     # Compare each face found in the frame against all faces in the current reference image
                     for frame_encoding in valid_encodings:
                         if self._check_stop_request(): logging.info(f"{log_prefix} Stop requested during comparison."); comparison_results = {}; break

                         # Ensure frame_encoding is valid
                         if not isinstance(frame_encoding, np.ndarray) or frame_encoding.size == 0: continue

                         # Calculate distances (lower distance = better match)
                         face_distances = face_recognition.face_distance(ref_encodings_list, frame_encoding)

                         if face_distances.size > 0:
                             min_distance = np.min(face_distances)
                             # Convert distance to similarity (higher similarity = better match)
                             similarity = max(0.0, 1.0 - min_distance)
                             # Track the highest similarity found for *any* face in this frame against this ref
                             max_similarity_for_this_ref = max(max_similarity_for_this_ref, similarity)

                     if self._check_stop_request(): break # Exit outer ref loop if stopped

                     # Store the highest similarity found for this frame against this reference
                     comparison_results[ref_idx] = max_similarity_for_this_ref
                     logging.debug(f"{log_prefix} -> Ref {ref_idx+1}: Max Similarity for Frame = {max_similarity_for_this_ref:.4f}")

                 except ValueError as ve: # Handle potential shape mismatches
                      logging.error(f"{log_prefix} ValueError comparing frame with Ref {ref_idx+1}: {ve}. Check encoding dimensions.")
                      comparison_results[ref_idx] = -1.0 # Indicate error
                 except Exception as compare_err:
                      logging.error(f"{log_prefix} Error comparing frame encodings with Ref {ref_idx+1}: {compare_err}", exc_info=True)
                      comparison_results[ref_idx] = -1.0 # Indicate error
                 if self._check_stop_request(): break # Exit outer ref loop if stopped

        # --- Update Overall Scores for Video (Inside Lock) ---
        self.active_tasks_lock.lock()
        try:
            if video_url not in self.active_video_processing: completion_check_needed = False; return
            video_data = self.active_video_processing[video_url]
            # If comparison already marked complete by another thread finishing, ignore score updates
            if video_data.get("comparison_complete", False): completion_check_needed = False; return

            if not self._check_stop_request():
                new_best_score_found_this_frame = False
                # Process comparison results calculated outside the lock
                for ref_idx, frame_similarity_for_ref in comparison_results.items():
                    if frame_similarity_for_ref < 0: continue # Skip errors

                    # Check if this frame's score for this ref is the new best overall for the VIDEO
                    if frame_similarity_for_ref > video_data["max_score"]:
                        logging.info(f"{log_prefix} ** New Best Score for Video! ** Score: {frame_similarity_for_ref:.3f} (Ref {ref_idx+1}, Frame {frame_number})")
                        video_data["max_score"] = frame_similarity_for_ref
                        video_data["best_frame"] = frame_number
                        video_data["best_ref_idx"] = ref_idx
                        new_best_score_found_this_frame = True

                    # Emit score for this specific frame and reference to update the UI FrameWidget display
                    logging.debug(f"{log_prefix} Emitting score for UI: Ref {ref_idx+1}, Score {frame_similarity_for_ref:.3f}")
                    self.processed_frame_score_signal.emit(video_url, frame_number, ref_idx, frame_similarity_for_ref)

                # If a new overall best score was found, update the reference image display in MainWindow
                if new_best_score_found_this_frame and 0 <= video_data["best_ref_idx"] < len(self.reference_pixmaps):
                     best_ref_pixmap = self.reference_pixmaps[video_data["best_ref_idx"]]
                     if best_ref_pixmap and not best_ref_pixmap.isNull():
                          # Emit signal with a copy of the pixmap
                          self.current_reference_image_signal.emit(best_ref_pixmap.copy(), video_data["best_ref_idx"])

                # If no faces were found in this frame, emit 0 scores for all refs
                if not valid_encodings:
                    logging.debug(f"{log_prefix} No faces found, emitting 0 scores for UI.")
                    for ref_idx, ref_encodings_list in enumerate(self.reference_encodings_all):
                         if ref_encodings_list: # Only emit if reference itself is valid
                             self.processed_frame_score_signal.emit(video_url, frame_number, ref_idx, 0.0)

                # Update frame state AFTER comparison based on whether faces were found
                final_frame_state = FrameWidget.STATE_FACE_FOUND if valid_encodings else FrameWidget.STATE_NO_FACE
                self.frame_status_update_signal.emit(video_url, frame_number, final_frame_state)

            else: # Stop requested during score update
                logging.info(f"{log_prefix} Stop requested, skipping score update and final state emission.")
                completion_check_needed = True # Still need to check completion state

        finally:
            self.active_tasks_lock.unlock()

        # --- Trigger Completion Check (Outside Lock) ---
        if completion_check_needed and not self._check_stop_request():
            QMetaObject.invokeMethod(self, "_check_video_analysis_completion_invoked", Qt.QueuedConnection, Q_ARG(str, video_url))

    @pyqtSlot(str)
    def _check_video_analysis_completion_invoked(self, video_url):
        """Checks if analysis is complete for a video. Runs in this thread."""
        if self._check_stop_request(): return

        final_result_to_emit = None
        remove_video_from_processing = False
        video_processing_time_ms = -1.0 # For stats

        self.active_tasks_lock.lock()
        try:
            if video_url not in self.active_video_processing: return # Already removed
            video_data = self.active_video_processing[video_url]
            # Check if already marked complete by another thread finishing slightly earlier
            if video_data.get("comparison_complete", False): return

            num_expected = len(video_data["frames_to_analyze"])
            all_frames_accounted_for = (video_data["analyzed_frames_count"] >= num_expected)
            log_prefix = f"[AnalyzeComplete Vid:{video_url[-10:]}]"

            if all_frames_accounted_for:
                # --- Finalization Logic ---
                video_data["comparison_complete"] = True # Mark as complete FIRST to prevent race conditions
                title = video_data['info'].get('videoTitle', 'Unknown Title')[:40]
                max_score = video_data['max_score']; best_frame = video_data['best_frame']; best_ref_idx = video_data['best_ref_idx']

                # Calculate video processing time
                start_time = video_data.get("start_time")
                if start_time:
                    video_processing_time_ms = (time.monotonic() - start_time) * 1000
                    logging.debug(f"{log_prefix} Video processing took {video_processing_time_ms:.2f} ms.")
                    # Add timing data immediately (before potentially removing video data)
                    if self.processing_stats and video_processing_time_ms >= 0:
                        self.processing_stats.add_video_processing_time(video_processing_time_ms)
                else:
                    logging.warning(f"{log_prefix} Could not calculate video processing time (start_time missing).")

                logging.info(f"{log_prefix} All {num_expected} frames accounted for for '{title}'. Analyzed count: {video_data['analyzed_frames_count']}")
                logging.info(f"{log_prefix} --> Final Result: Max Score={max_score:.3f} (Frame {best_frame}, Ref {best_ref_idx+1}), Threshold={self.threshold:.3f}")

                # Signal UI to highlight best frame in lane (if frame exists)
                if best_frame > 0:
                    self.frame_status_update_signal.emit(video_url, best_frame, FrameWidget.STATE_BEST_FRAME)
                    # Emit signal for MainWindow to handle complex UI updates if needed
                    # (e.g., ensuring only one frame is marked best) -> Handled by MainWindow slot now

                # Check against threshold and prepare final result
                if max_score >= self.threshold:
                    logging.info(f"{log_prefix} *** Threshold MET for '{title}'. Preparing final result. ***")
                    # Prepare result data, copying necessary info
                    # Retrieve best frame image data *before* potential removal
                    best_frame_image_data = video_data.get('frame_image_data', {}).get(best_frame)
                    final_result_to_emit = {
                        "video_url": video_url,
                        "video_data": {
                            'best_frame': best_frame,
                            'best_ref_idx': best_ref_idx,
                            'max_score': max_score,
                            'info': video_data['info'].copy(), # Pass a copy of the info dict
                            'best_frame_image_data': best_frame_image_data # Include image data
                        }
                    }
                    # Emit signal for lane UI flash effect
                    self.lane_result_found_signal.emit(video_url, max_score)
                else:
                    logging.info(f"{log_prefix} Threshold NOT met for '{title}'.")

                remove_video_from_processing = True # Mark for removal from active tracking
                # Signal that processing for this specific video is finished (for stats update in main window)
                self.video_processing_finished_signal.emit(video_url)
                logging.debug(f"{log_prefix} Signaled video processing finished.")

            else: # Not all frames accounted for yet
                 logging.debug(f"{log_prefix} Not all frames accounted for yet ({video_data['analyzed_frames_count']}/{num_expected}).")

        finally:
            self.active_tasks_lock.unlock()

        # --- Remove from Tracking and Emit Final Result (Outside Lock) ---
        if remove_video_from_processing:
            # Re-acquire lock briefly for safe removal
            with QMutexLocker(self.active_tasks_lock):
                 if video_url in self.active_video_processing:
                     logging.debug(f"{log_prefix} Removing video from active processing.")
                     # Remove the entry. Data needed for emit was captured earlier.
                     del self.active_video_processing[video_url]
                 else:
                     # Already removed by another thread? Log but don't error.
                     logging.warning(f"{log_prefix} Video {video_url} not found in active_processing during removal.")
                     final_result_to_emit = None # Prevent emitting result if data is gone

        # Emit the final result if threshold was met and data wasn't lost
        if final_result_to_emit:
            self._emit_final_result(final_result_to_emit["video_url"], final_result_to_emit["video_data"])

    def _emit_final_result(self, video_url, video_data):
        """Prepares and emits the final result dictionary, including thumbnail QImage/data."""
        # This method now receives the best frame image data directly in video_data
        best_frame_num = video_data.get('best_frame', 0)
        best_ref_idx = video_data.get('best_ref_idx', -1)
        max_overall_score = video_data.get('max_score', 0.0)
        all_urls = video_data['info'].get('allPreviewFrameURLs', [])
        best_frame_url = ""
        if 1 <= best_frame_num <= len(all_urls):
            best_frame_url = all_urls[best_frame_num - 1]
        else:
            self.log_signal.emit(f"Warning: Could not determine best frame URL (Frame: {best_frame_num}) for ...{video_url[-20:]}")

        thumbnail_qimage = None # Will hold the QImage data for the thumbnail
        best_frame_data = video_data.get("best_frame_image_data") # QByteArray or None

        # --- Load QImage from stored data or download ---
        if best_frame_data and isinstance(best_frame_data, QByteArray) and not best_frame_data.isNull():
             logging.debug(f"Using stored image data ({best_frame_data.size()} bytes) for best frame {best_frame_num}...")
             qimage_loader = QImage()
             if qimage_loader.loadFromData(best_frame_data):
                 thumbnail_qimage = qimage_loader.copy() # Use a copy
                 logging.debug(f"ScraperThread: Loaded best frame thumbnail QImage from stored data for ...{video_url[-20:]}.")
             else:
                 self.log_signal.emit(f"Warning: Failed to load QImage from stored best frame data for frame {best_frame_num}")
                 logging.warning(f"QImage loadFromData failed for stored best frame data: F{best_frame_num} for ...{video_url[-20:]}")
                 best_frame_data = None # Invalidate if load failed
        elif best_frame_url: # Fallback to downloading if stored data wasn't found/valid
            logging.warning(f"No valid stored image data for best frame {best_frame_num}. Attempting download: {best_frame_url}")
            try:
                # Use requests (simpler than Selenium for direct image URL)
                headers = {'User-Agent': 'Mozilla/5.0'}
                thumb_resp = requests.get(best_frame_url, timeout=15, headers=headers)
                thumb_resp.raise_for_status()
                best_frame_data_dl = thumb_resp.content # Get bytes
                # Try loading QImage from downloaded bytes
                qimage_loader = QImage()
                if qimage_loader.loadFromData(best_frame_data_dl):
                    thumbnail_qimage = qimage_loader.copy()
                    logging.debug(f"ScraperThread: Downloaded and loaded best frame thumbnail QImage for ...{video_url[-20:]}.")
                else:
                    self.log_signal.emit(f"Warning: Failed to load QImage from downloaded best frame data: {best_frame_url}")
                    logging.warning(f"QImage loadFromData failed for downloaded best frame: {best_frame_url}")
            except requests.exceptions.RequestException as thumb_net_err:
                self.log_signal.emit(f"Warning: Network error downloading best frame thumb ({best_frame_url}): {thumb_net_err}")
                logging.warning(f"Network error downloading best frame thumb: {thumb_net_err}", exc_info=False)
            except Exception as thumb_err:
                self.log_signal.emit(f"Warning: Error downloading/processing best frame ({best_frame_url}): {thumb_err}")
                logging.warning(f"Error downloading/processing best frame thumb: {thumb_err}", exc_info=True)

        # --- Prepare and Emit Final Result ---
        final_result = {
            "videoTitle": video_data['info'].get("videoTitle", "Unknown Title"),
            "videoURL": video_url,
            "thumbnailURL": video_data['info'].get("thumbnailURL", ""), # Original thumbnail URL
            "previewFramesAnimation": video_data['info'].get("previewFramesAnimation", []),
            "score": max_overall_score,
            "frameNumberWithHighestScore": best_frame_num,
            "bestFrameURL": best_frame_url, # URL of the best frame
            "referenceIndexWithHighestScore": best_ref_idx,
            "allPreviewFrameURLs": all_urls,
            # Pass the QImage object itself to the main thread for saving
            "thumbnailQImageData": thumbnail_qimage, # QImage or None
            "timestamp_added": time.time() # Add timestamp when result is finalized
        }
        self.final_result_signal.emit(final_result)
        logging.debug(f"Emitted final result signal for ...{video_url[-20:]}")

    def _get_active_analysis_tasks(self):
        """Returns the count of active tasks in the face analyzer thread pool."""
        # Check if pool exists before accessing it
        if hasattr(self, 'face_analyzer_threadpool'):
             return self.face_analyzer_threadpool.activeThreadCount()
        else:
             logging.warning("_get_active_analysis_tasks called but pool doesn't exist.")
             return 0


# --- JavaScript constants for Selenium ---
MOTHERLESS_EXTRACTION_JS = """
    // Arguments: [videoContainerSelector, linkSelector, titleSelector, thumbnailSelector]
    const containers = document.querySelectorAll(arguments[0]);
    const results = [];
    containers.forEach(container => {
        const linkEl = container.querySelector(arguments[1]);
        const titleEl = container.querySelector(arguments[2]);
        const thumbEl = container.querySelector(arguments[3]);

        const href = linkEl ? linkEl.href : null;
        // Use innerText and trim, provide default
        const title = titleEl ? titleEl.innerText.trim() : 'Unknown Title';
        // Ensure title is not empty after trim
        const finalTitle = title ? title : 'Unknown Title';
        const thumbnailSrc = thumbEl ? thumbEl.src : null;

        // Only add if we have a valid video URL
        if (href && href.startsWith('http')) {
            results.push({
                href: href,
                title: finalTitle,
                thumbnailSrc: thumbnailSrc
            });
        } else {
           // console.warn('Skipping container due to missing/invalid href:', container);
        }
    });
    return results;
    """

SELENIUM_IMAGE_EXTRACT_JS = """
    // Extracts the first image on the page as Base64 (JPEG preferred)
    var img = document.querySelector('img');
    // Check if image exists and seems validly loaded
    if (!img || !img.complete || typeof img.naturalWidth == "undefined" || img.naturalWidth <= 0) {
        // console.warn('Image element not found, not complete, or has zero dimensions.');
        return null; // Return null if image isn't ready or valid
    }
    try {
        var canvas = document.createElement('canvas');
        canvas.width = img.naturalWidth;
        canvas.height = img.naturalHeight;
        var ctx = canvas.getContext('2d');
        ctx.drawImage(img, 0, 0);
        // Try JPEG first (smaller size), fallback to PNG
        var dataURL = canvas.toDataURL('image/jpeg', 0.9); // Quality 0.9
        // Basic check if JPEG conversion worked (might return 'data:,')
        if (!dataURL || dataURL.length < 30 || dataURL === 'data:,') {
            // console.log('JPEG conversion failed or resulted in empty data, trying PNG for:', img.src);
            dataURL = canvas.toDataURL('image/png');
        }
        // Ensure we have a valid base64 part
        if (dataURL && dataURL.includes(',')) {
            return dataURL.split(',')[1]; // Return only the base64 part
        } else {
            console.error('Could not get valid base64 data from dataURL for:', img.src, ' DataURL:', dataURL);
            return null;
        }
    } catch (e) {
        console.error('Canvas error during image extraction for:', img.src, e);
        return null;
    }
    """

# =============================================================================
# FrameWidget - Widget to display a single frame in the video preview lane
# =============================================================================
class FrameWidget(QLabel):
    STATE_ANALYSIS_FAILED = 5  # Define a new state for analysis failure
    """Displays a single preview frame status and image in the live view lane."""
    # Revised States reflecting the process
    STATE_PENDING_DOWNLOAD = 0 # Initial state
    STATE_DOWNLOADING = 1      # Selenium download in progress
    STATE_DOWNLOAD_FAILED = 2  # Selenium download failed
    STATE_ANALYZING = 3        # Analysis task running (detection/encoding)
    STATE_COMPARING = 4        # Analysis done, comparing encodings
    STATE_NO_FACE = 5          # Analysis/Comparison done, no face found/matched above 0
    STATE_FACE_FOUND = 6       # Analysis/Comparison done, face found/matched above 0
    STATE_BEST_FRAME = 7       # Marked as the highest scoring frame for the video

    STATE_TOOLTIPS = {
        STATE_PENDING_DOWNLOAD: "Pending Download...",
        STATE_DOWNLOADING: "Downloading Frame...",
        STATE_DOWNLOAD_FAILED: "Download Failed!",
        STATE_ANALYZING: "Analyzing Image / Detecting Faces...",
        STATE_COMPARING: "Comparing Faces...",
        STATE_NO_FACE: "No Face Detected",
        STATE_FACE_FOUND: "Face Detected", # Tooltip updated dynamically with score
        STATE_BEST_FRAME: "Highest Scoring Frame!", # Tooltip updated dynamically
    }
    STATE_STYLES = { # Define styles based on state colors
        STATE_PENDING_DOWNLOAD: f"border: 1px dashed {COLOR_PENDING.name()}; background-color: #383838;",
        STATE_DOWNLOADING: f"border: 2px dashed {COLOR_DOWNLOADING.name()}; background-color: #3a3a3a;",
        STATE_DOWNLOAD_FAILED: f"border: 2px solid {COLOR_DOWNLOAD_FAILED.name()}; background-color: #403030;",
        STATE_ANALYZING: f"border: 2px dashed {COLOR_ANALYZING.name()}; background-color: #3a3a3a;",
        STATE_COMPARING: f"border: 2px dashed {COLOR_COMPARING.name()}; background-color: #3a3a3a;",
        STATE_NO_FACE: f"border: 1px solid {COLOR_NO_FACE.name()}; background-color: #383838;",
        STATE_FACE_FOUND: f"border: 1px solid {COLOR_FACE_FOUND.name()}; background-color: #383838;",
        STATE_BEST_FRAME: f"border: 3px solid {COLOR_BEST.name()}; background-color: #404040;", # Thick border for best
    }
    BASE_STYLE = "QLabel { border-radius: 3px; margin: 1px; color: #ccc; font-size: 7pt; }"

    def __init__(self, frame_number, parent=None, main_window=None, performance_mode=False):
        super().__init__(parent)
        self.frame_number = frame_number
        # Use weakref if MainWindow reference causes issues, but direct ref is simpler for now
        self.main_window = main_window # Keep ref for threshold access
        self.performance_mode = performance_mode
        self.setFixedSize(LANE_FRAME_WIDGET_SIZE)
        self.setAlignment(Qt.AlignCenter)
        self.setToolTip(f"Preview Frame {self.frame_number}")

        self._state = FrameWidget.STATE_PENDING_DOWNLOAD
        self.current_pixmap = None # Stores the actual loaded QPixmap
        self.highest_score_received = 0.0 # Highest score seen for *this* frame

        # Text overlay for state/score (always present, visibility toggled)
        self.text_overlay_label = QLabel(self)
        self.text_overlay_label.setAlignment(Qt.AlignBottom | Qt.AlignHCenter)
        self.text_overlay_label.setStyleSheet("QLabel { color: white; background-color: rgba(0, 0, 0, 170); border-radius: 3px; padding: 0px 2px; font-size: 7pt; font-weight: normal; }")
        self.text_overlay_label.hide() # Initially hidden

        # Score animation label and group
        self.score_anim_label = QLabel(self)
        self.score_anim_label.setAlignment(Qt.AlignCenter)
        self.score_anim_label.setStyleSheet("QLabel { color: white; background-color: rgba(0, 0, 0, 180); border-radius: 4px; padding: 1px 3px; font-size: 9pt; font-weight: bold; }")
        self.score_anim_label.hide()
        self.score_anim_group = None
        self.score_anim_effect = QGraphicsOpacityEffect(self.score_anim_label)
        self.score_anim_label.setGraphicsEffect(self.score_anim_effect)

        self.update_visual_state() # Set initial style/tooltip
        # Set initial placeholder pixmap
        if PLACEHOLDER_FRAME_PIXMAP:
            super().setPixmap(PLACEHOLDER_FRAME_PIXMAP.copy()) # Use super() to bypass override initially
        else:
            logging.warning("FrameWidget: PLACEHOLDER_FRAME_PIXMAP not initialized!")
            self.setText(f"F{self.frame_number}") # Fallback text

    def set_performance_mode(self, enabled):
        """Enable/disable performance mode (affects visual rendering)."""
        if self.performance_mode == enabled: return
        self.performance_mode = enabled
        self.update_visual_state() # Re-render based on new mode

    def setPixmap(self, pixmap):
        """Overrides setPixmap to store the pixmap and handle performance mode."""
        # Store the actual pixmap internally
        if pixmap and not pixmap.isNull():
            # Store a scaled copy appropriate for the widget size to save memory?
            # Or store original? Let's store original for potential future use.
            self.current_pixmap = pixmap.copy()
        else:
            self.current_pixmap = None

        # Update display immediately based on current performance mode
        self.update_visual_state()

    def _display_pixmap_internal(self, pixmap):
        """Helper method to scale and display a pixmap in the label."""
        if pixmap and not pixmap.isNull():
            # Scale pixmap to fit the label size while maintaining aspect ratio
            scaled_pixmap = pixmap.scaled(
                self.size() - QSize(4, 4), # Allow for border thickness
                Qt.KeepAspectRatio,
                Qt.SmoothTransformation
            )
            super().setPixmap(scaled_pixmap) # Use super() to set the visual pixmap
        else:
             if PLACEHOLDER_FRAME_PIXMAP:
                 super().setPixmap(PLACEHOLDER_FRAME_PIXMAP.copy())
             else: # Fallback if placeholder failed
                  super().clear()
                  super().setText(f"F{self.frame_number}")


    @pyqtSlot(QPixmap)
    def _set_pixmap_slot(self, pixmap):
        """Slot to receive and set the pixmap safely in the GUI thread."""
        self.setPixmap(pixmap) # Use the overridden setPixmap method


    @pyqtSlot(QByteArray)
    def set_image_data(self, image_data):
        """Loads image data received from analysis task."""
        log_prefix = f"[FrameWidget F{self.frame_number}]"
        if not image_data or image_data.isNull():
            logging.debug(f"{log_prefix} Received null/empty image data.")
            # Set placeholder and potentially error state if data is null
            self.current_pixmap = None
            if self._state not in (FrameWidget.STATE_DOWNLOAD_FAILED, FrameWidget.STATE_BEST_FRAME):
                 self.set_state(FrameWidget.STATE_DOWNLOAD_FAILED) # Indicate failure if not already failed/best
            else:
                self.update_visual_state() # Update visuals even if state doesn't change
            return

        pixmap = QPixmap()
        if pixmap.loadFromData(image_data):
            logging.debug(f"{log_prefix} Loaded pixmap from {image_data.size()} bytes.")
            self._set_pixmap_slot(pixmap) # Call the new slot to set pixmap
        else:
            logging.warning(f"{log_prefix} Failed to load pixmap from data.")
            self.current_pixmap = None
            if self._state not in (FrameWidget.STATE_DOWNLOAD_FAILED, FrameWidget.STATE_BEST_FRAME):
                self.set_state(FrameWidget.STATE_DOWNLOAD_FAILED)
            else:
                self.update_visual_state()


    @pyqtSlot(float)
    def display_score(self, new_score):
        """ Updates display based on a new score for *this frame*. """
        log_prefix = f"[FrameWidget F{self.frame_number}]"
        logging.debug(f"{log_prefix} Received score update: {new_score:.3f}. Current highest: {self.highest_score_received:.3f}, State: {self._state}")

        score_improved = new_score > self.highest_score_received
        # Update the highest score seen for this frame
        if score_improved:
            self.highest_score_received = new_score

        # Trigger animation only if score improved, is positive, and not in perf mode
        if score_improved and new_score > 0.001 and not self.performance_mode:
            self._trigger_score_animation(new_score)

        # Always update text/tooltip based on the *highest* score seen so far
        self.update_visual_state()


    def _trigger_score_animation(self, score_to_display):
        """Handles the visual animation of the score popping up."""
        log_prefix = f"[FrameWidget F{self.frame_number} Anim]"
        self.score_anim_label.setText(f"{score_to_display:.2f}")
        self.score_anim_label.adjustSize() # Fit text
        start_pos_x = (self.width() - self.score_anim_label.width()) // 2
        start_pos_y = (self.height() - self.score_anim_label.height()) // 2 - 10 # Start slightly above center
        self.score_anim_label.move(start_pos_x, start_pos_y)

        # --- Color based on score relative to threshold ---
        threshold = self.main_window.get_threshold() if self.main_window else 0.75
        if score_to_display >= threshold + 0.08: color = COLOR_BEST.lighter(130).name() # Brighter LimeGreen
        elif score_to_display >= threshold:      color = "#9ACD32" # YellowGreen (Above)
        elif score_to_display >= threshold - 0.1: color = COLOR_FACE_FOUND.name() # Gold (Slightly Below)
        else:                                    color = "#FFA500" # Orange (Below)

        self.score_anim_label.setStyleSheet(
            f"QLabel {{ color: {color}; background-color: rgba(0, 0, 0, 190); "
            f"border-radius: 4px; padding: 1px 3px; font-size: 9pt; font-weight: bold; }}"
        )
        self.score_anim_label.show()
        self.score_anim_label.raise_()

        # --- Setup Animation ---
        if self.score_anim_group and self.score_anim_group.state() == QPropertyAnimation.Running:
            logging.debug(f"{log_prefix} Stopping existing animation.")
            self.score_anim_group.stop() # Stop previous animation

        # Ensure effect is valid
        if not self.score_anim_effect:
             self.score_anim_effect = QGraphicsOpacityEffect(self.score_anim_label)
             self.score_anim_label.setGraphicsEffect(self.score_anim_effect)

        if self.score_anim_effect:
            # Create new animation group
            self.score_anim_group = QParallelAnimationGroup(self)

            anim_fade = QPropertyAnimation(self.score_anim_effect, b"opacity", self.score_anim_group) # Parent group
            anim_fade.setDuration(1800); anim_fade.setStartValue(1.0); anim_fade.setEndValue(0.0)
            anim_fade.setEasingCurve(QEasingCurve.InQuad)

            anim_move = QPropertyAnimation(self.score_anim_label, b"pos", self.score_anim_group) # Parent group
            anim_move.setDuration(1800); anim_move.setStartValue(self.score_anim_label.pos())
            anim_move.setEndValue(QPoint(start_pos_x, start_pos_y - 25)) # Move up
            anim_move.setEasingCurve(QEasingCurve.OutQuad)

            self.score_anim_group.addAnimation(anim_fade)
            self.score_anim_group.addAnimation(anim_move)
            # Connect finished signal to hide label and allow deletion
            self.score_anim_group.finished.connect(self.score_anim_label.hide)
            # Automatically delete animation objects when stopped
            self.score_anim_group.start(QPropertyAnimation.DeleteWhenStopped)
            logging.debug(f"{log_prefix} Starting score animation.")
        else:
             logging.warning(f"{log_prefix} Cannot start animation - graphics effect is missing.")
             self.score_anim_label.hide() # Hide immediately if effect failed

    def set_state(self, state):
        """Sets the visual state of the widget if it changed."""
        # Prevent reverting from BEST state unless explicitly unset
        if self._state == FrameWidget.STATE_BEST_FRAME and state != FrameWidget.STATE_BEST_FRAME:
             if hasattr(self, '_being_unset_as_best') and self._being_unset_as_best:
                 pass # Allow the state change if flag is set
             else:
                 # If trying to change state away from BEST without the flag, ignore it.
                 # This prevents intermediate states like COMPARING overwriting BEST.
                 logging.debug(f"[FrameWidget F{self.frame_number}] Ignoring state change from BEST ({self._state}) to {state} (not unset)")
                 return

        if self._state != state:
            logging.debug(f"[FrameWidget F{self.frame_number}] State changing from {self._state} to {state}")
            self._state = state
            # Don't reset highest score here, only when lane is created/reset
            self.update_visual_state() # Apply style/text for the new state

    def set_best_frame(self, is_best):
         """Highlights or de-highlights the frame as the best scoring frame for the video."""
         log_prefix = f"[FrameWidget F{self.frame_number}]"
         if is_best:
             if self._state != self.STATE_BEST_FRAME:
                 logging.debug(f"{log_prefix} Setting as BEST frame.")
                 self.set_state(FrameWidget.STATE_BEST_FRAME)
         else:
             # If it's currently marked as best, revert to the appropriate non-best state
             if self._state == self.STATE_BEST_FRAME:
                 # Determine previous state based on score (FACE_FOUND or NO_FACE)
                 new_state = self.STATE_FACE_FOUND if self.highest_score_received > 0.001 else self.STATE_NO_FACE
                 logging.debug(f"{log_prefix} Unsetting as BEST frame. Reverting to state {new_state}.")
                 # Temporarily set flag to allow state change away from BEST
                 self._being_unset_as_best = True
                 try:
                     self.set_state(new_state) # Attempt state change
                 finally:
                      # Always remove the flag after attempt
                      if hasattr(self, '_being_unset_as_best'):
                          del self._being_unset_as_best
             # else: it wasn't best, do nothing

    def reset_state_and_score(self):
        """Resets the widget to initial state and clears score."""
        logging.debug(f"[FrameWidget F{self.frame_number}] Resetting state and score.")
        self.highest_score_received = 0.0
        # Stop any ongoing animation
        if self.score_anim_group and self.score_anim_group.state() == QPropertyAnimation.Running:
            self.score_anim_group.stop()
        self.score_anim_label.hide()
        self.set_state(FrameWidget.STATE_PENDING_DOWNLOAD) # Reset to initial state
        # Reset pixmap using internal method (handles performance mode correctly)
        self.current_pixmap = None
        self.update_visual_state() # Will show placeholder


    def update_visual_state(self):
        """Applies the correct stylesheet, pixmap, text overlay, and tooltip."""
        # Apply base style + state-specific style
        style = FrameWidget.BASE_STYLE + FrameWidget.STATE_STYLES.get(self._state, FrameWidget.STATE_STYLES[FrameWidget.STATE_PENDING_DOWNLOAD])
        self.setStyleSheet(style)

        # --- Determine Tooltip ---
        tooltip_base = FrameWidget.STATE_TOOLTIPS.get(self._state, 'Unknown State')
        tooltip = f"Frame {self.frame_number}: {tooltip_base}"
        score_text = f"{self.highest_score_received:.3f}" # Use 3 decimal places for score
        if self._state in (FrameWidget.STATE_FACE_FOUND, FrameWidget.STATE_BEST_FRAME):
            tooltip += f" (Score: {score_text})"

        self.setToolTip(tooltip)

        # --- Determine Overlay Text ---
        overlay_text = ""
        if self.performance_mode: # Abbreviated text in performance mode
             if self._state == FrameWidget.STATE_PENDING_DOWNLOAD: overlay_text = "P"
             elif self._state == FrameWidget.STATE_DOWNLOADING: overlay_text = "D"
             elif self._state == FrameWidget.STATE_DOWNLOAD_FAILED: overlay_text = "F!"
             elif self._state == FrameWidget.STATE_ANALYZING: overlay_text = "S"
             elif self._state == FrameWidget.STATE_COMPARING: overlay_text = "C"
             elif self._state == FrameWidget.STATE_NO_FACE: overlay_text = "N"
             elif self._state == FrameWidget.STATE_FACE_FOUND: overlay_text = f"{self.highest_score_received:.1f}"
             elif self._state == FrameWidget.STATE_BEST_FRAME: overlay_text = f"★{self.highest_score_received:.1f}"
        else: # Normal mode text
             score_text_short = f"{self.highest_score_received:.2f}"
             if self._state == FrameWidget.STATE_PENDING_DOWNLOAD: overlay_text = "Wait"
             elif self._state == FrameWidget.STATE_DOWNLOADING: overlay_text = "DL..."
             elif self._state == FrameWidget.STATE_DOWNLOAD_FAILED: overlay_text = "Fail!"
             elif self._state == FrameWidget.STATE_ANALYZING: overlay_text = "Scan..."
             elif self._state == FrameWidget.STATE_COMPARING: overlay_text = "Cmp..."
             elif self._state == FrameWidget.STATE_NO_FACE: overlay_text = "No Face"
             elif self._state == FrameWidget.STATE_FACE_FOUND: overlay_text = score_text_short
             elif self._state == FrameWidget.STATE_BEST_FRAME: overlay_text = f"★ {score_text_short}"

        # --- Update Text Overlay ---
        if overlay_text:
            self.text_overlay_label.setText(overlay_text)
            self.text_overlay_label.adjustSize()
            overlay_x = (self.width() - self.text_overlay_label.width()) // 2
            overlay_y = self.height() - self.text_overlay_label.height() - 2 # Position at bottom
            self.text_overlay_label.move(overlay_x, overlay_y)
            self.text_overlay_label.show(); self.text_overlay_label.raise_()
        else:
            self.text_overlay_label.hide()

        # --- Update Pixmap Display ---
        if self.performance_mode:
            # Always show placeholder in performance mode
            if PLACEHOLDER_FRAME_PIXMAP:
                 super().setPixmap(PLACEHOLDER_FRAME_PIXMAP.copy())
            else:
                 super().clear(); super().setText(f"F{self.frame_number}")
        elif self.current_pixmap:
            # Show the actual stored pixmap (scaled)
            self._display_pixmap_internal(self.current_pixmap)
        else:
            # Show placeholder if no valid pixmap and not in performance mode
            if PLACEHOLDER_FRAME_PIXMAP:
                 super().setPixmap(PLACEHOLDER_FRAME_PIXMAP.copy())
            else:
                 super().clear(); super().setText(f"F{self.frame_number}")


    def cleanup(self):
        """Clean up resources like animations when the widget is destroyed."""
        logging.debug(f"Cleaning up FrameWidget {self.frame_number}")
        if self.score_anim_group:
            try:
                if self.score_anim_group.state() == QPropertyAnimation.Running:
                    self.score_anim_group.stop()
                # Disconnect manually? Usually handled by Qt or DeleteWhenStopped.
                # self.score_anim_group.finished.disconnect() # Risky if already deleted
            except RuntimeError: pass # Object might be gone
            except Exception as e: logging.error(f"FrameWidget {self.frame_number} anim cleanup error: {e}")
            finally: self.score_anim_group = None
        # No signals connected from this widget outwards currently

# =============================================================================
# ResultWidget - Widget to display a found video result
# =============================================================================
class ResultWidget(QFrame):
    """Displays a single video result with thumbnail, info, delete button, context menu."""
    delete_requested = pyqtSignal(object) # Pass self

    def __init__(self, result_data, parent=None, main_window=None):
        super().__init__(parent)
        self.result_data = result_data
        self.main_window = main_window # Keep reference
        self.setFrameShape(QFrame.StyledPanel)
        self.setStyleSheet("QFrame { border: 1px solid #555; border-radius: 5px; padding: 5px; background-color: #383838; } ResultWidget:hover { background-color: #404040; border: 1px solid #666; }")
        self.setMinimumHeight(105)
        self.setMaximumHeight(120)

        # Animation state
        self.animated_preview_timer = QTimer(self)
        self.animated_preview_timer.timeout.connect(self.animate_preview)
        self.animation_frame_index = 0
        self.preview_pixmaps = [] # Holds loaded QPixmap objects
        self.is_hovered = False
        self.thumbnail_pixmap = None # Holds the static QPixmap (best frame or initial thumb)
        self.preview_urls = self.result_data.get("previewFramesAnimation", [])
        self.frames_loaded_count = 0
        self.all_frames_load_attempted = (not self.preview_urls)

        # Thumbnail path
        self.thumbnail_path = None
        thumbnail_path_str = self.result_data.get("thumbnailPath")
        if thumbnail_path_str:
             try: self.thumbnail_path = Path(thumbnail_path_str)
             except Exception as path_err:
                 logging.error(f"Error creating Path for thumbnail: {thumbnail_path_str} - {path_err}")
                 self.result_data['thumbnailPath'] = None # Invalidate

        # UI Setup
        self.init_ui()
        self.load_static_thumbnail() # Load initially

        # Async load animation frames
        if self.preview_urls:
            if self.main_window and hasattr(self.main_window, 'download_threadpool') and self.main_window.download_threadpool:
                QTimer.singleShot(250, self.load_preview_frames_async) # Delay start slightly
            else:
                logging.error(f"Cannot load preview frames for ...{self.result_data.get('videoURL','?')[-20:]}: Missing download pool.")
                self.all_frames_load_attempted = True

        # Context Menu
        self.setContextMenuPolicy(Qt.CustomContextMenu)
        self.customContextMenuRequested.connect(self.show_context_menu)

    def init_ui(self):
        """Creates the UI elements within the widget."""
        layout = QHBoxLayout(self)
        layout.setContentsMargins(5, 5, 5, 5); layout.setSpacing(10)

        # Thumbnail
        self.thumb_label = QLabel()
        self.thumb_label.setFixedSize(PLACEHOLDER_RESULT_THUMB_SIZE)
        self.thumb_label.setAlignment(Qt.AlignCenter)
        self.thumb_label.setStyleSheet(f"QLabel {{ background-color: {PLACEHOLDER_COLOR.name()}; border: 1px solid #444; border-radius: 3px; color: #888; }}")
        if PLACEHOLDER_RESULT_THUMB_PIXMAP: self.thumb_label.setPixmap(PLACEHOLDER_RESULT_THUMB_PIXMAP)
        self.thumb_label.setCursor(Qt.PointingHandCursor)
        self.thumb_label.mousePressEvent = self.open_video_url
        self.thumb_label.setToolTip("Click to open Video\nHover for Preview Animation")
        self.thumb_label.installEventFilter(self) # For hover detection
        layout.addWidget(self.thumb_label)

        # Info Area
        info_container = QWidget()
        info_layout = QVBoxLayout(info_container); info_layout.setContentsMargins(0, 0, 0, 0); info_layout.setSpacing(4)
        info_container.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)

        # Title
        title_text = self.result_data.get("videoTitle", "No Title")
        self.title_label = QLabel(title_text); self.title_label.setFont(QFont("Segoe UI", 10, QFont.Bold)); self.title_label.setWordWrap(True)
        self.title_label.setCursor(Qt.PointingHandCursor); self.title_label.mousePressEvent = self.open_video_url
        self.title_label.setToolTip(f"Title: {title_text}\nClick to open video."); self.title_label.setTextInteractionFlags(Qt.TextSelectableByMouse)
        info_layout.addWidget(self.title_label)

        # URL
        video_url = self.result_data.get("videoURL", ""); fm = QFontMetrics(self.font()); elided_url = fm.elidedText(video_url, Qt.ElideMiddle, 450)
        url_label = QLabel(f"<a href='{video_url}' style='color: #87CEEB; text-decoration: none;'>{elided_url}</a>")
        url_label.setTextFormat(Qt.RichText); url_label.setTextInteractionFlags(Qt.TextBrowserInteraction); url_label.setOpenExternalLinks(True)
        url_label.setFont(QFont("Segoe UI", 8)); url_label.setToolTip(f"URL: {video_url}\nOpens in system browser.")
        info_layout.addWidget(url_label)

        # Score & Frame Info
        score = self.result_data.get("score", 0.0); frame_num = self.result_data.get("frameNumberWithHighestScore", "?"); ref_idx = self.result_data.get("referenceIndexWithHighestScore", -1)
        score_text = f"Score: <b>{score:.3f}</b> (Frame {frame_num}"
        if ref_idx != -1: score_text += f", Ref {ref_idx + 1}"
        score_text += ")"
        score_label = QLabel(score_text); score_label.setFont(QFont("Segoe UI", 9)); score_label.setTextInteractionFlags(Qt.TextSelectableByMouse)
        info_layout.addWidget(score_label)

        # Timestamp Added
        timestamp = self.result_data.get('timestamp_added', 0)
        if timestamp > 0:
            try:
                time_str = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(timestamp))
                ts_label = QLabel(f"Added: {time_str}"); ts_label.setFont(QFont("Segoe UI", 8)); ts_label.setStyleSheet("color: #aaa;")
                info_layout.addWidget(ts_label)
            except (ValueError, OSError): pass # Ignore errors formatting timestamp

        info_layout.addStretch(1)
        layout.addWidget(info_container, 1) # Allow info area to stretch

        # Delete Button
        self.delete_button = QToolButton(); self.delete_button.setText("✕"); self.delete_button.setFixedSize(20, 20)
        self.delete_button.setCursor(Qt.PointingHandCursor)
        self.delete_button.setStyleSheet("QToolButton { border: none; color: #aaa; font-weight: bold; font-size: 12pt; background-color: transparent; border-radius: 10px; } QToolButton:hover { color: #FF4500; background-color: #555; }")
        self.delete_button.clicked.connect(self.confirm_delete_result); self.delete_button.setToolTip("Delete this result")
        layout.addWidget(self.delete_button, 0, Qt.AlignTop | Qt.AlignRight) # Align top-right


    def eventFilter(self, obj, event):
        """Handle hover events for the thumbnail label."""
        if obj == self.thumb_label:
            if event.type() == QEvent.Enter:
                self.is_hovered = True
                # Start animation only if load attempts finished and frames exist
                if self.all_frames_load_attempted and any(p and not p.isNull() for p in self.preview_pixmaps):
                     self.start_animation()
                return True
            elif event.type() == QEvent.Leave:
                self.is_hovered = False
                self.stop_animation()
                return True
        return super().eventFilter(obj, event)

    def load_static_thumbnail(self):
        """Loads the static thumbnail (best frame) from saved path or shows placeholder."""
        self.thumbnail_pixmap = None # Reset
        if self.thumbnail_path and self.thumbnail_path.exists():
            loaded_pixmap = QPixmap(str(self.thumbnail_path))
            if not loaded_pixmap.isNull():
                self.thumbnail_pixmap = loaded_pixmap
                self._display_thumbnail(self.thumbnail_pixmap)
                logging.debug(f"Loaded static thumbnail from: {self.thumbnail_path}")
                return
            else:
                logging.warning(f"Failed QPixmap load from existing file: {self.thumbnail_path}")
                self.result_data['thumbnailPath'] = None; self.thumbnail_path = None

        elif self.thumbnail_path: # Path set but file doesn't exist
            logging.debug(f"Static thumbnail file not found: {self.thumbnail_path}")
            self.result_data['thumbnailPath'] = None; self.thumbnail_path = None

        # Fallback to placeholder
        self._display_thumbnail(None) # Will show placeholder


    def _display_thumbnail(self, pixmap):
        """Helper to scale and display pixmap in thumb_label or show placeholder."""
        if pixmap and not pixmap.isNull():
            scaled_pixmap = pixmap.scaled(self.thumb_label.size() - QSize(4, 4), Qt.KeepAspectRatio, Qt.SmoothTransformation)
            self.thumb_label.setPixmap(scaled_pixmap)
            self.thumb_label.setText("")
        else:
             if PLACEHOLDER_RESULT_THUMB_PIXMAP:
                 self.thumb_label.setPixmap(PLACEHOLDER_RESULT_THUMB_PIXMAP.copy())
             else: self.thumb_label.setText("No Thumb") # Fallback text
             # Ensure style remains consistent with placeholder
             self.thumb_label.setStyleSheet(f"QLabel {{ background-color: {PLACEHOLDER_COLOR.name()}; border: 1px solid #444; border-radius: 3px; color: #888; }}")


    def open_video_url(self, event=None):
        """Opens the video URL using MainWindow's safe opener."""
        url = self.result_data.get('videoURL')
        if url and self.main_window and hasattr(self.main_window, 'open_url_safely'):
            self.main_window.open_url_safely(url)
        elif not url: logging.warning("ResultWidget: Attempted to open result with missing URL.")
        elif not self.main_window: logging.error("ResultWidget: MainWindow reference is missing.")


    def confirm_delete_result(self):
        """Shows confirmation dialog before deleting."""
        reply = QMessageBox.question(self, 'Confirm Deletion',
                                     f"Delete result for:\n'{self.result_data.get('videoTitle', 'Unknown Video')}'?",
                                     QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
        if reply == QMessageBox.Yes:
            self.delete_requested.emit(self) # Signal MainWindow


    def show_context_menu(self, pos):
        """Creates and shows a context menu."""
        menu = QMenu(self)
        open_action = menu.addAction(QIcon.fromTheme("document-open", QIcon()), "Open Video URL")
        copy_url_action = menu.addAction(QIcon.fromTheme("edit-copy", QIcon()), "Copy Video URL")
        copy_title_action = menu.addAction("Copy Title")
        open_thumb_folder_action = None
        if self.thumbnail_path and self.thumbnail_path.exists():
           open_thumb_folder_action = menu.addAction(QIcon.fromTheme("folder-image", QIcon("")), "Open Thumbnail Location")
        menu.addSeparator()
        delete_action = menu.addAction(QIcon.fromTheme("edit-delete", QIcon()), "Delete Result")

        action = menu.exec_(self.mapToGlobal(pos))
        clipboard = QApplication.clipboard()
        logger_func = self.main_window.log if self.main_window else logging.info

        if action == open_action: self.open_video_url()
        elif action == copy_url_action:
            url = self.result_data.get('videoURL'); clipboard.setText(url if url else ""); logger_func(f"Copied URL: {url}")
        elif action == copy_title_action:
            title = self.result_data.get('videoTitle'); clipboard.setText(title if title else ""); logger_func(f"Copied Title: {title}")
        elif open_thumb_folder_action and action == open_thumb_folder_action:
           try: QDesktopServices.openUrl(QUrl.fromLocalFile(str(self.thumbnail_path.parent))); logger_func(f"Opened: {self.thumbnail_path.parent}")
           except Exception as e: logger_func(f"Error opening folder: {e}")
        elif action == delete_action: self.delete_requested.emit(self)


    def load_preview_frames_async(self):
        """Starts async download tasks for animation frames."""
        if not self.preview_urls or not self.main_window or not hasattr(self.main_window, 'download_threadpool') or not self.main_window.download_threadpool:
            self.all_frames_load_attempted = True
            return

        num_frames = len(self.preview_urls)
        self.preview_pixmaps = [None] * num_frames # Pre-fill
        self.frames_loaded_count = 0
        self.all_frames_load_attempted = False

        logging.debug(f"Queueing {num_frames} anim frame downloads for ...{self.result_data.get('videoURL','?')[-20:]}")

        for index, url in enumerate(self.preview_urls):
            if not url or not url.startswith("http"):
                 logging.warning(f"Invalid animation frame URL skipped: {url}")
                 # Manually trigger loaded handler for invalid URLs to maintain count
                 QMetaObject.invokeMethod(self, "_animation_frame_loaded", Qt.QueuedConnection,
                                          Q_ARG(int, index), Q_ARG(QPixmap, QPixmap()))
                 continue

            downloader_task = self._create_frame_downloader_task(url, index)
            self.main_window.download_threadpool.start(downloader_task)

    def _create_frame_downloader_task(self, url, index):
         """Factory method to create a QRunnable for downloading an animation frame."""
         class FrameDownloader(QRunnable):
             def __init__(self, url, index, result_widget):
                 super().__init__()
                 self.url = url; self.index = index
                 # Use weakref to avoid circular references if ResultWidget might be deleted
                 self.widget_ref = weakref.ref(result_widget)
                 self.setAutoDelete(True)
                 # Generate log prefix (try to get video URL safely)
                 vid_url_log = "?"
                 widget = self.widget_ref()
                 if widget and hasattr(widget, 'result_data') and isinstance(widget.result_data, dict):
                     vid_url_log = widget.result_data.get('videoURL','?')[-10:]
                 self.log_prefix = f"[AnimDL Idx:{index} Vid:{vid_url_log}]"

             def run(self):
                 """Downloads image and signals result widget if it still exists."""
                 logging.debug(f"{self.log_prefix} Starting download: {self.url}")
                 pixmap = None; image_data = None; headers = {'User-Agent': 'Mozilla/5.0'}
                 widget = self.widget_ref() # Get widget from weakref

                 try:
                     if not widget: raise TaskStopException("ResultWidget destroyed") # Stop early if widget gone

                     resp = requests.get(self.url, timeout=10, stream=False, headers=headers)
                     logging.debug(f"{self.log_prefix} Status: {resp.status_code}")
                     resp.raise_for_status()
                     image_data = resp.content

                     # Re-check widget validity after download
                     widget = self.widget_ref()
                     if not widget: raise TaskStopException("ResultWidget destroyed after download")

                     if image_data:
                          logging.debug(f"{self.log_prefix} Downloaded {len(image_data)} bytes. Loading pixmap...")
                          loaded_pixmap = QPixmap()
                          if loaded_pixmap.loadFromData(image_data): pixmap = loaded_pixmap.copy()
                          else: logging.warning(f"{self.log_prefix} Failed QPixmap load from data.")
                     else: logging.warning(f"{self.log_prefix} Empty image data received.")

                 except TaskStopException as ts: logging.debug(f"{self.log_prefix} {ts}")
                 except requests.exceptions.Timeout: logging.debug(f"{self.log_prefix} Timeout downloading.")
                 except requests.RequestException as e: logging.warning(f"{self.log_prefix} Network error: {e}")
                 except Exception as e: logging.error(f"{self.log_prefix} Unexpected error: {e}", exc_info=True)

                 finally:
                     # Signal result ONLY if widget still exists
                     widget = self.widget_ref()
                     if widget:
                          logging.debug(f"{self.log_prefix} Signaling result (Pixmap null: {pixmap is None or pixmap.isNull()}).")
                          QMetaObject.invokeMethod(
                              widget, "_animation_frame_loaded", Qt.QueuedConnection,
                              Q_ARG(int, self.index),
                              Q_ARG(QPixmap, pixmap if pixmap else QPixmap()) # Send empty on failure
                          )
                     else:
                          logging.debug(f"{self.log_prefix} ResultWidget no longer exists, skipping signal.")
                     del image_data # Explicitly delete potentially large data

         return FrameDownloader(url, index, self)


    @pyqtSlot(int, QPixmap)
    def _animation_frame_loaded(self, index, pixmap):
        """Slot to receive loaded animation frame pixmaps. Runs in GUI thread."""
        if not self: return # Check if widget still exists
        self.frames_loaded_count += 1
        valid_pixmap = pixmap and not pixmap.isNull()
        log_prefix = f"[AnimLoadResult Idx:{index} Vid:...{self.result_data.get('videoURL','?')[-10:]}]"
        logging.debug(f"{log_prefix} Signal received (Valid: {valid_pixmap}). Total attempts: {self.frames_loaded_count}/{len(self.preview_urls)}")

        if 0 <= index < len(self.preview_pixmaps) and valid_pixmap:
            self.preview_pixmaps[index] = pixmap # Store the valid pixmap

        # Check if all load attempts are finished
        if self.frames_loaded_count >= len(self.preview_urls):
             self.all_frames_load_attempted = True
             valid_count = sum(1 for p in self.preview_pixmaps if p and not p.isNull())
             logging.info(f"{log_prefix} Anim frame loading complete. Valid: {valid_count}/{len(self.preview_urls)}")
             # If currently hovered and some frames are valid, ensure animation is running
             if self.is_hovered and valid_count > 0:
                 self.start_animation()


    def animate_preview(self):
        """Slot called by timer to cycle through loaded preview pixmaps."""
        valid_pixmaps = [p for p in self.preview_pixmaps if p and not p.isNull()]
        if not valid_pixmaps:
            if self.animated_preview_timer.isActive(): self.stop_animation()
            return

        if self.animation_frame_index >= len(valid_pixmaps): self.animation_frame_index = 0 # Loop

        self._display_thumbnail(valid_pixmaps[self.animation_frame_index])
        self.animation_frame_index += 1

    def start_animation(self):
        """Starts the preview animation timer if frames are available."""
        if self.animated_preview_timer.isActive(): return
        has_valid_pixmaps = any(p and not p.isNull() for p in self.preview_pixmaps)

        if has_valid_pixmaps and self.all_frames_load_attempted:
            log_prefix = f"[AnimStart Vid:...{self.result_data.get('videoURL','?')[-10:]}]"
            logging.debug(f"{log_prefix} Starting animation.")
            self.animation_frame_index = 0
            QMetaObject.invokeMethod(self.animated_preview_timer, "start", Qt.QueuedConnection, Q_ARG(int, ANIMATION_FRAME_INTERVAL_MS))
            self.animate_preview() # Show first frame immediately


    def stop_animation(self):
        """Stops animation timer and reverts to static thumbnail."""
        if self.animated_preview_timer.isActive():
            log_prefix = f"[AnimStop Vid:...{self.result_data.get('videoURL','?')[-10:]}]"
            logging.debug(f"{log_prefix} Stopping animation.")
            self.animated_preview_timer.stop()

        # Revert to static thumbnail (use stored pixmap or reload)
        self._display_thumbnail(self.thumbnail_pixmap if self.thumbnail_pixmap else None)


    def cleanup(self):
         """Performs cleanup when the widget is being deleted."""
         logging.debug(f"Cleaning up ResultWidget for ...{self.result_data.get('videoURL','?')[-20:]}")
         if self.animated_preview_timer: self.animated_preview_timer.stop()
         try: self.delete_requested.disconnect()
         except TypeError: pass
         except Exception as e: logging.warning(f"Error disconnecting signal during cleanup: {e}")
         self.deleteLater() # Schedule for deletion


# =============================================================================
# VideoProcessingLane - Widget for the Live View
# =============================================================================
class VideoProcessingLane(QFrame):
    """Widget representing one video being actively processed in live view."""
    REMOVE_DELAY_MS = 4000 # Delay before removing completed lane
    RESULT_FLASH_DURATION_MS = 1000 # Duration of 'result found' flash

    def __init__(self, video_url, video_info, frames_to_analyze, performance_mode=False, parent=None, main_window_ref=None):
        super().__init__(parent)
        self.video_url = video_url
        self.video_info = video_info
        self.frames_to_analyze = sorted(list(set(map(int, frames_to_analyze))))
        self.performance_mode = performance_mode
        self.main_window_ref = main_window_ref # Keep reference to main window
        self.frame_widgets = {} # {frame_num: FrameWidget}
        self.thumb_download_task = None # Track download task if needed
        self._is_done = False # Flag indicating processing complete
        self.removal_timer = QTimer(self); self.removal_timer.setSingleShot(True); self.removal_timer.timeout.connect(self._request_removal)
        self.flash_timer = QTimer(self); self.flash_timer.setSingleShot(True); self.flash_timer.timeout.connect(self._clear_flash_effect)
        self.original_style_sheet = "" # Store original style for flash effect

        # UI Setup
        self.setFrameShape(QFrame.StyledPanel)
        self.original_style_sheet = "QFrame { border: 1px solid #444; border-radius: 4px; background-color: #3a3a3a; margin-bottom: 5px; }"
        self.setStyleSheet(self.original_style_sheet)
        self.setMinimumHeight(100); self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)

        main_layout = QHBoxLayout(self); main_layout.setContentsMargins(5, 3, 5, 3); main_layout.setSpacing(6)

        # Left Side (Thumbnail + Title)
        left_widget = QWidget(); left_layout = QVBoxLayout(left_widget); left_layout.setContentsMargins(0, 0, 0, 0); left_layout.setSpacing(2)
        self.video_thumb_label = QLabel(); self.video_thumb_label.setFixedSize(PLACEHOLDER_LANE_THUMB_SIZE); self.video_thumb_label.setAlignment(Qt.AlignCenter)
        self.video_thumb_label.setStyleSheet(f"QLabel {{ background-color: {PLACEHOLDER_COLOR.name()}; border: 1px solid #444; border-radius: 3px; color: #777; }}")
        if PLACEHOLDER_LANE_THUMB_PIXMAP: self.video_thumb_label.setPixmap(PLACEHOLDER_LANE_THUMB_PIXMAP.copy())
        self.video_thumb_label.setCursor(Qt.PointingHandCursor); self.video_thumb_label.mousePressEvent = self.open_video_url_from_lane
        self.video_thumb_label.setToolTip("Click to open Video URL")
        video_title = self.video_info.get("videoTitle", "Unknown Title"); self.video_title_label = QLabel(video_title)
        self.video_title_label.setWordWrap(True); self.video_title_label.setToolTip(f"{video_title}\n{self.video_url}"); self.video_title_label.setMaximumWidth(110)
        self.video_title_label.setFont(QFont("Segoe UI", 8)); self.video_title_label.setTextInteractionFlags(Qt.TextSelectableByMouse)
        left_layout.addWidget(self.video_thumb_label); left_layout.addWidget(self.video_title_label); left_layout.addStretch()
        left_widget.setFixedWidth(120); main_layout.addWidget(left_widget)

        # Right Side (Scrollable Frames)
        self.frame_scroll = QScrollArea(); self.frame_scroll.setWidgetResizable(True); self.frame_scroll.setFrameShape(QFrame.NoFrame)
        self.frame_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff); self.frame_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
        self.frame_scroll.setMinimumHeight(75)
        frame_container = QWidget(); self.frames_layout = QHBoxLayout(frame_container); self.frames_layout.setContentsMargins(0, 0, 0, 0)
        self.frames_layout.setSpacing(3); self.frames_layout.setAlignment(Qt.AlignLeft)

        for frame_num in self.frames_to_analyze:
            frame_widget = FrameWidget(frame_num, parent=self, main_window=self.main_window_ref, performance_mode=self.performance_mode)
            self.frames_layout.addWidget(frame_widget)
            self.frame_widgets[frame_num] = frame_widget

        self.frames_layout.addStretch(1); frame_container.setLayout(self.frames_layout)
        self.frame_scroll.setWidget(frame_container); main_layout.addWidget(self.frame_scroll, 1)

        # Initial setup based on performance mode
        self.update_performance_mode(self.performance_mode) # Call update to set initial state correctly
        if not self.performance_mode: self.load_video_thumbnail()

    def open_video_url_from_lane(self, event=None):
        """Opens video URL using MainWindow helper."""
        url = self.video_url
        if url and self.main_window_ref and hasattr(self.main_window_ref, 'open_url_safely'):
            self.main_window_ref.open_url_safely(url)
        elif not url: logging.warning("Lane thumbnail clicked but no video URL.")

    def load_video_thumbnail(self):
        """Loads the video thumbnail asynchronously if not in performance mode."""
        if self.performance_mode or self.thumb_download_task is not None: return
        if not self.main_window_ref or not hasattr(self.main_window_ref, 'download_threadpool') or not self.main_window_ref.download_threadpool:
            logging.error(f"Cannot load lane thumbnail for ...{self.video_url[-10:]}: Download pool missing.")
            self._set_placeholder_thumbnail(); return

        thumb_url = self.video_info.get("thumbnailURL")
        if not thumb_url: # Fallback to first preview frame
            all_previews = self.video_info.get("allPreviewFrameURLs", []); thumb_url = all_previews[0] if all_previews else None

        if thumb_url:
             # Use the same FrameDownloader logic as ResultWidget for consistency
             self.thumb_download_task = self.main_window_ref._create_result_frame_downloader_task(
                 thumb_url, -1, self.video_thumb_label, self.video_url # Use index -1 for thumb, pass label
             )
             self.main_window_ref.download_threadpool.start(self.thumb_download_task)
        else:
             logging.warning(f"No thumbnail URL found for lane: ...{self.video_url[-10:]}")
             self._set_placeholder_thumbnail()

    def _set_placeholder_thumbnail(self):
         """Sets the thumbnail label to the placeholder."""
         if PLACEHOLDER_LANE_THUMB_PIXMAP:
              self.video_thumb_label.setPixmap(PLACEHOLDER_LANE_THUMB_PIXMAP.copy())
         else:
              self.video_thumb_label.setText("No Thumb")
         # Ensure background style is correct
         self.video_thumb_label.setStyleSheet(f"QLabel {{ background-color: {PLACEHOLDER_COLOR.name()}; border: 1px solid #444; border-radius: 3px; color: #777; }}")

    @pyqtSlot(int, float)
    def update_frame_score(self, frame_num, score):
        """Updates a specific FrameWidget with a score."""
        if frame_num in self.frame_widgets:
             # logging.debug(f"Lane ...{self.video_url[-10:]}: Updating F{frame_num} score {score:.3f}") # Verbose
             self.frame_widgets[frame_num].display_score(score)

    @pyqtSlot(int, QByteArray)
    def update_frame_image(self, frame_num, image_data):
        """Updates the image data for a specific FrameWidget."""
        if frame_num in self.frame_widgets:
             # logging.debug(f"Lane ...{self.video_url[-10:]}: Setting image F{frame_num} ({image_data.size()} bytes)") # Verbose
             self.frame_widgets[frame_num].set_image_data(image_data)

    @pyqtSlot(int, int)
    def update_frame_state(self, frame_num, state):
        """Sets the state for a specific FrameWidget."""
        if frame_num in self.frame_widgets:
             # logging.debug(f"Lane ...{self.video_url[-10:]}: Setting state F{frame_num} to {state}") # Verbose
             self.frame_widgets[frame_num].set_state(state)

    @pyqtSlot(int)
    def highlight_best_frame(self, best_frame_num):
        """Highlights the best frame and de-highlights others."""
        logging.debug(f"Lane ...{self.video_url[-10:]}: Highlighting best frame {best_frame_num}")
        for fn, fw in self.frame_widgets.items():
            fw.set_best_frame(fn == best_frame_num)

    @pyqtSlot(float)
    def show_result_found_effect(self, score):
        """Applies a visual effect to indicate a result was found."""
        if self._is_done: return
        logging.debug(f"Lane ...{self.video_url[-10:]}: Showing result found effect (Score: {score:.3f})")
        if not self.flash_timer.isActive(): self.original_style_sheet = self.styleSheet()
        flash_style = f"QFrame {{ border: 2px solid {COLOR_THRESHOLD_MET_FLASH.name()}; border-radius: 4px; background-color: qlineargradient(spread:pad, x1:0, y1:0, x2:1, y2:0, stop:0 rgba(48, 58, 48, 255), stop:1 {COLOR_THRESHOLD_MET_FLASH.name(QColor.HexArgb)}); margin-bottom: 5px; }}"
        self.setStyleSheet(flash_style)
        self.flash_timer.start(VideoProcessingLane.RESULT_FLASH_DURATION_MS)

    @pyqtSlot()
    def _clear_flash_effect(self):
        """Restores the original or 'done' stylesheet after the flash."""
        logging.debug(f"Lane ...{self.video_url[-10:]}: Clearing flash effect.")
        style_to_restore = "QFrame { border: 1px solid #006400; border-radius: 4px; background-color: #303a30; margin-bottom: 5px; }" if self._is_done else self.original_style_sheet
        self.setStyleSheet(style_to_restore)

    @pyqtSlot(bool)
    def update_performance_mode(self, enabled):
        """Updates the lane's display based on performance mode."""
        if self.performance_mode == enabled: return
        self.performance_mode = enabled

        # Thumbnail visibility and content
        self.video_thumb_label.setVisible(not enabled)
        if enabled:
             # Stop download task if running? (Complex, maybe just ignore result)
             self._set_placeholder_thumbnail() # Show placeholder in perf mode
        elif not self.video_thumb_label.pixmap() or self.video_thumb_label.pixmap().cacheKey() == PLACEHOLDER_LANE_THUMB_PIXMAP.cacheKey():
             # If switching back to normal and no valid thumb loaded, trigger load
             self.thumb_download_task = None # Allow reload
             self.load_video_thumbnail()

        # Propagate to child FrameWidgets
        for fw in self.frame_widgets.values():
            fw.set_performance_mode(enabled)

    def is_done(self): return self._is_done

    @pyqtSlot()
    def mark_as_done(self):
         """Marks lane as visually done and schedules removal."""
         if self._is_done: return
         logging.debug(f"Marking lane done: ...{self.video_url[-10:]}")
         self._is_done = True
         done_style = "QFrame { border: 1px solid #006400; border-radius: 4px; background-color: #303a30; margin-bottom: 5px; }"
         if self.flash_timer.isActive(): self.original_style_sheet = done_style # Store done style if flashing
         else: self.setStyleSheet(done_style)
         self.removal_timer.start(VideoProcessingLane.REMOVE_DELAY_MS)

    @pyqtSlot()
    def _request_removal(self):
        """Emits signal to MainWindow to remove this lane."""
        if self.main_window_ref and hasattr(self.main_window_ref, 'request_lane_removal'):
            logging.debug(f"Lane ...{self.video_url[-10:]} requesting removal.")
            # Ensure signal emission happens in main thread
            QMetaObject.invokeMethod(self.main_window_ref, "request_lane_removal", Qt.QueuedConnection,
                                     Q_ARG(str, self.video_url))

    def cleanup(self):
        """Cleans up resources before deletion."""
        logging.debug(f"Cleaning up VideoProcessingLane for ...{self.video_url[-10:]}")
        if self.removal_timer: self.removal_timer.stop()
        if self.flash_timer: self.flash_timer.stop()
        for fw in self.frame_widgets.values(): fw.cleanup()
        self.frame_widgets.clear()
        # Cancel download task? For now, just clear ref.
        self.thumb_download_task = None
        self.deleteLater() # Schedule self for deletion

# =============================================================================
# MainWindow – The main application window
# =============================================================================
class MainWindow(QMainWindow):
    """Main application window for WebVidScrap Pro."""
    # Signals for cross-thread UI updates from ScraperThread -> MainWindow(GUI) -> VideoProcessingLane(GUI)
    request_lane_frame_score_update = pyqtSignal(str, int, float)
    request_lane_frame_image_update = pyqtSignal(str, int, QByteArray)
    request_lane_frame_state_update = pyqtSignal(str, int, int)
    request_lane_best_frame = pyqtSignal(str, int)
    request_lane_result_found_effect = pyqtSignal(str, float)
    # Signal for lane removal request from VideoProcessingLane -> MainWindow(GUI)
    request_lane_removal = pyqtSignal(str)

    def __init__(self, parent=None):
        super().__init__(parent)
        logging.info("MainWindow: Initializing...")
        super().__init__()
        self.settings = QSettings(SETTINGS_ORG, SETTINGS_APP)
        self.setWindowTitle(f"WebVidScrap Pro {APP_VERSION}")
        self.setGeometry(100, 100, 1400, 980)
        self.set_app_icon()

        # Initialize placeholders for UI elements
        self._init_ui_attributes()

        # Core Data Structures
        self.reference_pixmaps = [] # List[QPixmap | None]
        self.reference_encodings_all = [] # List[List[np.ndarray]]
        self.reference_image_paths = [] # List[str]
        self.reference_labels = {} # Dict[str, QLabel]
        self.encoding_cache = {} # Dict[str, Dict] - Loaded from/saved to JSON
        self.cache_mutex = QMutex() # Protects encoding_cache access
        self.current_results_data = {} # Dict[str, Dict] - Holds found video results data
        self.known_results_urls = set() # Set[str] - URLs already in results (used by scraper)

        # State Variables
        self.reference_images_in_progress = 0
        self.last_dir = self.settings.value("General/last_dir", str(Path.home()))
        self.active_lanes = {} # Dict[str, VideoProcessingLane] - Currently displayed lanes
        self.pending_lanes = deque() # Queue for videos waiting for a lane slot
        self.current_sort_key = 'score'; self.current_sort_reverse = True # Default sort

        # Thread Pools
        self.ref_load_threadpool = QThreadPool(self)
        ref_load_threads = self.settings.value(SETTING_REF_LOAD_THREADS, DEFAULT_REF_LOAD_THREADS, type=int)
        self.ref_load_threadpool.setMaxThreadCount(max(1, ref_load_threads))

        self.download_threadpool = QThreadPool(self) # For thumbnails, animation frames
        self.download_threadpool.setMaxThreadCount(DEFAULT_DOWNLOAD_THREADS)

        # Scraper Thread (initialized later)
        self.scraper_thread = None
        self.processing_stats = None # Stats object, created on scrape start

        # Setup
        self._ensure_thumbnails_dir()
        self.init_ui() # Create and arrange UI
        self.set_dark_theme() # Apply stylesheet
        self.load_window_state() # Restore size/pos
        self.load_encoding_cache()

        # Connect internal signals for lane updates
        self._connect_lane_update_signals()

        self.log("Application ready.")
        if self.status_label: self.status_label.setText("Ready")

        # Load settings and results AFTER UI is initialized
        self.load_settings_and_references()
        self.load_results()
        self.threadpool = QThreadPool()  # Thread pool for offloading tasks
        logging.info(f"Thread pool initialized with {self.threadpool.maxThreadCount()} threads.")

    def start_long_running_task(self, task_function, *args, **kwargs):
        """Starts a long-running task in a separate thread."""
        worker = QRunnable()
        worker.run = lambda: task_function(*args, **kwargs)
        self.threadpool.start(worker)

    def update_gui_from_thread(self, update_function, *args, **kwargs):
        """Safely updates the GUI from a worker thread."""
        QMetaObject.invokeMethod(self, lambda: update_function(*args, **kwargs), Qt.QueuedConnection)

    pass

    def _init_ui_attributes(self):
        """Initialize all UI related attributes to None."""
        self.central_widget = None; self.main_layout = None; self.main_split_layout = None
        self.left_scroll_area = None; self.left_widget = None; self.left_layout = None
        self.settings_group = None; self.settings_layout = None; self.basic_form = None
        self.pages_hbox = None; self.search_edit = None; self.start_page_spin = None
        self.end_page_spin = None; self.threshold_spin = None; self.headless_check = None
        self.detection_priority_combo = None; self.encoding_model_combo = None
        self.encoding_jitters_spin = None; self.max_upsample_spin = None
        self.force_all_attempts_check = None; self.use_face_mutex_check = None
        self.ref_load_threads_spin = None; self.analyzer_threads_spin = None
        self.frame_recognition_checkboxes = []; self.frame_animation_checkboxes = []
        self.add_ref_button = None; self.clear_ref_button = None; self.ref_scroll = None
        self.ref_container = None; self.ref_container_layout = None; self.ref_progress_bar = None
        self.ref_image_display_label = None; self.perf_mode_check = None
        self.live_view_scroll = None; self.live_view_container = None; self.live_view_lanes_layout = None
        self.no_lanes_label = None; self.log_text = None; self.sort_score_button = None
        self.sort_date_button = None; self.clear_results_button = None; self.results_scroll = None
        self.results_container = None; self.results_container_layout = None; self.start_button = None
        self.pause_button = None; self.stop_button = None; self.progress_bar = None
        self.status_label = None; self.page_progress_label = None; self.video_progress_label = None
        self.frame_time_label = None; self.video_time_label = None; self.runtime_label = None
        self.stats_update_timer = None; self.bottom_bar = None

    def set_app_icon(self):
        """Loads and sets the application icon."""
        try:
            icon_path = APP_DIR / "icon.png"
            if icon_path.exists():
                app_icon = QIcon(str(icon_path))
                if not app_icon.isNull():
                    self.setWindowIcon(app_icon)
                else:
                    self.setWindowIcon(QIcon.fromTheme("applications-internet"))
            else:
                self.setWindowIcon(QIcon.fromTheme("applications-internet"))
        except Exception as e:
            self.setWindowIcon(QIcon.fromTheme("applications-internet"))
            logging.error(f"Error loading app icon: {e}")

    def _ensure_thumbnails_dir(self):
        """Creates the thumbnails directory if it doesn't exist."""
        try:
            THUMBNAILS_DIR.mkdir(parents=True, exist_ok=True)
            logging.info(f"Thumbnails directory ensured: {THUMBNAILS_DIR}")
        except OSError as e:
            logging.critical(f"FATAL: Could not create thumbnails directory '{THUMBNAILS_DIR}': {e}")
            QMessageBox.critical(None, "Fatal Error", f"Could not create required directory:\n{THUMBNAILS_DIR}\n\nError: {e}")
            sys.exit(1) # Exit if essential directory cannot be created

    def init_ui(self):
        """Creates and configures the main UI."""
        logging.info("MainWindow: Initializing UI...")
        self.central_widget = QWidget()
        self.setCentralWidget(self.central_widget)
        self.main_layout = QVBoxLayout(self.central_widget)
        self.main_layout.setContentsMargins(5,5,5,5)

        self.main_split_layout = QHBoxLayout()
        self.main_layout.addLayout(self.main_split_layout, 1) # Add stretch factor

        # == Left Panel ==
        self._create_left_panel()
        self.main_split_layout.addWidget(self.left_scroll_area, 4) # Weight 4

        # == Right Panel ==
        self._create_right_panel()
        self.main_split_layout.addWidget(self.results_widget, 3) # Weight 3

        # == Bottom Bar ==
        self._create_bottom_bar()
        self.main_layout.addWidget(self.bottom_bar)

        # Connect signals AFTER all elements are created
        self._connect_signals()

        logging.info("MainWindow: UI Initialization complete.")

    def _create_left_panel(self):
        """Creates the widgets and layout for the left panel."""
        self.left_scroll_area = QScrollArea(); self.left_scroll_area.setWidgetResizable(True); self.left_scroll_area.setFrameShape(QFrame.NoFrame)
        self.left_scroll_area.setMinimumWidth(500); self.left_scroll_area.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding)
        self.left_widget = QWidget(); self.left_scroll_area.setWidget(self.left_widget)
        self.left_layout = QVBoxLayout(self.left_widget); self.left_layout.setSpacing(8)

        # --- Settings Group ---
        self.settings_group = QGroupBox("Settings"); self.settings_layout = QVBoxLayout(self.settings_group)
        self.basic_form = QFormLayout(); self.basic_form.setSpacing(8)
        self.search_edit = QLineEdit(); self.search_edit.setPlaceholderText("e.g., specific tag, name...")
        self.basic_form.addRow("Search Term:", self.search_edit)
        # Page Range
        self.pages_hbox = QHBoxLayout(); self.start_page_spin = QSpinBox(); self.start_page_spin.setMinimum(1); self.start_page_spin.setMaximum(9999); self.start_page_spin.setValue(1)
        self.end_page_spin = QSpinBox(); self.end_page_spin.setMinimum(-1); self.end_page_spin.setMaximum(9999); self.end_page_spin.setSpecialValueText("∞"); self.end_page_spin.setValue(1)
        self.pages_hbox.addWidget(QLabel("Pages:")); self.pages_hbox.addWidget(self.start_page_spin); self.pages_hbox.addWidget(QLabel("to")); self.pages_hbox.addWidget(self.end_page_spin); self.pages_hbox.addStretch()
        self.basic_form.addRow(self.pages_hbox); self.settings_layout.addLayout(self.basic_form)
        self.update_end_page_tooltip() # Initial tooltip
        # Threshold / Headless
        thresh_head_hbox = QHBoxLayout(); self.threshold_spin = QDoubleSpinBox(); self.threshold_spin.setRange(0.01, 1.00); self.threshold_spin.setValue(0.75); self.threshold_spin.setSingleStep(0.01); self.threshold_spin.setDecimals(2); self.threshold_spin.setToolTip("Minimum face similarity score (0.01-1.00). Higher=stricter.")
        thresh_head_hbox.addWidget(QLabel("Threshold:")); thresh_head_hbox.addWidget(self.threshold_spin); thresh_head_hbox.addStretch()
        self.headless_check = QCheckBox("Run Browser Headless"); self.headless_check.setChecked(True); self.headless_check.setToolTip("Run browser invisibly. Uncheck to see browser window.")
        thresh_head_hbox.addWidget(self.headless_check); self.settings_layout.addLayout(thresh_head_hbox)

        # --- Performance & Accuracy Group ---
        perf_group = QGroupBox("Performance & Accuracy"); perf_layout = QFormLayout(perf_group); perf_layout.setSpacing(8); perf_layout.setFieldGrowthPolicy(QFormLayout.ExpandingFieldsGrow)
        self.detection_priority_combo = QComboBox(); self.detection_priority_combo.addItems(["Accuracy (CNN First)", "Speed (HOG First)"]); self.detection_priority_combo.setToolTip("Detection model priority: Accuracy (slow, better) vs Speed (fast, good for clear faces).")
        perf_layout.addRow("Detection Priority:", self.detection_priority_combo)
        self.encoding_model_combo = QComboBox(); self.encoding_model_combo.addItems(["small", "large"]); self.encoding_model_combo.setToolTip("Encoding Model: small (faster, less accurate) vs large (slower, more accurate).")
        perf_layout.addRow("Encoding Model:", self.encoding_model_combo)
        self.encoding_jitters_spin = QSpinBox(); self.encoding_jitters_spin.setRange(1, 20); self.encoding_jitters_spin.setValue(1); self.encoding_jitters_spin.setToolTip("Encoding Jitters: Higher values are slower but may improve accuracy.")
        perf_layout.addRow("Encoding Jitters:", self.encoding_jitters_spin)
        self.max_upsample_spin = QSpinBox(); self.max_upsample_spin.setRange(0, 2); self.max_upsample_spin.setValue(1); self.max_upsample_spin.setToolTip("Max Detection Upsample: 0 (fastest), 1 (balance), 2 (slowest, best for small faces).")
        perf_layout.addRow("Max Detect Upsample:", self.max_upsample_spin)
        self.force_all_attempts_check = QCheckBox("Force All Detection Attempts"); self.force_all_attempts_check.setChecked(False); self.force_all_attempts_check.setToolTip("If checked, all detection methods/upsamples are tried even if faces are found early.")
        self.use_face_mutex_check = QCheckBox("Use Face Recognition Mutex (Safer, Slower)"); self.use_face_mutex_check.setChecked(True); self.use_face_mutex_check.setToolTip("Serializes face detection calls. Disable ONLY if sure about stability with parallel calls.")
        perf_layout.addRow(self.force_all_attempts_check); perf_layout.addRow(self.use_face_mutex_check)
        # Threads
        threads_hbox = QHBoxLayout(); core_info = f"(Cores: {CPU_CORES})"; self.ref_load_threads_spin = QSpinBox(); self.ref_load_threads_spin.setRange(1, CPU_CORES * 2); self.ref_load_threads_spin.setValue(DEFAULT_REF_LOAD_THREADS); self.ref_load_threads_spin.setToolTip(f"Max reference loading tasks. Rec: {CPU_CORES // 2}-{CPU_CORES}.")
        self.analyzer_threads_spin = QSpinBox(); self.analyzer_threads_spin.setRange(1, max(4, CPU_CORES * 2)); self.analyzer_threads_spin.setValue(DEFAULT_ANALYZER_THREADS); self.analyzer_threads_spin.setToolTip(f"Max frame analysis tasks. Rec: {CPU_CORES // 2}-{CPU_CORES}. High values risk instability.")
        threads_hbox.addWidget(QLabel("Ref Load:")); threads_hbox.addWidget(self.ref_load_threads_spin); threads_hbox.addSpacerItem(QSpacerItem(20,0)); threads_hbox.addWidget(QLabel("Analyzer:")); threads_hbox.addWidget(self.analyzer_threads_spin); threads_hbox.addStretch()
        perf_layout.addRow(f"Threads {core_info}:", threads_hbox); self.settings_layout.addWidget(perf_group)

        # --- Frame Selection ---
        frames_vbox = QVBoxLayout(); frames_vbox.setSpacing(5)
        rec_group = QGroupBox("Frames to Analyze"); rec_grid = QGridLayout(rec_group); rec_grid.setSpacing(5); self.frame_recognition_checkboxes = []
        default_rec = {1, 5, 8, 12, 15}; rec_group.setToolTip("Select frames (1-15) to analyze for faces.")
        for i in range(1,16): cb = QCheckBox(str(i)); cb.setChecked(i in default_rec); self.frame_recognition_checkboxes.append(cb); row, col = divmod(i-1, 5); rec_grid.addWidget(cb, row, col)
        frames_vbox.addWidget(rec_group)
        anim_group = QGroupBox("Result Preview Animation Frames"); anim_grid = QGridLayout(anim_group); anim_grid.setSpacing(5); self.frame_animation_checkboxes = []
        default_anim = set(range(1, 16)); anim_group.setToolTip("Select frames (1-15) for hover animation in results.")
        for i in range(1,16): cb = QCheckBox(str(i)); cb.setChecked(i in default_anim); self.frame_animation_checkboxes.append(cb); row, col = divmod(i-1, 5); anim_grid.addWidget(cb, row, col)
        frames_vbox.addWidget(anim_group); self.settings_layout.addLayout(frames_vbox)
        self.left_layout.addWidget(self.settings_group, 0) # Settings group stretch factor 0

        # --- Reference Images Group ---
        ref_group = QGroupBox("Reference Faces"); ref_layout = QVBoxLayout(ref_group); ref_layout.setSpacing(6)
        ref_btns = QHBoxLayout(); self.add_ref_button = QPushButton(QIcon.fromTheme("list-add", QIcon()), "Add Reference(s)..."); self.add_ref_button.setFixedHeight(28)
        self.clear_ref_button = QPushButton(QIcon.fromTheme("edit-clear", QIcon()), "Clear References"); self.clear_ref_button.setObjectName("clear_ref_button"); self.clear_ref_button.setFixedHeight(28)
        ref_btns.addWidget(self.add_ref_button); ref_btns.addWidget(self.clear_ref_button); ref_layout.addLayout(ref_btns)
        self.ref_scroll = QScrollArea(); self.ref_scroll.setWidgetResizable(True); self.ref_scroll.setMinimumHeight(100); self.ref_scroll.setMaximumHeight(120); self.ref_scroll.setFrameShape(QFrame.StyledPanel); self.ref_scroll.setStyleSheet("QScrollArea { background-color: #3E3E3E; border-radius: 3px; border: 1px solid #555; }")
        self.ref_container = QWidget(); self.ref_container.setObjectName("ref_container"); self.ref_container_layout = QHBoxLayout(self.ref_container); self.ref_container_layout.setAlignment(Qt.AlignLeft); self.ref_container_layout.setSpacing(5); self.ref_container_layout.setContentsMargins(3, 3, 3, 3)
        self.ref_scroll.setWidget(self.ref_container); ref_layout.addWidget(self.ref_scroll)
        self.ref_progress_bar = QProgressBar(); self.ref_progress_bar.setFixedHeight(10); self.ref_progress_bar.setTextVisible(False); self.ref_progress_bar.setVisible(False)
        ref_layout.addWidget(self.ref_progress_bar); self.left_layout.addWidget(ref_group, 0) # Ref group stretch 0

        # --- Live View Group ---
        live_group = QGroupBox("Live View"); live_layout = QVBoxLayout(live_group); live_layout.setSpacing(6)
        live_top_bar = QHBoxLayout(); live_top_bar.setContentsMargins(0,0,0,0)
        self.ref_image_display_label = QLabel(); self.ref_image_display_label.setFixedSize(PLACEHOLDER_REF_ICON_SIZE - QSize(10,10)); self.ref_image_display_label.setAlignment(Qt.AlignCenter); self.ref_image_display_label.setStyleSheet(f"QLabel {{ border: 1px solid #555; background-color: {PLACEHOLDER_COLOR.name()}; border-radius: 3px; color: #888; }}")
        self.ref_image_display_label.setToolTip("Last reference face compared"); self._set_ref_placeholder() # Set initial placeholder
        live_top_bar.addWidget(self.ref_image_display_label); live_top_bar.addStretch()
        self.perf_mode_check = QCheckBox("Performance Mode"); self.perf_mode_check.setToolTip(f"Disable image loading in Live View (Max lanes: {MAX_ACTIVE_LANES})")
        live_top_bar.addWidget(self.perf_mode_check); live_layout.addLayout(live_top_bar)
        self.live_view_scroll = QScrollArea(); self.live_view_scroll.setWidgetResizable(True); self.live_view_scroll.setFrameShape(QFrame.StyledPanel); self.live_view_scroll.setStyleSheet("QScrollArea { border: 1px solid #555; border-radius: 3px; }"); self.live_view_scroll.setMinimumHeight(200)
        self.live_view_container = QWidget(); self.live_view_container.setObjectName("live_view_container"); self.live_view_lanes_layout = QVBoxLayout(self.live_view_container); self.live_view_lanes_layout.setAlignment(Qt.AlignTop); self.live_view_lanes_layout.setSpacing(4); self.live_view_lanes_layout.setContentsMargins(2, 2, 2, 2)
        self.no_lanes_label = QLabel("Live view will show active video analysis here..."); self.no_lanes_label.setAlignment(Qt.AlignCenter); self.no_lanes_label.setStyleSheet("QLabel { color: #888; margin: 20px; }")
        self.live_view_lanes_layout.addWidget(self.no_lanes_label); self.live_view_lanes_layout.addStretch(1) # Add stretch initially
        self.live_view_scroll.setWidget(self.live_view_container); live_layout.addWidget(self.live_view_scroll, 1)
        self.left_layout.addWidget(live_group, 1) # Live view stretch 1

        # --- Processing Statistics Group ---
        stats_group = QGroupBox("Stats"); stats_layout = QGridLayout(stats_group); stats_layout.setSpacing(4); stats_layout.setContentsMargins(6, 6, 6, 6)
        self.page_progress_label = QLabel("P: -/-"); self.video_progress_label = QLabel("V: -/-"); self.frame_time_label = QLabel("Frame: - ms (50: - ms)"); self.video_time_label = QLabel("Video: - s (50: - s)"); self.runtime_label = QLabel("Time: -")
        small_font = QFont(); small_font.setPointSize(8)
        for label in [self.page_progress_label, self.video_progress_label, self.frame_time_label, self.video_time_label, self.runtime_label]: label.setFont(small_font); label.setStyleSheet("color: #ccc;")
        stats_layout.addWidget(QLabel("Page:"), 0, 0); stats_layout.addWidget(self.page_progress_label, 0, 1); stats_layout.addWidget(QLabel("Video:"), 1, 0); stats_layout.addWidget(self.video_progress_label, 1, 1)
        stats_layout.addWidget(QLabel("Frame Time:"), 0, 2); stats_layout.addWidget(self.frame_time_label, 0, 3); stats_layout.addWidget(QLabel("Video Time:"), 1, 2); stats_layout.addWidget(self.video_time_label, 1, 3)
        stats_layout.addWidget(QLabel("Runtime:"), 2, 0); stats_layout.addWidget(self.runtime_label, 2, 1, 1, 3); self.frame_time_label.setToolTip("Avg frame analysis time (Avg time for 50 frames)"); self.video_time_label.setToolTip("Avg video processing time (Avg time for 50 videos)"); self.runtime_label.setToolTip("Total runtime since process started")
        stats_group.setFixedHeight(95); self.left_layout.addWidget(stats_group)
        # Stats Timer
        self.stats_update_timer = QTimer(self); self.stats_update_timer.setInterval(1000)

        # --- Log Output Group ---
        log_group = QGroupBox("Log"); log_layout = QVBoxLayout(log_group)
        self.log_text = QTextEdit(); self.log_text.setReadOnly(True); self.log_text.setFont(QFont("Consolas", 8)); self.log_text.setLineWrapMode(QTextEdit.NoWrap)
        self.log_text.setMinimumHeight(80); self.log_text.setMaximumHeight(150)
        log_layout.addWidget(self.log_text); self.left_layout.addWidget(log_group, 0) # Log group stretch 0
        self.left_layout.addStretch(0) # Final stretch at bottom of left panel

    def _set_ref_placeholder(self):
        """Sets the placeholder for the reference image display."""
        if self.ref_image_display_label:
             if PLACEHOLDER_REF_PIXMAP:
                  self.ref_image_display_label.setPixmap(PLACEHOLDER_REF_PIXMAP.scaled(self.ref_image_display_label.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation))
             else: self.ref_image_display_label.setText("Ref")
             self.ref_image_display_label.setToolTip("Last reference face compared")

    def _create_right_panel(self):
        """Creates the widgets and layout for the right results panel."""
        self.results_widget = QWidget(); results_main_layout = QVBoxLayout(self.results_widget); results_main_layout.setContentsMargins(0, 0, 0, 0)
        results_group = QGroupBox("Results"); results_group.setStyleSheet("QGroupBox { margin-top: 7px; padding-top: 14px; }") # Align title
        results_layout = QVBoxLayout(results_group); results_layout.setSpacing(4)
        # Sort/Clear Buttons
        sort_hbox = QHBoxLayout(); sort_hbox.addWidget(QLabel("Sort by:")); self.sort_score_button = QPushButton("Score"); self.sort_score_button.setCheckable(True); self.sort_score_button.setChecked(True); self.sort_score_button.setFixedHeight(24)
        self.sort_date_button = QPushButton("Date Added"); self.sort_date_button.setCheckable(True); self.sort_date_button.setFixedHeight(24)
        sort_hbox.addWidget(self.sort_score_button); sort_hbox.addWidget(self.sort_date_button); sort_hbox.addStretch()
        self.clear_results_button = QPushButton(QIcon.fromTheme("edit-clear", QIcon()), "Clear Results"); self.clear_results_button.setObjectName("clear_results_button"); self.clear_results_button.setFixedHeight(24); self.clear_results_button.setToolTip("Delete all listed results and thumbnails.")
        sort_hbox.addWidget(self.clear_results_button); results_layout.addLayout(sort_hbox)
        # Results Scroll Area
        self.results_scroll = QScrollArea(); self.results_scroll.setWidgetResizable(True); self.results_scroll.setFrameShape(QFrame.NoFrame)
        self.results_container = QWidget(); self.results_container.setObjectName("results_container"); self.results_container_layout = QVBoxLayout(self.results_container); self.results_container_layout.setSpacing(5); self.results_container_layout.setAlignment(Qt.AlignTop); self.results_container_layout.setContentsMargins(2, 2, 2, 2)
        self.results_container_layout.addStretch(1) # Initial stretch
        self.results_scroll.setWidget(self.results_container); results_layout.addWidget(self.results_scroll, 1)
        results_main_layout.addWidget(results_group)

    def _create_bottom_bar(self):
        """Creates the bottom control bar."""
        self.bottom_bar = QFrame(); self.bottom_bar.setObjectName("bottom_bar"); self.bottom_bar.setFrameShape(QFrame.StyledPanel); self.bottom_bar.setFixedHeight(55)
        bottom_bar_layout = QHBoxLayout(self.bottom_bar); bottom_bar_layout.setContentsMargins(10, 5, 10, 5)
        # Buttons
        self.start_button = QPushButton("▶ Start"); self.start_button.setFixedHeight(35); self.start_button.setStyleSheet("QPushButton { font-size: 11pt; font-weight: bold; }")
        self.pause_button = QPushButton("❚❚ Pause"); self.pause_button.setEnabled(False); self.pause_button.setFixedHeight(35); self.pause_button.setStyleSheet("QPushButton { font-size: 11pt; font-weight: bold; }")
        self.stop_button = QPushButton("■ Stop"); self.stop_button.setObjectName("stop_button"); self.stop_button.setEnabled(False); self.stop_button.setFixedHeight(35); self.stop_button.setStyleSheet("QPushButton { font-size: 11pt; font-weight: bold; }")
        bottom_bar_layout.addWidget(self.start_button); bottom_bar_layout.addWidget(self.pause_button); bottom_bar_layout.addWidget(self.stop_button)
        # Progress Bar
        self.progress_bar = QProgressBar(); self.progress_bar.setFixedHeight(12); self.progress_bar.setTextVisible(False); self.progress_bar.setValue(0); self.progress_bar.setMaximum(100)
        bottom_bar_layout.addWidget(self.progress_bar, 1) # Stretch
        # Status Label
        self.status_label = QLabel("Ready"); self.status_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter); self.status_label.setStyleSheet("QLabel { color: #ccc; font-size: 9pt; margin-left: 10px; }"); self.status_label.setMinimumWidth(150)
        bottom_bar_layout.addWidget(self.status_label)

    def _connect_signals(self):
        """Connects UI element signals to slots."""
        # Settings
        self.search_edit.returnPressed.connect(self.perform_search)
        self.start_page_spin.valueChanged.connect(self._handle_start_page_change)
        self.end_page_spin.valueChanged.connect(self._handle_end_page_change)
        self.ref_load_threads_spin.valueChanged.connect(lambda v: self.ref_load_threadpool.setMaxThreadCount(v))
        # analyzer_threads_spin change handled when starting scraper
        # Reference Buttons
        self.add_ref_button.clicked.connect(self.add_reference_images)
        self.clear_ref_button.clicked.connect(self.confirm_clear_references)
        # Live View
        self.perf_mode_check.stateChanged.connect(self.toggle_performance_mode)
        # Results Buttons
        self.sort_score_button.clicked.connect(lambda: self.set_sort_order('score'))
        self.sort_date_button.clicked.connect(lambda: self.set_sort_order('timestamp_added'))
        self.clear_results_button.clicked.connect(self.confirm_clear_results)
        # Bottom Bar Buttons
        if self.start_button is None:
            logging.error("self.start_button is None!")
        self.start_button.clicked.connect(self.start_scraping)
        self.pause_button.clicked.connect(self.toggle_pause_scraping)
        self.stop_button.clicked.connect(lambda: self.stop_scraping(confirm=True))
        # Stats Timer
        self.stats_update_timer.timeout.connect(self.update_stats_display)

    def set_dark_theme(self):
        """Applies a dark theme stylesheet to the application."""
        dark_stylesheet = """
        QMainWindow, QDialog {
            background-color: #2b2b2b;
            color: #d4d4d4;
        }
        QWidget {
            background-color: #2b2b2b;
            color: #d4d4d4;
        }
        QMenuBar {
            background-color: #333333;
            color: #d4d4d4;
            border-bottom: 1px solid #444444;
        }
        QMenuBar::item:selected {
            background-color: #404040;
        }
        QMenu {
            background-color: #333333;
            color: #d4d4d4;
            border: 1px solid #444444;
        }
        QMenu::item:selected {
            background-color: #404040;
        }
        QPushButton {
            background-color: #404040;
            color: #d4d4d4;
            border: 1px solid #555555;
            border-radius: 3px;
            padding: 5px;
        }
        QPushButton:hover {
            background-color: #4a4a4a;
            border-color: #666666;
        }
        QPushButton:pressed {
            background-color: #353535;
        }
        QPushButton:disabled {
            background-color: #2d2d2d;
            color: #666666;
            border-color: #3d3d3d;
        }
        QLineEdit, QSpinBox, QDoubleSpinBox, QComboBox {
            background-color: #333333;
            color: #d4d4d4;
            border: 1px solid #555555;
            border-radius: 3px;
            padding: 3px;
        }
        QLineEdit:focus, QSpinBox:focus, QDoubleSpinBox:focus {
            border-color: #666666;
        }
        QTextEdit {
            background-color: #333333;
            color: #d4d4d4;
            border: 1px solid #555555;
        }
        QScrollArea {
            background-color: transparent;
            border: none;
        }
        QScrollBar:vertical {
            border: none;
            background-color: #2b2b2b;
            width: 12px;
            margin: 0px;
        }
        QScrollBar::handle:vertical {
            background-color: #555555;
            border-radius: 3px;
            min-height: 20px;
        }
        QScrollBar::handle:vertical:hover {
            background-color: #666666;
        }
        QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {
            height: 0px;
        }
        QScrollBar:horizontal {
            border: none;
            background-color: #2b2b2b;
            height: 12px;
            margin: 0px;
        }
        QScrollBar::handle:horizontal {
            background-color: #555555;
            border-radius: 3px;
            min-width: 20px;
        }
        QScrollBar::handle:horizontal:hover {
            background-color: #666666;
        }
        QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal {
            width: 0px;
        }
        QProgressBar {
            border: 1px solid #555555;
            border-radius: 3px;
            background-color: #333333;
            color: #d4d4d4;
            text-align: center;
        }
        QProgressBar::chunk {
            background-color: #4682B4;
        }
        QLabel {
            color: #d4d4d4;
        }
        QGroupBox {
            border: 1px solid #555555;
            border-radius: 3px;
            margin-top: 6px;
            color: #d4d4d4;
        }
        QGroupBox::title {
            subcontrol-origin: margin;
            left: 7px;
            padding: 0px 3px 0px 3px;
        }
        QCheckBox {
            color: #d4d4d4;
            spacing: 5px;
        }
        QCheckBox::indicator {
            width: 13px;
            height: 13px;
        }
        """
        self.setStyleSheet(dark_stylesheet)

    def _connect_lane_update_signals(self):
        """Connect signals for updating video processing lanes."""
        self.request_lane_frame_score_update.connect(self.handle_frame_score_for_lane, Qt.QueuedConnection)
        self.request_lane_frame_image_update.connect(self.update_video_lane_frame_image, Qt.QueuedConnection)
        self.request_lane_frame_state_update.connect(self.update_video_lane_frame_state, Qt.QueuedConnection)
        self.request_lane_best_frame.connect(self.highlight_video_lane_best_frame, Qt.QueuedConnection)
        self.request_lane_removal.connect(self.remove_video_lane, Qt.QueuedConnection)
        self.request_lane_result_found_effect.connect(self.show_lane_result_found_effect, Qt.QueuedConnection)

    # --- Placeholder for search action ---
    def perform_search(self):
        """Starts scraping when Enter is pressed in the search field."""
        self.start_scraping()

    # --- Rest of MainWindow Methods (Logging, Refs, Scraping Control, Results, Saving/Loading, etc.) ---
    # (Include the corrected/optimized methods from the previous analysis here)
    # ... (add_reference_images, handle_reference_image_loaded, clear_references, ...)
    # ... (start_scraping, toggle_pause_scraping, stop_scraping, scraping_finished, ...)
    # ... (add_result, remove_result_widget, clear_results, sort_results_view, ...)
    # ... (save_settings, load_settings_and_references, save_results, load_results, ...)
    # ... (log, handle_scraper_error, update_progress, update_stats_display, ...)
    # ... (handle_video_found, add_video_lane, handle_video_processing_finished, ...)
    # ... (handle_frame_score_for_lane, update_video_lane_frame_image, ...)
    # ... (update_current_reference_image_display, toggle_performance_mode, ...)
    # ... (closeEvent, etc.)

    @pyqtSlot(str)
    def log(self, message):
        """Appends a timestamped message to the log QTextEdit and logs to file."""
        # Log to File Immediately
        msg_lower = message.lower()
        log_level = logging.INFO
        if "error" in msg_lower or "critical" in msg_lower or "failed" in msg_lower:
            log_level = logging.ERROR
        elif "warning" in msg_lower:
            log_level = logging.WARNING
        elif "debug" in msg_lower:
            log_level = logging.DEBUG

        # Always log the message using the configured root logger
        # Let the logging framework handle potential duplicates if necessary
        # The format difference between console and file helps distinguish origin if needed.
        logging.log(log_level, message)  # Log the original message directly

        # Queue GUI Update if log_text is initialized
        if self.log_text:
            if QThread.currentThread() != self.thread():
                QMetaObject.invokeMethod(self, "_update_log_widget", Qt.QueuedConnection,
                                         Q_ARG(str, message))
            else:
                self._update_log_widget(message)  # Call directly if already in GUI thread

    def _update_log_widget(self, message):
        """Helper method to update the QTextEdit. MUST run in GUI thread."""
        if self.log_text and isinstance(self.log_text, QTextEdit): # Extra check
             try:
                timestamp = time.strftime('%H:%M:%S')
                # Use HTML for potential future formatting
                self.log_text.append(f"{timestamp} - {message}")
                # Auto-scroll only if the scrollbar is near the bottom
                scrollbar = self.log_text.verticalScrollBar()
                if not scrollbar or scrollbar.value() >= scrollbar.maximum() - 10:
                     self.log_text.ensureCursorVisible()
             except Exception as e:
                 # Log error updating GUI element
                 logging.error(f"Error updating log widget: {e}", exc_info=True)

    # --- Reference Image Handling ---
    def add_reference_images(self):
        logging.debug("add_reference_images called")
        start_dir = self.last_dir if Path(self.last_dir).is_dir() else str(Path.home())
        files, _ = QFileDialog.getOpenFileNames(self, "Select Reference Images", start_dir, "Images (*.png *.jpg *.jpeg *.bmp *.webp *.jfif)")
        if not files: return

        self.last_dir = str(Path(files[0]).parent)
        self.settings.setValue("General/last_dir", self.last_dir)

        new_file_paths = [Path(f) for f in files]
        current_paths_str = set(self.reference_image_paths) # Use set for faster lookup
        new_files_to_load = [p for p in new_file_paths if str(p) not in current_paths_str]

        if not new_files_to_load:
             self.log(f"Selected image(s) are already loaded or being processed.")
             return

        self.log(f"Queueing {len(new_files_to_load)} new reference image(s) for loading...")

        # Update Progress Bar state before starting tasks
        current_max = self.ref_progress_bar.maximum()
        new_tasks_count = len(new_files_to_load)
        new_total_tasks = self.reference_images_in_progress + new_tasks_count
        if new_total_tasks > 0:
            if self.reference_images_in_progress == 0: # Starting fresh batch
                 self.ref_progress_bar.setMaximum(new_total_tasks)
                 self.ref_progress_bar.setValue(0)
            else: # Adding to existing progress
                 self.ref_progress_bar.setMaximum(new_total_tasks)
                 # Value remains unchanged (representing already completed from previous batch)
            self.ref_progress_bar.setVisible(True)
        else:
             self.ref_progress_bar.setVisible(False) # Hide if somehow total is zero


        # Get Current Face Rec Settings
        detection_prio = self.detection_priority_combo.currentIndex()
        enc_model = self.encoding_model_combo.currentText()
        jitters = self.encoding_jitters_spin.value()
        max_up = self.max_upsample_spin.value()
        force_all = self.force_all_attempts_check.isChecked()
        use_mutex_setting = self.use_face_mutex_check.isChecked()

        # Start Tasks
        for file_path in new_files_to_load:
            # Double check again before incrementing/starting (paranoid check)
            if str(file_path) in self.reference_image_paths: continue

            self.reference_images_in_progress += 1 # Increment *before* starting task

            task = ReferenceImageLoaderTask(
                file_path, True, self.encoding_cache, self.cache_mutex,
                detection_prio, enc_model, jitters, max_up, force_all, use_mutex_setting # Pass mutex setting
            )
            task.signals.log_signal.connect(self.log)
            task.signals.log_signal.connect(self.handle_ref_image_error)
            task.signals.reference_image_loaded_signal.connect(self.handle_reference_image_loaded)
            self.ref_load_threadpool.start(task)


    @pyqtSlot(QPixmap, str, list)
    def handle_reference_image_loaded(self, pixmap, file_path_str, encodings_np):
        """Handles the result of a ReferenceImageLoaderTask. Runs in the main GUI thread."""
        file_path_obj = Path(file_path_str); file_name = file_path_obj.name
        log_prefix = f"[RefLoadResult {file_name}]"; logging.debug(f"{log_prefix} Signal received. Encodings: {len(encodings_np)}, Pixmap null: {pixmap.isNull()}")

        # Update Progress Bar (Decrement counter *after* processing result)
        task_completed_now = False
        if self.reference_images_in_progress > 0:
            current_max = self.ref_progress_bar.maximum()
            completed_count = current_max - self.reference_images_in_progress + 1
            self.ref_progress_bar.setValue(min(completed_count, current_max)) # Ensure value <= max
            self.reference_images_in_progress -= 1
            task_completed_now = True
        else: # Should not happen if counter logic is correct
             logging.warning(f"{log_prefix} handle_reference_image_loaded called but reference_images_in_progress was already 0.")

        # Check if this was the last pending task
        if task_completed_now and self.reference_images_in_progress == 0:
            QTimer.singleShot(500, lambda: self.ref_progress_bar.setVisible(False))
            self.log("Reference image loading complete.")
            self.save_encoding_cache(); self.save_settings() # Save after loading finishes
        elif self.reference_images_in_progress < 0: # Safety reset
             logging.error(f"{log_prefix} Reference progress counter went below zero!"); self.reference_images_in_progress = 0; self.ref_progress_bar.setVisible(False)

        # Handle Result Logic
        if file_path_str in self.reference_image_paths: logging.debug(f"{log_prefix} Path already processed. Ignoring duplicate signal."); return

        valid_encoding_items = isinstance(encodings_np, list) and all(isinstance(e, np.ndarray) for e in encodings_np)
        faces_found = valid_encoding_items and len(encodings_np) > 0
        no_faces_found = valid_encoding_items and len(encodings_np) == 0
        is_pixmap_valid = pixmap and not pixmap.isNull()

        try:
            if faces_found and is_pixmap_valid: self._add_reference_to_ui_and_data(pixmap, file_path_str, encodings_np)
            elif no_faces_found and is_pixmap_valid: self.log(f"{log_prefix} No face encodings found."); self._add_reference_to_ui_and_data(pixmap, file_path_str, [])
            elif faces_found and not is_pixmap_valid:
                 self.log(f"Warning: {log_prefix} Encodings OK ({len(encodings_np)} faces), but preview failed. Skipping '{file_name}'."); self._ensure_cache_cleaned(file_path_str)
            else: # Failure: invalid encodings OR (no faces AND no pixmap)
                 if not valid_encoding_items: self.log(f"{log_prefix} Failed (invalid encodings result). Not adding '{file_name}'.")
                 else: self.log(f"{log_prefix} No face found and preview failed. Not adding '{file_name}'.")
                 self._ensure_cache_cleaned(file_path_str)
        except Exception as e:
            self.log(f"CRITICAL Error handling reference result for '{file_name}': {e}"); logging.exception(f"Error in handle_reference_image_loaded for {file_path_str}")
            self._remove_reference_internal(file_path_str) # Attempt cleanup

    def _add_reference_to_ui_and_data(self, pixmap, file_path_str, encodings_np):
        """Adds a reference image's data and UI element. Assumes valid pixmap."""
        if file_path_str in self.reference_image_paths: logging.warning(f"Attempted duplicate add: {file_path_str}"); return
        file_name = Path(file_path_str).name
        try:
            num_encs = len(encodings_np); has_faces = num_encs > 0
            self.log(f"Adding reference '{file_name}' ({num_encs} face(s)).")
            # Create UI Label
            ref_label = QLabel(); scaled_thumb = pixmap.scaled(PLACEHOLDER_REF_ICON_SIZE, Qt.KeepAspectRatio, Qt.SmoothTransformation)
            ref_label.setPixmap(scaled_thumb); ref_label.setFixedSize(PLACEHOLDER_REF_ICON_SIZE + QSize(5, 5)); ref_label.setAlignment(Qt.AlignCenter)
            base_style = "QLabel { border-radius: 3px; background-color: #444; margin: 2px; }"; border_style = f"border: 1px dashed {COLOR_NO_FACE.name()};" if not has_faces else "border: 1px solid #666;"
            ref_label.setStyleSheet(base_style + border_style)
            tooltip_text = f"Path: {file_path_str}\n({num_encs} face(s) found)" if has_faces else f"Path: {file_path_str}\n(No faces found)"
            ref_label.setToolTip(tooltip_text + "\nRight-click to remove."); ref_label.setContextMenuPolicy(Qt.CustomContextMenu)
            ref_label.customContextMenuRequested.connect(lambda pos, p=file_path_str: self.show_ref_context_menu(p, pos))
            # Store Data & UI
            self.ref_container_layout.addWidget(ref_label); self.reference_labels[file_path_str] = ref_label
            self.reference_pixmaps.append(pixmap); self.reference_encodings_all.append(encodings_np); self.reference_image_paths.append(file_path_str)
        except Exception as e:
            self.log(f"CRITICAL Error adding reference UI/data for '{file_name}': {e}"); logging.exception(f"Error in _add_reference_to_ui_and_data for {file_path_str}")
            self._remove_reference_internal(file_path_str) # Attempt cleanup

    def show_ref_context_menu(self, file_path_str, position):
        """Shows context menu for a reference image label."""
        if file_path_str not in self.reference_labels: return
        label = self.reference_labels[file_path_str]
        menu = QMenu(self); file_name = Path(file_path_str).name
        remove_action = menu.addAction(QIcon.fromTheme("edit-delete", QIcon()), f"Remove '{file_name}'")
        action = menu.exec_(label.mapToGlobal(position))
        if action == remove_action:
            self._remove_reference_internal(file_path_str); self.save_settings(); self.save_encoding_cache()

    def _remove_reference_internal(self, file_path_str):
         """Removes a reference image from UI, data lists, and cache."""
         file_name = Path(file_path_str).name; logging.debug(f"Internal remove ref called for: {file_path_str}")
         removed_from_ui = False; removed_from_data = False
         # Remove from UI
         if file_path_str in self.reference_labels:
             label = self.reference_labels.pop(file_path_str)
             try: self.ref_container_layout.removeWidget(label); label.deleteLater(); removed_from_ui = True
             except RuntimeError: removed_from_ui = True # Assume gone if deletion error
             except Exception as e: logging.error(f"Error removing ref label '{file_name}' UI: {e}")
         # Remove from Data Lists
         if file_path_str in self.reference_image_paths:
             try:
                 idx = self.reference_image_paths.index(file_path_str)
                 self.reference_image_paths.pop(idx)
                 if idx < len(self.reference_pixmaps): self.reference_pixmaps.pop(idx)
                 if idx < len(self.reference_encodings_all): self.reference_encodings_all.pop(idx)
                 removed_from_data = True; self.log(f"Removed reference data: {file_name}")
             except Exception as e: logging.error(f"Error removing ref data for {file_name}", exc_info=True); self.log(f"Error removing ref data: {e}")
         # Remove from Cache
         self._ensure_cache_cleaned(file_path_str)
         if removed_from_ui or removed_from_data: logging.info(f"Reference '{file_name}' removal processed (UI: {removed_from_ui}, Data: {removed_from_data}).")

    def _ensure_cache_cleaned(self, file_path_str):
        """Removes a path from the encoding cache."""
        if self.cache_mutex:
            with QMutexLocker(self.cache_mutex):
                 if file_path_str in self.encoding_cache:
                     try: del self.encoding_cache[file_path_str]; logging.debug(f"Removed '{Path(file_path_str).name}' from cache.")
                     except KeyError: pass # Already removed

    @pyqtSlot(str)
    def handle_ref_image_error(self, message):
        """Logs errors reported by ReferenceImageLoaderTask."""
        if "No face found" in message or "Image processing error: All image loading methods failed" in message:
             self.log(f"Reference Info: {message}") # Log as info
        else: # Log genuine errors
            self.log(f"Reference Image Error: {message}"); logging.error(f"ReferenceImageLoaderTask Error: {message}")

    def confirm_clear_references(self):
        """Confirms before clearing all reference images."""
        if not self.reference_image_paths: QMessageBox.information(self, "Clear References", "No reference images loaded."); return
        reply = QMessageBox.question(self, 'Confirm Clear References', "Remove all loaded reference images and clear encoding cache?\n\nAre you sure?", QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
        if reply == QMessageBox.Yes: self.clear_references()

    def clear_references(self):
        """Clears all reference images, UI elements, data, and cache."""
        self.log("Clearing all reference images and cache...")
        if hasattr(self.ref_load_threadpool, 'clear'): self.ref_load_threadpool.clear()
        self.reference_images_in_progress = 0; self.ref_progress_bar.setVisible(False); self.ref_progress_bar.setValue(0)
        paths_to_remove = list(self.reference_image_paths)
        for path_str in paths_to_remove: self._remove_reference_internal(path_str)
        # Explicit clears for safety
        self.reference_pixmaps.clear(); self.reference_encodings_all.clear(); self.reference_image_paths.clear(); self.reference_labels.clear()
        if self.cache_mutex:
            with QMutexLocker(self.cache_mutex): self.encoding_cache.clear()
        self._set_ref_placeholder(); self.log("All reference images and cache cleared.")
        self.save_settings(); self.save_encoding_cache()

    # --- Getters for Settings ---
    def get_threshold(self): return float(self.threshold_spin.value()) if self.threshold_spin else 0.75
    def get_frames_for_recognition(self): return [i + 1 for i, cb in enumerate(self.frame_recognition_checkboxes) if cb.isChecked()]
    def get_frames_for_animation(self): return [i + 1 for i, cb in enumerate(self.frame_animation_checkboxes) if cb.isChecked()]
    def is_performance_mode(self): return self.perf_mode_check.isChecked() if self.perf_mode_check else False
    def get_encoding_model(self): return self.encoding_model_combo.currentText() if self.encoding_model_combo else "small"
    def get_num_jitters(self): return self.encoding_jitters_spin.value() if self.encoding_jitters_spin else 1
    def get_max_upsample(self): return self.max_upsample_spin.value() if self.max_upsample_spin else 1

    # --- Scraping Control ---
    def update_stats_display(self):
        """Updates the stats display labels. Runs via timer."""
        if not self.processing_stats: # Reset labels if no stats object
            if self.page_progress_label: self.page_progress_label.setText("P: -/-")
            if self.video_progress_label: self.video_progress_label.setText("V: -/-")
            if self.frame_time_label: self.frame_time_label.setText("Frame: - ms (50: - ms)")
            if self.video_time_label: self.video_time_label.setText("Video: - s (50: - s)")
            if self.runtime_label: self.runtime_label.setText("Time: -")
            return
        try: # Get stats and update labels
            stats = self.processing_stats.get_stats()
            if self.page_progress_label: self.page_progress_label.setText(f"{stats['page_progress']}")
            if self.video_progress_label: self.video_progress_label.setText(f"{stats['video_progress']}")
            if self.frame_time_label: self.frame_time_label.setText(f"{stats['avg_frame_time']:.0f} ms (50: {stats['avg_frame_time_50']:.0f} ms)"); self.frame_time_label.setToolTip(f"Avg Frame Time (Total: {stats['total_frames_processed']})")
            if self.video_time_label: self.video_time_label.setText(f"{stats['avg_video_time']/1000:.1f} s (50: {stats['avg_video_time_50']/1000:.1f} s)"); self.video_time_label.setToolTip(f"Avg Video Time (Total: {stats['total_videos_processed']})")
            if self.runtime_label: self.runtime_label.setText(f"{stats['total_runtime']}")
        except Exception as e: logging.error(f"Error updating stats display: {e}", exc_info=True)

    def start_scraping(self):
        """Starts the main scraping process in a separate thread."""
        logging.info("start_scraping method called!")
        if self.scraper_thread and self.scraper_thread.isRunning(): self.log("Scraping already in progress."); return

        # --- Pre-flight Checks ---
        valid_references_exist = any(isinstance(enc_list, list) and len(enc_list) > 0 for enc_list in self.reference_encodings_all)
        if not valid_references_exist:
            QMessageBox.warning(self, "Missing Reference Faces", "Add reference image(s) with detectable faces before starting."); return
        search_text = self.search_edit.text().strip(); frames_rec = self.get_frames_for_recognition(); start_page = self.start_page_spin.value(); end_page_val = self.end_page_spin.value()
        if not search_text: QMessageBox.warning(self, "Missing Search Term", "Please enter a search term."); return
        if not frames_rec: QMessageBox.warning(self, "Missing Frame Selection", "Select frame(s) to analyze."); return
        if end_page_val != -1 and start_page > end_page_val: QMessageBox.warning(self, "Invalid Page Range", "Start page must be <= end page."); return

        # --- Prepare for Start ---
        self.log("Saving settings before starting scrape..."); self.save_settings()
        self.processing_stats = ProcessingStats(); self.stats_update_timer.start() # Start stats
        # Get settings for thread
        end_page_for_thread = 99999 if end_page_val == -1 else end_page_val
        analyzer_threads = self.analyzer_threads_spin.value(); ref_load_threads = self.ref_load_threads_spin.value()
        self.ref_load_threadpool.setMaxThreadCount(ref_load_threads) # Ensure pool size is current
        enc_model = self.get_encoding_model(); jitters = self.get_num_jitters(); max_up = self.get_max_upsample()
        det_prio = self.detection_priority_combo.currentIndex(); force_all = self.force_all_attempts_check.isChecked()
        use_mutex = self.use_face_mutex_check.isChecked()

        end_page_log = "∞" if end_page_val == -1 else str(end_page_val)
        self.log(f"Starting scrape: '{search_text}' Pages {start_page}-{end_page_log}... (AnalyzerThr: {analyzer_threads})")

        self.status_label.setText("Initializing scraper..."); self.progress_bar.setMaximum(0); self.progress_bar.setValue(0) # Indeterminate progress
        self.start_button.setEnabled(False); self.pause_button.setEnabled(True); self.pause_button.setText("❚❚ Pause"); self.stop_button.setEnabled(True)
        self.clear_live_view_lanes(); self.no_lanes_label.setVisible(True); self._set_ref_placeholder()
        if self.processing_stats: self.processing_stats.update_video_queue(0, 0) # Reset video counts

        # --- Create and Start Scraper Thread ---
        try:
            self.scraper_thread = ScraperThread(
                search_text=search_text, start_page=start_page, end_page=end_page_for_thread,
                frames_for_recognition=frames_rec, frames_for_animation=self.get_frames_for_animation(),
                headless=self.headless_check.isChecked(),
                reference_encodings_all=self.reference_encodings_all, # Pass by reference
                threshold=self.get_threshold(),
                reference_pixmaps=self.reference_pixmaps, # Pass by reference
                known_results_urls=self.known_results_urls.copy(), # Pass a copy
                analyzer_max_threads=analyzer_threads, detection_priority=det_prio,
                encoding_model=enc_model, num_jitters=jitters, max_upsample=max_up,
                force_all_attempts=force_all, use_mutex=use_mutex,
                parent=self, processing_stats_ref=self.processing_stats # Pass reference
            )
            # Connect signals FROM scraper thread TO main window slots
            self.scraper_thread.log_signal.connect(self.log)
            self.scraper_thread.task_error_signal.connect(self.log) # Connect task errors to UI log
            self.scraper_thread.status_signal.connect(self.status_label.setText)
            self.scraper_thread.progress_signal.connect(self.update_progress)
            self.scraper_thread.final_result_signal.connect(self.add_result, Qt.QueuedConnection)
            self.scraper_thread.error_signal.connect(self.handle_scraper_error, Qt.QueuedConnection)
            self.scraper_thread.finished_signal.connect(self.scraping_finished, Qt.QueuedConnection) # Renamed from finished
            self.scraper_thread.current_reference_image_signal.connect(self.update_current_reference_image_display, Qt.QueuedConnection)
            self.scraper_thread.processed_frame_score_signal.connect(self.handle_processed_frame_score, Qt.QueuedConnection)
            self.scraper_thread.video_found_signal.connect(self.handle_video_found, Qt.QueuedConnection)
            self.scraper_thread.video_processing_finished_signal.connect(self.handle_video_processing_finished, Qt.QueuedConnection)
            self.scraper_thread.frame_status_update_signal.connect(self.update_video_lane_frame_state, Qt.QueuedConnection)
            self.scraper_thread.frame_image_loaded_signal.connect(self.update_video_lane_frame_image, Qt.QueuedConnection)
            self.scraper_thread.lane_result_found_signal.connect(self.request_lane_result_found_effect, Qt.QueuedConnection)
            # Connect stats signal (forwarded from analysis task via scraper)
            self.scraper_thread.frame_processing_time_signal.connect(self.handle_frame_processing_time_signal, Qt.QueuedConnection)

            self.scraper_thread.start(); self.status_label.setText("Scraping started...")
        except Exception as e:
            self.log(f"FATAL ERROR starting scraper thread: {e}"); logging.critical("Failed to create/start ScraperThread", exc_info=True)
            QMessageBox.critical(self, "Scraper Error", f"Failed to start scraping process:\n{e}"); self.scraping_finished(None) # Reset UI
            if self.stats_update_timer.isActive(): self.stats_update_timer.stop(); self.processing_stats = None

    def toggle_pause_scraping(self):
        """Pauses or resumes the scraper thread."""
        if not self.scraper_thread or not self.scraper_thread.isRunning(): return
        if self.scraper_thread._is_paused:
            self.scraper_thread.resume(); self.pause_button.setText("❚❚ Pause"); self.status_label.setText("Resuming..."); self.log("Resuming scraping.")
        else:
            self.scraper_thread.pause(); self.pause_button.setText("▶ Resume"); self.status_label.setText("Pausing..."); self.log("Pausing scraping.")

    def stop_scraping(self, confirm=True):
        """Stops the scraper thread, optionally with confirmation."""
        if not self.scraper_thread or not self.scraper_thread.isRunning(): self.scraping_finished(None); return
        if confirm:
            reply = QMessageBox.question(self, 'Confirm Stop', "Stop the current scraping process?", QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
            if reply == QMessageBox.No: return
        self.log("Stopping scraping process..."); self.status_label.setText("Stopping...")
        self.pause_button.setEnabled(False); self.pause_button.setText("❚❚ Pause"); self.stop_button.setEnabled(False)
        self.scraper_thread.stop() # Request stop

    @pyqtSlot(object, str)
    def scraping_finished(self, thread_obj=None, context=""):
        """Slot called when the scraper thread finishes."""
        if self.stats_update_timer.isActive(): self.stats_update_timer.stop()

        was_stopped = False; check_thread = thread_obj if thread_obj else self.scraper_thread
        if check_thread and hasattr(check_thread, '_stop_requested') and hasattr(check_thread, 'mutex'):
             try:
                 with QMutexLocker(check_thread.mutex): was_stopped = check_thread._stop_requested
             except Exception as e: was_stopped = True; logging.warning(f"Could not access ScraperThread state on finish: {e}")
        elif check_thread: was_stopped = True # Assume stopped if state inaccessible

        status_msg = "Stopped" if was_stopped else "Finished"
        self.log(f"Scraping process {status_msg.lower()}. Context: {context if context else 'N/A'}")
        if self.status_label: self.status_label.setText(f"Scraping {status_msg}")

        # Reset UI
        if self.start_button: self.start_button.setEnabled(True)
        if self.pause_button: self.pause_button.setEnabled(False); self.pause_button.setText("❚❚ Pause")
        if self.stop_button: self.stop_button.setEnabled(False)
        if self.progress_bar: self.progress_bar.setMaximum(100); self.progress_bar.setValue(0)
        self._set_ref_placeholder()

        # Clear stats object *after* potential final updates
        self.processing_stats = None; self.update_stats_display() # Update display to show '-'

        # Clear thread reference if it matches
        if check_thread == self.scraper_thread: self.scraper_thread = None; logging.info("Scraper thread reference cleared.")

    @pyqtSlot(str)
    def handle_scraper_error(self, message):
        """Handles critical errors reported by the scraper thread."""
        self.log(f"Scraper FATAL Error: {message}"); logging.critical(f"Critical error from ScraperThread: {message}")
        # QMessageBox.critical(self, "Scraping Error", f"Critical error during scraping:\n\n{message}\n\nCheck log file.") # Disabled popup
        self.scraping_finished(None, "Critical Error") # Reset UI

    @pyqtSlot(int, int)
    def update_progress(self, current, total):
        """Updates the main progress bar (page scraping progress)."""
        is_scraping = self.scraper_thread and self.scraper_thread.isRunning()
        if not self.progress_bar: return

        if is_scraping:
            if total > 0: # Definite progress
                if self.progress_bar.maximum() != total: self.progress_bar.setMaximum(total)
                self.progress_bar.setValue(min(current, total))
            else: # Indeterminate (e.g., initializing, between pages)
                if self.progress_bar.maximum() != 0: self.progress_bar.setMaximum(0)
                self.progress_bar.setValue(0)
        else: # Not scraping, reset bar
             if self.progress_bar.maximum() != 100 or self.progress_bar.value() != 0:
                 self.progress_bar.setMaximum(100); self.progress_bar.setValue(0)

    # --- Result Handling ---
    def set_sort_order(self, key):
        """Sets the sorting key and updates UI."""
        reverse = True # Always descending
        if key == self.current_sort_key and reverse == self.current_sort_reverse: return # No change
        self.current_sort_key = key; self.current_sort_reverse = reverse
        if key == 'score': self.sort_score_button.setChecked(True); self.sort_date_button.setChecked(False)
        else: self.sort_score_button.setChecked(False); self.sort_date_button.setChecked(True)
        self.log(f"Set results sort: {key} (Desc)")
        self.sort_results_view()

    def sort_results_view(self):
        """Sorts the ResultWidgets currently displayed."""
        widgets = []; spacer = None
        layout = self.results_container_layout
        for i in reversed(range(layout.count())):
            item = layout.itemAt(i)
            widget = item.widget()
            if isinstance(widget, ResultWidget): widgets.append(layout.takeAt(i).widget())
            elif isinstance(item, QSpacerItem): spacer = layout.takeAt(i)
            elif widget: layout.takeAt(i); widget.deleteLater() # Remove unexpected items
        widgets.reverse() # Extracted in reverse

        def sort_func(w):
            val = w.result_data.get(self.current_sort_key, 0)
            try: return float(val) if val is not None else 0.0
            except (ValueError, TypeError): return 0.0
        try: widgets.sort(key=sort_func, reverse=self.current_sort_reverse)
        except Exception as e: self.log(f"Error sorting results view: {e}"); logging.exception("Sorting error")

        # Re-add sorted widgets
        for widget in widgets: layout.addWidget(widget)
        # Re-add spacer or stretch
        if spacer: layout.addSpacerItem(spacer)
        else: layout.addStretch(1)
        # self.log(f"Results view sorted.") # Too verbose

    @pyqtSlot(dict)
    def add_result(self, result_data):
        """Adds a new result to the UI and data structures."""
        video_url = result_data.get('videoURL')
        if not video_url: self.log("Error: Received result without URL."); logging.error("Result data missing 'videoURL'"); return
        if video_url in self.current_results_data: self.log(f"Info: Result for ...{video_url[-20:]} already exists."); return

        title = result_data.get('videoTitle', 'Unknown')[:40]; score = result_data.get('score', 0.0)
        self.log(f"Result Found: '{title}' (Score: {score:.3f})")
        if 'timestamp_added' not in result_data: result_data['timestamp_added'] = time.time()

        # Handle Thumbnail Saving
        thumbnail_qimage = result_data.pop("thumbnailQImageData", None)
        thumbnail_path = None
        if thumbnail_qimage and isinstance(thumbnail_qimage, QImage) and not thumbnail_qimage.isNull():
            try:
                base_name = Path(video_url).name if video_url else "video"
                safe_slug = re.sub(r'[\\/*?:"<>|]+', "_", base_name)
                ts_int = int(result_data['timestamp_added'])
                filename = f"thumb_{safe_slug[:50]}_{ts_int}.png"
                THUMBNAILS_DIR.mkdir(parents=True, exist_ok=True) # Ensure dir exists
                save_path = THUMBNAILS_DIR / filename
                if thumbnail_qimage.save(str(save_path), "PNG"): thumbnail_path = str(save_path); logging.info(f"Thumbnail saved: {filename}")
                else: self.log(f"ERROR: Failed to save thumbnail: {filename}"); logging.error(f"QImage.save() failed: {save_path}")
            except Exception as e: self.log(f"ERROR saving thumbnail: {e}"); logging.exception("Thumbnail save error")
        result_data['thumbnailPath'] = thumbnail_path # Store path (or None)

        # Update Data
        self.current_results_data[video_url] = result_data
        self.known_results_urls.add(video_url)

        # Create and Add Widget
        try:
            widget = ResultWidget(result_data, main_window=self)
            widget.delete_requested.connect(self.remove_result_widget)
            self._insert_widget_sorted(widget)
        except Exception as e:
             self.log(f"Error creating result widget for {title}: {e}"); logging.exception(f"Failed to create ResultWidget for {video_url}")
             if video_url in self.current_results_data: del self.current_results_data[video_url]
             self.known_results_urls.discard(video_url)
             return

        # Ensure correct lane state if result found
        best_frame = result_data.get('frameNumberWithHighestScore')
        if best_frame is not None and best_frame > 0: self.request_lane_best_frame.emit(video_url, best_frame)
        # Save results
        self.save_results()

    def _insert_widget_sorted(self, widget_to_insert):
        """Inserts a ResultWidget into the layout, maintaining sort order."""
        if not isinstance(widget_to_insert, ResultWidget): return
        def sort_func_local(w):
            val = w.result_data.get(self.current_sort_key, 0)
            try:
                if val is not None:
                    return float(val)
                else:
                    return 0.0
            except:
                return 0.0
        new_value = sort_func_local(widget_to_insert); insert_index = 0
        num_items = self.results_container_layout.count()
        for i in range(num_items):
            item = self.results_container_layout.itemAt(i)
            
            # Check for spacer at the end
            if i == num_items - 1 and isinstance(item, QSpacerItem): insert_index = i; break
            existing_widget = item.widget()
            if isinstance(existing_widget, ResultWidget):
                existing_value = sort_func_local(existing_widget)
                should_insert_before = (existing_value < new_value) if self.current_sort_reverse else (existing_value > new_value)
                if should_insert_before: insert_index = i; break
                else: insert_index = i + 1
            elif item and not isinstance(item, QSpacerItem): insert_index = i; break # Insert before unexpected item
        self.results_container_layout.insertWidget(insert_index, widget_to_insert)

    @pyqtSlot(object)
    def remove_result_widget(self, widget_to_remove):
        """Removes a specific ResultWidget from the UI and data."""
        if not isinstance(widget_to_remove, ResultWidget): return
        video_url = widget_to_remove.result_data.get('videoURL'); title = widget_to_remove.result_data.get('videoTitle', 'Unknown')
        thumbnail_path_str = widget_to_remove.result_data.get('thumbnailPath'); thumbnail_path = Path(thumbnail_path_str) if thumbnail_path_str else None
        if not video_url: logging.error("Cannot remove result: Missing video URL."); return

        self.log(f"Removing result: '{title}' (...{video_url[-10:]})")
        if video_url in self.current_results_data: del self.current_results_data[video_url]
        self.known_results_urls.discard(video_url)
        try: self.results_container_layout.removeWidget(widget_to_remove)
        except Exception as e: logging.error(f"Error removing result widget from layout: {e}")
        widget_to_remove.cleanup() # Calls deleteLater

        if thumbnail_path and thumbnail_path.exists():
            try: thumbnail_path.unlink(); logging.info(f"Deleted thumbnail: {thumbnail_path.name}")
            except OSError as e: self.log(f"Error deleting thumbnail {thumbnail_path.name}: {e}"); logging.error(f"Error deleting thumbnail: {thumbnail_path}", exc_info=True)
        self.save_results()

    def confirm_clear_results(self):
        """Confirms before clearing all results."""
        if not self.current_results_data: QMessageBox.information(self, "Clear Results", "No results to clear."); return
        reply = QMessageBox.question(self, 'Confirm Clear All Results', f"Delete all {len(self.current_results_data)} results and thumbnails?\n\nAre you sure?", QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
        if reply == QMessageBox.Yes: self.clear_results()

    def clear_results(self):
        """Clears all results from UI, data, and deletes thumbnails."""
        self.log("Clearing all results...")
        widgets_to_remove = []; paths_to_delete = []
        layout = self.results_container_layout
        for i in reversed(range(layout.count())):
            item = layout.itemAt(i); widget = item.widget()
            if isinstance(widget, ResultWidget):
                widgets_to_remove.append(widget)
                thumb_path_str = widget.result_data.get('thumbnailPath')
                if thumb_path_str:
                    try: paths_to_delete.append(Path(thumb_path_str))
                    except Exception as path_err: logging.error(f"Bad thumb path during clear: {thumb_path_str} - {path_err}")
            elif not isinstance(item, QSpacerItem): layout.takeAt(i); widget.deleteLater() # Remove unexpected

        self.log(f"Removing {len(widgets_to_remove)} result widgets...")
        for widget in widgets_to_remove: layout.removeWidget(widget); widget.cleanup()

        deleted_thumbs = 0; errors_thumbs = 0
        if paths_to_delete: self.log(f"Deleting {len(paths_to_delete)} thumbnails...")
        for path in paths_to_delete:
             if path and path.exists():
                 try: path.unlink(); deleted_thumbs += 1
                 except OSError as e:
                     # Log error but continue deleting others
                     self.log(f"Error deleting thumbnail file {path.name}: {e}")
                     logging.error(f"Error deleting thumbnail {path}: {e}", exc_info=True)
                     errors_thumbs += 1
             elif path:
                  # Log if path existed in data but not on disk
                  logging.warning(f"Thumbnail path found but file doesn't exist during clear: {path}")

        if deleted_thumbs > 0: self.log(f"Deleted {deleted_thumbs} thumbnail files.")
        if errors_thumbs > 0: self.log(f"Failed to delete {errors_thumbs} thumbnail files.")

        # --- Clear Data Structures ---
        self.current_results_data.clear()
        self.known_results_urls.clear()

        # --- Save Empty Results ---
        self.save_results() # Save the now empty results data
        self.log("All results cleared.")


    def clear_results_ui_only(self):
         """Clears only the UI widgets from the results list, keeping data intact."""
         logging.debug("Clearing results UI only.")
         widgets_removed_count = 0
         layout = self.results_container_layout
         if not layout: return # Guard against missing layout
         # Iterate backwards through the layout items, excluding the final spacer
         num_items = layout.count()
         if num_items <= 1: return # Only spacer or empty
         for i in reversed(range(num_items - 1)): # Iterate up to item before spacer
             item = layout.itemAt(i)
             widget = item.widget()
             if isinstance(widget, ResultWidget):
                 taken_item = layout.takeAt(i)
                 if taken_item: widget.cleanup(); widgets_removed_count += 1
             elif widget: # Handle unexpected widgets
                  taken_item = layout.takeAt(i)
                  if taken_item: widget.deleteLater()
         logging.debug(f"Removed {widgets_removed_count} result widgets from UI.")

    @pyqtSlot(QPixmap, int)
    def update_current_reference_image_display(self, pixmap, ref_index):
        """Updates the display showing the last reference image compared."""
        if not self.ref_image_display_label: return

        tooltip = f"Last Compared Ref: #{ref_index + 1}"
        if 0 <= ref_index < len(self.reference_image_paths):
            try: tooltip += f"\n{Path(self.reference_image_paths[ref_index]).name}"
            except Exception: tooltip += "\n(Error getting name)"
        elif ref_index != -1: tooltip += "\n(Unknown Path)"
        else: tooltip = "Last reference face compared" # Reset tooltip

        perf_mode = self.is_performance_mode()
        self.ref_image_display_label.setVisible(not perf_mode)

        if not perf_mode:
            if pixmap and not pixmap.isNull():
                scaled = pixmap.scaled(self.ref_image_display_label.size() - QSize(2,2), Qt.KeepAspectRatio, Qt.SmoothTransformation)
                self.ref_image_display_label.setPixmap(scaled); self.ref_image_display_label.setText("")
            else: self._set_ref_placeholder() # Reset to placeholder
        else: # In performance mode
            self.ref_image_display_label.clear(); self.ref_image_display_label.setText("Ref\n(Off)")

        self.ref_image_display_label.setToolTip(tooltip)


    @pyqtSlot(str, dict)
    def handle_video_found(self, video_url, video_info):
        """Handles newly found videos: Adds to queue or starts lane immediately."""
        # Check if already processing or in results (thread-safe read might be needed if accessed elsewhere)
        # For now, assume access is primarily from GUI thread or serialized enough
        if video_url in self.active_lanes or video_url in self.current_results_data:
            logging.debug(f"Video ...{video_url[-10:]} already active/found. Ignoring.")
            return

        # Update stats for total videos found
        if self.processing_stats:
             self.processing_stats.increment_total_videos_found() # Increment total count
             # Update queued count (includes active lanes + pending queue + this new one)
             current_processed = self.processing_stats.processed_videos # Read needs lock? Assumed okay for now
             current_queued = len(self.active_lanes) + len(self.pending_lanes) + 1
             self.processing_stats.update_video_queue(current_processed, current_queued)

        # If space available, add lane immediately
        if len(self.active_lanes) < MAX_ACTIVE_LANES:
            self.add_video_lane(video_url, video_info)
        else: # Otherwise, add to pending queue
            self.pending_lanes.append((video_url, video_info))
            logging.debug(f"Max lanes reached. Added ...{video_url[-10:]} to pending (size: {len(self.pending_lanes)}).")
            if self.status_label: self.status_label.setText(f"Processing... ({len(self.pending_lanes)} pending)")


    @pyqtSlot(str, dict)
    def add_video_lane(self, video_url, video_info):
        """Adds a new video processing lane to the live view and starts analysis."""
        if video_url in self.active_lanes: logging.warning(f"Attempted to add duplicate lane: {video_url}"); return

        title = video_info.get('videoTitle', 'Unknown Video')[:40]
        self.log(f"Live View: Adding lane for '{title}'...")

        # Handle UI changes when the first lane is added
        if not self.active_lanes:
            self.no_lanes_label.setVisible(False)
            # Remove the final stretch item if it exists
            count = self.live_view_lanes_layout.count()
            if count > 0:
                last_item = self.live_view_lanes_layout.itemAt(count - 1)
                if isinstance(last_item, QSpacerItem): self.live_view_lanes_layout.removeItem(last_item)

        try:
            lane = VideoProcessingLane(video_url=video_url, video_info=video_info, frames_to_analyze=self.get_frames_for_recognition(),
                                       performance_mode=self.is_performance_mode(), parent=self.live_view_container, main_window_ref=self)
            self.live_view_lanes_layout.insertWidget(0, lane) # Insert new lane at the top
            self.active_lanes[video_url] = lane

            # Queue the analysis tasks for this new lane via the scraper thread
            if self.scraper_thread and self.scraper_thread.isRunning():
                QMetaObject.invokeMethod(self.scraper_thread, "queue_video_for_analysis", Qt.QueuedConnection,
                                         Q_ARG(str, video_url), Q_ARG(dict, video_info))
            else: logging.warning(f"Scraper thread not running, cannot queue analysis for {video_url}")

        except Exception as e:
            self.log(f"ERROR creating video lane for ...{video_url[-10:]}: {e}")
            logging.critical(f"Failed to create VideoProcessingLane for {video_url}", exc_info=True)
            # Restore placeholder if no lanes ended up being added
            if not self.active_lanes:
                 self.no_lanes_label.setVisible(True)
                 count = self.live_view_lanes_layout.count()
                 if count == 0 or not isinstance(self.live_view_lanes_layout.itemAt(count - 1), QSpacerItem):
                      self.live_view_lanes_layout.addStretch(1)


    @pyqtSlot(str, int, int, float)
    def handle_processed_frame_score(self, video_url, frame_num, ref_idx, score):
        """Slot to receive frame scores from ScraperThread and relay to lane."""
        # This signal is connected from ScraperThread, runs in ScraperThread.
        # It emits another signal connected to a slot in the MainWindow (GUI thread).
        # logging.debug(f"Relaying score from scraper: {video_url[-5:]} F:{frame_num} S:{score:.3f}") # VERY Verbose
        self.request_lane_frame_score_update.emit(video_url, frame_num, score)

    @pyqtSlot(str, int, float)
    def handle_frame_score_for_lane(self, video_url, frame_number, score):
        """Updates the visual state of a specific frame within a lane. Runs in GUI thread."""
        if video_url in self.active_lanes:
            self.active_lanes[video_url].update_frame_score(frame_number, score)
        # else: Lane might have been removed already, ignore score update

    @pyqtSlot(str, int, QByteArray)
    def update_video_lane_frame_image(self, video_url, frame_number, image_data):
        """Updates the image of a specific frame within a lane. Runs in GUI thread."""
        if video_url in self.active_lanes:
            self.active_lanes[video_url].update_frame_image(frame_number, image_data)

    @pyqtSlot(str, int, int)
    def update_video_lane_frame_state(self, video_url, frame_number, state):
        """Updates the state (border/text) of a specific frame. Runs in GUI thread."""
        if video_url in self.active_lanes:
            self.active_lanes[video_url].update_frame_state(frame_number, state)

    @pyqtSlot(str, int)
    def highlight_video_lane_best_frame(self, video_url, best_frame_number):
        """Highlights the best scoring frame in a specific lane. Runs in GUI thread."""
        if video_url in self.active_lanes:
            self.active_lanes[video_url].highlight_best_frame(best_frame_number)

    @pyqtSlot(str, float)
    def show_lane_result_found_effect(self, video_url, score):
        """Triggers the visual 'result found' effect on a lane. Runs in GUI thread."""
        if video_url in self.active_lanes:
            self.active_lanes[video_url].show_result_found_effect(score)

    @pyqtSlot(str)
    def handle_video_processing_finished(self, video_url):
        """Handles signal that video processing is complete. Updates stats, marks lane done."""
        # Update stats: Increment processed, decrement queued
        if self.processing_stats:
            # Access stats potentially needs locking if read elsewhere, but adding usually safe with deque
            # Recalculate counts based on current lane/queue state
            current_processed = self.processing_stats.processed_videos + 1 # Increment processed
            current_queued = len(self.active_lanes) + len(self.pending_lanes) - 1 # Decrement queued (-1 for the one finishing)
            self.processing_stats.update_video_queue(current_processed, max(0, current_queued)) # Update stats object

        # Mark the lane as done visually (starts its removal timer)
        self.mark_lane_done(video_url)

    @pyqtSlot(str)
    def mark_lane_done(self, video_url):
        """Marks a lane as visually complete and schedules its removal. Runs in GUI thread."""
        if video_url in self.active_lanes:
            lane = self.active_lanes[video_url]
            if not lane.is_done():
                try: lane.mark_as_done() # Changes style and starts timer in lane
                except Exception as e:
                    logging.error(f"Error marking lane done {video_url}: {e}", exc_info=True); self.log(f"Error marking lane ...{video_url[-10:]} done.")
                    # Fallback: Request removal directly if marking fails
                    self.request_lane_removal.emit(video_url)
        # else: Lane already removed or never existed

    @pyqtSlot(str)
    def remove_video_lane(self, video_url):
        """Removes a specific video processing lane from the UI and handles pending queue."""
        if video_url in self.active_lanes:
            lane = self.active_lanes.pop(video_url) # Remove from dictionary
            title = lane.video_info.get('videoTitle', 'Unknown')[:40]
            self.log(f"Live View: Removing lane '{title}'")
            try: self.live_view_lanes_layout.removeWidget(lane) # Remove widget from layout
            except Exception as e: logging.error(f"Error removing lane widget from layout: {e}")
            lane.cleanup() # Calls deleteLater

            # Process pending queue if space is now available
            if self.pending_lanes and len(self.active_lanes) < MAX_ACTIVE_LANES:
                pending_url, pending_info = self.pending_lanes.popleft()
                logging.debug(f"Processing pending lane: ...{pending_url[-10:]}")
                self.add_video_lane(pending_url, pending_info)
                if self.status_label: self.status_label.setText(f"Processing... ({len(self.pending_lanes)} pending)")

            # If no lanes left, restore placeholder and stretch
            if not self.active_lanes and not self.pending_lanes:
                self.no_lanes_label.setVisible(True)
                count = self.live_view_lanes_layout.count()
                if count <= 1: # Only label or empty
                     if count == 0 or not isinstance(self.live_view_lanes_layout.itemAt(count - 1), QSpacerItem):
                         self.live_view_lanes_layout.addStretch(1)
        # else: Lane already removed

    def clear_live_view_lanes(self):
        """Removes all active lanes and clears pending queue."""
        self.log("Clearing all live view lanes...")
        urls_to_remove = list(self.active_lanes.keys())
        for url in urls_to_remove: self.remove_video_lane(url) # Use existing removal logic
        self.pending_lanes.clear(); self.active_lanes.clear() # Ensure cleared
        # Restore placeholder and stretch
        self.no_lanes_label.setVisible(True)
        count = self.live_view_lanes_layout.count()
        if count <= 1:
             if count == 0 or not isinstance(self.live_view_lanes_layout.itemAt(count - 1), QSpacerItem):
                 self.live_view_lanes_layout.addStretch(1)


    @pyqtSlot(int)
    def toggle_performance_mode(self, state):
        """Toggles live view performance mode."""
        enabled = (state == Qt.Checked); mode_text = "Enabled" if enabled else "Disabled"
        self.log(f"Live View Performance Mode {mode_text}.")
        # Update reference display visibility
        current_pixmap = self.ref_image_display_label.pixmap() if self.ref_image_display_label else QPixmap()
        self.update_current_reference_image_display(current_pixmap, -1) # Update display based on mode
        # Propagate change to active lanes
        for lane in self.active_lanes.values(): lane.update_performance_mode(enabled)
        # Save setting
        self.settings.setValue("MainWindow/" + SETTING_LIVE_VIEW_PERF_MODE, enabled); self.settings.sync()

    # --- Saving/Loading ---
    def save_window_state(self):
        """Saves main window size and position."""
        logging.debug("Saving window state.")
        self.settings.beginGroup("MainWindow")
        if not self.isMinimized() and not self.isMaximized(): self.settings.setValue(SETTING_WIN_SIZE, self.size())
        self.settings.setValue(SETTING_WIN_GEOM, self.saveGeometry())
        self.settings.endGroup(); self.settings.sync()

    def load_window_state(self):
        """Restores main window size and position."""
        logging.debug("Loading window state.")
        self.settings.beginGroup("MainWindow")
        try:
            geom = self.settings.value(SETTING_WIN_GEOM)
            if isinstance(geom, (QByteArray, bytes)): self.restoreGeometry(geom)
            if not self.isMaximized() and not self.isMinimized():
                 size = self.settings.value(SETTING_WIN_SIZE, QSize(1400, 980))
                 if isinstance(size, QSize) and size.isValid(): self.resize(size)
                 else: self.resize(1400, 980) # Fallback
        except Exception as e: logging.error(f"Error loading window state: {e}", exc_info=True); self.setGeometry(100, 100, 1400, 980) # Fallback
        finally: self.settings.endGroup()

    def save_settings(self):
        """Saves application settings to QSettings."""
        logging.debug("Saving settings...")
        try:
            self.settings.beginGroup("MainWindow")
            if self.search_edit: self.settings.setValue(SETTING_SEARCH_TEXT, self.search_edit.text())
            if self.start_page_spin: self.settings.setValue(SETTING_START_PAGE, self.start_page_spin.value())
            if self.end_page_spin:
                 end_val = -1 if self.end_page_spin.specialValueText() == "∞" and self.end_page_spin.value() == -1 else self.end_page_spin.value()
                 self.settings.setValue(SETTING_END_PAGE, end_val)
            if self.threshold_spin: self.settings.setValue(SETTING_THRESHOLD, self.get_threshold())
            if self.headless_check: self.settings.setValue(SETTING_HEADLESS, self.headless_check.isChecked())
            if self.frame_recognition_checkboxes: self.settings.setValue(SETTING_REC_FRAMES, [str(f) for f in self.get_frames_for_recognition()])
            if self.frame_animation_checkboxes: self.settings.setValue(SETTING_ANIM_FRAMES, [str(f) for f in self.get_frames_for_animation()])
            if self.reference_image_paths: self.settings.setValue(SETTING_REF_PATHS, self.reference_image_paths)
            if self.ref_load_threads_spin: self.settings.setValue(SETTING_REF_LOAD_THREADS, self.ref_load_threads_spin.value())
            if self.analyzer_threads_spin: self.settings.setValue(SETTING_ANALYZER_THREADS, self.analyzer_threads_spin.value())
            if self.detection_priority_combo: self.settings.setValue(SETTING_DETECTION_PRIORITY, self.detection_priority_combo.currentIndex())
            if self.perf_mode_check: self.settings.setValue(SETTING_LIVE_VIEW_PERF_MODE, self.is_performance_mode())
            if self.encoding_model_combo: self.settings.setValue(SETTING_ENCODING_MODEL, self.get_encoding_model())
            if self.encoding_jitters_spin: self.settings.setValue(SETTING_ENCODING_JITTERS, self.get_num_jitters())
            if self.max_upsample_spin: self.settings.setValue(SETTING_MAX_DETECTION_UPSAMPLE, self.get_max_upsample())
            if self.force_all_attempts_check: self.settings.setValue(SETTING_FORCE_ALL_DETECTION_ATTEMPTS, self.force_all_attempts_check.isChecked())
            if self.use_face_mutex_check: self.settings.setValue(SETTING_USE_FACE_MUTEX, self.use_face_mutex_check.isChecked())
            self.settings.endGroup()
            self.settings.beginGroup("General"); self.settings.setValue(SETTING_LAST_DIR, self.last_dir); self.settings.endGroup()
            self.settings.sync(); logging.debug("Settings saved.")
        except Exception as e: self.log(f"Error saving settings: {e}"); logging.exception("Error saving app settings")

    def load_settings_and_references(self):
        if not hasattr(self, 'ref_progress_bar'):
            logging.warning("Skipping load_settings_and_references because ref_progress_bar is missing.")
            return
        """Loads settings and triggers loading for saved reference paths."""
        logging.debug("Loading settings and references..."); ui_available = bool(self.search_edit) # Check if UI is initialized
        try:
            self.settings.beginGroup("MainWindow")
            if ui_available: self.search_edit.setText(self.settings.value(SETTING_SEARCH_TEXT, ""))
            start_page = self.settings.value(SETTING_START_PAGE, 1, type=int)
            if ui_available: self.start_page_spin.setValue(start_page)
            end_page_loaded = self.settings.value(SETTING_END_PAGE, 1, type=int)
            if ui_available:
                 if end_page_loaded == -1: self.end_page_spin.setValue(-1)
                 else: self.end_page_spin.setValue(max(start_page, end_page_loaded))
                 self.update_end_page_tooltip() # Update tooltip after loading
            th = self.settings.value(SETTING_THRESHOLD, 0.75, type=float)
            if ui_available: self.threshold_spin.setValue(max(0.01, min(1.00, th)))
            if ui_available: self.headless_check.setChecked(self.settings.value(SETTING_HEADLESS, True, type=bool))
            dp = self.settings.value(SETTING_DETECTION_PRIORITY, DETECT_PRIO_ACCURACY, type=int)
            if ui_available: self.detection_priority_combo.setCurrentIndex(max(0, min(1, dp)))
            if ui_available: self.perf_mode_check.setChecked(self.settings.value(SETTING_LIVE_VIEW_PERF_MODE, False, type=bool))
            em = self.settings.value(SETTING_ENCODING_MODEL, "small")
            if ui_available: emi = self.encoding_model_combo.findText(em); self.encoding_model_combo.setCurrentIndex(emi if emi >= 0 else 0)
            if ui_available: self.encoding_jitters_spin.setValue(self.settings.value(SETTING_ENCODING_JITTERS, 1, type=int))
            if ui_available: self.max_upsample_spin.setValue(self.settings.value(SETTING_MAX_DETECTION_UPSAMPLE, 1, type=int))
            if ui_available: self.force_all_attempts_check.setChecked(self.settings.value(SETTING_FORCE_ALL_DETECTION_ATTEMPTS, False, type=bool))
            if ui_available: self.use_face_mutex_check.setChecked(self.settings.value(SETTING_USE_FACE_MUTEX, True, type=bool))
            rt = self.settings.value(SETTING_REF_LOAD_THREADS, DEFAULT_REF_LOAD_THREADS, type=int)
            if ui_available: self.ref_load_threads_spin.setValue(max(1, min(CPU_CORES*2, rt)))
            at = self.settings.value(SETTING_ANALYZER_THREADS, DEFAULT_ANALYZER_THREADS, type=int)
            if ui_available: self.analyzer_threads_spin.setValue(max(1, min(max(4, CPU_CORES*2), at)))
            if ui_available: self.ref_load_threadpool.setMaxThreadCount(self.ref_load_threads_spin.value())

            # Load frame selections
            default_rec_set = {1, 5, 8, 12, 15}; default_anim_set = set(range(1, 16))
            if ui_available:
                 rec_frames_str = self.settings.value(SETTING_REC_FRAMES, [str(f) for f in default_rec_set])
                 try: rec_frames = {int(f) for f in rec_frames_str if isinstance(f,str) and f.isdigit() and 1<=int(f)<=15} if isinstance(rec_frames_str, list) else default_rec_set
                 except: rec_frames = default_rec_set
                 if not rec_frames: rec_frames = default_rec_set
                 for i, cb in enumerate(self.frame_recognition_checkboxes): cb.setChecked((i + 1) in rec_frames)
                 anim_frames_str = self.settings.value(SETTING_ANIM_FRAMES, [str(f) for f in default_anim_set])
                 try: anim_frames = {int(f) for f in anim_frames_str if isinstance(f,str) and f.isdigit() and 1<=int(f)<=15} if isinstance(anim_frames_str, list) else default_anim_set
                 except: anim_frames = default_anim_set
                 if not anim_frames: anim_frames = default_anim_set
                 for i, cb in enumerate(self.frame_animation_checkboxes): cb.setChecked((i + 1) in anim_frames)

            # Load reference paths (saved as list of strings)
            ref_paths_saved = self.settings.value(SETTING_REF_PATHS, [])
            if not isinstance(ref_paths_saved, list): ref_paths_saved = [] # Ensure it's a list
            self.settings.endGroup()

            self.settings.beginGroup("General"); self.last_dir = self.settings.value(SETTING_LAST_DIR, str(Path.home())); self.settings.endGroup()

            # Trigger Loading for Saved Reference Paths
            valid_paths_to_load = []; invalid_paths_logged = set()
            current_paths_str = set(self.reference_image_paths) # Use set for quick check
            for p_str in ref_paths_saved:
                if not isinstance(p_str, str): continue # Skip non-strings
                p_obj = Path(p_str)
                if p_obj.exists() and p_obj.is_file() and p_str not in current_paths_str:
                    valid_paths_to_load.append(p_obj)
                elif p_str not in current_paths_str and p_str not in invalid_paths_logged:
                    msg = f"Warning: Saved reference path not found/invalid: '{p_str}'. Skipping."
                    self.log(msg); logging.warning(msg); invalid_paths_logged.add(p_str)

            if valid_paths_to_load:
                self.log(f"Loading/Verifying {len(valid_paths_to_load)} saved reference(s)...")
                num_new = len(valid_paths_to_load)
                if self.reference_images_in_progress == 0: self.ref_progress_bar.setMaximum(num_new); self.ref_progress_bar.setValue(0)
                else: self.ref_progress_bar.setMaximum(self.reference_images_in_progress + num_new)
                if num_new > 0: self.ref_progress_bar.setVisible(True)

                # Get current settings for tasks
                det_p=self.detection_priority_combo.currentIndex(); enc_m=self.get_encoding_model(); jit=self.get_num_jitters(); max_u=self.get_max_upsample(); force_all=self.force_all_attempts_check.isChecked(); use_mutex_s = self.use_face_mutex_check.isChecked()
                for path_obj in valid_paths_to_load:
                    self.reference_images_in_progress += 1
                    task = ReferenceImageLoaderTask(path_obj, True, self.encoding_cache, self.cache_mutex, det_p, enc_m, jit, max_u, force_all, use_mutex_s)
                    task.signals.log_signal.connect(self.log); task.signals.error.connect(self.handle_ref_image_error); task.signals.reference_image_loaded_signal.connect(self.handle_reference_image_loaded)
                    self.ref_load_threadpool.start(task)
                if num_new == 0 and self.reference_images_in_progress == 0: self.ref_progress_bar.setVisible(False) # Hide if nothing was actually queued
            elif self.reference_images_in_progress == 0: self.ref_progress_bar.setVisible(False) # Hide if started with 0 refs in progress and none loaded

        except Exception as e: self.log(f"CRITICAL Error loading settings: {e}"); logging.exception("CRITICAL: Error during load_settings_and_references")

    def save_encoding_cache(self):
        """Saves the face encoding cache to JSON."""
        logging.debug(f"Saving encoding cache ({len(self.encoding_cache)} items)...")
        if not self.cache_mutex: logging.error("Cannot save cache: Mutex unavailable."); return

        cache_to_save = {}
        with QMutexLocker(self.cache_mutex):
            for path, data in self.encoding_cache.items():
                # Basic validation of structure before saving
                if isinstance(data, dict) and 'encodings' in data and 'timestamp' in data and isinstance(data['encodings'], list):
                     # Ensure encodings are lists for JSON
                     try: enc_list = [list(e) if isinstance(e, np.ndarray) else e for e in data['encodings']]; data['encodings'] = enc_list
                     except: pass # Ignore conversion errors, might already be lists
                     cache_to_save[path] = data.copy()
                else: logging.warning(f"Skipping invalid cache entry during save for: '{Path(path).name}'.")

        if not cache_to_save: # Handle empty cache
            if ENCODING_CACHE_FILE.exists():
                try:
                    ENCODING_CACHE_FILE.unlink()
                    logging.debug("Removed empty cache file.")
                except OSError as e:
                    logging.warning(f"Could not remove cache file: {e}")
            return
        # Atomic write
        temp_file = ENCODING_CACHE_FILE.with_suffix(".json.tmp")
        try:
            with temp_file.open('w', encoding='utf-8') as f: json.dump(cache_to_save, f, indent=2)
            os.replace(temp_file, ENCODING_CACHE_FILE); logging.debug(f"Encoding cache saved ({len(cache_to_save)} valid items).")
        except Exception as e:
            self.log(f"Error saving encoding cache: {e}")
            logging.exception("Failed to save encoding cache")
            try:
                temp_file.unlink(missing_ok=True)
            except OSError:
                pass

    def load_encoding_cache(self):
        """Loads the face encoding cache from JSON."""
        logging.debug("Loading encoding cache...")
        if not self.cache_mutex: logging.error("Cannot load cache: Mutex unavailable."); return

        loaded_cache_data = {}; valid_entries = 0; invalid_entries = 0
        if ENCODING_CACHE_FILE.exists():
            try:
                if ENCODING_CACHE_FILE.stat().st_size == 0: logging.warning("Encoding cache file is empty."); cache_data = {}
                else:
                    with ENCODING_CACHE_FILE.open('r', encoding='utf-8') as f: cache_data = json.load(f)
                if not isinstance(cache_data, dict): raise TypeError("Cache file is not a dict.")

                for path, data in cache_data.items(): # Validate structure
                    if (isinstance(path, str) and isinstance(data, dict) and 'encodings' in data and 'timestamp' in data and isinstance(data['encodings'], list)):
                        loaded_cache_data[path] = data; valid_entries += 1
                    else: invalid_entries += 1; logging.warning(f"Invalid cache entry skipped for: {path}")
                log_msg = f"Loaded {valid_entries} items from cache."; log_msg += f" Skipped {invalid_entries} invalid." if invalid_entries else ""
                self.log(log_msg); logging.info(log_msg)
            except (json.JSONDecodeError, TypeError, Exception) as e:
                self.log(f"Error loading cache ({ENCODING_CACHE_FILE.name}): {e}. Starting empty."); logging.error(f"Failed load cache: {e}", exc_info=True)
                self.rename_corrupted_file(ENCODING_CACHE_FILE); loaded_cache_data = {}
        else: self.log("Encoding cache file not found. Starting empty."); loaded_cache_data = {}

        with QMutexLocker(self.cache_mutex): self.encoding_cache = loaded_cache_data

    def save_results(self):
        """Saves the current results data to JSON."""
        logging.debug(f"Saving results ({len(self.current_results_data)} items)...")
        if not self.current_results_data: # Handle empty results
            if RESULTS_FILE.exists():
                try:
                    RESULTS_FILE.unlink()
                    logging.debug("Removed empty results file.")
                except OSError as e:
                    logging.warning(f"Could not remove results file: {e}")
            return
        # Atomic write
        temp_file = RESULTS_FILE.with_suffix(".json.tmp")
        try:
            data_to_save = self.current_results_data.copy() # Work on copy
            # Ensure thumbnailPath is string
            for url, data in data_to_save.items():
                 if 'thumbnailPath' in data and isinstance(data['thumbnailPath'], Path): data['thumbnailPath'] = str(data['thumbnailPath'])
            with temp_file.open('w', encoding='utf-8') as f: json.dump(data_to_save, f, indent=2, ensure_ascii=False)
            os.replace(temp_file, RESULTS_FILE); logging.debug("Results saved successfully.")
        except Exception as e:
            self.log(f"Error saving results: {e}")
            logging.exception("Failed to save results")
            try:
                temp_file.unlink(missing_ok=True)
            except OSError:
                pass

    def load_results(self):
        """Loads previous results from JSON and populates UI."""
        logging.debug("Loading results...")
        self.clear_results_ui_only(); loaded_results_data = {}
        if RESULTS_FILE.exists():
            try:
                if RESULTS_FILE.stat().st_size == 0: logging.warning("Results file is empty."); loaded_results_data = {}
                else:
                     with RESULTS_FILE.open('r', encoding='utf-8') as f: loaded_results_data = json.load(f)
                if not isinstance(loaded_results_data, dict): raise TypeError("Results file not a dict.")
            except (json.JSONDecodeError, TypeError, Exception) as e:
                self.log(f"Error loading results file ({RESULTS_FILE.name}): {e}. Starting fresh."); logging.error(f"Failed load results: {e}", exc_info=True)
                self.rename_corrupted_file(RESULTS_FILE); loaded_results_data = {}
        else: self.log("Results file not found. Starting fresh."); loaded_results_data = {}

        # Update internal data
        self.current_results_data = loaded_results_data
        self.known_results_urls = set(self.current_results_data.keys())
        self.log(f"Loaded {len(self.current_results_data)} results.")

        # Validate and Create Widgets
        widgets_to_add = []; removed_count = 0
        for url in list(self.current_results_data.keys()): # Iterate copy
            data = self.current_results_data[url]
            if not isinstance(data, dict) or 'videoURL' not in data: # Basic validation
                self.log(f"Warning: Removing invalid result entry: {url}"); del self.current_results_data[url]; self.known_results_urls.discard(url); removed_count += 1; continue
            data.setdefault('timestamp_added', 0); data.setdefault('score', 0.0); data.setdefault('videoTitle', 'Unknown Title')
            # Validate thumbnail path
            thumb_path_str = data.get("thumbnailPath"); thumb_path_obj = Path(thumb_path_str) if isinstance(thumb_path_str, str) else None
            data['thumbnailPath'] = str(thumb_path_obj) if thumb_path_obj else None # Store string path or None
            if thumb_path_str and (not thumb_path_obj or not thumb_path_obj.exists()): self.log(f"Warning: Thumb missing for '{data.get('videoTitle','?')}'")

            try: # Create widget
                widget = ResultWidget(data, main_window=self); widget.delete_requested.connect(self.remove_result_widget); widgets_to_add.append(widget)
            except Exception as create_err:
                self.log(f"Error creating widget for {url}: {create_err}. Removing."); del self.current_results_data[url]; self.known_results_urls.discard(url); removed_count += 1

        # Sort and Add Widgets
        if widgets_to_add: self.sort_widgets_and_add_to_layout(widgets_to_add)
        # Update Sort Button State
        if self.sort_score_button: self.sort_score_button.setChecked(self.current_sort_key == 'score')
        if self.sort_date_button: self.sort_date_button.setChecked(self.current_sort_key == 'timestamp_added')
        # Save if invalid entries removed
        if removed_count > 0: self.log(f"Removed {removed_count} invalid results."); self.save_results()

    def sort_widgets_and_add_to_layout(self, widgets):
        """Sorts a list of ResultWidgets and adds them to the results layout."""
        def sort_func(w):
            val = w.result_data.get(self.current_sort_key, 0)
            try:
                return float(val) if val is not None else 0.0
            except:
                return 0.0
            val = w.result_data.get(self.current_sort_key, 0)
            try:
                return float(val) if val is not None else 0.0
            except:
                return 0.0
        try: widgets.sort(key=sort_func, reverse=self.current_sort_reverse)
        except Exception as e: self.log(f"Error sorting widgets: {e}"); logging.exception("Widget sorting error")
        # Insert widgets before the final spacer/stretch
        layout = self.results_container_layout; insert_pos = 0; num_items = layout.count()
        if num_items > 0 and isinstance(layout.itemAt(num_items - 1), QSpacerItem): insert_pos = num_items - 1
        else: insert_pos = num_items
        for widget in widgets: layout.insertWidget(insert_pos, widget); insert_pos += 1

    def rename_corrupted_file(self, file_path: Path):
        """Renames a file suspected of being corrupted."""
        if file_path.exists():
             try:
                 backup_suffix = f"{file_path.suffix}.corrupted_{int(time.time())}"
                 backup_path = file_path.with_suffix(backup_suffix)
                 file_path.rename(backup_path)
                 self.log(f"Renamed potentially corrupted file '{file_path.name}' to '{backup_path.name}'.")
                 logging.warning(f"Renamed corrupted file '{file_path}' to '{backup_path}'")
             except OSError as e: self.log(f"Error renaming corrupted '{file_path.name}': {e}"); logging.error(f"Failed rename {file_path}: {e}")

    # --- Misc Methods ---
    def open_url_safely(self, url_string):
        """Opens a URL using QDesktopServices with validation."""
        logger_func = self.log
        if url_string:
            try:
                qurl = QUrl(url_string)
                if not qurl.isValid() or qurl.scheme() not in ['http', 'https']: raise ValueError(f"Invalid URL/scheme: '{qurl.scheme()}'")
                if not QDesktopServices.openUrl(qurl): raise RuntimeError("QDesktopServices failed")
                logger_func(f"Opening URL: {url_string}")
            except (ValueError, RuntimeError, Exception) as e:
                 msg = f"Error opening URL {url_string}: {e}"; logger_func(msg)
                 QMessageBox.warning(self, "Error Opening URL", f"Could not open URL:\n{url_string}\n\nError: {e}")
        else: logger_func("Attempted to open an empty URL.")

    def _handle_start_page_change(self, new_value):
        """Ensures end page >= start page (unless end is infinity)."""
        if not self.end_page_spin: return # Guard against calls during init
        current_end_value = self.end_page_spin.value()
        if current_end_value != -1 and current_end_value < new_value: self.end_page_spin.setValue(new_value)
        self.update_end_page_tooltip() # Update tooltip

    @pyqtSlot(str, int, QByteArray) # Changed QPixmap to QByteArray
    def handle_frame_pixmap_updated(self, video_url, frame_number, image_data): # Changed pixmap to image_data
        """Slot to receive frame image data (QByteArray) and update FrameWidget in lane."""
        if video_url in self.active_lanes:
            lane = self.active_lanes[video_url]
            if frame_number in lane.frame_widgets:
                frame_widget = lane.frame_widgets[frame_number]
                pixmap = QPixmap() # Create empty pixmap
                if pixmap.loadFromData(image_data): # Load data into pixmap
                    frame_widget._set_pixmap_slot(pixmap) # Call FrameWidget's slot to update pixmap
                else:
                    # Log error if pixmap loading fails
                    self.log(f"Error loading pixmap from data for {video_url} frame {frame_number}")

    def _handle_end_page_change(self, new_value):
        """Handles infinity logic for end page."""
        if not self.start_page_spin or not self.end_page_spin: return # Guard
        start_value = self.start_page_spin.value()
        self.end_page_spin.blockSignals(True) # Prevent recursion
        if new_value < start_value and new_value != -1: self.end_page_spin.setValue(-1) # Set to infinity if below start
        self.update_end_page_tooltip() # Update tooltip based on final value
        self.end_page_spin.blockSignals(False)

    def update_end_page_tooltip(self):
        """Updates the tooltip for the end page spinbox."""
        if not self.start_page_spin or not self.end_page_spin: return
        start_page = self.start_page_spin.value()
        tooltip = f"Infinite (∞): Scrapes until no more pages found.\nStart Page: {start_page}" if self.end_page_spin.value() == -1 else f"Set end page.\nSet to {start_page - 1} or lower for Infinite (∞).\nStart Page: {start_page}"
        self.end_page_spin.setToolTip(tooltip)

    @pyqtSlot(str, int, float)
    def handle_frame_processing_time_signal(self, video_url, frame_number, processing_time_ms):
        """Slot to receive frame processing time from ScraperThread."""
        # This runs in the GUI thread
        if self.processing_stats: self.processing_stats.add_frame_processing_time(processing_time_ms)
        # else: logging.warning(f"Received frame time for ...{video_url[-10:]} F:{frame_number} but stats obj is None.") # Optional log

    # --- Close Event ---
    def closeEvent(self, event):
        """Handles application closing: stops threads, saves data."""
        logging.info("Close event triggered. Starting shutdown...")
        # Stop Scraper Thread
        if self.scraper_thread and self.scraper_thread.isRunning():
            self.log("Stopping scraper thread..."); self.status_label.setText("Stopping scraper..."); QApplication.processEvents()
            self.stop_scraping(confirm=False)
            if not self.scraper_thread.wait(5000): self.log("Warning: Scraper thread cleanup timeout."); logging.warning("ScraperThread did not finish gracefully.")
            else: self.log("Scraper thread stopped.")
            self.scraper_thread = None
        # Clear Thread Pools
        if self.status_label: self.status_label.setText("Clearing task pools..."); QApplication.processEvents()
        self.log("Clearing ref loader pool..."); self.ref_load_threadpool.clear(); self.ref_load_threadpool.waitForDone(2000)
        self.log("Clearing download pool..."); self.download_threadpool.clear(); self.download_threadpool.waitForDone(2000)
        # Save Data and State
        if self.status_label: self.status_label.setText("Saving data..."); QApplication.processEvents()
        self.log("Saving state and data...")
        try:
            if self.central_widget: # Check if UI was initialized
                 self.save_window_state(); self.save_settings()
            self.save_encoding_cache(); self.save_results(); self.settings.sync()
            self.log("Saving complete.")
        except Exception as save_err: self.log(f"ERROR saving on exit: {save_err}"); logging.error("Error saving data on exit.", exc_info=True)
        self.log("Exiting application."); logging.info("Application closing.")
        event.accept()

    # --- Helper for ResultWidget thumbnail download ---
    def _create_result_frame_downloader_task(self, url, index, label_widget, video_url_for_log):
         """Creates a QRunnable to download thumbnail/anim frame for ResultWidget/Lane"""
         class Downloader(QRunnable):
             # Minimal downloader, signals pixmap to the *target widget*
             # Assumes target widget has a slot like 'set_thumbnail_pixmap(QPixmap)'
             # or '_animation_frame_loaded(int, QPixmap)'
             # We modify this slightly for the lane thumbnail label case
             def __init__(self, url, index, target_widget, video_url_for_log):
                 super().__init__()
                 self.url = url; self.index = index
                 self.widget_ref = weakref.ref(target_widget) # Use weakref
                 self.video_url_for_log = video_url_for_log
                 self.setAutoDelete(True)
                 vid_short = self.video_url_for_log[-10:] if self.video_url_for_log else '?'
                 # Differentiate purpose in log
                 purpose = f"Anim Idx:{index}" if index != -1 else "Thumb"
                 self.log_prefix = f"[{purpose} Vid:{vid_short}]"

             def run(self):
                 logging.debug(f"{self.log_prefix} Starting DL: {self.url}")
                 pixmap = None; image_data = None; headers = {'User-Agent': 'Mozilla/5.0'}
                 widget = self.widget_ref() # Check weakref

                 try:
                     if not widget: raise TaskStopException("Target widget destroyed")
                     resp = requests.get(self.url, timeout=10, headers=headers); resp.raise_for_status()
                     image_data = resp.content; widget = self.widget_ref() # Re-check widget
                     if not widget: raise TaskStopException("Target widget destroyed post-DL")
                     if image_data:
                          loaded = QPixmap();
                          if loaded.loadFromData(image_data): pixmap = loaded.copy(); logging.debug(f"{self.log_prefix} Pixmap loaded.")
                          else: logging.warning(f"{self.log_prefix} Failed QPixmap load.")
                     else: logging.warning(f"{self.log_prefix} Empty image data.")
                 except TaskStopException as ts: logging.debug(f"{self.log_prefix} {ts}")
                 except requests.RequestException as e: logging.warning(f"{self.log_prefix} DL error: {e}")
                 except Exception as e: logging.error(f"{self.log_prefix} Unexpected error: {e}", exc_info=True)

                 finally:
                     widget = self.widget_ref()
                     if widget and widget.isWidgetType() and widget.isVisible():
                         # Determine the correct slot to call based on widget type and index
                         if isinstance(widget, ResultWidget) and self.index != -1: # Animation frame for ResultWidget
                             slot_name = "_animation_frame_loaded"
                             args = (Q_ARG(int, self.index), Q_ARG(QPixmap, pixmap if pixmap else QPixmap()))
                         elif isinstance(widget, QLabel) and self.index == -1: # Thumbnail for VideoProcessingLane's QLabel
                             slot_name = "setPixmap" # QLabel's standard slot
                             # Scale pixmap here before sending? Or let label handle it?
                             # Let label handle scaling for simplicity. Send original (or empty)
                             args = (Q_ARG(QPixmap, pixmap if pixmap else QPixmap()),)
                             # Also clear text if setting pixmap
                             QMetaObject.invokeMethod(widget, "setText", Qt.QueuedConnection, Q_ARG(str, ""))
                         else: # Unknown target or purpose
                             logging.warning(f"{self.log_prefix} Unknown target widget type/index for signal.")
                             slot_name = None
                     else:
                         logging.warning(f"{self.log_prefix} Target widget is invalid or being deleted, skipping signal.")
                         slot_name = None # Prevent signal emission

                         if slot_name:
                             logging.debug(f"{self.log_prefix} Signaling '{slot_name}' (Pixmap null: {pixmap is None or pixmap.isNull()}).")
                             QMetaObject.invokeMethod(widget, slot_name, Qt.QueuedConnection, *args)
                     del image_data # Cleanup
                 else: logging.debug(f"{self.log_prefix} Target widget gone, skipping signal.")
         return Downloader(url, index, label_widget, video_url_for_log)


# =============================================================================
# Main Execution Block
# =============================================================================
if __name__ == '__main__':
    # Enable High DPI scaling BEFORE QApplication initialization
    if hasattr(Qt, 'AA_EnableHighDpiScaling'): QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True)
    if hasattr(Qt, 'AA_UseHighDpiPixmaps'): QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True)

    app = QApplication(sys.argv)
    app.setApplicationName(SETTINGS_APP); app.setOrganizationName(SETTINGS_ORG)

    # Initialize Placeholders AFTER QApplication exists
    PLACEHOLDER_REF_PIXMAP = create_placeholder_pixmap(PLACEHOLDER_REF_ICON_SIZE, "Ref")
    PLACEHOLDER_LANE_THUMB_PIXMAP = create_placeholder_pixmap(PLACEHOLDER_LANE_THUMB_SIZE, "Thumb")
    PLACEHOLDER_RESULT_THUMB_PIXMAP = create_placeholder_pixmap(PLACEHOLDER_RESULT_THUMB_SIZE, "Loading...")
    PLACEHOLDER_FRAME_PIXMAP = create_placeholder_pixmap(LANE_FRAME_WIDGET_SIZE, "")

    # Optional: Force style
    # app.setStyle("Fusion")

    try:
        window = MainWindow()
        window.show()
        logging.info("Application initialized and shown.")
        exit_code = app.exec_()
        logging.info(f"Application event loop finished. Exit code: {exit_code}.")
        sys.exit(exit_code)
    except Exception as main_err:
         logging.critical("FATAL ERROR during app init or exec:", exc_info=True)
         # Try showing message box (global handler might also catch this)
         try:
             QMessageBox.critical(None, f"{SETTINGS_APP} - Fatal Error", f"Fatal error:\n\n{main_err}\n\nCheck log file.")
         except Exception as mb_err:
              logging.error(f"Failed show fatal error msg box: {mb_err}")
              print(f"FATAL APP ERROR: {main_err}\nCheck log.", file=sys.stderr)
         sys.exit(1)



[{
	"resource": "/d:/_Projekte/_MyCodes/WebVidScrapCLEAN/WebVidScrapApp_CLEAN_backup.py",
	"owner": "python",
	"severity": 8,
	"message": "Expected indented block",
	"source": "Pylance",
	"startLineNumber": 595,
	"startColumn": 5,
	"endLineNumber": 595,
	"endColumn": 5
},{
	"resource": "/d:/_Projekte/_MyCodes/WebVidScrapCLEAN/WebVidScrapApp_CLEAN_backup.py",
	"owner": "python",
	"severity": 8,
	"message": "Expected expression",
	"source": "Pylance",
	"startLineNumber": 4478,
	"startColumn": 18,
	"endLineNumber": 4478,
	"endColumn": 22
},{
	"resource": "/d:/_Projekte/_MyCodes/WebVidScrapCLEAN/WebVidScrapApp_CLEAN_backup.py",
	"owner": "python",
	"code": {
		"value": "reportUndefinedVariable",
		"target": {
			"$mid": 1,
			"path": "/microsoft/pyright/blob/main/docs/configuration.md",
			"scheme": "https",
			"authority": "github.com",
			"fragment": "reportUndefinedVariable"
		}
	},
	"severity": 4,
	"message": "\"image_np_cv\" is not defined",
	"source": "Pylance",
	"startLineNumber": 400,
	"startColumn": 42,
	"endLineNumber": 400,
	"endColumn": 53
},{
	"resource": "/d:/_Projekte/_MyCodes/WebVidScrapCLEAN/WebVidScrapApp_CLEAN_backup.py",
	"owner": "python",
	"code": {
		"value": "reportUndefinedVariable",
		"target": {
			"$mid": 1,
			"path": "/microsoft/pyright/blob/main/docs/configuration.md",
			"scheme": "https",
			"authority": "github.com",
			"fragment": "reportUndefinedVariable"
		}
	},
	"severity": 4,
	"message": "\"FaceAnalysisOnlyTask\" is not defined",
	"source": "Pylance",
	"startLineNumber": 1326,
	"startColumn": 37,
	"endLineNumber": 1326,
	"endColumn": 57
},{
	"resource": "/d:/_Projekte/_MyCodes/WebVidScrapCLEAN/WebVidScrapApp_CLEAN_backup.py",
	"owner": "python",
	"code": {
		"value": "reportUndefinedVariable",
		"target": {
			"$mid": 1,
			"path": "/microsoft/pyright/blob/main/docs/configuration.md",
			"scheme": "https",
			"authority": "github.com",
			"fragment": "reportUndefinedVariable"
		}
	},
	"severity": 4,
	"message": "\"DEFAULT_REF_LOAD_THREADS\" is not defined",
	"source": "Pylance",
	"startLineNumber": 2751,
	"startColumn": 74,
	"endLineNumber": 2751,
	"endColumn": 98
},{
	"resource": "/d:/_Projekte/_MyCodes/WebVidScrapCLEAN/WebVidScrapApp_CLEAN_backup.py",
	"owner": "python",
	"code": {
		"value": "reportUndefinedVariable",
		"target": {
			"$mid": 1,
			"path": "/microsoft/pyright/blob/main/docs/configuration.md",
			"scheme": "https",
			"authority": "github.com",
			"fragment": "reportUndefinedVariable"
		}
	},
	"severity": 4,
	"message": "\"DEFAULT_DOWNLOAD_THREADS\" is not defined",
	"source": "Pylance",
	"startLineNumber": 2755,
	"startColumn": 52,
	"endLineNumber": 2755,
	"endColumn": 76
},{
	"resource": "/d:/_Projekte/_MyCodes/WebVidScrapCLEAN/WebVidScrapApp_CLEAN_backup.py",
	"owner": "python",
	"code": {
		"value": "reportUndefinedVariable",
		"target": {
			"$mid": 1,
			"path": "/microsoft/pyright/blob/main/docs/configuration.md",
			"scheme": "https",
			"authority": "github.com",
			"fragment": "reportUndefinedVariable"
		}
	},
	"severity": 4,
	"message": "\"CPU_CORES\" is not defined",
	"source": "Pylance",
	"startLineNumber": 2908,
	"startColumn": 62,
	"endLineNumber": 2908,
	"endColumn": 71
},{
	"resource": "/d:/_Projekte/_MyCodes/WebVidScrapCLEAN/WebVidScrapApp_CLEAN_backup.py",
	"owner": "python",
	"code": {
		"value": "reportUndefinedVariable",
		"target": {
			"$mid": 1,
			"path": "/microsoft/pyright/blob/main/docs/configuration.md",
			"scheme": "https",
			"authority": "github.com",
			"fragment": "reportUndefinedVariable"
		}
	},
	"severity": 4,
	"message": "\"CPU_CORES\" is not defined",
	"source": "Pylance",
	"startLineNumber": 2908,
	"startColumn": 156,
	"endLineNumber": 2908,
	"endColumn": 165
},{
	"resource": "/d:/_Projekte/_MyCodes/WebVidScrapCLEAN/WebVidScrapApp_CLEAN_backup.py",
	"owner": "python",
	"code": {
		"value": "reportUndefinedVariable",
		"target": {
			"$mid": 1,
			"path": "/microsoft/pyright/blob/main/docs/configuration.md",
			"scheme": "https",
			"authority": "github.com",
			"fragment": "reportUndefinedVariable"
		}
	},
	"severity": 4,
	"message": "\"DEFAULT_REF_LOAD_THREADS\" is not defined",
	"source": "Pylance",
	"startLineNumber": 2908,
	"startColumn": 208,
	"endLineNumber": 2908,
	"endColumn": 232
},{
	"resource": "/d:/_Projekte/_MyCodes/WebVidScrapCLEAN/WebVidScrapApp_CLEAN_backup.py",
	"owner": "python",
	"code": {
		"value": "reportUndefinedVariable",
		"target": {
			"$mid": 1,
			"path": "/microsoft/pyright/blob/main/docs/configuration.md",
			"scheme": "https",
			"authority": "github.com",
			"fragment": "reportUndefinedVariable"
		}
	},
	"severity": 4,
	"message": "\"CPU_CORES\" is not defined",
	"source": "Pylance",
	"startLineNumber": 2908,
	"startColumn": 310,
	"endLineNumber": 2908,
	"endColumn": 319
},{
	"resource": "/d:/_Projekte/_MyCodes/WebVidScrapCLEAN/WebVidScrapApp_CLEAN_backup.py",
	"owner": "python",
	"code": {
		"value": "reportUndefinedVariable",
		"target": {
			"$mid": 1,
			"path": "/microsoft/pyright/blob/main/docs/configuration.md",
			"scheme": "https",
			"authority": "github.com",
			"fragment": "reportUndefinedVariable"
		}
	},
	"severity": 4,
	"message": "\"CPU_CORES\" is not defined",
	"source": "Pylance",
	"startLineNumber": 2908,
	"startColumn": 327,
	"endLineNumber": 2908,
	"endColumn": 336
},{
	"resource": "/d:/_Projekte/_MyCodes/WebVidScrapCLEAN/WebVidScrapApp_CLEAN_backup.py",
	"owner": "python",
	"code": {
		"value": "reportUndefinedVariable",
		"target": {
			"$mid": 1,
			"path": "/microsoft/pyright/blob/main/docs/configuration.md",
			"scheme": "https",
			"authority": "github.com",
			"fragment": "reportUndefinedVariable"
		}
	},
	"severity": 4,
	"message": "\"CPU_CORES\" is not defined",
	"source": "Pylance",
	"startLineNumber": 2909,
	"startColumn": 96,
	"endLineNumber": 2909,
	"endColumn": 105
},{
	"resource": "/d:/_Projekte/_MyCodes/WebVidScrapCLEAN/WebVidScrapApp_CLEAN_backup.py",
	"owner": "python",
	"code": {
		"value": "reportUndefinedVariable",
		"target": {
			"$mid": 1,
			"path": "/microsoft/pyright/blob/main/docs/configuration.md",
			"scheme": "https",
			"authority": "github.com",
			"fragment": "reportUndefinedVariable"
		}
	},
	"severity": 4,
	"message": "\"DEFAULT_ANALYZER_THREADS\" is not defined",
	"source": "Pylance",
	"startLineNumber": 2909,
	"startColumn": 149,
	"endLineNumber": 2909,
	"endColumn": 173
},{
	"resource": "/d:/_Projekte/_MyCodes/WebVidScrapCLEAN/WebVidScrapApp_CLEAN_backup.py",
	"owner": "python",
	"code": {
		"value": "reportUndefinedVariable",
		"target": {
			"$mid": 1,
			"path": "/microsoft/pyright/blob/main/docs/configuration.md",
			"scheme": "https",
			"authority": "github.com",
			"fragment": "reportUndefinedVariable"
		}
	},
	"severity": 4,
	"message": "\"CPU_CORES\" is not defined",
	"source": "Pylance",
	"startLineNumber": 2909,
	"startColumn": 248,
	"endLineNumber": 2909,
	"endColumn": 257
},{
	"resource": "/d:/_Projekte/_MyCodes/WebVidScrapCLEAN/WebVidScrapApp_CLEAN_backup.py",
	"owner": "python",
	"code": {
		"value": "reportUndefinedVariable",
		"target": {
			"$mid": 1,
			"path": "/microsoft/pyright/blob/main/docs/configuration.md",
			"scheme": "https",
			"authority": "github.com",
			"fragment": "reportUndefinedVariable"
		}
	},
	"severity": 4,
	"message": "\"CPU_CORES\" is not defined",
	"source": "Pylance",
	"startLineNumber": 2909,
	"startColumn": 265,
	"endLineNumber": 2909,
	"endColumn": 274
},{
	"resource": "/d:/_Projekte/_MyCodes/WebVidScrapCLEAN/WebVidScrapApp_CLEAN_backup.py",
	"owner": "python",
	"code": {
		"value": "reportUndefinedVariable",
		"target": {
			"$mid": 1,
			"path": "/microsoft/pyright/blob/main/docs/configuration.md",
			"scheme": "https",
			"authority": "github.com",
			"fragment": "reportUndefinedVariable"
		}
	},
	"severity": 4,
	"message": "\"MAX_ACTIVE_LANES\" is not defined",
	"source": "Pylance",
	"startLineNumber": 2942,
	"startColumn": 145,
	"endLineNumber": 2942,
	"endColumn": 161
},{
	"resource": "/d:/_Projekte/_MyCodes/WebVidScrapCLEAN/WebVidScrapApp_CLEAN_backup.py",
	"owner": "python",
	"code": {
		"value": "reportUndefinedVariable",
		"target": {
			"$mid": 1,
			"path": "/microsoft/pyright/blob/main/docs/configuration.md",
			"scheme": "https",
			"authority": "github.com",
			"fragment": "reportUndefinedVariable"
		}
	},
	"severity": 4,
	"message": "\"ReferenceImageLoaderTask\" is not defined",
	"source": "Pylance",
	"startLineNumber": 3290,
	"startColumn": 20,
	"endLineNumber": 3290,
	"endColumn": 44
},{
	"resource": "/d:/_Projekte/_MyCodes/WebVidScrapCLEAN/WebVidScrapApp_CLEAN_backup.py",
	"owner": "python",
	"code": {
		"value": "reportUndefinedVariable",
		"target": {
			"$mid": 1,
			"path": "/microsoft/pyright/blob/main/docs/configuration.md",
			"scheme": "https",
			"authority": "github.com",
			"fragment": "reportUndefinedVariable"
		}
	},
	"severity": 4,
	"message": "\"ScraperThread\" is not defined",
	"source": "Pylance",
	"startLineNumber": 3500,
	"startColumn": 35,
	"endLineNumber": 3500,
	"endColumn": 48
},{
	"resource": "/d:/_Projekte/_MyCodes/WebVidScrapCLEAN/WebVidScrapApp_CLEAN_backup.py",
	"owner": "python",
	"code": {
		"value": "reportUndefinedVariable",
		"target": {
			"$mid": 1,
			"path": "/microsoft/pyright/blob/main/docs/configuration.md",
			"scheme": "https",
			"authority": "github.com",
			"fragment": "reportUndefinedVariable"
		}
	},
	"severity": 4,
	"message": "\"MAX_ACTIVE_LANES\" is not defined",
	"source": "Pylance",
	"startLineNumber": 3855,
	"startColumn": 37,
	"endLineNumber": 3855,
	"endColumn": 53
},{
	"resource": "/d:/_Projekte/_MyCodes/WebVidScrapCLEAN/WebVidScrapApp_CLEAN_backup.py",
	"owner": "python",
	"code": {
		"value": "reportUndefinedVariable",
		"target": {
			"$mid": 1,
			"path": "/microsoft/pyright/blob/main/docs/configuration.md",
			"scheme": "https",
			"authority": "github.com",
			"fragment": "reportUndefinedVariable"
		}
	},
	"severity": 4,
	"message": "\"MAX_ACTIVE_LANES\" is not defined",
	"source": "Pylance",
	"startLineNumber": 3981,
	"startColumn": 64,
	"endLineNumber": 3981,
	"endColumn": 80
},{
	"resource": "/d:/_Projekte/_MyCodes/WebVidScrapCLEAN/WebVidScrapApp_CLEAN_backup.py",
	"owner": "python",
	"code": {
		"value": "reportUndefinedVariable",
		"target": {
			"$mid": 1,
			"path": "/microsoft/pyright/blob/main/docs/configuration.md",
			"scheme": "https",
			"authority": "github.com",
			"fragment": "reportUndefinedVariable"
		}
	},
	"severity": 4,
	"message": "\"DEFAULT_REF_LOAD_THREADS\" is not defined",
	"source": "Pylance",
	"startLineNumber": 4103,
	"startColumn": 64,
	"endLineNumber": 4103,
	"endColumn": 88
},{
	"resource": "/d:/_Projekte/_MyCodes/WebVidScrapCLEAN/WebVidScrapApp_CLEAN_backup.py",
	"owner": "python",
	"code": {
		"value": "reportUndefinedVariable",
		"target": {
			"$mid": 1,
			"path": "/microsoft/pyright/blob/main/docs/configuration.md",
			"scheme": "https",
			"authority": "github.com",
			"fragment": "reportUndefinedVariable"
		}
	},
	"severity": 4,
	"message": "\"CPU_CORES\" is not defined",
	"source": "Pylance",
	"startLineNumber": 4104,
	"startColumn": 77,
	"endLineNumber": 4104,
	"endColumn": 86
},{
	"resource": "/d:/_Projekte/_MyCodes/WebVidScrapCLEAN/WebVidScrapApp_CLEAN_backup.py",
	"owner": "python",
	"code": {
		"value": "reportUndefinedVariable",
		"target": {
			"$mid": 1,
			"path": "/microsoft/pyright/blob/main/docs/configuration.md",
			"scheme": "https",
			"authority": "github.com",
			"fragment": "reportUndefinedVariable"
		}
	},
	"severity": 4,
	"message": "\"DEFAULT_ANALYZER_THREADS\" is not defined",
	"source": "Pylance",
	"startLineNumber": 4105,
	"startColumn": 64,
	"endLineNumber": 4105,
	"endColumn": 88
},{
	"resource": "/d:/_Projekte/_MyCodes/WebVidScrapCLEAN/WebVidScrapApp_CLEAN_backup.py",
	"owner": "python",
	"code": {
		"value": "reportUndefinedVariable",
		"target": {
			"$mid": 1,
			"path": "/microsoft/pyright/blob/main/docs/configuration.md",
			"scheme": "https",
			"authority": "github.com",
			"fragment": "reportUndefinedVariable"
		}
	},
	"severity": 4,
	"message": "\"CPU_CORES\" is not defined",
	"source": "Pylance",
	"startLineNumber": 4106,
	"startColumn": 84,
	"endLineNumber": 4106,
	"endColumn": 93
},{
	"resource": "/d:/_Projekte/_MyCodes/WebVidScrapCLEAN/WebVidScrapApp_CLEAN_backup.py",
	"owner": "python",
	"code": {
		"value": "reportUndefinedVariable",
		"target": {
			"$mid": 1,
			"path": "/microsoft/pyright/blob/main/docs/configuration.md",
			"scheme": "https",
			"authority": "github.com",
			"fragment": "reportUndefinedVariable"
		}
	},
	"severity": 4,
	"message": "\"ReferenceImageLoaderTask\" is not defined",
	"source": "Pylance",
	"startLineNumber": 4153,
	"startColumn": 28,
	"endLineNumber": 4153,
	"endColumn": 52
}]


Skript 2:

# -*- coding: utf-8 -*-

# Standard Library Imports
import sys
import os
import re
import io
import time
import json
import requests
import logging
import traceback
import tempfile
import shutil
import math
import weakref
import base64
import multiprocessing # For cpu_count
from pathlib import Path
from collections import deque

# Third-party imports
import numpy as np
from PIL import Image, ImageEnhance, ImageOps
import cv2
import face_recognition # Core face recognition library
from bs4 import BeautifulSoup
from selenium import webdriver
from selenium.webdriver.chrome.service import Service as ChromeService
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.common.exceptions import (NoSuchElementException, TimeoutException,
                                        WebDriverException, StaleElementReferenceException,
                                        ElementClickInterceptedException,
                                        NoSuchWindowException)
# Consider adding seleniumwire if advanced network interception is needed later
# from seleniumwire import webdriver
from webdriver_manager.chrome import ChromeDriverManager

# PyQt5 Imports
from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QGroupBox,
                             QPushButton, QLabel, QLineEdit, QSpinBox, QDoubleSpinBox, QComboBox,
                             QTextEdit, QCheckBox, QFileDialog, QScrollArea, QProgressBar, QFrame,
                             QGridLayout, QMessageBox, QGraphicsOpacityEffect, QSizePolicy, QToolButton,
                             QFormLayout, QMenu, QSpacerItem)
from PyQt5.QtGui import (QPixmap, QFont, QCursor, QImage, QMovie, QColor, QPalette, QIcon,
                         QDesktopServices, QFontMetrics, QPainter)
from PyQt5.QtCore import (Qt, QThread, pyqtSignal, QRunnable, QObject, QThreadPool, QTimer, QBuffer,
                          QSettings, QPropertyAnimation, QSize, pyqtSlot, QMetaObject, QPoint,
                          QParallelAnimationGroup, QEasingCurve, QEvent, QMargins, Q_ARG, QByteArray,
                          QMutex, QMutexLocker, QWaitCondition, QUrl, QSemaphore, QCoreApplication)


# --- Logging Configuration ---
# Use UTF-8 for the log file to handle various characters
log_formatter = logging.Formatter('%(asctime)s - %(levelname)s - [%(threadName)s] - %(message)s')
log_file_handler = logging.FileHandler("webvidscrap.log", mode='w', encoding='utf-8')
log_file_handler.setFormatter(log_formatter)

# Console handler (optional, for debugging in console)
# log_console_handler = logging.StreamHandler(sys.stdout)
# log_console_handler.setFormatter(log_formatter)

# Set logging level (DEBUG for dev, INFO for release)
log_level = logging.DEBUG # Or logging.INFO
root_logger = logging.getLogger()
root_logger.setLevel(log_level)
root_logger.addHandler(log_file_handler)
# Uncomment to also log to console:
# root_logger.addHandler(log_console_handler)

# Suppress noisy logs from dependencies
logging.getLogger("urllib3").setLevel(logging.WARNING)
logging.getLogger("selenium").setLevel(logging.WARNING)
logging.getLogger("webdriver_manager").setLevel(logging.WARNING)

logging.info("--- Application Start ---")


# --- Globale Custom Exception ---
class TaskStopException(Exception):
    """Custom exception to signal controlled task stopping."""
    pass

# --- Global Exception Hook ---
def global_exception_handler(exctype, value, tb):
    """Logs unhandled exceptions and attempts to show a message box."""
    logging.critical("Unhandled exception caught by global handler:", exc_info=(exctype, value, tb))
    traceback_str = "".join(traceback.format_exception(exctype, value, tb))
    logging.critical(f"Traceback:\n{traceback_str}")

    app_name = QCoreApplication.applicationName() if QCoreApplication.instance() else "Application"

    try:
        app = QApplication.instance()
        # Only show message box if the main application instance exists
        if app:
            msgBox = QMessageBox()
            msgBox.setIcon(QMessageBox.Critical)
            msgBox.setWindowTitle(f"{app_name} - Unhandled Exception")
            msgBox.setText(f"A critical error occurred:\n\n{value}\n\nSee 'webvidscrap.log' for details.")
            # Limit detailed text length
            if len(traceback_str) < 8000:
                 msgBox.setDetailedText(traceback_str)
            else:
                 msgBox.setDetailedText("Traceback too long to display here. Please check the log file.")
            msgBox.setStandardButtons(QMessageBox.Ok)
            msgBox.exec_()
        else:
             # Fallback if no QApplication instance (e.g., error during very early init)
             print(f"CRITICAL ERROR ({app_name}): {value}\nCheck 'webvidscrap.log'", file=sys.stderr)
             print(f"Traceback:\n{traceback_str}", file=sys.stderr)
    except Exception as mb_err:
        # Log error if showing the message box itself fails
        logging.error(f"Error showing critical error message box: {mb_err}")
        print(f"CRITICAL ERROR ({app_name}): {value}\nCheck 'webvidscrap.log'", file=sys.stderr)
        print(f"Traceback:\n{traceback_str}", file=sys.stderr)

    # Call the default excepthook AFTER logging/showing message
    sys.__excepthook__(exctype, value, tb)
    # Consider exiting explicitly after handling a critical error
    # sys.exit(1)

sys.excepthook = global_exception_handler


# --- Global Mutex for Face Recognition ---
FACE_RECOGNITION_MUTEX = QMutex()

# --- Constants ---
APP_DIR = Path(__file__).parent.resolve()
THUMBNAILS_DIR = APP_DIR / "thumbnails"
RESULTS_FILE = APP_DIR / "results.json"
ENCODING_CACHE_FILE = APP_DIR / "encoding_cache.json"
SETTINGS_ORG = "WebVidScrapProOrg"
SETTINGS_APP = "WebVidScrapProApp"
APP_VERSION = "1.7.1" # Incremented version

# Settings Keys (Keep as strings for QSettings compatibility)
SETTING_SEARCH_TEXT = "searchText"; SETTING_START_PAGE = "startPage"; SETTING_END_PAGE = "endPage"
SETTING_THRESHOLD = "threshold"; SETTING_HEADLESS = "headless"
SETTING_REC_FRAMES = "recognitionFrames"; SETTING_ANIM_FRAMES = "animationFrames"
SETTING_REF_PATHS = "referenceImagePaths"; SETTING_LAST_DIR = "last_dir"
SETTING_WIN_SIZE = "mainWindowSize"; SETTING_WIN_GEOM = "mainWindowGeometry"
SETTING_REF_LOAD_THREADS = "refLoadThreads"; SETTING_ANALYZER_THREADS = "analyzerThreads"
SETTING_DETECTION_PRIORITY = "detectionPriority"; SETTING_LIVE_VIEW_PERF_MODE = "liveViewPerformanceMode"
SETTING_ENCODING_MODEL = "encodingModel"; SETTING_ENCODING_JITTERS = "encodingJitters"
SETTING_MAX_DETECTION_UPSAMPLE = "maxDetectionUpsample"
SETTING_FORCE_ALL_DETECTION_ATTEMPTS = "forceAllDetectionAttempts"
SETTING_USE_FACE_MUTEX = "useFaceRecognitionMutex"

# Detection Priority Enum
DETECT_PRIO_ACCURACY = 0; DETECT_PRIO_SPEED = 1

# Image Processing Constants
DEFAULT_MAX_IMAGE_SIZE = 1024 # Max dimension for processing reference images

# Website Specific Selectors (Motherless) - Keep specific if needed
MOTHERLESS_VIDEO_CONTAINER_SELECTOR = "div.thumb-container.video"
MOTHERLESS_VIDEO_LINK_SELECTOR = "a.img-container" # Link within the container
MOTHERLESS_VIDEO_TITLE_SELECTOR = ".caption.title" # Title within the container
MOTHERLESS_THUMBNAIL_IMG_SELECTOR = "img.static" # Thumbnail image within the container

# UI Constants
ANIMATION_FRAME_INTERVAL_MS = 250 # Speed for result hover animation
PLACEHOLDER_REF_ICON_SIZE = QSize(80, 80)
PLACEHOLDER_LANE_THUMB_SIZE = QSize(96, 54)
PLACEHOLDER_RESULT_THUMB_SIZE = QSize(160, 90)
LANE_FRAME_WIDGET_SIZE = QSize(96, 54) # Standard size for frame widgets in lane
PLACEHOLDER_COLOR = QColor("#282828")
# Frame Widget State Colors
COLOR_PENDING = QColor("#555555"); COLOR_DOWNLOADING = QColor("#4682B4"); COLOR_DOWNLOAD_FAILED = QColor("#8B0000")
COLOR_ANALYZING = QColor("#DAA520"); COLOR_COMPARING = QColor("#9370DB"); COLOR_NO_FACE = QColor("#A52A2A")
COLOR_FACE_FOUND = QColor("#FFD700"); COLOR_BEST = QColor("#32CD32"); COLOR_THRESHOLD_MET_FLASH = QColor("#00FF7F")

# Threading Defaults
CPU_CORES = os.cpu_count() or 1 # Get CPU core count safely
DEFAULT_REF_LOAD_THREADS = max(1, CPU_CORES // 2) # Default threads for loading reference images
DEFAULT_ANALYZER_THREADS = max(1, CPU_CORES // 2) # Default threads for analyzing video frames
DEFAULT_DOWNLOAD_THREADS = max(2, CPU_CORES)     # Default threads for general downloads (thumbnails, etc.)
MAX_ACTIVE_LANES = 10 # Max number of live view processing lanes displayed simultaneously

# Placeholder Pixmap Cache (defined globally, initialized after QApplication)
PLACEHOLDER_REF_PIXMAP = None; PLACEHOLDER_LANE_THUMB_PIXMAP = None
PLACEHOLDER_RESULT_THUMB_PIXMAP = None; PLACEHOLDER_FRAME_PIXMAP = None

# Placeholder Pixmap Generation Function
def create_placeholder_pixmap(size, text=""):
    """Creates a QPixmap filled with a solid color and optional centered text."""
    pixmap = QPixmap(size); pixmap.fill(PLACEHOLDER_COLOR)
    if text:
        painter = QPainter(pixmap); painter.setPen(QColor("#888888"))
        font_size = 8 if size.width() < 100 else 10; font = QFont("Arial", font_size)
        painter.setFont(font); rect = pixmap.rect().adjusted(2, 2, -2, -2) # Small margin
        flags = Qt.AlignCenter | Qt.TextWordWrap; painter.drawText(rect, flags, text); painter.end()
    return pixmap

# =============================================================================
# ProcessingStats - Class for tracking processing statistics
# =============================================================================
class ProcessingStats:
    """Tracks statistics during the scraping and analysis process. Thread-safe."""
    def __init__(self):
        self.mutex = QMutex() # Mutex to protect updates from multiple threads
        self.current_page = 0
        self.total_pages = 0
        self.processed_videos = 0 # Videos for which processing *completed* (success or fail)
        self.queued_videos = 0    # Videos currently being processed or waiting in queue/lanes
        self.frame_processing_times = deque() # Use deque for potentially faster appends
        self.video_processing_times = deque()
        self.process_start_time = time.monotonic() # Use monotonic for elapsed time
        # Total counts across the session
        self.total_video_count_session = 0 # Total videos added to lanes/queue
        self.total_frame_count_session = 0 # Total frames analyzed (incremented directly)

    def update_page_progress(self, current, total):
        """Updates current page and total pages to scan."""
        with QMutexLocker(self.mutex): self.current_page = current; self.total_pages = total

    def update_video_queue(self, processed, queued):
        """ Updates processed/queued counts, ensuring thread safety. """
        with QMutexLocker(self.mutex): self.processed_videos = processed; self.queued_videos = queued

    def increment_total_videos_found(self):
         """ Increments the total count when a video is initially found. """
         with QMutexLocker(self.mutex): self.total_video_count_session += 1

    def add_frame_processing_time(self, milliseconds):
        """Adds frame processing time in milliseconds, thread-safe."""
        if milliseconds >= 0: # Ignore potential negative times
            with QMutexLocker(self.mutex):
                self.frame_processing_times.append(milliseconds)
                self.total_frame_count_session += 1 # Increment total frame count here
        else: logging.warning(f"Ignored invalid frame processing time: {milliseconds}")

    def add_video_processing_time(self, milliseconds):
        """Adds video processing time in milliseconds, thread-safe."""
        if milliseconds >= 0:
            with QMutexLocker(self.mutex): self.video_processing_times.append(milliseconds)
        else: logging.warning(f"Ignored invalid video processing time: {milliseconds}")

    def get_stats(self):
        """Calculates and returns current statistics, thread-safe read."""
        with QMutexLocker(self.mutex):
            # Local copies for calculation to minimize lock time
            frame_times = list(self.frame_processing_times); video_times = list(self.video_processing_times)
            current_page = self.current_page; total_pages = self.total_pages
            processed_vids = self.processed_videos; queued_vids = self.queued_videos # Queued includes active + pending
            total_vids_session = self.total_video_count_session; total_frames_session = self.total_frame_count_session
            start_time = self.process_start_time

        # Calculations outside the lock
        avg_frame = self._calculate_average(frame_times); avg_video = self._calculate_average(video_times)
        avg_frame_50 = self._calculate_average_per_50(frame_times); avg_video_50 = self._calculate_average_per_50(video_times)
        total_pages_display = "∞" if total_pages > 9998 else str(total_pages) # Format total pages
        runtime_seconds = time.monotonic() - start_time; runtime_str = self._format_runtime(int(runtime_seconds)) # Runtime calculation

        return {
            'page_progress': f"{current_page}/{total_pages_display}",
            'video_progress': f"{processed_vids}/{total_vids_session}", # Show processed / total found
            'avg_frame_time': avg_frame, 'avg_frame_time_50': avg_frame_50,
            'avg_video_time': avg_video, 'avg_video_time_50': avg_video_50,
            'total_runtime': runtime_str, 'total_frames_processed': total_frames_session,
            'total_videos_processed': len(video_times) # Videos fully processed with timing
        }

    def _calculate_average(self, values):
        """Calculates the average of a list of values, returns 0 if empty."""
        return sum(values) / len(values) if values else 0

    def _calculate_average_per_50(self, values):
        """Calculates the average time for 50 units based on total data, returns 0 if empty."""
        if not values: return 0; total_time = sum(values); total_count = len(values)
        return (total_time / total_count) * 50 if total_count > 0 else 0

    def _format_runtime(self, total_seconds):
        """Formats seconds into Hh Mm Ss or Mm Ss string."""
        if total_seconds < 0: total_seconds = 0; hours = total_seconds // 3600; minutes = (total_seconds % 3600) // 60; seconds = total_seconds % 60
        return f"{hours}h {minutes}m {seconds}s" if hours > 0 else f"{minutes}m {seconds}s"

# =============================================================================
# ImageProcessor - Robust image loading and processing
# =============================================================================
class ImageProcessor:
    """
    Utility class for processing and optimizing images for face recognition.
    Handles various formats and potential errors during loading. Uses Path objects.
    """
    @staticmethod
    def process_image_file(file_path: Path, max_size=DEFAULT_MAX_IMAGE_SIZE):
        """Loads/processes image file robustly (PIL, OpenCV), ensures RGB, resizes."""
        pil_image = None; file_path_str = str(file_path)
        try:
            try: # Attempt 1: PIL (Handles more formats, EXIF)
                logging.debug(f"Trying PIL.Image.open: {file_path_str}")
                with Image.open(file_path) as img: pil_image = img.copy() # Work with a copy
                pil_image = ImageOps.exif_transpose(pil_image) # Handle EXIF orientation
                logging.debug(f"PIL loaded: {file_path_str}, Mode: {pil_image.mode}, Size: {pil_image.size}")
                pil_image = ImageProcessor._ensure_rgb(pil_image, file_path) # Ensure RGB
                image_np = np.array(pil_image) # Convert to NumPy array
                logging.debug(f"PIL converted to numpy: {file_path_str}")
                return ImageProcessor.resize_if_needed(image_np, pil_image, max_size, file_path) # Resize if needed
            except Exception as e:
                logging.warning(f"PIL failed for {file_path_str}: {e}. Trying OpenCV.")
                pil_image = None # Reset pil_image if PIL fails

            try: # Attempt 2: OpenCV (Good fallback)
                logging.debug(f"Trying cv2.imread: {file_path_str}")
                image_cv = cv2.imread(str(file_path), cv2.IMREAD_COLOR) # Force 3 channels
                if image_cv is None: raise ValueError("cv2.imread returned None")
                image_rgb = cv2.cvtColor(image_cv, cv2.COLOR_BGR2RGB) # OpenCV loads as BGR, convert to RGB
                pil_image = Image.fromarray(image_rgb) # Convert back to PIL for consistency if needed later
                logging.debug(f"OpenCV loaded: {file_path_str}, Mode: {pil_image.mode}, Size: {pil_image.size}")
                image_np = np.array(pil_image) # Already RGB numpy array
                logging.debug(f"OpenCV converted to numpy: {file_path_str}")
                return ImageProcessor.resize_if_needed(image_np, pil_image, max_size, file_path)
            except Exception as e:
                logging.error(f"OpenCV also failed for {file_path_str}: {e}")
                pil_image = None # Reset

            # If both failed
            raise ValueError(f"All image loading methods failed for: {file_path.name}")

        except Exception as e:
            logging.error(f"Error processing image file {file_path_str}: {e}", exc_info=True)
            if pil_image: logging.error(f"Image info at error: Mode: {pil_image.mode}, Size: {pil_image.size}")
            # Raise a more specific error indicating failure to process this file
            raise ValueError(f"Image processing failed for {file_path.name}: {e}") from e

    @staticmethod
    def process_image_bytes(image_bytes, file_path_hint="memory", max_size=DEFAULT_MAX_IMAGE_SIZE):
        """Loads/processes image from bytes robustly (PIL, OpenCV), ensures RGB, resizes."""
        pil_image = None
        try:
            try: # Attempt 1: PIL
                logging.debug(f"Trying PIL from bytes: {file_path_hint}")
                with Image.open(io.BytesIO(image_bytes)) as img: pil_image = img.copy()
                pil_image = ImageOps.exif_transpose(pil_image)
                logging.debug(f"PIL from bytes: {file_path_hint}, Mode: {pil_image.mode}, Size: {pil_image.size}")
                pil_image = ImageProcessor._ensure_rgb(pil_image, Path(file_path_hint))
                image_np = np.array(pil_image)
                logging.debug(f"PIL from bytes converted to numpy: {file_path_hint}")
                return ImageProcessor.resize_if_needed(image_np, pil_image, max_size, Path(file_path_hint))
            except Exception as e:
                logging.warning(f"PIL from bytes failed for {file_path_hint}: {e}")
                if pil_image: logging.warning(f"PIL Info: Mode: {pil_image.mode}, Size: {pil_image.size}")
                pil_image = None

            try: # Attempt 2: OpenCV
                logging.debug(f"Trying cv2.imdecode: {file_path_hint}")
                image_np_cv = cv2.imdecode(np.frombuffer(image_bytes, np.uint8), cv2.IMREAD_COLOR)
                if image_np_cv is None: raise ValueError("cv2.imdecode returned None")
                image_rgb = cv2.cvtColor(image_np_cv, cv2.COLOR_BGR2RGB)
                pil_image = Image.fromarray(image_rgb)
                logging.debug(f"OpenCV from bytes: {file_path_hint}, Mode: {pil_image.mode}, Size: {pil_image.size}")
                image_np = np.array(pil_image)
                logging.debug(f"OpenCV from bytes converted to numpy: {file_path_hint}")
                return ImageProcessor.resize_if_needed(image_np, pil_image, max_size, Path(file_path_hint))
            except Exception as e:
                logging.error(f"OpenCV from bytes failed for {file_path_hint}: {e}")
                pil_image = None

            logging.error(f"All image loading methods failed for bytes: {file_path_hint}")
            return None, None # Return tuple indicating failure

        except Exception as e:
            logging.error(f"Error processing image bytes {file_path_hint}: {e}", exc_info=True)
            if pil_image: logging.error(f"Image info at error: Mode: {pil_image.mode}, Size: {pil_image.size}")
            return None, None # Return tuple indicating failure

    @staticmethod
    def _ensure_rgb(pil_image, file_path: Path):
        """Converts a PIL image to RGB format if it isn't already."""
        file_name = file_path.name; logging.debug(f"Ensuring RGB for: {file_name}, Current Mode: {pil_image.mode}")
        if pil_image.mode == 'RGB': return pil_image
        try:
            # Handle modes with alpha channel
            if pil_image.mode in ('RGBA', 'LA'):
                has_transparency = False; alpha_channel_index = 3 if pil_image.mode == 'RGBA' else 1
                try: # Check if alpha channel has actual transparency
                    alpha_min = pil_image.getextrema()[alpha_channel_index][0]
                    if alpha_min < 255: has_transparency = True
                except (IndexError, TypeError): logging.warning(f"Could not reliably get alpha extrema for mode {pil_image.mode} on {file_name}. Assuming opaque.")

                if has_transparency: # Paste onto white background
                    logging.debug(f"Detected transparency in {file_name} (mode {pil_image.mode}). Pasting onto white bg.")
                    background = Image.new("RGB", pil_image.size, (255, 255, 255))
                    try: background.paste(pil_image, mask=pil_image.split()[alpha_channel_index])
                    except IndexError: background.paste(pil_image) # Fallback paste without mask
                    logging.debug(f"Converted {pil_image.mode}->RGB (pasted on white) for '{file_name}'."); return background
                else: # Alpha channel exists but is opaque, just convert
                    logging.debug(f"Converting {pil_image.mode}->RGB (opaque alpha) for '{file_name}'."); return pil_image.convert('RGB')

            # Handle indexed (P) and grayscale (L)
            elif pil_image.mode in ('P', 'L'):
                 logging.debug(f"Converting {pil_image.mode}->RGB for '{file_name}'.")
                 # Special handling for P mode with transparency info
                 if pil_image.mode == 'P' and 'transparency' in pil_image.info:
                     try: # Try converting via RGBA first to preserve transparency handling
                         logging.debug(f"Palette image '{file_name}' has transparency info. Converting via RGBA first.")
                         return ImageProcessor._ensure_rgb(pil_image.convert('RGBA'), file_path) # Recurse with RGBA
                     except Exception as p_conv_err: logging.warning(f"Failed convert P with transparency via RGBA for {file_name}: {p_conv_err}. Trying direct RGB.")
                 return pil_image.convert('RGB') # Direct conversion for L or P without handled transparency

            # Handle other modes (CMYK, YCbCr, etc.)
            else:
                 logging.debug(f"Converting {pil_image.mode}->RGB for '{file_name}'.")
                 return pil_image.convert('RGB')

        except Exception as e:
            logging.error(f"Failed to convert image '{file_name}' from mode '{pil_image.mode}' to RGB: {e}", exc_info=True)
            raise ValueError(f"Cannot convert image '{file_name}' (mode: {pil_image.mode}) to RGB.") from e

    @staticmethod
    def resize_if_needed(image_np, pil_image, max_size, file_path: Path):
        """Resizes the image using LANCZOS if its width or height exceeds max_size."""
        file_name = file_path.name
        try:
            height, width = image_np.shape[:2]; logging.debug(f"Resize check for {file_name}: {width}x{height}, max_size={max_size}")
            if max_size is not None and max_size > 0 and (width > max_size or height > max_size):
                scale = min(max_size / width, max_size / height); new_width = int(width * scale); new_height = int(height * scale)
                # Prevent zero dimensions or upscaling
                if new_width <= 0 or new_height <= 0: logging.warning(f"Resize calc zero dim ({new_width}x{new_height}). Using original."); return image_np, pil_image
                if new_width > width or new_height > height: logging.warning(f"Resize calc upscale ({width}x{height}->{new_width}x{new_height}). Using original."); return image_np, pil_image
                # Perform resize
                logging.debug(f"Resizing {file_name} from {width}x{height} to {new_width}x{new_height} using LANCZOS")
                resized_pil = pil_image.resize((new_width, new_height), Image.Resampling.LANCZOS); resized_np = np.array(resized_pil)
                logging.debug(f"Resize successful for {file_name}."); return resized_np, resized_pil
            else: logging.debug(f"No resize needed for {file_name}."); return image_np, pil_image # No resize needed
        except Exception as e: logging.error(f"Error during image resizing for {file_name}: {e}", exc_info=True); return image_np, pil_image # Return original on error

    @staticmethod
    def enhance_image_for_face_detection(pil_image):
        """Applies minor contrast/brightness enhancements. Expects RGB PIL image."""
        try: # Subtle enhancements
            enhancer = ImageEnhance.Contrast(pil_image); enhanced = enhancer.enhance(1.2)
            enhancer = ImageEnhance.Brightness(enhanced); enhanced = enhancer.enhance(1.1)
            logging.debug("Applied subtle contrast/brightness enhancement."); return enhanced
        except Exception as e: logging.warning(f"Image enhancement failed: {e}"); return pil_image # Return original if enhancement fails

    @staticmethod
    def detect_faces_with_multiple_attempts(image_np,
                                            detection_priority=DETECT_PRIO_ACCURACY,
                                            encoding_model='small', num_jitters=1, max_upsample=1,
                                            force_all_attempts=False, use_mutex=False,
                                            context_hint=""):
        """
        Detects faces using face_recognition with fallbacks, priority, and mutex.
        Returns locations, encodings, and processing time in ms.
        """
        start_time = time.monotonic(); face_locations = []; face_encodings = []; detection_methods_tried = []
        log_prefix = f"[FaceDetect {context_hint}]" if context_hint else "[FaceDetect]"
        logging.debug(f"{log_prefix} Starting detection. Prio: {'Speed' if detection_priority==DETECT_PRIO_SPEED else 'Accuracy'}, "
                      f"Model: {encoding_model}, Jitters: {num_jitters}, Upsample: {max_upsample}, ForceAll: {force_all_attempts}, Mutex: {use_mutex}")

        # --- Input Validation ---
        if not isinstance(image_np, np.ndarray): logging.error(f"{log_prefix} Invalid input: image_np is not numpy array."); return [], [], 0.0
        if image_np.ndim != 3 or image_np.shape[2] != 3: logging.error(f"{log_prefix} Invalid input: image_np must be 3-channel RGB. Shape: {image_np.shape}"); return [], [], 0.0

        # Determine detection models and upsample levels based on priority/settings
        models_to_try = ['hog', 'cnn'] if detection_priority == DETECT_PRIO_SPEED else ['cnn', 'hog']
        upsample_levels = range(max_upsample + 1) # Try 0..max_upsample
        detection_successful = False

        # --- Optional Mutex Lock ---
        locker = QMutexLocker(FACE_RECOGNITION_MUTEX) if use_mutex else None
        if locker: logging.debug(f"{log_prefix} Acquired face recognition mutex.")

        try: # --- Main Detection Loop ---
            for upsample_level in upsample_levels:
                if detection_successful and not force_all_attempts:
                    logging.debug(f"{log_prefix} Found faces, skip further attempts (force_all=False).")
                    break # Exit outer loop

                for model in models_to_try:
                    if detection_successful and not force_all_attempts: break # Exit inner loop

                    attempt_desc = f"Model={model}, Upsample={upsample_level}"
                    logging.debug(f"{log_prefix} Trying: {attempt_desc}")
                    detection_methods_tried.append(attempt_desc)

                    try:
                        # 1. Face Location
                        loc_start = time.monotonic()
                        current_locations = face_recognition.face_locations(
                            image_np, number_of_times_to_upsample=upsample_level, model=model
                        )
                        loc_time = (time.monotonic() - loc_start) * 1000
                        logging.debug(f"{log_prefix} -> Location ({attempt_desc}): Found {len(current_locations)} faces in {loc_time:.1f} ms.")

                        if current_locations:
                            # 2. Face Encoding (only if locations found)
                            enc_start = time.monotonic()
                            current_encodings = face_recognition.face_encodings(
                                image_np, known_face_locations=current_locations,
                                model=encoding_model, num_jitters=num_jitters
                            )
                            enc_time = (time.monotonic() - enc_start) * 1000
                            logging.debug(f"{log_prefix} -> Encoding ({len(current_locations)} faces, Model={encoding_model}, Jit={num_jitters}): {enc_time:.1f} ms.")

                            # Check for successful encoding & match count
                            if current_encodings and len(current_encodings) == len(current_locations):
                                face_locations = current_locations; face_encodings = current_encodings
                                detection_successful = True
                                if not force_all_attempts: break # Break inner loop
                            else:
                                logging.warning(f"{log_prefix} -> Encoding fail/mismatch {attempt_desc}. Locs:{len(current_locations)}, Encs:{len(current_encodings or [])}.")
                        # else: No locations found with this combo, continue loop

                    except RuntimeError as e: # Catch CUDA/GPU errors
                        if "CUDA" in str(e) or "cublas" in str(e): logging.error(f"{log_prefix} GPU/CUDA error {attempt_desc}: {e}. Check setup/memory.")
                        else: logging.warning(f"{log_prefix} Runtime error {attempt_desc}: {e}")
                    except Exception as e: logging.error(f"{log_prefix} Unexpected error {attempt_desc}: {e}", exc_info=True)

        finally: # --- Release Mutex ---
            if locker: locker.unlock(); logging.debug(f"{log_prefix} Released face recognition mutex.")

        end_time = time.monotonic(); total_time_ms = (end_time - start_time) * 1000
        logging.info(f"{log_prefix} Detection finished in {total_time_ms:.1f} ms. Found {len(face_encodings)} faces. Methods: {', '.join(detection_methods_tried)}")
        return face_locations, face_encodings, total_time_ms

# =============================================================================
# Base Task Signal Class
# =============================================================================
class TaskSignals(QObject):
    """Defines signals common to QRunnable tasks."""
    finished = pyqtSignal() # Task completion signal
    error = pyqtSignal(str) # Error reporting signal (string message)
    log_signal = pyqtSignal(str) # General logging signal
    progress = pyqtSignal(int) # Progress reporting signal (e.g., percentage)

# =============================================================================
# FaceAnalysisOnlyTask - QRunnable for analyzing a single frame
# =============================================================================
class FaceAnalysisOnlyTaskSignals(TaskSignals):
    """Specific signals for face analysis task."""
    # video_url, frame_number, list_of_encodings (numpy arrays), image_bytes (QByteArray)
    frame_analysis_finished_signal = pyqtSignal(str, int, list, QByteArray)
    # video_url, frame_number, state_constant (from FrameWidget)
    frame_status_update_signal = pyqtSignal(str, int, int)
    # video_url, frame_number, time_ms (float)
    frame_processing_time_signal = pyqtSignal(str, int, float)

class FaceAnalysisOnlyTask(QRunnable):
    """
    Analyzes image bytes for faces, extracts encodings. Runs in a thread pool.
    Emits results via signals. Handles potential errors gracefully.
    """
    def __init__(self, video_url, frame_number, image_bytes,
                 detection_priority, encoding_model, num_jitters, max_upsample,
                 force_all_attempts, use_mutex, parent=None):
        super().__init__()
        self.signals = FaceAnalysisOnlyTaskSignals()
        # Store parameters
        self.video_url = video_url; self.frame_number = frame_number; self.image_bytes = image_bytes # Keep original bytes
        self.detection_priority = detection_priority; self.encoding_model = encoding_model
        self.num_jitters = num_jitters; self.max_upsample = max_upsample
        self.force_all_attempts = force_all_attempts; self.use_mutex = use_mutex
        self.setAutoDelete(True) # Enable auto-deletion after run() finishes

    @pyqtSlot()
    def run(self):
        """Performs image loading, face detection, and encoding."""
        log_prefix = f"[AnalysisTask F:{self.frame_number} Vid:{self.video_url[-10:]}]"
        logging.debug(f"{log_prefix} Starting analysis.")
        start_time = time.monotonic(); face_encodings = []; image_np = None
        # Ensure we pass the original QByteArray in the signal
        processed_image_bytes = QByteArray(self.image_bytes)

        try:
            # 1. Process Image Bytes (Load and prepare numpy array)
            image_np, pil_image = ImageProcessor.process_image_bytes(
                self.image_bytes, file_path_hint=f"F{self.frame_number}_{self.video_url[-10:]}"
            )
            if image_np is None or pil_image is None: raise ValueError("Image processing from bytes failed.")

            # 2. Detect Faces and Get Encodings using the utility function
            logging.debug(f"{log_prefix} Detecting faces...")
            _, face_encodings, detection_time_ms = ImageProcessor.detect_faces_with_multiple_attempts(
                image_np, self.detection_priority, self.encoding_model,
                self.num_jitters, self.max_upsample, self.force_all_attempts, self.use_mutex,
                context_hint=f"F{self.frame_number}_{self.video_url[-10:]}" # Pass context
            )
            logging.debug(f"{log_prefix} Detection complete. Found {len(face_encodings)} encodings.")

            # --- Emit Results ---
            total_time_ms = (time.monotonic() - start_time) * 1000
            logging.debug(f"{log_prefix} Analysis finished in {total_time_ms:.1f} ms.")
            self.signals.frame_processing_time_signal.emit(self.video_url, self.frame_number, total_time_ms)
            # Emit results: URL, frame num, list of numpy encodings, and the QByteArray
            self.signals.frame_analysis_finished_signal.emit(self.video_url, self.frame_number, face_encodings, processed_image_bytes)

        except ValueError as ve: # Catch specific image processing errors
             err_msg = f"{log_prefix} Image processing failed: {ve}"
             logging.error(err_msg)
             self.signals.error.emit(f"F{self.frame_number}: {err_msg}") # Report error
             # Emit failure signal (empty encodings list, original bytes)
             self.signals.frame_analysis_finished_signal.emit(self.video_url, self.frame_number, [], processed_image_bytes)
             total_time_ms = (time.monotonic() - start_time) * 1000 # Still report time taken
             self.signals.frame_processing_time_signal.emit(self.video_url, self.frame_number, total_time_ms)

        except Exception as e: # Catch any other unexpected errors
            err_msg = f"{log_prefix} Unexpected error during analysis: {e}"
            logging.exception(err_msg) # Log full traceback
            self.signals.error.emit(f"F{self.frame_number}: {err_msg}") # Report error
            # Emit failure signal (empty encodings list, original bytes)
            self.signals.frame_analysis_finished_signal.emit(self.video_url, self.frame_number, [], processed_image_bytes)
            total_time_ms = (time.monotonic() - start_time) * 1000 # Still report time taken
            self.signals.frame_processing_time_signal.emit(self.video_url, self.frame_number, total_time_ms)

        finally:
            self.signals.finished.emit() # Always signal task completion

# =============================================================================
# ReferenceImageLoaderTask - QRunnable for loading reference images
# =============================================================================
class ReferenceImageLoaderTaskSignals(TaskSignals):
    """Specific signals for reference image loading task."""
    # pixmap (QPixmap), file_path_str (str), list_of_encodings_np (List[np.ndarray])
    reference_image_loaded_signal = pyqtSignal(QPixmap, str, list)

class ReferenceImageLoaderTask(QRunnable):
    """
    Loads a reference image file, processes it, detects faces, calculates encodings,
    and checks/updates a shared cache. Runs in a thread pool.
    """
    def __init__(self, file_path: Path, create_pixmap: bool, encoding_cache: dict, cache_mutex: QMutex,
                 detection_priority: int, encoding_model: str, num_jitters: int, max_upsample: int,
                 force_all_attempts: bool, use_mutex: bool, parent=None):
        super().__init__()
        self.signals = ReferenceImageLoaderTaskSignals()
        # Store parameters and references
        self.file_path = file_path; self.create_pixmap = create_pixmap
        self.encoding_cache_ref = encoding_cache; self.cache_mutex = cache_mutex # Use references to shared data
        self.detection_priority = detection_priority; self.encoding_model = encoding_model
        self.num_jitters = num_jitters; self.max_upsample = max_upsample
        self.force_all_attempts = force_all_attempts; self.use_mutex = use_mutex
        self.setAutoDelete(True) # Enable auto-deletion

    @pyqtSlot()
    def run(self):
        """Main execution logic for the reference image loading task."""
        file_path_str = str(self.file_path); log_prefix = f"[RefLoadTask {self.file_path.name}]"
        logging.debug(f"{log_prefix} Starting processing.")
        pixmap = QPixmap(); encodings_np = []; cache_hit = False; timestamp = 0

        try:
            # --- Initial Checks ---
            if not self.file_path.exists() or not self.file_path.is_file(): raise FileNotFoundError(f"File not found: {file_path_str}")
            timestamp = self.file_path.stat().st_mtime # Get last modification time for cache check

            # 1. Check Cache (Thread-safe access)
            with QMutexLocker(self.cache_mutex): cached_data = self.encoding_cache_ref.get(file_path_str)
            if cached_data and cached_data.get('timestamp') == timestamp:
                 try: # Attempt to load encodings from cache
                     encodings_np = [np.array(enc) for enc in cached_data.get('encodings', [])]
                     cache_hit = True; logging.debug(f"{log_prefix} Cache hit. Loaded {len(encodings_np)} encodings.")
                     self.signals.log_signal.emit(f"Cache hit for '{self.file_path.name}'.")
                 except Exception as cache_load_err: # Handle potential errors loading cached numpy arrays
                      logging.warning(f"{log_prefix} Error loading encodings from cache: {cache_load_err}. Re-processing.")
                      cache_hit = False; encodings_np = [] # Reset if cache loading fails

            # 2. Process Image and Detect Faces (if not a cache hit)
            if not cache_hit:
                self.signals.log_signal.emit(f"Processing reference: '{self.file_path.name}'...")
                # Process image file to get numpy array and optionally PIL image
                image_np, pil_image = ImageProcessor.process_image_file(self.file_path)
                if image_np is None or pil_image is None: raise ValueError("Image processing failed.") # Check for failure

                # Detect faces and get encodings using the robust utility function
                _, encodings_np, _ = ImageProcessor.detect_faces_with_multiple_attempts(
                    image_np, self.detection_priority, self.encoding_model,
                    self.num_jitters, self.max_upsample, self.force_all_attempts, self.use_mutex,
                    context_hint=self.file_path.name # Pass filename for context
                )
                logging.debug(f"{log_prefix} Detection complete. Found {len(encodings_np)} encodings.")

                # Update Cache (Thread-safe access)
                with QMutexLocker(self.cache_mutex):
                     self.encoding_cache_ref[file_path_str] = {
                         'timestamp': timestamp,
                         'encodings': [enc.tolist() for enc in encodings_np] # Store as nested lists for JSON compatibility
                     }
                logging.debug(f"{log_prefix} Updated cache.")
                self.signals.log_signal.emit(f"Processed '{self.file_path.name}' ({len(encodings_np)} faces found).")

            # 3. Create QPixmap for UI (if requested)
            if self.create_pixmap:
                 # Try loading from file path (generally reliable for QPixmap)
                 if not pixmap.load(file_path_str):
                      logging.error(f"{log_prefix} Failed QPixmap load from path: {file_path_str}")
                      pixmap = QPixmap() # Ensure it's an empty QPixmap on failure
                 else:
                     logging.debug(f"{log_prefix} QPixmap created successfully.")

            # 4. Emit Result Signal (Pixmap, path string, list of numpy encodings)
            self.signals.reference_image_loaded_signal.emit(pixmap, file_path_str, encodings_np)

        except FileNotFoundError as fnf_err:
             error_message = f"File not found: {fnf_err}"
             logging.error(f"{log_prefix} {error_message}")
             self.signals.error.emit(error_message) # Emit error signal
        except ValueError as val_err: # Catch image processing errors specifically
            error_message = f"Image processing error: {val_err}"
            logging.error(f"{log_prefix} {error_message}")
            self.signals.error.emit(f"'{self.file_path.name}': {val_err}") # Emit specific error
            # Emit empty result so UI knows the task finished, even with error
            self.signals.reference_image_loaded_signal.emit(QPixmap(), file_path_str, [])
        except Exception as e: # Catch any other unexpected errors
            error_message = f"Unexpected error processing '{self.file_path.name}': {e}"
            logging.exception(f"{log_prefix} Unexpected error.") # Log full traceback
            self.signals.error.emit(error_message) # Emit generic error
            # Emit empty result on unexpected error
            self.signals.reference_image_loaded_signal.emit(QPixmap(), file_path_str, [])
        finally:
            self.signals.finished.emit() # Always signal task completion

# =============================================================================
# ScraperThread - Main thread for web scraping and analysis coordination
# =============================================================================
class ScraperThread(QThread):
    """
    Manages WebDriver, scrapes website pages using BeautifulSoup, triggers
    frame downloads (requests) & analysis tasks, signals results/status.
    Analysis tasks run in an internal thread pool. Continues analysis after scraping.
    """
    # --- Signals to MainWindow ---
    log_signal = pyqtSignal(str)                       # General logging messages
    status_signal = pyqtSignal(str)                    # Updates for the status bar label
    progress_signal = pyqtSignal(int, int)             # Progress (current_item, total_items) e.g., videos on page
    error_signal = pyqtSignal(str)                     # Critical errors within this thread (e.g., WebDriver failure)
    task_error_signal = pyqtSignal(str)                # Errors reported back from worker tasks (e.g., analysis failure)
    finished_signal = pyqtSignal(object, str)          # Emits self and context string on completion/stop
    final_result_signal = pyqtSignal(dict)             # Emitted when a video meets the threshold {video_data_dict}
    current_reference_image_signal = pyqtSignal(QPixmap, int) # Displays the ref image used for the current best match (pixmap, ref_idx)
    processed_frame_score_signal = pyqtSignal(str, int, int, float) # video_url, frame_num, ref_idx, score (for detailed lane updates)
    video_found_signal = pyqtSignal(str, dict)         # video_url, video_info (dict) - when a potential video is found on page
    video_processing_finished_signal = pyqtSignal(str) # video_url - when all analysis for a video is complete
    # --- Signals for Lane Updates (relayed via MainWindow) ---
    frame_status_update_signal = pyqtSignal(str, int, int)      # video_url, frame_num, FrameWidget.STATE_*
    frame_image_loaded_signal = pyqtSignal(str, int, QByteArray)# video_url, frame_num, image_data (QByteArray)
    lane_result_found_signal = pyqtSignal(str, float)           # video_url, score (triggers flash effect on lane)
    # --- Stats Signal ---
    frame_processing_time_signal = pyqtSignal(str, int, float)  # video_url, frame_number, time_ms (forwarded from analysis task)

    def __init__(self, search_text, start_page, end_page, frames_for_recognition, frames_for_animation,
                 headless, reference_encodings_all, threshold, reference_pixmaps, known_results_urls,
                 analyzer_max_threads, detection_priority, encoding_model, num_jitters, max_upsample,
                 force_all_attempts, use_mutex, processing_stats_ref=None, parent=None):
        super().__init__(parent)
        # --- Store configuration ---
        self.search_text = search_text; self.start_page = start_page; self.end_page = end_page # Note: end_page can be 99999 for "infinity"
        self.frames_for_recognition = frames_for_recognition; self.frames_for_animation = frames_for_animation
        self.headless = headless; self.reference_encodings_all = reference_encodings_all # Direct reference (list of lists of ndarrays)
        self.threshold = threshold; self.reference_pixmaps = reference_pixmaps # Direct reference (list of QPixmaps)
        self.known_results_urls = known_results_urls # Copy received (set of strings), use internally
        self.analyzer_max_threads = analyzer_max_threads; self.detection_priority = detection_priority
        self.encoding_model = encoding_model; self.num_jitters = num_jitters; self.max_upsample = max_upsample
        self.force_all_attempts = force_all_attempts; self.use_mutex = use_mutex # Store mutex usage setting
        self.processing_stats = processing_stats_ref # Reference to MainWindow's stats object

        # --- Internal state ---
        self.driver = None # Selenium WebDriver instance (used only if BS4 fails or for specific actions) - Currently minimal use
        self.temp_dir = None # Temporary directory for potential file operations
        self.mutex = QMutex() # Mutex for internal state (_is_running, _stop_requested, _is_paused)
        self.pause_cond = QWaitCondition() # Condition variable for pausing/resuming
        self._is_running = False; self._stop_requested = False; self._is_paused = False
        # --- Tracking active analysis tasks ---
        self.active_tasks_lock = QMutex() # Lock for accessing the active_video_processing dictionary
        self.active_video_processing = {} # {video_url: {info_dict, frames_to_analyze_set, frame_encodings_dict, analyzed_count, ...}}
        self.last_page_reached = False # Flag to indicate the scraper has reached the end page or found no more results
        # --- Thread pool for face analysis tasks (created within this thread's context) ---
        self.face_analyzer_threadpool = QThreadPool(self) # Use self as parent for lifetime management
        self.face_analyzer_threadpool.setMaxThreadCount(self.analyzer_max_threads)
        # --- Error tracking for analysis tasks ---
        self._consecutive_analysis_errors = 0 # Track consecutive errors from analysis tasks
        self._max_consecutive_analysis_errors = 10 # Stop scrape if this many analysis tasks fail consecutively
        self.current_page_num = 0 # Track the currently processed page number

        logging.info(f"ScraperThread: Analyzer ThreadPool max threads set to {self.analyzer_max_threads}")

    def _handle_analysis_task_error(self, error_message):
        """Receives error signal from analysis task, tracks errors, and stops if threshold exceeded."""
        logging.warning(f"ScraperThread received task error: {error_message}") # Log internally too
        self.task_error_signal.emit(f"Analysis Task Error: {error_message}") # Signal UI/main thread

        self._consecutive_analysis_errors += 1
        logging.warning(f"Consecutive analysis errors: {self._consecutive_analysis_errors}/{self._max_consecutive_analysis_errors}")

        # Check if consecutive error threshold is reached
        if self._consecutive_analysis_errors >= self._max_consecutive_analysis_errors:
            error_msg = f"Stopping scrape due to {self._consecutive_analysis_errors} consecutive analysis errors."
            logging.error(error_msg)
            self.error_signal.emit(error_msg) # Signal critical error to main window
            self.stop() # Request the scraper thread to stop itself

    def _extract_video_data_bs4(self, page_url):
        """Extracts video data from a page using requests and BeautifulSoup."""
        video_data_list = []
        try:
            # Send GET request with timeout and user agent
            response = requests.get(page_url, timeout=20, headers={'User-Agent': 'Mozilla/5.0'})
            response.raise_for_status() # Raise HTTPError for bad responses (4xx or 5xx)

            # Parse HTML content
            soup = BeautifulSoup(response.content, 'html.parser')
            video_containers = soup.select(MOTHERLESS_VIDEO_CONTAINER_SELECTOR) # Find all video containers

            for container in video_containers:
                # Extract elements within each container using specific selectors
                link_element = container.select_one(MOTHERLESS_VIDEO_LINK_SELECTOR)
                title_element = container.select_one(MOTHERLESS_VIDEO_TITLE_SELECTOR)
                thumbnail_element = container.select_one(MOTHERLESS_THUMBNAIL_IMG_SELECTOR)

                # Get attributes and text, providing defaults if not found
                video_url = link_element['href'] if link_element and link_element.has_attr('href') else None
                video_title_raw = title_element.text.strip() if title_element else 'Unknown Title'
                video_title = video_title_raw if video_title_raw else 'Unknown Title' # Ensure not empty
                thumbnail_url = thumbnail_element['src'] if thumbnail_element and thumbnail_element.has_attr('src') else None

                # Add to list only if a valid video URL was found
                if video_url and video_url.startswith('http'):
                     video_data_list.append({
                         'href': video_url,
                         'title': video_title,
                         'thumbnailSrc': thumbnail_url
                     })
                else:
                    logging.debug(f"Skipped container on {page_url} due to missing/invalid href.")

        except requests.exceptions.Timeout:
             logging.warning(f"[BS4 Scraper] Timeout requesting page {page_url}")
        except requests.exceptions.RequestException as e:
            logging.warning(f"[BS4 Scraper] Request error for {page_url}: {e}")
        except Exception as e:
            logging.error(f"[BS4 Scraper] Error parsing page {page_url}: {e}", exc_info=True)
        return video_data_list

    def _process_extracted_video_data(self, video_data):
            """Processes raw video data extracted from the page to generate frame URLs."""
            # 1. Basic structure validation
            if not isinstance(video_data, dict): logging.warning("Invalid video_data structure received."); return None

            # 2. Extract data safely using .get()
            video_url = video_data.get('href')
            video_title = video_data.get('title', 'Unknown Title')
            thumbnail_url = video_data.get('thumbnailSrc')

            # 3. CRITICAL CHECK: Validate video URL *after* extraction
            if not video_url or not video_url.startswith("http"):
                logging.warning(f"Skipping processing, invalid/missing URL. Data: href='{video_data.get('href', 'N/A')}', title='{video_title}'")
                return None # Exit if URL is bad

            # 4. Process valid data (URL is guaranteed good here)
            all_preview_frame_urls = []; url_log_suffix = video_url[-20:] # For logging brevity
            try:
                # --- Generate Preview Frame URLs (based on thumbnail URL pattern) ---
                if thumbnail_url:
                    try:
                        logging.debug(f"Base Thumb URL found: {thumbnail_url} for Vid: ...{url_log_suffix}")
                        base_url_part, ext = os.path.splitext(thumbnail_url)
                        # Validate extension
                        if not ext or ext.lower() not in ['.jpg', '.jpeg', '.png', '.webp']: raise ValueError(f"Unexpected thumbnail extension: {ext}")

                        # Determine base part for generating frame URLs (handle different patterns)
                        base_for_previews = base_url_part
                        # Pattern 1: Ends with -<number> (e.g., -1.jpg) -> replace with dash
                        if re.search(r'(-\d+)$', base_for_previews): base_for_previews = re.sub(r'(-\d+)$', '-', base_for_previews)
                        # Pattern 2: Ends with -small (e.g., -small.jpg) -> replace with -small-
                        elif base_for_previews.endswith('-small'): base_for_previews = base_for_previews[:-len('-small')] + '-small-'
                        # Pattern 3: Ends with _thumb or _preview -> replace with underscore
                        elif base_for_previews.endswith(('_thumb', '_preview')): base_for_previews = re.sub(r'(_thumb|_preview)$', '_', base_for_previews)
                        # Default: If no pattern matched and doesn't end with '-', append one
                        elif not base_for_previews.endswith('-'): base_for_previews = base_for_previews + '-'
                        # else: Assume base already ends correctly (e.g., already has trailing '-')

                        # Generate URLs for frames 1 to 15 (common range for Motherless)
                        all_preview_frame_urls = [f"{base_for_previews}{i}{ext}" for i in range(1, 16)]
                        logging.debug(f"Generated {len(all_preview_frame_urls)} potential preview URLs for ...{url_log_suffix}")

                    except ValueError as path_err: # Catch specific errors like bad extension
                        logging.error(f"Cannot generate preview URLs from thumbnail URL '{thumbnail_url}' for ...{url_log_suffix}: {path_err}")
                        all_preview_frame_urls = [] # Reset generated list
                    except Exception as e: # Catch other URL generation errors
                        logging.error(f"Error generating preview URLs from '{thumbnail_url}' for ...{url_log_suffix}: {e}", exc_info=True); all_preview_frame_urls = []
                else:
                    # Log warning if no thumbnail URL was provided
                    logging.warning(f"Thumbnail URL was missing for ...{url_log_suffix}. Cannot generate preview URLs."); all_preview_frame_urls = []

                # --- Prepare Result Dictionary ---
                # Filter generated URLs based on selected animation frames
                preview_frames_for_animation_urls = []
                if all_preview_frame_urls: # Only filter if URLs were generated
                    preview_frames_for_animation_urls = [
                        all_preview_frame_urls[i-1] for i in self.frames_for_animation # Uses setting from __init__
                        if 1 <= i <= len(all_preview_frame_urls) # Ensure index is valid
                    ]

                # Create the final video_info dictionary
                video_info_processed = {
                    "videoTitle": video_title,
                    "videoURL": video_url, # Guaranteed valid here
                    "thumbnailURL": thumbnail_url, # Can be None
                    "previewFramesAnimation": preview_frames_for_animation_urls, # URLs for result widget animation
                    "allPreviewFrameURLs": all_preview_frame_urls # All potential preview URLs (1-15)
                }
                return video_info_processed # Return successful result

            except Exception as e:
                # Catch unexpected errors *after* URL validation
                logging.error(f"Unexpected error processing data for URL: ...{url_log_suffix}: {e}", exc_info=True)
                return None # Return None on error

    # --- State Control Methods ---
    def stop(self):
        """Requests the thread to stop gracefully."""
        with QMutexLocker(self.mutex):
            if self._is_running:
                logging.info("ScraperThread: Stop requested.")
                self._stop_requested = True
                if self._is_paused: self.pause_cond.wakeAll() # Wake if paused
            else: logging.debug("ScraperThread: Stop requested but not running.")

    def pause(self):
        """Requests the thread to pause."""
        with QMutexLocker(self.mutex):
            if self._is_running and not self._is_paused:
                logging.info("ScraperThread: Pausing."); self._is_paused = True; self.status_signal.emit("Pausing...")
            elif self._is_paused: logging.debug("ScraperThread: Pause requested but already paused.")
            else: logging.debug("ScraperThread: Pause requested but not running.")

    def resume(self):
        """Requests the thread to resume from pause."""
        with QMutexLocker(self.mutex):
            if self._is_running and self._is_paused:
                logging.info("ScraperThread: Resuming."); self._is_paused = False; self.status_signal.emit("Resuming...")
                self.pause_cond.wakeAll() # Wake the run loop
            elif not self._is_paused: logging.debug("ScraperThread: Resume requested but not paused.")
            else: logging.debug("ScraperThread: Resume requested but not running.")

    # --- Main Execution Logic ---
    def run(self):
        """Main execution: Scrapes pages (BS4), queues downloads (requests) & analysis (ThreadPool)."""
        with QMutexLocker(self.mutex): self._is_running = True; self._stop_requested = False; self._is_paused = False
        self.log_signal.emit("ScraperThread: Starting run method.")
        self.driver = None # Ensure driver is reset (currently not used in main loop)
        total_videos_scraped_session = 0 # Track videos found in this session

        try:
            # --- Setup ---
            self.temp_dir = Path(tempfile.mkdtemp(prefix="webvidscrap_")); logging.info(f"ScraperThread: Temp dir created: {self.temp_dir}")
            # WebDriver initialization (kept for potential future use, but not used in BS4 loop)
            # self.status_signal.emit("Initializing browser...")
            # if self._check_stop_request(): return
            # self.driver = self._initialize_webdriver()
            # if self.driver is None: self.log_signal.emit("ScraperThread: WebDriver init failed. Stopping."); return

            # --- Main Scraping Loop (Using BS4) ---
            search_term_encoded = self.search_text.strip().replace(" ", "+")
            self.current_page_num = self.start_page
            end_page_display = "∞" if self.end_page > 9998 else str(self.end_page)
            actual_end_page = self.end_page # The actual limit for the loop
            if self.processing_stats: self.processing_stats.update_page_progress(self.current_page_num -1, actual_end_page)

            # --- Loop Condition: Continue while not stopped AND (either analysis is ongoing OR scraping isn't finished) ---
            while not self._check_stop_request() and (self._get_active_analysis_tasks() > 0 or not self.last_page_reached):

                # Check if scraping part is finished, but analysis is still running
                if self.last_page_reached:
                     active_tasks = self._get_active_analysis_tasks()
                     if active_tasks > 0:
                         self.status_signal.emit(f"Scraping done. Finishing analysis ({active_tasks} tasks)...")
                         self.msleep(1000) # Wait a second before checking again
                         continue # Skip rest of the loop, just wait for analysis
                     else:
                         break # Exit loop: scraping done AND analysis done

                # --- Handle Pause/Stop before processing the next page ---
                if not self._check_running_or_handle_pause(): break # Exit loop if stopped or should not run

                # --- Process Current Page (if scraping is not yet finished) ---
                page_num = self.current_page_num
                if page_num > actual_end_page: # Check page limit
                    self.log_signal.emit(f"ScraperThread: Reached end page limit ({actual_end_page}). Finishing scrape.")
                    self.last_page_reached = True # Mark scraping as done
                    continue # Go to top of loop to handle analysis wait

                # Update status and log
                if self.processing_stats: self.processing_stats.update_page_progress(page_num, actual_end_page)
                self.status_signal.emit(f"Scraping Page {page_num}/{end_page_display}...")
                page_url = f"https://motherless.com/term/videos/{search_term_encoded}?page={page_num}"
                self.log_signal.emit(f"ScraperThread: Scraping URL: {page_url}")

                # --- Extract video data using BeautifulSoup ---
                extracted_data_list = self._extract_video_data_bs4(page_url)
                if not extracted_data_list:
                    # Could be end of results or temporary issue. Assume end for now.
                    self.log_signal.emit(f"ScraperThread: No video data extracted via BS4 on page {page_num}. Assuming end of results.")
                    self.last_page_reached = True # Mark scraping as done
                    continue # Go to top of loop to handle analysis wait

                self.log_signal.emit(f"ScraperThread: Extracted data for {len(extracted_data_list)} potential videos on page {page_num}.")
                self.progress_signal.emit(0, len(extracted_data_list)) # Progress for items on this page
                page_video_index = 0; INTER_VIDEO_DELAY_MS = 10 # Small delay between processing items

                # --- Process Videos found on Page ---
                for video_data in extracted_data_list:
                    if not self._check_running_or_handle_pause(): break # Check stop/pause between items
                    page_video_index += 1; self.progress_signal.emit(page_video_index, len(extracted_data_list))

                    try:
                        # Process the extracted raw data (generate frame URLs etc.)
                        video_info = self._process_extracted_video_data(video_data)
                        if video_info:
                            video_url = video_info.get('videoURL'); if not video_url: continue # Skip if URL missing after processing

                            # Check if known/active (using lock for safety)
                            with QMutexLocker(self.active_tasks_lock): is_known = video_url in self.known_results_urls; is_active = video_url in self.active_video_processing
                            if is_known or is_active: logging.debug(f"Skipping known/active URL: ...{video_url[-20:]}"); continue

                            # If new, emit signal for MainWindow to handle queuing/lane creation
                            total_videos_scraped_session += 1
                            if self.processing_stats: self.processing_stats.increment_total_videos_found() # Update total count
                            self.video_found_signal.emit(video_url, video_info) # Signal MainWindow
                        # else: Error logged within _process_extracted_video_data
                    except Exception as video_proc_err: # Catch errors during data processing
                        video_url_log = video_data.get('href', 'Unknown URL') # Get URL for logging if possible
                        self.log_signal.emit(f"Error processing item {page_video_index} (URL: ...{video_url_log[-20:]}) on page {page_num}: {video_proc_err}")
                        logging.exception(f"Error processing extracted data on page {page_num} for URL: {video_url_log}")
                        continue # Continue to the next video item on the page
                    self.msleep(INTER_VIDEO_DELAY_MS) # Small delay between processing elements

                if not self._check_running_or_handle_pause(): break # Check if stopped during page processing

                # Optional delay between pages if still running and not finished scraping
                if self._is_running and not self.last_page_reached and page_num < actual_end_page:
                    self.log_signal.emit(f"ScraperThread: Finished page {page_num}. Waiting briefly..."); self.msleep(1200) # Wait 1.2 sec
                self.current_page_num += 1 # Increment page number for next iteration

            # --- End of Main While Loop ---
            if self._check_stop_request(): self.log_signal.emit("ScraperThread: Stop requested during loop.")
            elif self.last_page_reached: self.log_signal.emit("ScraperThread: Finished scraping pages or no more results found.")
            else: self.log_signal.emit("ScraperThread: Main loop exited unexpectedly.") # Should ideally not happen

        except TaskStopException as stop_ex: # Custom exception for controlled stops
             self.log_signal.emit(f"ScraperThread: Task stopped internally: {stop_ex}"); logging.info(f"ScraperThread caught TaskStopException: {stop_ex}")
        except WebDriverException as wd_err: # Catch critical WebDriver errors (less likely with BS4 focus)
             err_msg = f"ScraperThread: Critical WebDriver error: {wd_err}"; self.log_signal.emit(err_msg); self.error_signal.emit(err_msg)
             logging.critical(f"Unhandled WebDriverException in ScraperThread run loop: {wd_err}", exc_info=True); self._stop_requested = True # Ensure stop
        except Exception as e: # Catch any other unexpected errors
            err_msg = f"ScraperThread: Unhandled error during scraping: {e}"; self.log_signal.emit(err_msg); self.error_signal.emit(err_msg)
            logging.critical("Unhandled error in ScraperThread run loop", exc_info=True); self._stop_requested = True # Ensure stop

        finally: # --- Cleanup ---
            self.status_signal.emit("Finishing process...")
            self.log_signal.emit("ScraperThread: Waiting for remaining analysis tasks...")
            wait_start_time_proc = time.monotonic(); max_wait_seconds_proc = 120 # Increased wait time for analysis tasks
            try: # Wait for analysis pool to finish tasks gracefully
                active_count = self._get_active_analysis_tasks()
                while active_count > 0:
                    if self._check_stop_request(): self.log_signal.emit("ScraperThread: Stop requested during final task wait."); break
                    self.status_signal.emit(f"Finishing analysis ({active_count} tasks)...")
                    if time.monotonic() - wait_start_time_proc > max_wait_seconds_proc:
                        self.log_signal.emit(f"Warning: Timed out ({max_wait_seconds_proc}s) waiting for final analysis. Active: {active_count}")
                        break
                    self.msleep(500); active_count = self._get_active_analysis_tasks()

                # Force clear pool if tasks remain after wait or stop request
                remaining_tasks = self._get_active_analysis_tasks()
                if remaining_tasks > 0:
                     logging.warning(f"ScraperThread: Forcing clear of analysis pool ({remaining_tasks} tasks remain).")
                     self.face_analyzer_threadpool.clear() # Request stop for remaining runnables
                     self.face_analyzer_threadpool.waitForDone(2000) # Give them 2s to finish/cancel
                     # Check again - log if tasks are truly stuck
                     if self._get_active_analysis_tasks() > 0: logging.warning("ScraperThread: Analysis pool tasks did not fully stop after clear().")
                self.log_signal.emit("ScraperThread: Analysis tasks finished or stopped.")
            except Exception as pool_wait_err: logging.error(f"Error waiting for analysis pool: {pool_wait_err}", exc_info=True)

            # Quit WebDriver (if it was initialized)
            if self.driver:
                try:
                    self.log_signal.emit("ScraperThread: Quitting browser..."); self.driver.quit(); self.log_signal.emit("ScraperThread: Browser quit successfully.")
                except (WebDriverException, NoSuchWindowException) as quit_wd_err: # Ignore errors if browser already closed
                    if "no such window" not in str(quit_wd_err).lower(): logging.error(f"Error quitting browser: {quit_wd_err}", exc_info=False); self.log_signal.emit(f"Warn: Error quitting browser: {quit_wd_err}")
                except Exception as quit_err: logging.error("Unexpected error quitting browser", exc_info=True); self.log_signal.emit(f"Warn: Unexpected error quitting browser: {quit_err}")
                finally: self.driver = None # Ensure reference is cleared

            # Cleanup temporary directory
            self.cleanup_temp_dir()

            # Final status update and signal completion
            final_status = "Finished"; is_stopped = False
            try: # Safely get final state
                with QMutexLocker(self.mutex): is_stopped = self._stop_requested; self._is_running = False; self._is_paused = False
                final_status = "Scraping stopped." if is_stopped else "Scraping finished."
            except Exception as mutex_err: logging.error(f"Error accessing mutex in finally block: {mutex_err}"); final_status = "Finished with errors"

            self.log_signal.emit(f"ScraperThread: Run method finished. Status: {final_status}")
            self.status_signal.emit(final_status)

            # Signal completion to the parent (MainWindow) using invokeMethod for thread safety
            parent_obj = self.parent()
            if parent_obj and hasattr(parent_obj, "scraping_finished"): # Check if parent exists and has the slot
                 try: QMetaObject.invokeMethod(parent_obj, "scraping_finished", Qt.QueuedConnection, Q_ARG(object, self), Q_ARG(str, "ScraperThread"))
                 except Exception as signal_err: logging.error(f"Error invoking scraping_finished signal to parent: {signal_err}")
            else: logging.warning("ScraperThread parent missing or has no scraping_finished slot on finish.")

    def _check_stop_request(self):
        """Checks if stop has been requested, thread-safe."""
        with QMutexLocker(self.mutex): return self._stop_requested

    def _check_running_or_handle_pause(self):
        """Checks if running, handles pause state, returns False if should stop."""
        self.mutex.lock()
        try:
            # While paused, wait on the condition variable
            while self._is_running and self._is_paused and not self._stop_requested:
                self.status_signal.emit(f"Paused on page {self.current_page_num}...")
                self.pause_cond.wait(self.mutex, 500) # Wait with timeout (e.g., 500ms)
            # Return True if running and not stopped, False otherwise
            return self._is_running and not self._stop_requested
        finally: self.mutex.unlock()

    def cleanup_temp_dir(self):
        """Removes the temporary directory created by the thread."""
        if self.temp_dir and self.temp_dir.exists():
            try: shutil.rmtree(self.temp_dir); logging.info(f"ScraperThread: Deleted temporary directory: {self.temp_dir}")
            except Exception as e: logging.warning(f"ScraperThread: Error deleting temp dir {self.temp_dir}: {e}", exc_info=True); self.log_signal.emit(f"Warning: Error cleaning up temp files: {e}")
        self.temp_dir = None

    def _initialize_webdriver(self):
        """Initializes the Selenium Chrome WebDriver (kept for potential future use)."""
        try:
            self.log_signal.emit("ScraperThread: Configuring Chrome options...")
            options = webdriver.ChromeOptions(); options.add_argument("--log-level=3") # Suppress console logs
            if self.headless: options.add_argument("--headless=new"); logging.info("Headless mode enabled.")
            else: logging.info("Headless mode disabled.")
            # Common options for stability
            options.add_argument("--disable-gpu"); options.add_argument("--no-sandbox"); options.add_argument("--disable-dev-shm-usage")
            options.add_argument("--window-size=1920,1080"); options.add_argument("--start-maximized"); options.add_argument("--disable-extensions")
            options.add_argument("--disable-infobars"); options.add_argument("user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36") # Example UA
            # Anti-detection options
            options.add_experimental_option('excludeSwitches', ['enable-logging', "enable-automation"]); options.add_experimental_option('useAutomationExtension', False)
            options.add_argument("--disable-blink-features=AutomationControlled")

            # Driver setup using webdriver-manager
            try:
                self.log_signal.emit("ScraperThread: Setting up ChromeDriver via webdriver-manager...")
                service = ChromeService(executable_path=ChromeDriverManager().install())
                self.log_signal.emit(f"ScraperThread: ChromeDriver installed/located at: {service.path}")
            except Exception as driver_manager_err:
                error_msg = (f"Fatal Error: Failed setup ChromeDriver via manager: {driver_manager_err}\n\n"
                             "- Check internet connection & firewall.\n- Ensure Chrome installed & compatible.\n"
                             "- Verify write permissions for cache dir.\n- Try 'webdriver-manager chrome' in terminal.")
                logging.critical(error_msg, exc_info=True); self.error_signal.emit(error_msg); return None

            self.log_signal.emit("ScraperThread: Initializing Chrome WebDriver...")
            driver = webdriver.Chrome(service=service, options=options)
            driver.set_page_load_timeout(60); driver.implicitly_wait(5) # Set timeouts

            # Inject anti-detection scripts using CDP (ExecuteDevToolsProtocol)
            try:
                script_source = """
                    Object.defineProperty(navigator, 'webdriver', {get: () => undefined});
                    Object.defineProperty(navigator, 'languages', {get: () => ['en-US', 'en']});
                    Object.defineProperty(navigator, 'plugins', {get: () => [1, 2, 3]});
                """
                driver.execute_cdp_cmd('Page.addScriptToEvaluateOnNewDocument', {'source': script_source})
                self.log_signal.emit("ScraperThread: Anti-detection script injected via CDP.")
            except Exception as cdp_err: # Log as warning, might not be critical
                self.log_signal.emit(f"ScraperThread: Warning - Failed to inject anti-detection script: {cdp_err}"); logging.warning(f"Failed to inject CDP script: {cdp_err}")

            self.log_signal.emit("ScraperThread: Browser initialized successfully."); return driver

        except WebDriverException as browser_wd_error: # Handle critical init errors
             error_msg = (f"Fatal Error: Failed WebDriver init: {browser_wd_error}\n\n- Ensure Chrome installed/updated.\n- Check ChromeDriver compatibility.")
             self.error_signal.emit(error_msg); logging.critical("WebDriver Init Failed (WebDriverException)", exc_info=True); return None
        except Exception as browser_error: # Catch other unexpected errors
             error_msg = f"Fatal Error: Unexpected error initializing Chrome: {browser_error}"
             self.error_signal.emit(error_msg); logging.critical("WebDriver Init Failed (Unexpected)", exc_info=True); return None

    def _load_page_and_wait(self, driver, page_url):
        """Loads page and waits for elements (kept for potential future Selenium use)."""
        # Currently unused in the BS4 scraping flow
        logging.warning("_load_page_and_wait (Selenium) called but BS4 is primary.")
        if self._check_stop_request(): return False
        try:
            driver.get(page_url)
            WebDriverWait(driver, 20).until(EC.presence_of_element_located((By.CSS_SELECTOR, MOTHERLESS_VIDEO_CONTAINER_SELECTOR)))
            self.log_signal.emit(f"ScraperThread [Selenium]: Page loaded: {page_url}")
            return True
        except TimeoutException:
             self.log_signal.emit(f"Warning [Selenium]: Timeout waiting for elements on {page_url}. Assuming end/empty.")
             return False # Treat timeout as potentially end of results
        except WebDriverException as e:
             self.log_signal.emit(f"Warning [Selenium]: WebDriver error loading {page_url}: {e}")
             logging.error(f"WebDriver error loading page {page_url}: {e}", exc_info=False)
             if "disconnected" in str(e) or "unable to connect" in str(e) or "no such window" in str(e).lower():
                 self.error_signal.emit(f"WebDriver disconnected: {e}"); self._stop_requested = True # Critical
             return False
        except Exception as e:
             self.log_signal.emit(f"Warning [Selenium]: Unexpected error loading {page_url}: {e}")
             logging.error(f"Unexpected error loading page {page_url}", exc_info=True)
             return False

    def _download_frame_via_selenium(self, frame_url):
        """ Downloads frame using requests (renamed from Selenium, kept requests logic)."""
        if not self._is_running or self._check_stop_request(): logging.warning(f"[RequestsDL] Stop requested. Skip: ...{Path(frame_url).name}"); return None
        log_prefix_dl = f"[RequestsDL F:{Path(frame_url).name}]"; logging.info(f"{log_prefix_dl} Attempting download: {frame_url}")
        image_bytes = None; headers = {'User-Agent': 'Mozilla/5.0'} # Basic user agent
        try:
            resp = requests.get(frame_url, timeout=10, stream=False, headers=headers) # Use stream=False for immediate download
            logging.debug(f"{log_prefix_dl} Status: {resp.status_code}"); resp.raise_for_status(); image_bytes = resp.content
            if image_bytes: logging.info(f"{log_prefix_dl} Downloaded {len(image_bytes)} bytes via requests.")
            else: logging.warning(f"{log_prefix_dl} Could not download via requests (empty content): {frame_url}.")
        except TaskStopException: logging.info(f"{log_prefix_dl} TaskStopException caught during download.")
        except requests.exceptions.Timeout: logging.warning(f"{log_prefix_dl} Timeout loading frame URL via requests: {frame_url}")
        except requests.exceptions.RequestException as e: logging.error(f"{log_prefix_dl} Requests exception downloading: {e}", exc_info=False)
        except Exception as e: logging.error(f"{log_prefix_dl} Unexpected error downloading via requests: {e}", exc_info=True)
        return image_bytes # Return bytes or None

    # --- Analysis Orchestration ---
    @pyqtSlot(str, dict)
    def queue_video_for_analysis(self, video_url, video_info):
        """Downloads frames (requests) and queues FaceAnalysisOnlyTasks."""
        if self._check_stop_request(): return # Check before starting work
        all_preview_urls = video_info.get('allPreviewFrameURLs', [])
        max_available_idx = len(all_preview_urls)
        # Filter requested frames to valid indices for this video
        unique_frames_to_analyze = sorted(list(set(f for f in self.frames_for_recognition if 1 <= f <= max_available_idx)))
        num_frames = len(unique_frames_to_analyze); log_prefix = f"[Queue Vid:{video_url[-10:]}]"

        if not unique_frames_to_analyze: # Handle case where no valid frames selected/available
            self.log_signal.emit(f"{log_prefix} No valid frames ({self.frames_for_recognition}) in range (1-{max_available_idx}). Skipping analysis.")
            # Ensure completion check is triggered to finalize video state
            QMetaObject.invokeMethod(self, "_check_video_analysis_completion_invoked", Qt.QueuedConnection, Q_ARG(str, video_url)); return

        logging.info(f"{log_prefix} Preparing {num_frames} frames for analysis (requests DL)...")
        # Store video info and start time for tracking (thread-safe)
        with QMutexLocker(self.active_tasks_lock):
            if video_url in self.active_video_processing: logging.warning(f"{log_prefix} Video already in processing queue. Skipping add."); return
            self.active_video_processing[video_url] = {
                "info": video_info, "frames_to_analyze": set(unique_frames_to_analyze),
                "frame_encodings": {}, "frame_image_data": {}, "analyzed_frames_count": 0,
                "max_score": 0.0, "best_frame": 0, "best_ref_idx": -1,
                "comparison_complete": False, "start_time": time.monotonic() # Record start time
            }

        tasks_queued_count = 0
        for frame_num in unique_frames_to_analyze:
            if self._check_stop_request(): logging.info(f"{log_prefix} Stop requested while preparing analysis tasks."); break
            frame_url = all_preview_urls[frame_num - 1] # Get URL (0-based index)
            self.frame_status_update_signal.emit(video_url, frame_num, FrameWidget.STATE_DOWNLOADING) # Signal UI: Downloading

            # Download the frame using Requests
            image_bytes = self._download_frame_via_selenium(frame_url) # Method now uses requests
            if self._check_stop_request(): break # Check again after potentially long download

            if image_bytes:
                # Emit the loaded image data (QByteArray) for the lane widget
                self.frame_image_loaded_signal.emit(video_url, frame_num, QByteArray(image_bytes))
                # Update status to analyzing
                self.frame_status_update_signal.emit(video_url, frame_num, FrameWidget.STATE_ANALYZING)
                try: # Create and start the analysis task
                    analysis_task = FaceAnalysisOnlyTask( # Use the defined QRunnable
                        video_url=video_url, frame_number=frame_num, image_bytes=image_bytes,
                        detection_priority=self.detection_priority, encoding_model=self.encoding_model,
                        num_jitters=self.num_jitters, max_upsample=self.max_upsample,
                        force_all_attempts=self.force_all_attempts, use_mutex=self.use_mutex
                    )
                    # Connect signals FROM the analysis task TO this scraper thread's slots/signals
                    analysis_task.signals.frame_analysis_finished_signal.connect(self.handle_face_analysis_finished) # Connect to result handler
                    analysis_task.signals.log_signal.connect(self.log_signal) # Forward log signals
                    analysis_task.signals.error.connect(self._handle_analysis_task_error) # Route errors through internal handler
                    analysis_task.signals.frame_status_update_signal.connect(self.frame_status_update_signal) # Forward status updates
                    analysis_task.signals.frame_processing_time_signal.connect(self.frame_processing_time_signal) # Forward timing info

                    logging.debug(f"{log_prefix} Submitting FaceAnalysisOnlyTask F{frame_num} to pool.")
                    self.face_analyzer_threadpool.start(analysis_task) # Submit task to the pool
                    tasks_queued_count += 1
                except Exception as task_create_err: # Handle errors creating/starting the task
                    logging.error(f"{log_prefix} Failed to create/start FaceAnalysisOnlyTask for F{frame_num}: {task_create_err}", exc_info=True)
                    self.error_signal.emit(f"Error starting analysis task for ...{video_url[-10:]} F{frame_num}") # Emit critical error
                    self._mark_frame_as_failed(video_url, frame_num) # Mark frame as failed if task creation fails
            else: # Download failed (reason logged in _download_frame_via_selenium)
                self.frame_status_update_signal.emit(video_url, frame_num, FrameWidget.STATE_DOWNLOAD_FAILED)
                self._mark_frame_as_failed(video_url, frame_num) # Mark frame as failed in tracking

        # After attempting all frames for this video:
        if not self._check_stop_request():
            if tasks_queued_count == 0 and unique_frames_to_analyze: logging.warning(f"{log_prefix} No analysis tasks were successfully queued (Downloads failed?).")
            # Always check completion state, even if no tasks queued (to handle videos with no valid frames)
            QMetaObject.invokeMethod(self, "_check_video_analysis_completion_invoked", Qt.QueuedConnection, Q_ARG(str, video_url))

    def _mark_frame_as_failed(self, video_url, frame_num):
        """Updates tracking info when frame analysis can't start (e.g., download fail). Thread-safe."""
        log_prefix = f"[Queue Vid:{video_url[-10:]}]"; logging.debug(f"{log_prefix} Marking frame F{frame_num} as analysis start failed.")
        completion_check_needed = False
        with QMutexLocker(self.active_tasks_lock):
            if video_url in self.active_video_processing:
                video_data = self.active_video_processing[video_url]
                # Check if frame was expected and not already processed/marked
                if frame_num in video_data.get("frames_to_analyze", set()) and frame_num not in video_data["frame_encodings"]:
                    video_data["frame_encodings"][frame_num] = None # Mark as processed (failed), value is None
                    video_data["frame_image_data"][frame_num] = None # No image data either
                    video_data["analyzed_frames_count"] += 1 # Increment count
                    logging.debug(f"{log_prefix} Increased analyzed count for failed F{frame_num}. Count: {video_data['analyzed_frames_count']}")
                    completion_check_needed = True # Need to check if video is now complete
                elif frame_num in video_data["frame_encodings"]: logging.warning(f"{log_prefix} Tried to mark F{frame_num} failed, but already processed/marked."); completion_check_needed = True # Check anyway
                else: logging.warning(f"{log_prefix} Tried to mark unexpected frame F{frame_num} as failed for video ...{video_url[-10:]}.")
            else: logging.warning(f"{log_prefix} Video not found in tracking when marking F{frame_num} as failed.")
        # Trigger completion check outside the lock using invokeMethod (runs in this thread's event loop)
        if completion_check_needed: QMetaObject.invokeMethod(self, "_check_video_analysis_completion_invoked", Qt.QueuedConnection, Q_ARG(str, video_url))

    @pyqtSlot(str, int, list, QByteArray) # Slot to receive analysis results from FaceAnalysisOnlyTask
    def handle_face_analysis_finished(self, video_url, frame_number, frame_encodings, image_byte_array):
        """Handles result from FaceAnalysisOnlyTask. Compares faces, updates scores. Runs in ScraperThread."""
        if self._check_stop_request(): return # Ignore results if stopping
        log_prefix = f"[AnalyzeResult Vid:{video_url[-10:]} F:{frame_number}]"
        logging.debug(f"{log_prefix} Result received. Encodings: {len(frame_encodings)}, ImgBytes: {image_byte_array.size()}.")
        self._consecutive_analysis_errors = 0 # Reset error count on successful signal receive
        completion_check_needed = False; valid_encodings = [enc for enc in frame_encodings if isinstance(enc, np.ndarray) and enc.size > 0]

        # --- Update Tracking Info (Inside Lock) ---
        self.active_tasks_lock.lock()
        try:
            if video_url not in self.active_video_processing: logging.debug(f"{log_prefix} Video {video_url} no longer active. Ignore."); return
            video_data = self.active_video_processing[video_url]
            if frame_number in video_data["frame_encodings"]: logging.warning(f"{log_prefix} Duplicate result F{frame_number}. Ignore."); completion_check_needed = True; return # Check completion anyway
            # Store results and increment count
            video_data["frame_encodings"][frame_number] = valid_encodings # Store list (empty if no faces)
            video_data["frame_image_data"][frame_number] = image_byte_array if not image_byte_array.isNull() else None # Store QByteArray or None
            video_data["analyzed_frames_count"] += 1
            progress = f"{video_data['analyzed_frames_count']}/{len(video_data['frames_to_analyze'])}"
            logging.debug(f"{log_prefix} Stored {len(valid_encodings)} encodings. Progress: {progress}")
            self.frame_status_update_signal.emit(video_url, frame_number, FrameWidget.STATE_COMPARING); completion_check_needed = True # Signal UI state
        finally: self.active_tasks_lock.unlock()

        # --- Comparisons (Outside Lock - computationally intensive part) ---
        comparison_results = {}; max_similarity_this_frame = 0.0 # Track max score within this frame across all refs
        if valid_encodings: # Only compare if faces were found in this frame
             logging.debug(f"{log_prefix} Comparing frame faces vs {len(self.reference_encodings_all)} ref image(s)...")
             for ref_idx, ref_encodings_list in enumerate(self.reference_encodings_all):
                 if not ref_encodings_list: continue # Skip references that had no faces
                 max_similarity_for_this_ref = 0.0
                 try:
                     # Compare each face found in frame against all faces in current reference image
                     for frame_encoding in valid_encodings:
                         if self._check_stop_request(): logging.info(f"{log_prefix} Stop requested during comparison."); comparison_results = {}; break
                         if not isinstance(frame_encoding, np.ndarray) or frame_encoding.size == 0: continue # Skip invalid encodings
                         # Calculate distances (lower distance = better match)
                         face_distances = face_recognition.face_distance(ref_encodings_list, frame_encoding)
                         if face_distances.size > 0:
                             similarity = max(0.0, 1.0 - np.min(face_distances)) # Convert distance to similarity
                             max_similarity_for_this_ref = max(max_similarity_for_this_ref, similarity) # Track max for this ref
                     if self._check_stop_request(): break # Exit outer ref loop if stopped
                     comparison_results[ref_idx] = max_similarity_for_this_ref # Store max similarity against this ref
                     max_similarity_this_frame = max(max_similarity_this_frame, max_similarity_for_this_ref) # Update overall max for frame
                     # logging.debug(f"{log_prefix} -> Ref {ref_idx+1}: Max Sim Frame = {max_similarity_for_this_ref:.4f}") # Verbose log
                 except ValueError as ve: logging.error(f"{log_prefix} ValueError comparing Ref {ref_idx+1}: {ve}. Check dims."); comparison_results[ref_idx] = -1.0 # Indicate error
                 except Exception as compare_err: logging.error(f"{log_prefix} Error comparing Ref {ref_idx+1}: {compare_err}", exc_info=True); comparison_results[ref_idx] = -1.0 # Indicate error
                 if self._check_stop_request(): break # Exit outer ref loop if stopped

        # --- Update Overall Scores for Video & Emit UI Updates (Inside Lock) ---
        self.active_tasks_lock.lock()
        try:
            if video_url not in self.active_video_processing: completion_check_needed = False; return # Check again if video still active
            video_data = self.active_video_processing[video_url]
            if video_data.get("comparison_complete", False): completion_check_needed = False; return # Ignore if already finalized

            if not self._check_stop_request():
                new_best_score_found_this_frame = False
                # Process comparison results calculated outside lock
                for ref_idx, frame_similarity_for_ref in comparison_results.items():
                    if frame_similarity_for_ref < 0: continue # Skip errors (-1.0)
                    # Check if this frame's score for this ref is new best overall for VIDEO
                    if frame_similarity_for_ref > video_data["max_score"]:
                        logging.info(f"{log_prefix} ** New Best Score for Video! ** Score: {frame_similarity_for_ref:.3f} (Ref {ref_idx+1}, Frame {frame_number})")
                        video_data["max_score"] = frame_similarity_for_ref; video_data["best_frame"] = frame_number; video_data["best_ref_idx"] = ref_idx
                        new_best_score_found_this_frame = True
                    # Emit score for this specific frame & ref to update FrameWidget display
                    # logging.debug(f"{log_prefix} Emit score UI: Ref {ref_idx+1}, Score {frame_similarity_for_ref:.3f}") # Verbose
                    self.processed_frame_score_signal.emit(video_url, frame_number, ref_idx, frame_similarity_for_ref)

                # If new overall best, update reference image display in MainWindow
                if new_best_score_found_this_frame and 0 <= video_data["best_ref_idx"] < len(self.reference_pixmaps):
                     best_ref_pixmap = self.reference_pixmaps[video_data["best_ref_idx"]]
                     if best_ref_pixmap and not best_ref_pixmap.isNull(): self.current_reference_image_signal.emit(best_ref_pixmap.copy(), video_data["best_ref_idx"])

                # If no faces found in this frame, emit 0 scores for UI consistency
                if not valid_encodings:
                    logging.debug(f"{log_prefix} No faces found, emitting 0 scores for UI.")
                    for ref_idx, ref_enc_list in enumerate(self.reference_encodings_all):
                         if ref_enc_list: self.processed_frame_score_signal.emit(video_url, frame_number, ref_idx, 0.0)

                # Update frame state AFTER comparison based on whether faces/matches occurred
                final_frame_state = FrameWidget.STATE_FACE_FOUND if max_similarity_this_frame > 0.001 else FrameWidget.STATE_NO_FACE
                self.frame_status_update_signal.emit(video_url, frame_number, final_frame_state)
            else: logging.info(f"{log_prefix} Stop requested, skipping score update."); completion_check_needed = True # Still need completion check
        finally: self.active_tasks_lock.unlock()

        # --- Trigger Completion Check (Outside Lock) ---
        if completion_check_needed and not self._check_stop_request():
            QMetaObject.invokeMethod(self, "_check_video_analysis_completion_invoked", Qt.QueuedConnection, Q_ARG(str, video_url))

    @pyqtSlot(str) # Runs in ScraperThread via invokeMethod
    def _check_video_analysis_completion_invoked(self, video_url):
        """Checks if analysis/comparison is complete for a video. Marks done, emits result if needed."""
        if self._check_stop_request(): return
        final_result_to_emit = None; remove_video_from_processing = False; video_processing_time_ms = -1.0
        log_prefix = f"[AnalyzeComplete Vid:{video_url[-10:]}]"

        self.active_tasks_lock.lock()
        try:
            if video_url not in self.active_video_processing: return # Already removed or finished
            video_data = self.active_video_processing[video_url]
            if video_data.get("comparison_complete", False): return # Already marked complete

            num_expected = len(video_data["frames_to_analyze"])
            all_frames_accounted_for = (video_data["analyzed_frames_count"] >= num_expected)

            if all_frames_accounted_for:
                # --- Finalization Logic ---
                video_data["comparison_complete"] = True # Mark complete FIRST to prevent races
                title = video_data['info'].get('videoTitle', 'Unknown Title')[:40]
                max_score = video_data['max_score']; best_frame = video_data['best_frame']; best_ref_idx = video_data['best_ref_idx']

                # Calculate & report video processing time
                start_time = video_data.get("start_time")
                if start_time:
                    video_processing_time_ms = (time.monotonic() - start_time) * 1000
                    logging.debug(f"{log_prefix} Video processing took {video_processing_time_ms:.2f} ms.")
                    if self.processing_stats and video_processing_time_ms >= 0: self.processing_stats.add_video_processing_time(video_processing_time_ms)
                else: logging.warning(f"{log_prefix} Could not calculate video processing time (start_time missing).")

                logging.info(f"{log_prefix} All {num_expected} frames accounted for '{title}'. Analyzed: {video_data['analyzed_frames_count']}")
                logging.info(f"{log_prefix} --> Final Result: Max Score={max_score:.3f} (Frame {best_frame}, Ref {best_ref_idx+1}), Threshold={self.threshold:.3f}")

                # Signal UI to highlight best frame in lane
                if best_frame > 0: self.frame_status_update_signal.emit(video_url, best_frame, FrameWidget.STATE_BEST_FRAME)

                # Check threshold and prepare final result for emission
                if max_score >= self.threshold:
                    logging.info(f"{log_prefix} *** Threshold MET for '{title}'. Preparing final result. ***")
                    best_frame_image_data = video_data.get('frame_image_data', {}).get(best_frame) # Get image data before potential removal
                    final_result_to_emit = { # Prepare data structure for signal
                        "video_url": video_url,
                        "video_data": {'best_frame': best_frame, 'best_ref_idx': best_ref_idx, 'max_score': max_score,
                                       'info': video_data['info'].copy(), # Pass a copy of info
                                       'best_frame_image_data': best_frame_image_data} # Include image data
                    }
                    self.lane_result_found_signal.emit(video_url, max_score) # Trigger lane flash effect
                else: logging.info(f"{log_prefix} Threshold NOT met for '{title}'.")

                remove_video_from_processing = True # Mark for removal from active tracking
                self.video_processing_finished_signal.emit(video_url) # Signal MainWindow this video is done processing
                logging.debug(f"{log_prefix} Signaled video processing finished.")
            else: logging.debug(f"{log_prefix} Not all frames accounted ({video_data['analyzed_frames_count']}/{num_expected}).")
        finally: self.active_tasks_lock.unlock()

        # --- Remove from Tracking and Emit Final Result (Outside Lock) ---
        if remove_video_from_processing:
            with QMutexLocker(self.active_tasks_lock): # Re-acquire lock briefly for safe removal
                 if video_url in self.active_video_processing:
                     logging.debug(f"{log_prefix} Removing video from active processing dict.")
                     del self.active_video_processing[video_url] # Data needed was captured earlier
                 else: logging.warning(f"{log_prefix} Video {video_url} not found during removal."); final_result_to_emit = None # Prevent emit if data lost

        # Emit the final result if threshold was met
        if final_result_to_emit: self._emit_final_result(final_result_to_emit["video_url"], final_result_to_emit["video_data"])

    def _emit_final_result(self, video_url, video_data):
        """Prepares and emits the final result dictionary, including thumbnail QImage."""
        best_frame_num = video_data.get('best_frame', 0); best_ref_idx = video_data.get('best_ref_idx', -1)
        max_score = video_data.get('max_score', 0.0); all_urls = video_data['info'].get('allPreviewFrameURLs', [])
        best_frame_url = all_urls[best_frame_num - 1] if 1 <= best_frame_num <= len(all_urls) else ""
        if not best_frame_url: self.log_signal.emit(f"Warn: Could not get best frame URL (F:{best_frame_num}) for ...{video_url[-20:]}")

        thumbnail_qimage = None; best_frame_data = video_data.get("best_frame_image_data") # QByteArray or None

        # --- Load QImage from stored data or download if necessary ---
        if best_frame_data and isinstance(best_frame_data, QByteArray) and not best_frame_data.isNull():
             qimg_loader = QImage();
             if qimg_loader.loadFromData(best_frame_data): thumbnail_qimage = qimg_loader.copy(); logging.debug(f"Loaded QImage from stored F{best_frame_num} for ...{video_url[-20:]}.")
             else: logging.warning(f"QImage loadFromData failed stored F{best_frame_num} ...{video_url[-20:]}")
        elif best_frame_url: # Fallback: Download if stored data failed or wasn't available
            logging.warning(f"No valid stored image data F{best_frame_num}. Attempt DL: {best_frame_url}")
            try:
                resp = requests.get(best_frame_url, timeout=15, headers={'User-Agent':'Mozilla/5.0'}); resp.raise_for_status()
                data_dl = resp.content; qimg_loader = QImage()
                if qimg_loader.loadFromData(data_dl): thumbnail_qimage = qimg_loader.copy(); logging.debug(f"DL/loaded best frame QImage ...{video_url[-20:]}.")
                else: self.log_signal.emit(f"Warn: Failed load QImage from DL: {best_frame_url}"); logging.warning(f"QImage loadFromData fail DL: {best_frame_url}")
            except requests.exceptions.RequestException as net_err: self.log_signal.emit(f"Warn: Net error DL thumb ({best_frame_url}): {net_err}"); logging.warning(f"Net error DL thumb: {net_err}")
            except Exception as thumb_err: self.log_signal.emit(f"Warn: Error DL/proc thumb ({best_frame_url}): {thumb_err}"); logging.warning(f"Error DL/proc thumb: {thumb_err}", exc_info=True)

        # Prepare final result dict for MainWindow
        final_result = {
            "videoTitle": video_data['info'].get("videoTitle", "Unknown"), "videoURL": video_url,
            "thumbnailURL": video_data['info'].get("thumbnailURL", ""), # Original thumb URL
            "previewFramesAnimation": video_data['info'].get("previewFramesAnimation", []),
            "score": max_score, "frameNumberWithHighestScore": best_frame_num, "bestFrameURL": best_frame_url,
            "referenceIndexWithHighestScore": best_ref_idx, "allPreviewFrameURLs": all_urls,
            "thumbnailQImageData": thumbnail_qimage, # Pass QImage object (or None)
            "timestamp_added": time.time() # Add timestamp when result finalized
        }
        self.final_result_signal.emit(final_result) # Emit to MainWindow
        logging.debug(f"Emitted final result signal for ...{video_url[-20:]}")

    def _get_active_analysis_tasks(self):
        """Returns the count of active tasks in the face analyzer thread pool. Safe check."""
        try: return self.face_analyzer_threadpool.activeThreadCount() if self.face_analyzer_threadpool else 0
        except Exception as e: logging.error(f"Error getting active task count: {e}"); return 0


# --- JavaScript constants (kept for potential Selenium fallback/future use) ---
MOTHERLESS_EXTRACTION_JS = """
    // ... (JS code as provided previously, currently unused by BS4 path) ...
    """
SELENIUM_IMAGE_EXTRACT_JS = """
    // ... (JS code as provided previously, currently unused by requests path) ...
    """

# =============================================================================
# FrameWidget - Widget to display a single frame in the video preview lane
# =============================================================================
class FrameWidget(QLabel):
    """Displays a single preview frame status and image in the live view lane."""
    # --- State Constants ---
    STATE_PENDING_DOWNLOAD = 0; STATE_DOWNLOADING = 1; STATE_DOWNLOAD_FAILED = 2
    STATE_ANALYZING = 3; STATE_COMPARING = 4; STATE_NO_FACE = 5; STATE_FACE_FOUND = 6; STATE_BEST_FRAME = 7
    # STATE_ANALYSIS_FAILED = 8 # If needed for analysis task errors vs download errors

    STATE_TOOLTIPS = {
        STATE_PENDING_DOWNLOAD: "Pending Download...", STATE_DOWNLOADING: "Downloading Frame...", STATE_DOWNLOAD_FAILED: "Download Failed!",
        STATE_ANALYZING: "Analyzing Image...", STATE_COMPARING: "Comparing Faces...", STATE_NO_FACE: "No Face Detected",
        STATE_FACE_FOUND: "Face Detected", STATE_BEST_FRAME: "Highest Scoring Frame!" # Tooltips updated dynamically with score
    }
    # Simplified style definitions (adjust background-color as needed)
    STATE_STYLES = {
        STATE_PENDING_DOWNLOAD: f"border: 1px dashed {COLOR_PENDING.name()}; background-color: #383838;",
        STATE_DOWNLOADING: f"border: 2px dashed {COLOR_DOWNLOADING.name()}; background-color: #3a3a3a;",
        STATE_DOWNLOAD_FAILED: f"border: 2px solid {COLOR_DOWNLOAD_FAILED.name()}; background-color: #403030;",
        STATE_ANALYZING: f"border: 2px dashed {COLOR_ANALYZING.name()}; background-color: #3a3a3a;",
        STATE_COMPARING: f"border: 2px dashed {COLOR_COMPARING.name()}; background-color: #3a3a3a;",
        STATE_NO_FACE: f"border: 1px solid {COLOR_NO_FACE.name()}; background-color: #383838;",
        STATE_FACE_FOUND: f"border: 1px solid {COLOR_FACE_FOUND.name()}; background-color: #383838;",
        STATE_BEST_FRAME: f"border: 3px solid {COLOR_BEST.name()}; background-color: #404040;",
    }
    BASE_STYLE = "QLabel { border-radius: 3px; margin: 1px; color: #ccc; font-size: 7pt; }"

    def __init__(self, frame_number, parent=None, main_window=None, performance_mode=False):
        super().__init__(parent); self.frame_number = frame_number; self.main_window = main_window # Weakref not strictly needed here if parent manages lifecycle
        self.performance_mode = performance_mode; self.setFixedSize(LANE_FRAME_WIDGET_SIZE); self.setAlignment(Qt.AlignCenter)
        self.setToolTip(f"Preview Frame {self.frame_number}"); self._state = FrameWidget.STATE_PENDING_DOWNLOAD; self.current_pixmap = None
        self.highest_score_received = 0.0 # Highest score *this frame* has seen across all references
        # Text overlay for state/score
        self.text_overlay_label = QLabel(self); self.text_overlay_label.setAlignment(Qt.AlignBottom | Qt.AlignHCenter)
        self.text_overlay_label.setStyleSheet("QLabel { color: white; background-color: rgba(0, 0, 0, 170); border-radius: 3px; padding: 0px 2px; font-size: 7pt; font-weight: normal; }"); self.text_overlay_label.hide()
        # Score animation elements
        self.score_anim_label = QLabel(self); self.score_anim_label.setAlignment(Qt.AlignCenter)
        self.score_anim_label.setStyleSheet("QLabel { color: white; background-color: rgba(0, 0, 0, 180); border-radius: 4px; padding: 1px 3px; font-size: 9pt; font-weight: bold; }"); self.score_anim_label.hide()
        self.score_anim_group = None; self.score_anim_effect = QGraphicsOpacityEffect(self.score_anim_label); self.score_anim_label.setGraphicsEffect(self.score_anim_effect)
        # Initial setup
        self.update_visual_state() # Set initial style/tooltip
        if PLACEHOLDER_FRAME_PIXMAP: super().setPixmap(PLACEHOLDER_FRAME_PIXMAP.copy()) # Use super() to bypass override initially
        else: logging.warning("FrameWidget: PLACEHOLDER_FRAME_PIXMAP not initialized!"); self.setText(f"F{self.frame_number}") # Fallback

    def set_performance_mode(self, enabled):
        """Enable/disable performance mode (affects visual rendering)."""
        if self.performance_mode == enabled: return; self.performance_mode = enabled; self.update_visual_state() # Re-render

    def setPixmap(self, pixmap):
        """Overrides setPixmap to store the pixmap and handle performance mode."""
        # Store the actual pixmap internally (make a copy)
        self.current_pixmap = pixmap.copy() if pixmap and not pixmap.isNull() else None
        # Update display based on current performance mode
        self.update_visual_state()

    def _display_pixmap_internal(self, pixmap):
        """Helper method to scale and display a pixmap in the label, or show placeholder."""
        if pixmap and not pixmap.isNull():
            scaled_pixmap = pixmap.scaled(self.size() - QSize(4, 4), Qt.KeepAspectRatio, Qt.SmoothTransformation) # Allow for border
            super().setPixmap(scaled_pixmap) # Use super() to set the visual pixmap
        elif PLACEHOLDER_FRAME_PIXMAP: super().setPixmap(PLACEHOLDER_FRAME_PIXMAP.copy())
        else: super().clear(); super().setText(f"F{self.frame_number}") # Fallback if placeholder failed

    @pyqtSlot(QPixmap)
    def _set_pixmap_slot(self, pixmap):
        """Slot to receive and set the pixmap safely in the GUI thread."""
        self.setPixmap(pixmap) # Use the overridden setPixmap method

    @pyqtSlot(QByteArray)
    def set_image_data(self, image_data):
        """Loads image data (QByteArray) received from analysis task and updates pixmap."""
        log_prefix = f"[FrameWidget F{self.frame_number}]"
        if not image_data or image_data.isNull(): # Handle null/empty data
            logging.debug(f"{log_prefix} Received null/empty image data.")
            self.current_pixmap = None # Clear stored pixmap
            # Set state to failed if appropriate, otherwise just update visuals
            if self._state not in (FrameWidget.STATE_DOWNLOAD_FAILED, FrameWidget.STATE_BEST_FRAME): self.set_state(FrameWidget.STATE_DOWNLOAD_FAILED)
            else: self.update_visual_state()
            return
        # Load QPixmap from data
        pixmap = QPixmap()
        if pixmap.loadFromData(image_data):
            logging.debug(f"{log_prefix} Loaded pixmap from {image_data.size()} bytes.")
            self._set_pixmap_slot(pixmap) # Call slot to safely update internal pixmap & display
        else: # Handle pixmap loading failure
            logging.warning(f"{log_prefix} Failed to load pixmap from data.")
            self.current_pixmap = None
            if self._state not in (FrameWidget.STATE_DOWNLOAD_FAILED, FrameWidget.STATE_BEST_FRAME): self.set_state(FrameWidget.STATE_DOWNLOAD_FAILED)
            else: self.update_visual_state()

    @pyqtSlot(float)
    def display_score(self, new_score):
        """ Updates display based on a new score for *this frame* against *any* reference. """
        log_prefix = f"[FrameWidget F{self.frame_number}]"
        # logging.debug(f"{log_prefix} Received score update: {new_score:.3f}. Current highest: {self.highest_score_received:.3f}, State: {self._state}") # Verbose
        score_improved = new_score > self.highest_score_received
        # Update the highest score seen *for this specific frame*
        if score_improved: self.highest_score_received = new_score
        # Trigger animation only if score improved significantly and not in perf mode
        if score_improved and new_score > 0.001 and not self.performance_mode: self._trigger_score_animation(new_score)
        # Always update text/tooltip based on the *highest* score seen so far
        self.update_visual_state()

    def _trigger_score_animation(self, score_to_display):
        """Handles the visual animation of the score popping up."""
        log_prefix = f"[FrameWidget F{self.frame_number} Anim]"
        self.score_anim_label.setText(f"{score_to_display:.2f}"); self.score_anim_label.adjustSize() # Fit text
        start_x = (self.width()-self.score_anim_label.width())//2; start_y = (self.height()-self.score_anim_label.height())//2 - 10 # Start slightly above center
        self.score_anim_label.move(start_x, start_y)
        # --- Color based on score relative to threshold ---
        threshold = self.main_window.get_threshold() if self.main_window else 0.75
        if score_to_display >= threshold + 0.08: color = COLOR_BEST.lighter(130).name() # Brighter LimeGreen for strong match
        elif score_to_display >= threshold: color = "#9ACD32" # YellowGreen (Above threshold)
        elif score_to_display >= threshold - 0.1: color = COLOR_FACE_FOUND.name() # Gold (Slightly Below)
        else: color = "#FFA500" # Orange (Clearly Below)
        self.score_anim_label.setStyleSheet(f"QLabel {{ color: {color}; background-color: rgba(0, 0, 0, 190); border-radius: 4px; padding: 1px 3px; font-size: 9pt; font-weight: bold; }}")
        self.score_anim_label.show(); self.score_anim_label.raise_()
        # --- Setup Animation ---
        if self.score_anim_group and self.score_anim_group.state() == QPropertyAnimation.Running: logging.debug(f"{log_prefix} Stopping existing animation."); self.score_anim_group.stop() # Stop previous
        # Ensure effect is valid
        if not self.score_anim_effect: self.score_anim_effect = QGraphicsOpacityEffect(self.score_anim_label); self.score_anim_label.setGraphicsEffect(self.score_anim_effect)
        if self.score_anim_effect: # Create new animation group
            self.score_anim_group = QParallelAnimationGroup(self) # Parent group
            anim_fade = QPropertyAnimation(self.score_anim_effect, b"opacity", self.score_anim_group); anim_fade.setDuration(1800); anim_fade.setStartValue(1.0); anim_fade.setEndValue(0.0); anim_fade.setEasingCurve(QEasingCurve.InQuad)
            anim_move = QPropertyAnimation(self.score_anim_label, b"pos", self.score_anim_group); anim_move.setDuration(1800); anim_move.setStartValue(self.score_anim_label.pos()); anim_move.setEndValue(QPoint(start_x, start_y - 25)); anim_move.setEasingCurve(QEasingCurve.OutQuad)
            self.score_anim_group.addAnimation(anim_fade); self.score_anim_group.addAnimation(anim_move)
            self.score_anim_group.finished.connect(self.score_anim_label.hide) # Hide label on finish
            self.score_anim_group.start(QPropertyAnimation.DeleteWhenStopped); logging.debug(f"{log_prefix} Starting score animation.")
        else: logging.warning(f"{log_prefix} Cannot start animation - graphics effect is missing."); self.score_anim_label.hide() # Hide immediately if effect failed

    def set_state(self, state):
        """Sets the visual state of the widget if it changed, handling BEST state."""
        # Prevent reverting from BEST state unless explicitly being unset
        if self._state == FrameWidget.STATE_BEST_FRAME and state != FrameWidget.STATE_BEST_FRAME:
             if not (hasattr(self, '_being_unset_as_best') and self._being_unset_as_best):
                 logging.debug(f"[F{self.frame_number}] Ignoring state change from BEST ({self._state}) to {state} (not unset)")
                 return # Ignore state change away from BEST unless flag is set
        # If state changed, update and refresh visuals
        if self._state != state:
            logging.debug(f"[FrameWidget F{self.frame_number}] State changing from {self._state} to {state}")
            self._state = state; self.update_visual_state() # Apply style/text for new state

    def set_best_frame(self, is_best):
         """Highlights or de-highlights the frame as the best scoring frame for the video."""
         log_prefix = f"[FrameWidget F{self.frame_number}]"
         if is_best: # Highlight as best
             if self._state != self.STATE_BEST_FRAME: logging.debug(f"{log_prefix} Setting as BEST frame."); self.set_state(self.STATE_BEST_FRAME)
         else: # De-highlight (if currently best)
             if self._state == self.STATE_BEST_FRAME:
                 # Revert to appropriate non-best state based on score
                 new_state = self.STATE_FACE_FOUND if self.highest_score_received > 0.001 else self.STATE_NO_FACE
                 logging.debug(f"{log_prefix} Unsetting as BEST frame. Reverting to state {new_state}.")
                 self._being_unset_as_best = True # Temporarily allow state change away from BEST
                 try: self.set_state(new_state)
                 finally: # Always remove the flag
                      if hasattr(self, '_being_unset_as_best'): del self._being_unset_as_best

    def reset_state_and_score(self):
        """Resets the widget to initial state and clears score."""
        logging.debug(f"[FrameWidget F{self.frame_number}] Resetting state and score.")
        self.highest_score_received = 0.0 # Reset score tracking
        if self.score_anim_group and self.score_anim_group.state() == QPropertyAnimation.Running: self.score_anim_group.stop() # Stop animation
        self.score_anim_label.hide(); self.set_state(FrameWidget.STATE_PENDING_DOWNLOAD) # Reset to initial state
        self.current_pixmap = None; self.update_visual_state() # Reset pixmap & update display (will show placeholder)

    def update_visual_state(self):
        """Applies the correct stylesheet, pixmap, text overlay, and tooltip based on state."""
        # Apply base style + state-specific style
        style = FrameWidget.BASE_STYLE + FrameWidget.STATE_STYLES.get(self._state, FrameWidget.STATE_STYLES[FrameWidget.STATE_PENDING_DOWNLOAD])
        self.setStyleSheet(style)
        # --- Determine Tooltip ---
        tooltip_base = FrameWidget.STATE_TOOLTIPS.get(self._state, 'Unknown State'); tooltip = f"Frame {self.frame_number}: {tooltip_base}"
        score_text = f"{self.highest_score_received:.3f}" # Use 3 decimal places for score display
        if self._state in (FrameWidget.STATE_FACE_FOUND, FrameWidget.STATE_BEST_FRAME): tooltip += f" (Score: {score_text})"
        self.setToolTip(tooltip)
        # --- Determine Overlay Text ---
        overlay_text = ""
        if self.performance_mode: # Abbreviated text in performance mode
             if self._state == 0: overlay_text="P"; elif self._state == 1: overlay_text="D"; elif self._state == 2: overlay_text="F!"; elif self._state == 3: overlay_text="S"; elif self._state == 4: overlay_text="C"
             elif self._state == 5: overlay_text="N"; elif self._state == 6: overlay_text=f"{self.highest_score_received:.1f}"; elif self._state == 7: overlay_text=f"★{self.highest_score_received:.1f}"
        else: # Normal mode text
             score_short = f"{self.highest_score_received:.2f}" # Shorter score for overlay
             if self._state == 0: overlay_text="Wait"; elif self._state == 1: overlay_text="DL..."; elif self._state == 2: overlay_text="Fail!"; elif self._state == 3: overlay_text="Scan..."; elif self._state == 4: overlay_text="Cmp..."
             elif self._state == 5: overlay_text="No Face"; elif self._state == 6: overlay_text=score_short; elif self._state == 7: overlay_text=f"★ {score_short}"
        # --- Update Text Overlay ---
        if overlay_text:
            self.text_overlay_label.setText(overlay_text); self.text_overlay_label.adjustSize()
            overlay_x = (self.width()-self.text_overlay_label.width())//2; overlay_y = self.height()-self.text_overlay_label.height()-2 # Position at bottom
            self.text_overlay_label.move(overlay_x, overlay_y); self.text_overlay_label.show(); self.text_overlay_label.raise_()
        else: self.text_overlay_label.hide()
        # --- Update Pixmap Display ---
        if self.performance_mode: # Always show placeholder in performance mode
            if PLACEHOLDER_FRAME_PIXMAP: super().setPixmap(PLACEHOLDER_FRAME_PIXMAP.copy())
            else: super().clear(); super().setText(f"F{self.frame_number}")
        elif self.current_pixmap: self._display_pixmap_internal(self.current_pixmap) # Show actual stored pixmap (scaled)
        else: # Show placeholder if no valid pixmap and not in performance mode
            if PLACEHOLDER_FRAME_PIXMAP: super().setPixmap(PLACEHOLDER_FRAME_PIXMAP.copy())
            else: super().clear(); super().setText(f"F{self.frame_number}")

    def cleanup(self):
        """Clean up resources like animations when the widget is destroyed."""
        logging.debug(f"Cleaning up FrameWidget {self.frame_number}")
        if self.score_anim_group: # Safely stop animation
            try: self.score_anim_group.stop() if self.score_anim_group.state() == QPropertyAnimation.Running else None
            except RuntimeError: pass # Object might be gone
            except Exception as e: logging.error(f"FrameWidget {self.frame_number} anim cleanup error: {e}")
            finally: self.score_anim_group = None
        # No outgoing signals connected from this widget to disconnect

# =============================================================================
# ResultWidget - Widget to display a found video result
# =============================================================================
class ResultWidget(QFrame):
    """Displays a single video result with thumbnail, info, delete button, context menu."""
    delete_requested = pyqtSignal(object) # Pass self when delete is requested

    def __init__(self, result_data, parent=None, main_window=None):
        super().__init__(parent); self.result_data = result_data; self.main_window = main_window # Keep reference
        self.setFrameShape(QFrame.StyledPanel); self.setStyleSheet("QFrame{border:1px solid #555;border-radius:5px;padding:5px;background-color:#383838;}ResultWidget:hover{background-color:#404040;border:1px solid #666;}")
        self.setMinimumHeight(105); self.setMaximumHeight(120) # Fixed height range
        # Animation state
        self.animated_preview_timer = QTimer(self); self.animated_preview_timer.timeout.connect(self.animate_preview); self.animated_preview_timer.setInterval(ANIMATION_FRAME_INTERVAL_MS)
        self.animation_frame_index = 0; self.preview_pixmaps = []; self.is_hovered = False
        self.thumbnail_pixmap = None # Holds the static QPixmap (best frame or initial thumb)
        self.preview_urls = self.result_data.get("previewFramesAnimation", []) # URLs for animation
        self.frames_loaded_count = 0; self.all_frames_load_attempted = (not self.preview_urls) # Flag if loading finished/skipped
        # Thumbnail path handling
        self.thumbnail_path = None; thumbnail_path_str = self.result_data.get("thumbnailPath")
        if thumbnail_path_str: # Convert saved string path to Path object
             try: self.thumbnail_path = Path(thumbnail_path_str)
             except Exception as path_err: logging.error(f"Error creating Path for thumbnail: {thumbnail_path_str} - {path_err}"); self.result_data['thumbnailPath'] = None # Invalidate if error
        # Setup UI and load initial data
        self.init_ui(); self.load_static_thumbnail() # Load initially
        # Asynchronously load animation frames if URLs exist and download pool is available
        if self.preview_urls:
            if self.main_window and hasattr(self.main_window, 'download_threadpool') and self.main_window.download_threadpool:
                QTimer.singleShot(250, self.load_preview_frames_async) # Delay start slightly to avoid overwhelming at startup
            else: logging.error(f"Cannot load preview frames for ...{self.result_data.get('videoURL','?')[-20:]}: Missing download pool."); self.all_frames_load_attempted = True # Mark as attempted if pool missing
        # Context Menu setup
        self.setContextMenuPolicy(Qt.CustomContextMenu); self.customContextMenuRequested.connect(self.show_context_menu)

    def init_ui(self):
        """Creates the UI elements within the widget."""
        layout = QHBoxLayout(self); layout.setContentsMargins(5,5,5,5); layout.setSpacing(10)
        # Thumbnail Label
        self.thumb_label = QLabel(); self.thumb_label.setFixedSize(PLACEHOLDER_RESULT_THUMB_SIZE); self.thumb_label.setAlignment(Qt.AlignCenter)
        self.thumb_label.setStyleSheet(f"QLabel {{ background-color: {PLACEHOLDER_COLOR.name()}; border: 1px solid #444; border-radius: 3px; color: #888; }}")
        if PLACEHOLDER_RESULT_THUMB_PIXMAP: self.thumb_label.setPixmap(PLACEHOLDER_RESULT_THUMB_PIXMAP.copy()) # Use placeholder copy
        self.thumb_label.setCursor(Qt.PointingHandCursor); self.thumb_label.mousePressEvent = self.open_video_url; self.thumb_label.setToolTip("Click to open Video\nHover for Preview Animation"); self.thumb_label.installEventFilter(self) # For hover detection
        layout.addWidget(self.thumb_label)
        # Info Area Container
        info_container = QWidget(); info_layout = QVBoxLayout(info_container); info_layout.setContentsMargins(0,0,0,0); info_layout.setSpacing(4); info_container.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
        # Title Label
        title_text = self.result_data.get("videoTitle", "No Title"); self.title_label = QLabel(title_text); self.title_label.setFont(QFont("Segoe UI", 10, QFont.Bold)); self.title_label.setWordWrap(True)
        self.title_label.setCursor(Qt.PointingHandCursor); self.title_label.mousePressEvent = self.open_video_url; self.title_label.setToolTip(f"Title: {title_text}\nClick to open video."); self.title_label.setTextInteractionFlags(Qt.TextSelectableByMouse)
        info_layout.addWidget(self.title_label)
        # URL Label (Elided)
        video_url = self.result_data.get("videoURL",""); fm = QFontMetrics(self.font()); elided_url = fm.elidedText(video_url, Qt.ElideMiddle, 450) # Elide middle part
        url_label = QLabel(f"<a href='{video_url}' style='color: #87CEEB; text-decoration: none;'>{elided_url}</a>"); url_label.setTextFormat(Qt.RichText); url_label.setTextInteractionFlags(Qt.TextBrowserInteraction); url_label.setOpenExternalLinks(True)
        url_label.setFont(QFont("Segoe UI", 8)); url_label.setToolTip(f"URL: {video_url}\nOpens in system browser.")
        info_layout.addWidget(url_label)
        # Score & Frame Info Label
        score = self.result_data.get("score",0.0); frame = self.result_data.get("frameNumberWithHighestScore","?"); ref = self.result_data.get("referenceIndexWithHighestScore",-1)
        score_text = f"Score: <b>{score:.3f}</b> (Frame {frame}" + (f", Ref {ref+1}" if ref!=-1 else "") + ")"; score_label = QLabel(score_text); score_label.setFont(QFont("Segoe UI", 9)); score_label.setTextInteractionFlags(Qt.TextSelectableByMouse); info_layout.addWidget(score_label)
        # Timestamp Added Label
        timestamp = self.result_data.get('timestamp_added', 0);
        if timestamp > 0:
            try: time_str = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(timestamp)); ts_label = QLabel(f"Added: {time_str}"); ts_label.setFont(QFont("Segoe UI", 8)); ts_label.setStyleSheet("color:#aaa;"); info_layout.addWidget(ts_label)
            except (ValueError, OSError): pass # Ignore errors formatting timestamp
        info_layout.addStretch(1); layout.addWidget(info_container, 1) # Allow info area to stretch
        # Delete Button (Top Right)
        self.delete_button = QToolButton(); self.delete_button.setText("✕"); self.delete_button.setFixedSize(20,20); self.delete_button.setCursor(Qt.PointingHandCursor)
        self.delete_button.setStyleSheet("QToolButton { border: none; color: #aaa; font-weight: bold; font-size: 12pt; background-color: transparent; border-radius: 10px; } QToolButton:hover { color: #FF4500; background-color: #555; }")
        self.delete_button.clicked.connect(self.confirm_delete_result); self.delete_button.setToolTip("Delete this result")
        layout.addWidget(self.delete_button, 0, Qt.AlignTop|Qt.AlignRight) # Align top-right

    def eventFilter(self, obj, event):
        """Handle hover events (Enter/Leave) for the thumbnail label to trigger animation."""
        if obj == self.thumb_label:
            if event.type() == QEvent.Enter:
                self.is_hovered = True
                # Start animation only if load attempts finished and valid frames exist
                if self.all_frames_load_attempted and any(p and not p.isNull() for p in self.preview_pixmaps): self.start_animation()
                return True # Event handled
            elif event.type() == QEvent.Leave:
                self.is_hovered = False; self.stop_animation(); return True # Event handled
        return super().eventFilter(obj, event) # Pass unhandled events on

    def load_static_thumbnail(self):
        """Loads the static thumbnail (best frame) from saved path or shows placeholder."""
        self.thumbnail_pixmap = None # Reset stored pixmap
        # Check if path exists and is a file
        if self.thumbnail_path and self.thumbnail_path.exists() and self.thumbnail_path.is_file():
            loaded_pixmap = QPixmap(str(self.thumbnail_path)) # Load from path
            if not loaded_pixmap.isNull():
                self.thumbnail_pixmap = loaded_pixmap # Store the loaded pixmap
                self._display_thumbnail(self.thumbnail_pixmap) # Display it
                logging.debug(f"Loaded static thumbnail from: {self.thumbnail_path}")
                return # Success
            else: # Loading failed even though file exists
                logging.warning(f"Failed QPixmap load from existing file: {self.thumbnail_path}")
                self.result_data['thumbnailPath'] = None; self.thumbnail_path = None # Invalidate path
        elif self.thumbnail_path: # Path was set but file doesn't exist
            logging.debug(f"Static thumbnail file not found: {self.thumbnail_path}")
            self.result_data['thumbnailPath'] = None; self.thumbnail_path = None # Invalidate path
        # Fallback to placeholder if no valid path or loading failed
        self._display_thumbnail(None)

    def _display_thumbnail(self, pixmap):
        """Helper to scale and display pixmap in thumb_label or show placeholder."""
        if pixmap and not pixmap.isNull(): # Display the provided pixmap
            scaled_pixmap = pixmap.scaled(self.thumb_label.size()-QSize(4,4), Qt.KeepAspectRatio, Qt.SmoothTransformation)
            self.thumb_label.setPixmap(scaled_pixmap); self.thumb_label.setText("") # Clear any fallback text
        else: # Show placeholder
             if PLACEHOLDER_RESULT_THUMB_PIXMAP: self.thumb_label.setPixmap(PLACEHOLDER_RESULT_THUMB_PIXMAP.copy())
             else: self.thumb_label.setText("No Thumb") # Fallback text if placeholder failed
             # Ensure placeholder style is applied
             self.thumb_label.setStyleSheet(f"QLabel {{ background-color: {PLACEHOLDER_COLOR.name()}; border: 1px solid #444; border-radius: 3px; color: #888; }}")

    def open_video_url(self, event=None):
        """Opens the video URL using MainWindow's safe opener."""
        url = self.result_data.get('videoURL')
        if url and self.main_window and hasattr(self.main_window, 'open_url_safely'): self.main_window.open_url_safely(url)
        elif not url: logging.warning("ResultWidget: Attempted to open result with missing URL.")
        elif not self.main_window: logging.error("ResultWidget: MainWindow reference is missing for opening URL.")

    def confirm_delete_result(self):
        """Shows confirmation dialog before emitting delete request."""
        reply = QMessageBox.question(self, 'Confirm Deletion', f"Delete result for:\n'{self.result_data.get('videoTitle', 'Unknown Video')}'?", QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
        if reply == QMessageBox.Yes: self.delete_requested.emit(self) # Signal MainWindow to handle deletion

    def show_context_menu(self, pos):
        """Creates and shows a context menu for the result widget."""
        menu = QMenu(self); # Actions with icons where appropriate
        open_action = menu.addAction(QIcon.fromTheme("document-open", QIcon()), "Open Video URL")
        copy_url_action = menu.addAction(QIcon.fromTheme("edit-copy", QIcon()), "Copy Video URL")
        copy_title_action = menu.addAction("Copy Title")
        open_thumb_folder_action = None # Only add if thumbnail exists
        if self.thumbnail_path and self.thumbnail_path.exists(): open_thumb_folder_action = menu.addAction(QIcon.fromTheme("folder-image", QIcon("")), "Open Thumbnail Location")
        menu.addSeparator(); delete_action = menu.addAction(QIcon.fromTheme("edit-delete", QIcon()), "Delete Result")
        # Execute menu and handle selected action
        action = menu.exec_(self.mapToGlobal(pos)); clipboard = QApplication.clipboard()
        logger_func = self.main_window.log if self.main_window else logging.info # Use MainWindow log if possible

        if action == open_action: self.open_video_url()
        elif action == copy_url_action: url = self.result_data.get('videoURL'); clipboard.setText(url or ""); logger_func(f"Copied URL: {url}")
        elif action == copy_title_action: title = self.result_data.get('videoTitle'); clipboard.setText(title or ""); logger_func(f"Copied Title: {title}")
        elif open_thumb_folder_action and action == open_thumb_folder_action:
           try: QDesktopServices.openUrl(QUrl.fromLocalFile(str(self.thumbnail_path.parent))); logger_func(f"Opened Folder: {self.thumbnail_path.parent}")
           except Exception as e: logger_func(f"Error opening folder: {e}")
        elif action == delete_action: self.delete_requested.emit(self) # Signal deletion

    def load_preview_frames_async(self):
        """Starts async download tasks for animation frames using MainWindow's pool and factory."""
        # Check prerequisites
        if not self.preview_urls or not self.main_window or not hasattr(self.main_window, 'download_threadpool') or not self.main_window.download_threadpool:
            self.all_frames_load_attempted = True; return # Mark as attempted if cannot proceed
        num_frames = len(self.preview_urls); self.preview_pixmaps = [None] * num_frames # Pre-fill list
        self.frames_loaded_count = 0; self.all_frames_load_attempted = False # Reset flags
        logging.debug(f"Queueing {num_frames} anim frame downloads for ...{self.result_data.get('videoURL','?')[-20:]}")
        # Create and start a download task for each frame URL
        for index, url in enumerate(self.preview_urls):
            if not url or not url.startswith("http"): # Skip invalid URLs
                 logging.warning(f"Invalid animation frame URL skipped: {url}")
                 # Manually trigger loaded handler for invalid URLs to maintain count integrity
                 QMetaObject.invokeMethod(self, "_animation_frame_loaded", Qt.QueuedConnection, Q_ARG(int, index), Q_ARG(QPixmap, QPixmap()))
                 continue
            # Use MainWindow's factory method to create the downloader task
            downloader_task = self.main_window._create_result_frame_downloader_task(url, index, self, self.result_data.get('videoURL','?'))
            self.main_window.download_threadpool.start(downloader_task)

    @pyqtSlot(int, QPixmap) # Slot receives index and loaded pixmap (or empty if failed)
    def _animation_frame_loaded(self, index, pixmap):
        """Slot to receive loaded animation frame pixmaps. Runs in GUI thread."""
        if not self: return # Check if widget still exists (might have been deleted)
        self.frames_loaded_count += 1 # Increment attempt counter
        valid_pixmap = pixmap and not pixmap.isNull() # Check if loaded pixmap is valid
        log_prefix = f"[AnimLoadResult Idx:{index} Vid:...{self.result_data.get('videoURL','?')[-10:]}]"
        # logging.debug(f"{log_prefix} Signal received (Valid: {valid_pixmap}). Attempts: {self.frames_loaded_count}/{len(self.preview_urls)}") # Verbose log
        # Store valid pixmap in the pre-allocated list
        if 0 <= index < len(self.preview_pixmaps) and valid_pixmap: self.preview_pixmaps[index] = pixmap
        # Check if all load attempts are finished
        if self.frames_loaded_count >= len(self.preview_urls):
             self.all_frames_load_attempted = True; valid_count = sum(1 for p in self.preview_pixmaps if p and not p.isNull())
             logging.info(f"{log_prefix} Anim frame loading complete. Valid: {valid_count}/{len(self.preview_urls)}")
             # If currently hovered and some frames are valid, ensure animation starts/is running
             if self.is_hovered and valid_count > 0: self.start_animation()

    def animate_preview(self):
        """Slot called by timer to cycle through loaded preview pixmaps."""
        # Filter out None or null pixmaps
        valid_pixmaps = [p for p in self.preview_pixmaps if p and not p.isNull()]
        if not valid_pixmaps: # Stop animation if no valid frames
            if self.animated_preview_timer.isActive(): self.stop_animation()
            return
        # Loop animation index
        if self.animation_frame_index >= len(valid_pixmaps): self.animation_frame_index = 0
        # Display the current frame
        self._display_thumbnail(valid_pixmaps[self.animation_frame_index])
        self.animation_frame_index += 1 # Move to next frame index

    def start_animation(self):
        """Starts the preview animation timer if frames are available and loaded."""
        if self.animated_preview_timer.isActive(): return # Already running
        has_valid_pixmaps = any(p and not p.isNull() for p in self.preview_pixmaps)
        # Only start if loading is complete and valid frames exist
        if has_valid_pixmaps and self.all_frames_load_attempted:
            log_prefix = f"[AnimStart Vid:...{self.result_data.get('videoURL','?')[-10:]}]"; logging.debug(f"{log_prefix} Starting animation.")
            self.animation_frame_index = 0 # Reset index
            # Use invokeMethod to ensure timer starts in correct thread context if needed, though usually okay from GUI
            QMetaObject.invokeMethod(self.animated_preview_timer, "start", Qt.QueuedConnection) # Use default interval set in init
            self.animate_preview() # Show first frame immediately

    def stop_animation(self):
        """Stops animation timer and reverts to static thumbnail."""
        if self.animated_preview_timer.isActive():
            log_prefix = f"[AnimStop Vid:...{self.result_data.get('videoURL','?')[-10:]}]"; logging.debug(f"{log_prefix} Stopping animation.")
            self.animated_preview_timer.stop()
        # Revert display to the static thumbnail (use stored pixmap or reload via helper)
        self._display_thumbnail(self.thumbnail_pixmap if self.thumbnail_pixmap else None)

    def cleanup(self):
         """Performs cleanup when the widget is being deleted."""
         logging.debug(f"Cleaning up ResultWidget for ...{self.result_data.get('videoURL','?')[-20:]}")
         if self.animated_preview_timer: self.animated_preview_timer.stop() # Stop timer
         try: self.delete_requested.disconnect() # Disconnect signal safely
         except TypeError: pass # Ignore if already disconnected
         except Exception as e: logging.warning(f"Error disconnecting signal during cleanup: {e}")
         self.deleteLater() # Schedule for deletion by Qt event loop

# =============================================================================
# VideoProcessingLane - Widget for the Live View
# =============================================================================
class VideoProcessingLane(QFrame):
    """Widget representing one video being actively processed in live view."""
    REMOVE_DELAY_MS = 4000 # Delay (ms) before removing completed lane from view
    RESULT_FLASH_DURATION_MS = 1000 # Duration (ms) of 'result found' flash effect

    def __init__(self, video_url, video_info, frames_to_analyze, performance_mode=False, parent=None, main_window_ref=None):
        super().__init__(parent); self.video_url = video_url; self.video_info = video_info
        self.frames_to_analyze = sorted(list(set(map(int, frames_to_analyze)))) # Ensure unique, sorted ints
        self.performance_mode = performance_mode; self.main_window_ref = main_window_ref # Keep reference
        self.frame_widgets = {} # {frame_num: FrameWidget} - Dictionary to hold frame widgets
        self.thumb_download_task = None # Track download task for the lane's thumbnail
        self._is_done = False # Flag indicating processing complete for this lane
        # Timers for delayed removal and flash effect
        self.removal_timer = QTimer(self); self.removal_timer.setSingleShot(True); self.removal_timer.timeout.connect(self._request_removal)
        self.flash_timer = QTimer(self); self.flash_timer.setSingleShot(True); self.flash_timer.timeout.connect(self._clear_flash_effect)
        self.original_style_sheet = "QFrame{border:1px solid #444;border-radius:4px;background-color:#3a3a3a;margin-bottom:5px;}" # Store original style

        # UI Setup
        self.setFrameShape(QFrame.StyledPanel); self.setStyleSheet(self.original_style_sheet)
        self.setMinimumHeight(100); self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) # Expand horizontally, fixed vertically
        main_layout = QHBoxLayout(self); main_layout.setContentsMargins(5,3,5,3); main_layout.setSpacing(6)

        # Left Side (Thumbnail + Title)
        left_widget = QWidget(); left_layout = QVBoxLayout(left_widget); left_layout.setContentsMargins(0,0,0,0); left_layout.setSpacing(2)
        self.video_thumb_label = QLabel(); self.video_thumb_label.setFixedSize(PLACEHOLDER_LANE_THUMB_SIZE); self.video_thumb_label.setAlignment(Qt.AlignCenter)
        self.video_thumb_label.setStyleSheet(f"QLabel{{background-color:{PLACEHOLDER_COLOR.name()};border:1px solid #444;border-radius:3px;color:#777;}}")
        if PLACEHOLDER_LANE_THUMB_PIXMAP: self.video_thumb_label.setPixmap(PLACEHOLDER_LANE_THUMB_PIXMAP.copy()) # Use placeholder
        self.video_thumb_label.setCursor(Qt.PointingHandCursor); self.video_thumb_label.mousePressEvent = self.open_video_url_from_lane; self.video_thumb_label.setToolTip("Click to open Video URL")
        title = self.video_info.get("videoTitle","?"); self.video_title_label = QLabel(title); self.video_title_label.setWordWrap(True); self.video_title_label.setToolTip(f"{title}\n{self.video_url}"); self.video_title_label.setMaximumWidth(110) # Limit title width
        self.video_title_label.setFont(QFont("Segoe UI", 8)); self.video_title_label.setTextInteractionFlags(Qt.TextSelectableByMouse)
        left_layout.addWidget(self.video_thumb_label); left_layout.addWidget(self.video_title_label); left_layout.addStretch(); left_widget.setFixedWidth(120); main_layout.addWidget(left_widget)

        # Right Side (Scrollable Frames Area)
        self.frame_scroll = QScrollArea(); self.frame_scroll.setWidgetResizable(True); self.frame_scroll.setFrameShape(QFrame.NoFrame); self.frame_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff); self.frame_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded); self.frame_scroll.setMinimumHeight(75)
        frame_container = QWidget(); self.frames_layout = QHBoxLayout(frame_container); self.frames_layout.setContentsMargins(0,0,0,0); self.frames_layout.setSpacing(3); self.frames_layout.setAlignment(Qt.AlignLeft)
        # Create FrameWidgets for each frame to analyze
        for frame_num in self.frames_to_analyze:
            frame_widget = FrameWidget(frame_num, self, self.main_window_ref, self.performance_mode) # Create child widget
            self.frames_layout.addWidget(frame_widget); self.frame_widgets[frame_num] = frame_widget # Add to layout and dictionary
        self.frames_layout.addStretch(1); frame_container.setLayout(self.frames_layout) # Add stretch at the end
        self.frame_scroll.setWidget(frame_container); main_layout.addWidget(self.frame_scroll, 1) # Add scroll area to main layout

        # Initial setup based on performance mode and load thumbnail if needed
        self.update_performance_mode(self.performance_mode)
        if not self.performance_mode: self.load_video_thumbnail()

    def open_video_url_from_lane(self, event=None):
        """Opens video URL using MainWindow helper."""
        if self.video_url and self.main_window_ref and hasattr(self.main_window_ref, 'open_url_safely'): self.main_window_ref.open_url_safely(self.video_url)
        elif not self.video_url: logging.warning("Lane thumbnail clicked but no video URL.")

    def load_video_thumbnail(self):
        """Loads the video thumbnail asynchronously if not in performance mode."""
        if self.performance_mode or self.thumb_download_task is not None: return # Skip if in perf mode or already loading
        # Check if download pool is available
        if not self.main_window_ref or not hasattr(self.main_window_ref, 'download_threadpool') or not self.main_window_ref.download_threadpool:
            logging.error(f"Cannot load lane thumbnail for ...{self.video_url[-10:]}: Download pool missing."); self._set_placeholder_thumbnail(); return
        # Determine thumbnail URL (use provided thumb or fallback to first preview)
        thumb_url = self.video_info.get("thumbnailURL") or (self.video_info.get("allPreviewFrameURLs", []) or [None])[0]
        if thumb_url: # If URL found, start download task
             self.thumb_download_task = self.main_window_ref._create_result_frame_downloader_task(thumb_url, -1, self.video_thumb_label, self.video_url) # Use factory, index -1 for thumb
             self.main_window_ref.download_threadpool.start(self.thumb_download_task)
        else: logging.warning(f"No thumbnail URL found for lane: ...{self.video_url[-10:]}"); self._set_placeholder_thumbnail() # Set placeholder if no URL

    def _set_placeholder_thumbnail(self):
         """Sets the thumbnail label to the placeholder image."""
         if PLACEHOLDER_LANE_THUMB_PIXMAP: self.video_thumb_label.setPixmap(PLACEHOLDER_LANE_THUMB_PIXMAP.copy())
         else: self.video_thumb_label.setText("No Thumb") # Fallback text
         self.video_thumb_label.setStyleSheet(f"QLabel{{background-color:{PLACEHOLDER_COLOR.name()};border:1px solid #444;border-radius:3px;color:#777;}}") # Ensure style

    # --- Slots to Update Child FrameWidgets ---
    @pyqtSlot(int, float)
    def update_frame_score(self, frame_num, score):
        """Updates a specific FrameWidget with a score."""
        if frame_num in self.frame_widgets: self.frame_widgets[frame_num].display_score(score)

    @pyqtSlot(int, QByteArray)
    def update_frame_image(self, frame_num, image_data):
        """Updates the image data for a specific FrameWidget."""
        if frame_num in self.frame_widgets: self.frame_widgets[frame_num].set_image_data(image_data)

    @pyqtSlot(int, int)
    def update_frame_state(self, frame_num, state):
        """Sets the state for a specific FrameWidget."""
        if frame_num in self.frame_widgets: self.frame_widgets[frame_num].set_state(state)

    @pyqtSlot(int)
    def highlight_best_frame(self, best_frame_num):
        """Highlights the best frame and de-highlights others."""
        logging.debug(f"Lane ...{self.video_url[-10:]}: Highlighting best frame {best_frame_num}")
        for fn, fw in self.frame_widgets.items(): fw.set_best_frame(fn == best_frame_num) # Tell each widget if it's the best

    @pyqtSlot(float)
    def show_result_found_effect(self, score):
        """Applies a visual flash effect to indicate a result was found."""
        if self._is_done: return # Don't flash if already marked done
        logging.debug(f"Lane ...{self.video_url[-10:]}: Showing result found effect (Score: {score:.3f})")
        if not self.flash_timer.isActive(): self.original_style_sheet = self.styleSheet() # Store current style before flashing
        # Apply flash style (e.g., green border/gradient)
        flash_style = f"QFrame{{border:2px solid {COLOR_THRESHOLD_MET_FLASH.name()};border-radius:4px;background-color:qlineargradient(spread:pad,x1:0,y1:0,x2:1,y2:0,stop:0 rgba(48,58,48,255),stop:1 {COLOR_THRESHOLD_MET_FLASH.name(QColor.HexArgb)});margin-bottom:5px;}}"
        self.setStyleSheet(flash_style); self.flash_timer.start(self.RESULT_FLASH_DURATION_MS) # Start timer to clear effect

    @pyqtSlot()
    def _clear_flash_effect(self):
        """Restores the original or 'done' stylesheet after the flash."""
        logging.debug(f"Lane ...{self.video_url[-10:]}: Clearing flash effect.")
        # Restore to 'done' style if marked done, otherwise restore original
        style_to_restore = "QFrame{border:1px solid #006400;border-radius:4px;background-color:#303a30;margin-bottom:5px;}" if self._is_done else self.original_style_sheet
        self.setStyleSheet(style_to_restore)

    @pyqtSlot(bool)
    def update_performance_mode(self, enabled):
        """Updates the lane's display based on performance mode."""
        if self.performance_mode == enabled: return; self.performance_mode = enabled
        # Thumbnail visibility and loading logic
        self.video_thumb_label.setVisible(not enabled) # Hide thumb in perf mode
        if enabled: self._set_placeholder_thumbnail() # Show placeholder in perf mode
        elif not self.video_thumb_label.pixmap() or (PLACEHOLDER_LANE_THUMB_PIXMAP and self.video_thumb_label.pixmap().cacheKey() == PLACEHOLDER_LANE_THUMB_PIXMAP.cacheKey()):
             self.thumb_download_task = None; self.load_video_thumbnail() # Reload if switching back and no thumb loaded
        # Propagate change to child FrameWidgets
        for fw in self.frame_widgets.values(): fw.set_performance_mode(enabled)

    def is_done(self): return self._is_done

    @pyqtSlot()
    def mark_as_done(self):
         """Marks lane as visually done (changes style) and schedules removal via timer."""
         if self._is_done: return; logging.debug(f"Marking lane done: ...{self.video_url[-10:]}")
         self._is_done = True
         # Apply 'done' style (e.g., muted green border/background)
         done_style = "QFrame{border:1px solid #006400;border-radius:4px;background-color:#303a30;margin-bottom:5px;}"
         # If currently flashing, store the 'done' style to restore after flash finishes
         if self.flash_timer.isActive(): self.original_style_sheet = done_style
         else: self.setStyleSheet(done_style) # Apply immediately if not flashing
         self.removal_timer.start(self.REMOVE_DELAY_MS) # Start timer for removal

    @pyqtSlot()
    def _request_removal(self):
        """Emits signal to MainWindow (via invokeMethod) to remove this lane from the layout."""
        if self.main_window_ref and hasattr(self.main_window_ref, 'request_lane_removal'):
            logging.debug(f"Lane ...{self.video_url[-10:]} requesting removal.")
            # Use invokeMethod to ensure the signal is handled in the main GUI thread
            QMetaObject.invokeMethod(self.main_window_ref, "request_lane_removal", Qt.QueuedConnection, Q_ARG(str, self.video_url))

    def cleanup(self):
        """Cleans up resources before deletion (stops timers, cleans child widgets)."""
        logging.debug(f"Cleaning up VideoProcessingLane for ...{self.video_url[-10:]}")
        if self.removal_timer: self.removal_timer.stop() # Stop timers
        if self.flash_timer: self.flash_timer.stop()
        for fw in self.frame_widgets.values(): fw.cleanup() # Cleanup children
        self.frame_widgets.clear(); self.thumb_download_task = None # Clear references
        self.deleteLater() # Schedule self for deletion

# =============================================================================
# MainWindow – The main application window
# =============================================================================
class MainWindow(QMainWindow):
    """Main application window for WebVidScrap Pro."""
    # --- Signals for cross-thread UI updates from ScraperThread/Tasks -> MainWindow(GUI) -> VideoProcessingLane(GUI) ---
    request_lane_frame_score_update = pyqtSignal(str, int, float)       # video_url, frame_num, score
    request_lane_frame_image_update = pyqtSignal(str, int, QByteArray)  # video_url, frame_num, image_data
    request_lane_frame_state_update = pyqtSignal(str, int, int)         # video_url, frame_num, FrameWidget.STATE_*
    request_lane_best_frame = pyqtSignal(str, int)                      # video_url, best_frame_num
    request_lane_result_found_effect = pyqtSignal(str, float)           # video_url, score (for flash)
    # --- Signal for lane removal request from VideoProcessingLane -> MainWindow(GUI) ---
    request_lane_removal = pyqtSignal(str)                              # video_url

    def __init__(self, parent=None):
        super().__init__(parent)
        logging.info("MainWindow: Initializing...")
        self.settings = QSettings(SETTINGS_ORG, SETTINGS_APP) # For saving/loading app state
        self.setWindowTitle(f"WebVidScrap Pro {APP_VERSION}"); self.setGeometry(100, 100, 1400, 980) # Set initial size/pos
        self.set_app_icon(); self._init_ui_attributes() # Initialize UI attribute placeholders
        # --- Core Data Structures ---
        self.reference_pixmaps = [] # List[QPixmap | None] - For display and potentially passing to threads
        self.reference_encodings_all = [] # List[List[np.ndarray]] - The core face data for comparison
        self.reference_image_paths = [] # List[str] - Paths of loaded reference images
        self.reference_labels = {} # Dict[str, QLabel] - UI elements displaying reference thumbnails
        self.encoding_cache = {} # Dict[str, Dict] - Loaded from/saved to JSON, holds {path: {'timestamp': float, 'encodings': List[List[float]]}}
        self.cache_mutex = QMutex() # Protects access to encoding_cache from multiple threads
        self.current_results_data = {} # Dict[str(video_url), Dict] - Holds found video results data, saved to JSON
        self.known_results_urls = set() # Set[str] - URLs already in results (passed to scraper to avoid duplicates)
        # --- State Variables ---
        self.reference_images_in_progress = 0 # Counter for active reference image loading tasks
        self.last_dir = self.settings.value("General/last_dir", str(Path.home())) # Remember last directory used
        self.active_lanes = {} # Dict[str(video_url), VideoProcessingLane] - Currently displayed live view lanes
        self.pending_lanes = deque() # Queue for videos waiting for a lane slot (FIFO)
        self.current_sort_key = 'score'; self.current_sort_reverse = True # Default sort order for results
        # --- Thread Pools ---
        self.ref_load_threadpool = QThreadPool(self); ref_thr = self.settings.value(SETTING_REF_LOAD_THREADS, DEFAULT_REF_LOAD_THREADS, type=int); self.ref_load_threadpool.setMaxThreadCount(max(1, ref_thr)) # Pool for loading reference images
        self.download_threadpool = QThreadPool(self); self.download_threadpool.setMaxThreadCount(DEFAULT_DOWNLOAD_THREADS) # Pool for general downloads (thumbnails, animation frames)
        # --- Scraper Thread & Stats ---
        self.scraper_thread = None # Holds the instance of the ScraperThread when running
        self.processing_stats = None # Holds the ProcessingStats instance during a scrape
        # --- Initial Setup ---
        self._ensure_thumbnails_dir(); # Create thumbnails directory if needed
        self.init_ui(); # Create and arrange all UI elements
        self.set_dark_theme(); # Apply stylesheet
        self.load_window_state(); # Restore previous window size/position
        self.load_encoding_cache(); # Load cached face encodings
        self._connect_lane_update_signals(); # Connect internal signals for lane updates
        self.log("Application ready.");
        if self.status_label: self.status_label.setText("Ready") # Set initial status
        # Load settings and results AFTER UI is fully initialized
        self.load_settings_and_references(); # Load saved settings and trigger reference loading
        self.load_results(); # Load saved results and populate the results view
        # Global thread pool (Example - if needed for other background tasks)
        # self.threadpool = QThreadPool()
        # logging.info(f"Global thread pool initialized with {self.threadpool.maxThreadCount()} threads.")

    def _init_ui_attributes(self):
        """Initialize all UI related attributes to None to prevent runtime errors before creation."""
        attrs = ["central_widget", "main_layout", "main_split_layout", "left_scroll_area", "left_widget",
                 "left_layout", "settings_group", "settings_layout", "basic_form", "pages_hbox",
                 "search_edit", "start_page_spin", "end_page_spin", "threshold_spin", "headless_check",
                 "detection_priority_combo", "encoding_model_combo", "encoding_jitters_spin",
                 "max_upsample_spin", "force_all_attempts_check", "use_face_mutex_check",
                 "ref_load_threads_spin", "analyzer_threads_spin", "frame_recognition_checkboxes",
                 "frame_animation_checkboxes", "add_ref_button", "clear_ref_button", "ref_scroll",
                 "ref_container", "ref_container_layout", "ref_progress_bar", "ref_image_display_label",
                 "perf_mode_check", "live_view_scroll", "live_view_container", "live_view_lanes_layout",
                 "no_lanes_label", "log_text", "sort_score_button", "sort_date_button", "clear_results_button",
                 "results_scroll", "results_container", "results_container_layout", "start_button",
                 "pause_button", "stop_button", "progress_bar", "status_label", "page_progress_label",
                 "video_progress_label", "frame_time_label", "video_time_label", "runtime_label",
                 "stats_update_timer", "bottom_bar", "results_widget"]
        for attr in attrs: setattr(self, attr, None)
        self.frame_recognition_checkboxes = []; self.frame_animation_checkboxes = [] # Ensure lists are initialized

    def set_app_icon(self):
        """Loads and sets the application icon."""
        try: # Try loading custom icon, fallback to system default
            icon_path = APP_DIR / "icon.png"; app_icon = QIcon(str(icon_path)) if icon_path.exists() else QIcon.fromTheme("applications-internet")
            self.setWindowIcon(app_icon if not app_icon.isNull() else QIcon.fromTheme("applications-internet"))
        except Exception as e: logging.error(f"Error loading app icon: {e}"); self.setWindowIcon(QIcon.fromTheme("applications-internet"))

    def _ensure_thumbnails_dir(self):
        """Creates the thumbnails directory if it doesn't exist. Exits on failure."""
        try: THUMBNAILS_DIR.mkdir(parents=True, exist_ok=True); logging.info(f"Thumbnails directory ensured: {THUMBNAILS_DIR}")
        except OSError as e: logging.critical(f"FATAL: Could not create thumbnails directory '{THUMBNAILS_DIR}': {e}"); QMessageBox.critical(None, "Fatal Error", f"Could not create required directory:\n{THUMBNAILS_DIR}\n\nError: {e}"); sys.exit(1)

    def init_ui(self):
        """Creates and configures the main UI layout and widgets."""
        logging.info("MainWindow: Initializing UI...")
        self.central_widget = QWidget(); self.setCentralWidget(self.central_widget)
        self.main_layout = QVBoxLayout(self.central_widget); self.main_layout.setContentsMargins(5,5,5,5)
        self.main_split_layout = QHBoxLayout(); self.main_layout.addLayout(self.main_split_layout, 1) # Add splitter layout with stretch
        # Create Panels
        self._create_left_panel(); self.main_split_layout.addWidget(self.left_scroll_area, 4) # Add left panel (weight 4)
        self._create_right_panel(); self.main_split_layout.addWidget(self.results_widget, 3) # Add right panel (weight 3)
        # Create Bottom Bar
        self._create_bottom_bar(); self.main_layout.addWidget(self.bottom_bar)
        # Connect signals AFTER all UI elements exist
        self._connect_signals(); logging.info("MainWindow: UI Initialization complete.")

    def _create_left_panel(self):
        """Creates the widgets and layout for the left panel (Settings, Refs, Live View, Stats, Log)."""
        self.left_scroll_area=QScrollArea(); self.left_scroll_area.setWidgetResizable(True); self.left_scroll_area.setFrameShape(QFrame.NoFrame); self.left_scroll_area.setMinimumWidth(500); self.left_scroll_area.setSizePolicy(QSizePolicy.Preferred,QSizePolicy.Expanding)
        self.left_widget=QWidget(); self.left_scroll_area.setWidget(self.left_widget); self.left_layout=QVBoxLayout(self.left_widget); self.left_layout.setSpacing(8)

        # --- Settings Group ---
        self.settings_group=QGroupBox("Settings"); self.settings_layout=QVBoxLayout(self.settings_group); self.basic_form=QFormLayout(); self.basic_form.setSpacing(8)
        self.search_edit=QLineEdit(); self.search_edit.setPlaceholderText("e.g., tag, name...")
        self.basic_form.addRow("Search:",self.search_edit)
        # Page Range HBox
        self.pages_hbox=QHBoxLayout(); self.start_page_spin=QSpinBox(); self.start_page_spin.setRange(1,9999); self.start_page_spin.setValue(1)
        self.end_page_spin=QSpinBox(); self.end_page_spin.setRange(-1,9999); self.end_page_spin.setSpecialValueText("∞"); self.end_page_spin.setValue(1) # Use -1 for infinity
        self.pages_hbox.addWidget(QLabel("Pages:")); self.pages_hbox.addWidget(self.start_page_spin); self.pages_hbox.addWidget(QLabel("to")); self.pages_hbox.addWidget(self.end_page_spin); self.pages_hbox.addStretch()
# Previous code in _create_left_panel...
        self.basic_form.addRow(self.pages_hbox); self.settings_layout.addLayout(self.basic_form); self.update_end_page_tooltip() # <-- Start here

        # Threshold / Headless HBox
        thresh_head=QHBoxLayout();
        self.threshold_spin=QDoubleSpinBox(); self.threshold_spin.setRange(0.01,1.00); self.threshold_spin.setValue(0.75); self.threshold_spin.setSingleStep(0.01); self.threshold_spin.setDecimals(2); self.threshold_spin.setToolTip("Minimum face similarity score (0.01-1.00). Higher value means stricter matching.")
        thresh_head.addWidget(QLabel("Threshold:")); thresh_head.addWidget(self.threshold_spin); thresh_head.addStretch()
        self.headless_check=QCheckBox("Headless"); self.headless_check.setChecked(True); self.headless_check.setToolTip("Run the web browser invisibly in the background. Uncheck to see the browser window during scraping (useful for debugging).")
        thresh_head.addWidget(self.headless_check);
        self.settings_layout.addLayout(thresh_head) # Add this HBox to the settings layout

        # --- Performance & Accuracy Group ---
        perf_group=QGroupBox("Performance & Accuracy"); perf_layout=QFormLayout(perf_group); perf_layout.setSpacing(8); perf_layout.setFieldGrowthPolicy(QFormLayout.ExpandingFieldsGrow)
        self.detection_priority_combo=QComboBox(); self.detection_priority_combo.addItems(["Accuracy (CNN First)", "Speed (HOG First)"]); self.detection_priority_combo.setToolTip("Detection model priority:\n- Accuracy (CNN): Slower, uses more memory/GPU, generally better for difficult angles/lighting.\n- Speed (HOG): Faster, less resource intensive, good for clear, front-facing faces.")
        perf_layout.addRow("Detection Priority:", self.detection_priority_combo)

        self.encoding_model_combo=QComboBox(); self.encoding_model_combo.addItems(["small", "large"]); self.encoding_model_combo.setToolTip("Face Encoding Model:\n- small: Faster, less accurate.\n- large: Slower, more accurate, requires more memory.")
        perf_layout.addRow("Encoding Model:", self.encoding_model_combo)

        self.encoding_jitters_spin=QSpinBox(); self.encoding_jitters_spin.setRange(1, 20); self.encoding_jitters_spin.setValue(1); self.encoding_jitters_spin.setToolTip("Encoding Jitters:\nHow many times to re-sample the face when calculating encoding.\nHigher values are slower but may improve accuracy for tricky faces (e.g., 5-10). Default is 1.")
        perf_layout.addRow("Encoding Jitters:", self.encoding_jitters_spin)

        self.max_upsample_spin=QSpinBox(); self.max_upsample_spin.setRange(0, 2); self.max_upsample_spin.setValue(1); self.max_upsample_spin.setToolTip("Max Detection Upsample:\nHow many times to upsample the image before detecting faces.\n- 0: Fastest, only detects larger faces.\n- 1: Good balance (default).\n- 2: Slowest, best for finding very small faces.")
        perf_layout.addRow("Max Detect Upsample:", self.max_upsample_spin)

        self.force_all_attempts_check=QCheckBox("Force All Detection Attempts"); self.force_all_attempts_check.setChecked(False); self.force_all_attempts_check.setToolTip("If checked, the system will try all detection models (CNN/HOG) and upsample levels,\neven if faces are found early with a faster method. Slower, but potentially more thorough.")
        perf_layout.addRow(self.force_all_attempts_check)

        self.use_face_mutex_check=QCheckBox("Use Face Recognition Mutex (Safer, Slower)"); self.use_face_mutex_check.setChecked(True); self.use_face_mutex_check.setToolTip("Ensures only one face recognition operation runs at a time across all threads.\nPrevents potential instability in the underlying library when run heavily in parallel.\nDisabling might increase speed but carries a risk of crashes on some systems.")
        perf_layout.addRow(self.use_face_mutex_check)

        # Threads HBox
        threads_hbox = QHBoxLayout(); core_info = f"(Cores: {CPU_CORES})";
        self.ref_load_threads_spin = QSpinBox(); self.ref_load_threads_spin.setRange(1, CPU_CORES * 2); self.ref_load_threads_spin.setValue(DEFAULT_REF_LOAD_THREADS); self.ref_load_threads_spin.setToolTip(f"Max parallel tasks for loading reference images.\nRecommended: {CPU_CORES // 2}-{CPU_CORES}.")
        self.analyzer_threads_spin = QSpinBox(); self.analyzer_threads_spin.setRange(1, max(4, CPU_CORES * 2)); self.analyzer_threads_spin.setValue(DEFAULT_ANALYZER_THREADS); self.analyzer_threads_spin.setToolTip(f"Max parallel tasks for analyzing video frames (face detection/encoding).\nRecommended: {CPU_CORES // 2}-{CPU_CORES}.\nSetting too high might lead to instability or high resource usage.")
        threads_hbox.addWidget(QLabel("Ref Load:")); threads_hbox.addWidget(self.ref_load_threads_spin); threads_hbox.addSpacerItem(QSpacerItem(20,0)); threads_hbox.addWidget(QLabel("Analyzer:")); threads_hbox.addWidget(self.analyzer_threads_spin); threads_hbox.addStretch()
        perf_layout.addRow(f"Threads {core_info}:", threads_hbox)
        self.settings_layout.addWidget(perf_group) # Add Perf group to main settings layout

        # --- Frame Selection ---
        frames_vbox = QVBoxLayout(); frames_vbox.setSpacing(5)
        # Recognition Frames Group
        rec_group = QGroupBox("Frames to Analyze"); rec_grid = QGridLayout(rec_group); rec_grid.setSpacing(5); self.frame_recognition_checkboxes = []
        default_rec = {1, 5, 8, 12, 15}; rec_group.setToolTip("Select frames (1-15) from the preview sequence to analyze for faces.")
        for i in range(1,16):
            cb = QCheckBox(str(i)); cb.setChecked(i in default_rec)
            self.frame_recognition_checkboxes.append(cb)
            row, col = divmod(i-1, 5); rec_grid.addWidget(cb, row, col)
        frames_vbox.addWidget(rec_group)
        # Animation Frames Group
        anim_group = QGroupBox("Result Preview Animation Frames"); anim_grid = QGridLayout(anim_group); anim_grid.setSpacing(5); self.frame_animation_checkboxes = []
        default_anim = set(range(1, 16)); anim_group.setToolTip("Select frames (1-15) to use for the hover animation in the results list.")
        for i in range(1,16):
            cb = QCheckBox(str(i)); cb.setChecked(i in default_anim)
            self.frame_animation_checkboxes.append(cb)
            row, col = divmod(i-1, 5); anim_grid.addWidget(cb, row, col)
        frames_vbox.addWidget(anim_group)
        self.settings_layout.addLayout(frames_vbox) # Add frame selection VBox to settings layout
        self.left_layout.addWidget(self.settings_group, 0) # Add Settings group to the main left layout (stretch factor 0)

        # --- Reference Images Group ---
        ref_group = QGroupBox("Reference Faces"); ref_layout = QVBoxLayout(ref_group); ref_layout.setSpacing(6)
        ref_btns = QHBoxLayout();
        self.add_ref_button = QPushButton(QIcon.fromTheme("list-add", QIcon()), "Add Reference(s)..."); self.add_ref_button.setFixedHeight(28)
        self.clear_ref_button = QPushButton(QIcon.fromTheme("edit-clear", QIcon()), "Clear References"); self.clear_ref_button.setObjectName("clear_ref_button"); self.clear_ref_button.setFixedHeight(28); self.clear_ref_button.setStyleSheet("QPushButton#clear_ref_button { background-color: #5c4040; } QPushButton#clear_ref_button:hover { background-color: #6c4a4a; }") # Specific style
        ref_btns.addWidget(self.add_ref_button); ref_btns.addWidget(self.clear_ref_button); ref_layout.addLayout(ref_btns)
        # Scroll Area for Reference Thumbnails
        self.ref_scroll = QScrollArea(); self.ref_scroll.setWidgetResizable(True); self.ref_scroll.setMinimumHeight(100); self.ref_scroll.setMaximumHeight(120); self.ref_scroll.setFrameShape(QFrame.StyledPanel); self.ref_scroll.setStyleSheet("QScrollArea { background-color: #3E3E3E; border-radius: 3px; border: 1px solid #555; }")
        self.ref_container = QWidget(); self.ref_container.setObjectName("ref_container"); self.ref_container_layout = QHBoxLayout(self.ref_container); self.ref_container_layout.setAlignment(Qt.AlignLeft); self.ref_container_layout.setSpacing(5); self.ref_container_layout.setContentsMargins(3, 3, 3, 3)
        self.ref_scroll.setWidget(self.ref_container); ref_layout.addWidget(self.ref_scroll)
        # Progress Bar for Reference Loading
        self.ref_progress_bar = QProgressBar(); self.ref_progress_bar.setFixedHeight(10); self.ref_progress_bar.setTextVisible(False); self.ref_progress_bar.setVisible(False)
        ref_layout.addWidget(self.ref_progress_bar)
        self.left_layout.addWidget(ref_group, 0) # Add Ref group to main left layout (stretch 0)

        # --- Live View Group ---
        live_group = QGroupBox("Live View"); live_layout = QVBoxLayout(live_group); live_layout.setSpacing(6)
        # Top bar inside Live View (Ref display + Perf mode checkbox)
        live_top_bar = QHBoxLayout(); live_top_bar.setContentsMargins(0,0,0,0)
        self.ref_image_display_label = QLabel(); self.ref_image_display_label.setFixedSize(PLACEHOLDER_REF_ICON_SIZE - QSize(10,10)); self.ref_image_display_label.setAlignment(Qt.AlignCenter); self.ref_image_display_label.setStyleSheet(f"QLabel {{ border: 1px solid #555; background-color: {PLACEHOLDER_COLOR.name()}; border-radius: 3px; color: #888; }}")
        self.ref_image_display_label.setToolTip("Shows the reference face corresponding to the highest score found so far."); self._set_ref_placeholder() # Set initial placeholder
        live_top_bar.addWidget(self.ref_image_display_label); live_top_bar.addStretch()
        self.perf_mode_check = QCheckBox("Performance Mode"); self.perf_mode_check.setToolTip(f"Disables image loading in the Live View lanes below to reduce GUI load.\nUseful when running many analysis threads or on slower systems.\nMax lanes displayed: {MAX_ACTIVE_LANES}")
        live_top_bar.addWidget(self.perf_mode_check); live_layout.addLayout(live_top_bar)
        # Scroll Area for Live View Lanes
        self.live_view_scroll = QScrollArea(); self.live_view_scroll.setWidgetResizable(True); self.live_view_scroll.setFrameShape(QFrame.StyledPanel); self.live_view_scroll.setStyleSheet("QScrollArea { border: 1px solid #555; border-radius: 3px; } QWidget#live_view_container { background-color: #333333; }"); self.live_view_scroll.setMinimumHeight(200) # Ensure it has some height
        self.live_view_container = QWidget(); self.live_view_container.setObjectName("live_view_container"); self.live_view_lanes_layout = QVBoxLayout(self.live_view_container); self.live_view_lanes_layout.setAlignment(Qt.AlignTop); self.live_view_lanes_layout.setSpacing(4); self.live_view_lanes_layout.setContentsMargins(2, 2, 2, 2)
        # Placeholder label when no lanes are active
        self.no_lanes_label = QLabel("Live view will show active video analysis here..."); self.no_lanes_label.setAlignment(Qt.AlignCenter); self.no_lanes_label.setStyleSheet("QLabel { color: #888; margin: 20px; }")
        self.live_view_lanes_layout.addWidget(self.no_lanes_label); self.live_view_lanes_layout.addStretch(1) # Add stretch initially to push label up
        self.live_view_scroll.setWidget(self.live_view_container); live_layout.addWidget(self.live_view_scroll, 1) # Add scroll area (stretch 1)
        self.left_layout.addWidget(live_group, 1) # Add Live View group to main left layout (stretch 1)

        # --- Processing Statistics Group ---
        stats_group = QGroupBox("Stats"); stats_layout = QGridLayout(stats_group); stats_layout.setSpacing(4); stats_layout.setContentsMargins(6, 6, 6, 6)
        self.page_progress_label = QLabel("P: -/-"); self.video_progress_label = QLabel("V: -/-"); self.frame_time_label = QLabel("Frame: - ms (50: - ms)"); self.video_time_label = QLabel("Video: - s (50: - s)"); self.runtime_label = QLabel("Time: -")
        small_font = QFont(); small_font.setPointSize(8) # Use smaller font for stats
        for label in [self.page_progress_label, self.video_progress_label, self.frame_time_label, self.video_time_label, self.runtime_label]: label.setFont(small_font); label.setStyleSheet("color: #ccc;")
        # Arrange stats labels in grid
        stats_layout.addWidget(QLabel("Page:"), 0, 0); stats_layout.addWidget(self.page_progress_label, 0, 1)
        stats_layout.addWidget(QLabel("Video:"), 1, 0); stats_layout.addWidget(self.video_progress_label, 1, 1)
        stats_layout.addWidget(QLabel("Frame Time:"), 0, 2); stats_layout.addWidget(self.frame_time_label, 0, 3)
        stats_layout.addWidget(QLabel("Video Time:"), 1, 2); stats_layout.addWidget(self.video_time_label, 1, 3)
        stats_layout.addWidget(QLabel("Runtime:"), 2, 0); stats_layout.addWidget(self.runtime_label, 2, 1, 1, 3) # Span runtime across columns
        # Add tooltips for clarity
        self.frame_time_label.setToolTip("Average frame analysis time (Average time for 50 frames)")
        self.video_time_label.setToolTip("Average video processing time (Average time for 50 videos)")
        self.runtime_label.setToolTip("Total runtime since process started")
        stats_group.setFixedHeight(95); # Fixed height for stats group
        self.left_layout.addWidget(stats_group) # Add stats group (stretch 0)
        # Stats Update Timer
        self.stats_update_timer = QTimer(self); self.stats_update_timer.setInterval(1000) # Update stats every second

        # --- Log Output Group ---
        log_group = QGroupBox("Log"); log_layout = QVBoxLayout(log_group)
        self.log_text = QTextEdit(); self.log_text.setReadOnly(True); self.log_text.setFont(QFont("Consolas", 8)); self.log_text.setLineWrapMode(QTextEdit.NoWrap) # Use monospace font, no wrap
        self.log_text.setMinimumHeight(80); self.log_text.setMaximumHeight(150) # Limit height
        log_layout.addWidget(self.log_text)
        self.left_layout.addWidget(log_group, 0) # Add Log group (stretch 0)
        # ... (Ende der _create_left_panel Methode) ...
        self.left_layout.addStretch(0) # Final stretch at bottom of left panel to push content up

    def _set_ref_placeholder(self):
        """Sets the placeholder image and tooltip for the reference image display label."""
        if self.ref_image_display_label:
             # Use the globally defined placeholder pixmap if available
             if PLACEHOLDER_REF_PIXMAP:
                  # Scale the placeholder to fit the label size
                  scaled_placeholder = PLACEHOLDER_REF_PIXMAP.scaled(
                      self.ref_image_display_label.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation
                  )
                  self.ref_image_display_label.setPixmap(scaled_placeholder)
             else:
                  # Fallback text if placeholder pixmap failed to initialize
                  self.ref_image_display_label.setText("Ref")
             # Set default tooltip
             self.ref_image_display_label.setToolTip("Shows the reference face corresponding to the highest score found so far.")

    def _create_right_panel(self):
        """Creates the widgets and layout for the right results panel."""
        self.results_widget = QWidget(); results_main_layout = QVBoxLayout(self.results_widget); results_main_layout.setContentsMargins(0, 0, 0, 0)
        results_group = QGroupBox("Results"); results_group.setStyleSheet("QGroupBox { margin-top: 7px; padding-top: 14px; } QWidget#results_container { background-color: #333333; }") # Align title & set container bg
        results_layout = QVBoxLayout(results_group); results_layout.setSpacing(4)
        # Top Bar: Sort Buttons & Clear Button
        sort_hbox = QHBoxLayout(); sort_hbox.addWidget(QLabel("Sort by:"));
        self.sort_score_button = QPushButton("Score"); self.sort_score_button.setCheckable(True); self.sort_score_button.setChecked(True); self.sort_score_button.setFixedHeight(24)
        self.sort_date_button = QPushButton("Date Added"); self.sort_date_button.setCheckable(True); self.sort_date_button.setFixedHeight(24)
        sort_hbox.addWidget(self.sort_score_button); sort_hbox.addWidget(self.sort_date_button); sort_hbox.addStretch()
        self.clear_results_button = QPushButton(QIcon.fromTheme("edit-clear", QIcon()), "Clear Results"); self.clear_results_button.setObjectName("clear_results_button"); self.clear_results_button.setFixedHeight(24); self.clear_results_button.setToolTip("Delete all listed results and their associated thumbnails from disk."); self.clear_results_button.setStyleSheet("QPushButton#clear_results_button { background-color: #5c4040; } QPushButton#clear_results_button:hover { background-color: #6c4a4a; }") # Specific style
        sort_hbox.addWidget(self.clear_results_button); results_layout.addLayout(sort_hbox)
        # Results Scroll Area
        self.results_scroll = QScrollArea(); self.results_scroll.setWidgetResizable(True); self.results_scroll.setFrameShape(QFrame.NoFrame) # No border for scroll area itself
        self.results_container = QWidget(); self.results_container.setObjectName("results_container"); # Set object name for styling
        self.results_container_layout = QVBoxLayout(self.results_container); self.results_container_layout.setSpacing(5); self.results_container_layout.setAlignment(Qt.AlignTop); self.results_container_layout.setContentsMargins(2, 2, 2, 2)
        self.results_container_layout.addStretch(1) # Initial stretch to push results up
        self.results_scroll.setWidget(self.results_container); results_layout.addWidget(self.results_scroll, 1) # Add scroll area (stretch 1)
        results_main_layout.addWidget(results_group) # Add the groupbox to the right panel's main layout

    def _create_bottom_bar(self):
        """Creates the bottom control bar with Start/Pause/Stop buttons and progress."""
        self.bottom_bar = QFrame(); self.bottom_bar.setObjectName("bottom_bar"); self.bottom_bar.setFrameShape(QFrame.StyledPanel); self.bottom_bar.setFixedHeight(55)
        bottom_bar_layout = QHBoxLayout(self.bottom_bar); bottom_bar_layout.setContentsMargins(10, 5, 10, 5)
        # Control Buttons
        self.start_button = QPushButton("▶ Start"); self.start_button.setFixedHeight(35); self.start_button.setStyleSheet("QPushButton { font-size: 11pt; font-weight: bold; }")
        self.pause_button = QPushButton("❚❚ Pause"); self.pause_button.setEnabled(False); self.pause_button.setFixedHeight(35); self.pause_button.setStyleSheet("QPushButton { font-size: 11pt; font-weight: bold; }")
        self.stop_button = QPushButton("■ Stop"); self.stop_button.setObjectName("stop_button"); self.stop_button.setEnabled(False); self.stop_button.setFixedHeight(35); self.stop_button.setStyleSheet("QPushButton#stop_button { font-size: 11pt; font-weight: bold; background-color: #6e4740; } QPushButton#stop_button:hover { background-color: #7e5750; }") # Specific style
        bottom_bar_layout.addWidget(self.start_button); bottom_bar_layout.addWidget(self.pause_button); bottom_bar_layout.addWidget(self.stop_button)
        # Progress Bar
        self.progress_bar = QProgressBar(); self.progress_bar.setFixedHeight(12); self.progress_bar.setTextVisible(False); self.progress_bar.setValue(0); self.progress_bar.setMaximum(100) # Default range
        bottom_bar_layout.addWidget(self.progress_bar, 1) # Allow progress bar to stretch
        # Status Label (Right Aligned)
        self.status_label = QLabel("Ready"); self.status_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter); self.status_label.setStyleSheet("QLabel { color: #ccc; font-size: 9pt; margin-left: 10px; }"); self.status_label.setMinimumWidth(150)
        bottom_bar_layout.addWidget(self.status_label)

    def _connect_signals(self):
        """Connects UI element signals to their corresponding slots."""
        logging.debug("Connecting UI signals...")
        # Settings Panel
        if self.search_edit: self.search_edit.returnPressed.connect(self.perform_search) # Start search on Enter
        if self.start_page_spin: self.start_page_spin.valueChanged.connect(self._handle_start_page_change)
        if self.end_page_spin: self.end_page_spin.valueChanged.connect(self._handle_end_page_change)
        if self.ref_load_threads_spin: self.ref_load_threads_spin.valueChanged.connect(lambda v: self.ref_load_threadpool.setMaxThreadCount(v)) # Update pool size dynamically
        # Note: analyzer_threads_spin value is read when starting the scraper

        # Reference Image Panel
        if self.add_ref_button: self.add_ref_button.clicked.connect(self.add_reference_images)
        if self.clear_ref_button: self.clear_ref_button.clicked.connect(self.confirm_clear_references)

        # Live View Panel
        if self.perf_mode_check: self.perf_mode_check.stateChanged.connect(self.toggle_performance_mode)

        # Results Panel
        if self.sort_score_button: self.sort_score_button.clicked.connect(lambda: self.set_sort_order('score'))
        if self.sort_date_button: self.sort_date_button.clicked.connect(lambda: self.set_sort_order('timestamp_added'))
        if self.clear_results_button: self.clear_results_button.clicked.connect(self.confirm_clear_results)

        # Bottom Bar Controls
        if self.start_button: self.start_button.clicked.connect(self.start_scraping)
        if self.pause_button: self.pause_button.clicked.connect(self.toggle_pause_scraping)
        if self.stop_button: self.stop_button.clicked.connect(lambda: self.stop_scraping(confirm=True)) # Confirm stop by default

        # Stats Update Timer
        if self.stats_update_timer: self.stats_update_timer.timeout.connect(self.update_stats_display)
        logging.debug("UI signals connected.")

    def set_dark_theme(self):
        """Applies a dark theme stylesheet to the application."""
        # Stylesheet definition (as provided before)
        dark_stylesheet = """
        QMainWindow, QDialog { background-color: #2b2b2b; color: #d4d4d4; }
        QWidget { background-color: #2b2b2b; color: #d4d4d4; } /* Default for widgets */
        QMenuBar { background-color: #333333; color: #d4d4d4; border-bottom: 1px solid #444444; }
        QMenuBar::item:selected { background-color: #404040; }
        QMenu { background-color: #333333; color: #d4d4d4; border: 1px solid #444444; }
        QMenu::item:selected { background-color: #404040; }
        QPushButton { background-color: #404040; color: #d4d4d4; border: 1px solid #555555; border-radius: 3px; padding: 5px; }
        QPushButton:hover { background-color: #4a4a4a; border-color: #666666; }
        QPushButton:pressed { background-color: #353535; }
        QPushButton:disabled { background-color: #2d2d2d; color: #666666; border-color: #3d3d3d; }
        QLineEdit, QSpinBox, QDoubleSpinBox, QComboBox { background-color: #333333; color: #d4d4d4; border: 1px solid #555555; border-radius: 3px; padding: 3px; }
        QLineEdit:focus, QSpinBox:focus, QDoubleSpinBox:focus { border-color: #666666; } /* Highlight focus */
        QTextEdit { background-color: #333333; color: #d4d4d4; border: 1px solid #555555; }
        QScrollArea { background-color: transparent; border: none; } /* Make scroll area background transparent */
        QScrollBar:vertical { border: none; background-color: #2b2b2b; width: 12px; margin: 0px; }
        QScrollBar::handle:vertical { background-color: #555555; border-radius: 3px; min-height: 20px; }
        QScrollBar::handle:vertical:hover { background-color: #666666; }
        QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0px; } /* Hide arrows */
        QScrollBar:horizontal { border: none; background-color: #2b2b2b; height: 12px; margin: 0px; }
        QScrollBar::handle:horizontal { background-color: #555555; border-radius: 3px; min-width: 20px; }
        QScrollBar::handle:horizontal:hover { background-color: #666666; }
        QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { width: 0px; } /* Hide arrows */
        QProgressBar { border: 1px solid #555555; border-radius: 3px; background-color: #333333; color: #d4d4d4; text-align: center; }
        QProgressBar::chunk { background-color: #4682B4; } /* Progress bar color */
        QLabel { color: #d4d4d4; } /* Default label color */
        QGroupBox { border: 1px solid #555555; border-radius: 3px; margin-top: 6px; color: #d4d4d4; }
        QGroupBox::title { subcontrol-origin: margin; left: 7px; padding: 0px 3px 0px 3px; } /* Position group box title */
        QCheckBox { color: #d4d4d4; spacing: 5px; }
        QCheckBox::indicator { width: 13px; height: 13px; }
        /* Specific Styles for Buttons and Containers */
        QPushButton#clear_ref_button, QPushButton#clear_results_button { background-color: #5c4040; } /* Dark red for clear buttons */
        QPushButton#clear_ref_button:hover, QPushButton#clear_results_button:hover { background-color: #6c4a4a; border-color: #777; }
        QPushButton#stop_button { background-color: #6e4740; } /* Darker red/brown for stop button */
        QPushButton#stop_button:hover { background-color: #7e5750; border-color: #777; }
        QWidget#ref_container, QWidget#live_view_container, QWidget#results_container { background-color: #333333; } /* Set background for specific containers */
        """
        self.setStyleSheet(dark_stylesheet)

    def _connect_lane_update_signals(self):
        """Connect signals used for updating VideoProcessingLane widgets from the main thread."""
        logging.debug("Connecting lane update signals...")
        self.request_lane_frame_score_update.connect(self.handle_frame_score_for_lane, Qt.QueuedConnection)
        self.request_lane_frame_image_update.connect(self.update_video_lane_frame_image, Qt.QueuedConnection)
        self.request_lane_frame_state_update.connect(self.update_video_lane_frame_state, Qt.QueuedConnection)
        self.request_lane_best_frame.connect(self.highlight_video_lane_best_frame, Qt.QueuedConnection)
        self.request_lane_removal.connect(self.remove_video_lane, Qt.QueuedConnection) # Connect lane removal request
        self.request_lane_result_found_effect.connect(self.show_lane_result_found_effect, Qt.QueuedConnection)
        logging.debug("Lane update signals connected.")

    # --- Action Slots ---
    def perform_search(self):
        """Starts scraping when Enter is pressed in the search field (or button clicked)."""
        self.start_scraping()

    # --- Logging ---
    @pyqtSlot(str)
    def log(self, message):
        """Appends a timestamped message to the log QTextEdit and logs to file."""
        # Determine log level based on message content (simple heuristic)
        msg_lower = message.lower()
        log_level = logging.INFO
        if "error" in msg_lower or "critical" in msg_lower or "failed" in msg_lower or "fatal" in msg_lower: log_level = logging.ERROR
        elif "warning" in msg_lower or "warn" in msg_lower: log_level = logging.WARNING
        elif "debug" in msg_lower: log_level = logging.DEBUG
        # Log to file using the root logger
        logging.log(log_level, message)

        # Update GUI log widget (ensure it runs in the GUI thread)
        if self.log_text:
            if QThread.currentThread() != self.thread():
                # If called from another thread, use invokeMethod to queue the update
                QMetaObject.invokeMethod(self, "_update_log_widget", Qt.QueuedConnection, Q_ARG(str, message))
            else:
                # If already in GUI thread, call directly
                self._update_log_widget(message)

    def _update_log_widget(self, message):
        """Helper method to update the QTextEdit. MUST run in GUI thread."""
        if self.log_text and isinstance(self.log_text, QTextEdit): # Check if widget exists
             try:
                timestamp = time.strftime('%H:%M:%S')
                self.log_text.append(f"{timestamp} - {message}") # Append timestamped message
                # Auto-scroll to bottom if scrollbar is near the end
                scrollbar = self.log_text.verticalScrollBar()
                if not scrollbar or scrollbar.value() >= scrollbar.maximum() - 10: self.log_text.ensureCursorVisible()
             except Exception as e: logging.error(f"Error updating log widget: {e}", exc_info=True) # Log GUI update errors

    # --- Reference Image Handling ---
    def add_reference_images(self):
        """Opens file dialog to select reference images and starts loading tasks."""
        logging.debug("add_reference_images called")
        start_dir = self.last_dir if Path(self.last_dir).is_dir() else str(Path.home()) # Use last dir or home
        # Open file dialog allowing multiple image selections
        files, _ = QFileDialog.getOpenFileNames(self, "Select Reference Images", start_dir, "Images (*.png *.jpg *.jpeg *.bmp *.webp *.jfif)")
        if not files: return # User cancelled

        self.last_dir = str(Path(files[0]).parent) # Remember directory for next time
        self.settings.setValue("General/last_dir", self.last_dir) # Save setting

        new_file_paths = [Path(f) for f in files] # Convert strings to Path objects
        current_paths_str = set(self.reference_image_paths) # Use set for faster lookup of existing paths
        # Filter out files that are already loaded or being processed
        new_files_to_load = [p for p in new_file_paths if str(p) not in current_paths_str]

        if not new_files_to_load: self.log(f"Selected image(s) are already loaded or being processed."); return
        self.log(f"Queueing {len(new_files_to_load)} new reference image(s) for loading...")

        # --- Update Progress Bar State ---
        current_max = self.ref_progress_bar.maximum(); new_tasks = len(new_files_to_load)
        new_total = self.reference_images_in_progress + new_tasks # Total tasks including currently running
        if new_total > 0:
            # If starting a fresh batch, reset progress bar
            if self.reference_images_in_progress == 0: self.ref_progress_bar.setMaximum(new_total); self.ref_progress_bar.setValue(0)
            else: self.ref_progress_bar.setMaximum(new_total) # Otherwise, just update the maximum
            self.ref_progress_bar.setVisible(True) # Show progress bar
        else: self.ref_progress_bar.setVisible(False) # Hide if somehow total is zero

        # --- Get Current Face Recognition Settings for Tasks ---
        det_prio = self.detection_priority_combo.currentIndex(); enc_model = self.encoding_model_combo.currentText()
        jitters = self.encoding_jitters_spin.value(); max_up = self.max_upsample_spin.value()
        force_all = self.force_all_attempts_check.isChecked(); use_mutex = self.use_face_mutex_check.isChecked()

        # --- Start Loading Tasks ---
        for file_path in new_files_to_load:
            if str(file_path) in self.reference_image_paths: continue # Double check before starting task
            self.reference_images_in_progress += 1 # Increment counter *before* starting task
            # Create and configure the task
            task = ReferenceImageLoaderTask(file_path, True, self.encoding_cache, self.cache_mutex, # Pass create_pixmap=True
                                            det_prio, enc_model, jitters, max_up, force_all, use_mutex)
            # Connect task signals to main window slots
            task.signals.log_signal.connect(self.log) # Forward log messages
            task.signals.error.connect(self.handle_ref_image_error) # Handle specific errors
            task.signals.reference_image_loaded_signal.connect(self.handle_reference_image_loaded) # Handle successful load
            self.ref_load_threadpool.start(task) # Start task in the pool

    @pyqtSlot(QPixmap, str, list) # Slot receives pixmap, path string, list of numpy encodings
    def handle_reference_image_loaded(self, pixmap, file_path_str, encodings_np):
        """Handles the result of a ReferenceImageLoaderTask. Runs in the main GUI thread."""
        file_path_obj = Path(file_path_str); file_name = file_path_obj.name
        log_prefix = f"[RefLoadResult {file_name}]"; logging.debug(f"{log_prefix} Signal received. Encs: {len(encodings_np)}, Pixmap null: {pixmap.isNull()}")

        # --- Update Progress Bar ---
        task_completed = False
        if self.reference_images_in_progress > 0:
            current_max = self.ref_progress_bar.maximum()
            completed_count = current_max - self.reference_images_in_progress + 1
            self.ref_progress_bar.setValue(min(completed_count, current_max)) # Update value
            self.reference_images_in_progress -= 1; task_completed = True # Decrement counter
        else: logging.warning(f"{log_prefix} handle_reference_image_loaded called but progress counter was already 0.")

        # Check if this was the last pending task
        if task_completed and self.reference_images_in_progress == 0:
            QTimer.singleShot(500, lambda: self.ref_progress_bar.setVisible(False)) # Hide bar after short delay
            self.log("Reference image loading complete."); self.save_encoding_cache(); self.save_settings() # Save cache & settings after batch
        elif self.reference_images_in_progress < 0: # Safety reset if counter goes negative
             logging.error(f"{log_prefix} Reference progress counter went below zero!"); self.reference_images_in_progress = 0; self.ref_progress_bar.setVisible(False)

        # --- Handle Result Logic ---
        if file_path_str in self.reference_image_paths: logging.debug(f"{log_prefix} Path already processed. Ignoring duplicate signal."); return # Avoid duplicates

        # Validate results from task
        valid_encodings_list = isinstance(encodings_np, list) and all(isinstance(e, np.ndarray) for e in encodings_np)
        faces_found = valid_encodings_list and len(encodings_np) > 0
        no_faces_found = valid_encodings_list and len(encodings_np) == 0
        is_pixmap_valid = pixmap and not pixmap.isNull()

        try: # Add to UI and data based on results
            if faces_found and is_pixmap_valid: # Success: Faces found, pixmap OK
                self._add_reference_to_ui_and_data(pixmap, file_path_str, encodings_np)
            elif no_faces_found and is_pixmap_valid: # Partial Success: No faces, but pixmap OK (show placeholder with warning)
                self.log(f"{log_prefix} No face encodings found."); self._add_reference_to_ui_and_data(pixmap, file_path_str, []) # Add with empty encodings list
            elif faces_found and not is_pixmap_valid: # Failure: Encodings OK, but pixmap failed
                 self.log(f"Warning: {log_prefix} Encodings OK ({len(encodings_np)} faces), but preview failed. Skipping '{file_name}'."); self._ensure_cache_cleaned(file_path_str) # Clean cache if needed
            else: # Complete Failure: Invalid encodings OR (no faces AND no pixmap)
                 log_msg = f"Failed (invalid encodings result)." if not valid_encodings_list else f"No face found and preview failed."
                 self.log(f"{log_prefix} {log_msg} Not adding '{file_name}'."); self._ensure_cache_cleaned(file_path_str) # Clean cache
        except Exception as e: # Catch errors during UI update/data storage
            self.log(f"CRITICAL Error handling reference result for '{file_name}': {e}"); logging.exception(f"Error in handle_reference_image_loaded for {file_path_str}")
            self._remove_reference_internal(file_path_str) # Attempt cleanup if adding failed

    def _add_reference_to_ui_and_data(self, pixmap, file_path_str, encodings_np):
        """Adds a reference image's data and UI element. Assumes valid pixmap received."""
        if file_path_str in self.reference_image_paths: logging.warning(f"Attempted duplicate add: {file_path_str}"); return
        file_name = Path(file_path_str).name
        try:
            num_encs = len(encodings_np); has_faces = num_encs > 0
            self.log(f"Adding reference '{file_name}' ({num_encs} face(s)).")
            # Create UI Label for the reference thumbnail
            ref_label = QLabel(); scaled_thumb = pixmap.scaled(PLACEHOLDER_REF_ICON_SIZE, Qt.KeepAspectRatio, Qt.SmoothTransformation)
            ref_label.setPixmap(scaled_thumb); ref_label.setFixedSize(PLACEHOLDER_REF_ICON_SIZE + QSize(5, 5)); ref_label.setAlignment(Qt.AlignCenter)
            # Style based on whether faces were found
            base_style = "QLabel { border-radius: 3px; background-color: #444; margin: 2px; }"; border_style = f"border: 1px dashed {COLOR_NO_FACE.name()};" if not has_faces else "border: 1px solid #666;"
            ref_label.setStyleSheet(base_style + border_style)
            # Set tooltip
            tooltip_text = f"Path: {file_path_str}\n({num_encs} face(s) found)" if has_faces else f"Path: {file_path_str}\n(No faces found)"
            ref_label.setToolTip(tooltip_text + "\nRight-click to remove."); ref_label.setContextMenuPolicy(Qt.CustomContextMenu)
            # Connect context menu request
            ref_label.customContextMenuRequested.connect(lambda pos, p=file_path_str: self.show_ref_context_menu(p, pos))
            # Store Data & Add UI Element
            self.ref_container_layout.addWidget(ref_label); self.reference_labels[file_path_str] = ref_label # Add widget to layout and dict
            self.reference_pixmaps.append(pixmap); self.reference_encodings_all.append(encodings_np); self.reference_image_paths.append(file_path_str) # Add data to lists
        except Exception as e: # Catch errors during UI/data update
            self.log(f"CRITICAL Error adding reference UI/data for '{file_name}': {e}"); logging.exception(f"Error in _add_reference_to_ui_and_data for {file_path_str}")
            self._remove_reference_internal(file_path_str) # Attempt cleanup if adding failed

    def show_ref_context_menu(self, file_path_str, position):
        """Shows context menu for a reference image label (Remove option)."""
        if file_path_str not in self.reference_labels: return # Check if label exists
        label = self.reference_labels[file_path_str]; menu = QMenu(self); file_name = Path(file_path_str).name
        remove_action = menu.addAction(QIcon.fromTheme("edit-delete", QIcon()), f"Remove '{file_name}'")
        action = menu.exec_(label.mapToGlobal(position)) # Show menu at cursor position
        if action == remove_action: # Handle remove action
            self._remove_reference_internal(file_path_str); self.save_settings(); self.save_encoding_cache() # Remove and save changes

    def _remove_reference_internal(self, file_path_str):
         """Removes a reference image from UI, data lists, and cache. Internal helper."""
         file_name = Path(file_path_str).name; logging.debug(f"Internal remove ref called for: {file_path_str}")
         removed_from_ui = False; removed_from_data = False
         # Remove from UI (Label dictionary and layout)
         if file_path_str in self.reference_labels:
             label = self.reference_labels.pop(file_path_str)
             try: self.ref_container_layout.removeWidget(label); label.deleteLater(); removed_from_ui = True
             except RuntimeError: removed_from_ui = True # Assume gone if deletion error (already deleted?)
             except Exception as e: logging.error(f"Error removing ref label '{file_name}' UI: {e}")
         # Remove from Data Lists (Paths, Pixmaps, Encodings)
         if file_path_str in self.reference_image_paths:
             try:
                 idx = self.reference_image_paths.index(file_path_str) # Find index
                 self.reference_image_paths.pop(idx) # Remove path
                 if idx < len(self.reference_pixmaps): self.reference_pixmaps.pop(idx) # Remove corresponding pixmap
                 if idx < len(self.reference_encodings_all): self.reference_encodings_all.pop(idx) # Remove corresponding encodings
                 removed_from_data = True; self.log(f"Removed reference data: {file_name}")
             except ValueError: pass # Path might have already been removed
             except Exception as e: logging.error(f"Error removing ref data for {file_name}", exc_info=True); self.log(f"Error removing ref data: {e}")
         # Remove from Cache (Thread-safe)
         self._ensure_cache_cleaned(file_path_str)
         if removed_from_ui or removed_from_data: logging.info(f"Reference '{file_name}' removal processed (UI: {removed_from_ui}, Data: {removed_from_data}).")

    def _ensure_cache_cleaned(self, file_path_str):
        """Removes a specific path from the encoding cache (thread-safe)."""
        if self.cache_mutex:
            with QMutexLocker(self.cache_mutex):
                 if file_path_str in self.encoding_cache:
                     try: del self.encoding_cache[file_path_str]; logging.debug(f"Removed '{Path(file_path_str).name}' from cache.")
                     except KeyError: pass # Already removed, ignore

    @pyqtSlot(str)
    def handle_ref_image_error(self, message):
        """Logs errors reported by ReferenceImageLoaderTask, distinguishing info from errors."""
        # Treat common non-critical messages as info/warnings
        if "No face found" in message or "All image loading methods failed" in message: self.log(f"Reference Info: {message}") # Log as info
        else: self.log(f"Reference Image Error: {message}"); logging.error(f"ReferenceImageLoaderTask Error: {message}") # Log genuine errors

    def confirm_clear_references(self):
        """Confirms with the user before clearing all reference images."""
        if not self.reference_image_paths: QMessageBox.information(self, "Clear References", "No reference images are currently loaded."); return
        reply = QMessageBox.question(self, 'Confirm Clear References', "This will remove all loaded reference images and clear the face encoding cache.\n\nAre you sure?", QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
        if reply == QMessageBox.Yes: self.clear_references()

    def clear_references(self):
        """Clears all reference images, UI elements, data lists, and the encoding cache."""
        self.log("Clearing all reference images and cache...")
        # Stop any ongoing reference loading tasks
        if hasattr(self.ref_load_threadpool, 'clear'): self.ref_load_threadpool.clear(); self.ref_load_threadpool.waitForDone(1000) # Clear pool and wait briefly
        self.reference_images_in_progress = 0; self.ref_progress_bar.setVisible(False); self.ref_progress_bar.setValue(0) # Reset progress
        # Remove all references using the internal removal function
        paths_to_remove = list(self.reference_image_paths) # Copy list before iterating
        for path_str in paths_to_remove: self._remove_reference_internal(path_str)
        # Explicitly clear data lists for safety (should be empty after loop)
        self.reference_pixmaps.clear(); self.reference_encodings_all.clear(); self.reference_image_paths.clear(); self.reference_labels.clear()
        # Clear encoding cache (thread-safe)
        if self.cache_mutex:
            with QMutexLocker(self.cache_mutex): self.encoding_cache.clear()
        self._set_ref_placeholder(); self.log("All reference images and cache cleared.")
        self.save_settings(); self.save_encoding_cache() # Save the cleared state

    # --- Getters for Settings ---
    def get_threshold(self): return float(self.threshold_spin.value()) if self.threshold_spin else 0.75
    def get_frames_for_recognition(self): return [i + 1 for i, cb in enumerate(self.frame_recognition_checkboxes) if cb and cb.isChecked()]
    def get_frames_for_animation(self): return [i + 1 for i, cb in enumerate(self.frame_animation_checkboxes) if cb and cb.isChecked()]
    def is_performance_mode(self): return self.perf_mode_check.isChecked() if self.perf_mode_check else False
    def get_encoding_model(self): return self.encoding_model_combo.currentText() if self.encoding_model_combo else "small"
    def get_num_jitters(self): return self.encoding_jitters_spin.value() if self.encoding_jitters_spin else 1
    def get_max_upsample(self): return self.max_upsample_spin.value() if self.max_upsample_spin else 1

    # --- Scraping Control ---
    def update_stats_display(self):
        """Updates the statistics display labels in the UI. Runs via QTimer."""
        # If no stats object (not scraping), reset labels to default
        if not self.processing_stats:
            if self.page_progress_label: self.page_progress_label.setText("P: -/-")
            if self.video_progress_label: self.video_progress_label.setText("V: -/-")
            if self.frame_time_label: self.frame_time_label.setText("Frame: - ms (50: - ms)")
            if self.video_time_label: self.video_time_label.setText("Video: - s (50: - s)")
            if self.runtime_label: self.runtime_label.setText("Time: -")
            return
        try: # Get stats and update labels
            stats = self.processing_stats.get_stats() # Get latest stats dictionary
            if self.page_progress_label: self.page_progress_label.setText(f"{stats['page_progress']}")
            if self.video_progress_label: self.video_progress_label.setText(f"{stats['video_progress']}")
            # Format frame time (ms) and video time (s)
            if self.frame_time_label: self.frame_time_label.setText(f"{stats['avg_frame_time']:.0f}ms(50:{stats['avg_frame_time_50']:.0f}ms)"); self.frame_time_label.setToolTip(f"Avg Frame Time (Total Processed: {stats['total_frames_processed']})")
            if self.video_time_label: self.video_time_label.setText(f"{stats['avg_video_time']/1000:.1f}s(50:{stats['avg_video_time_50']/1000:.1f}s)"); self.video_time_label.setToolTip(f"Avg Video Time (Total Processed: {stats['total_videos_processed']})")
            if self.runtime_label: self.runtime_label.setText(f"{stats['total_runtime']}")
        except Exception as e: logging.error(f"Error updating stats display: {e}", exc_info=True) # Log errors during update

    def start_scraping(self):
        """Validates settings and starts the main scraping process in ScraperThread."""
        logging.info("start_scraping method called!")
        if self.scraper_thread and self.scraper_thread.isRunning(): self.log("Scraping is already in progress."); return

        # --- Pre-flight Checks ---
        # 1. Check for valid reference faces
        valid_refs_exist = any(isinstance(enc_list, list) and len(enc_list) > 0 for enc_list in self.reference_encodings_all)
        if not valid_refs_exist: QMessageBox.warning(self,"Missing Reference Faces","Please add reference image(s) with detectable faces before starting."); return
        # 2. Check required settings
        search_text = self.search_edit.text().strip(); frames_rec = self.get_frames_for_recognition(); start_page = self.start_page_spin.value(); end_page_val = self.end_page_spin.value()
        if not search_text: QMessageBox.warning(self,"Missing Search Term","Please enter a search term."); return
        if not frames_rec: QMessageBox.warning(self,"Missing Frame Selection","Please select at least one frame number to analyze."); return
        # 3. Check page range validity
        if end_page_val != -1 and start_page > end_page_val: QMessageBox.warning(self,"Invalid Page Range","Start page cannot be greater than end page (unless end page is Infinite)."); return

        # --- Prepare for Start ---
        self.log("Saving settings before starting scrape..."); self.save_settings() # Save current settings
        self.processing_stats = ProcessingStats(); self.stats_update_timer.start() # Create new stats object and start timer
        # Get settings to pass to the thread
        end_page_for_thread = 99999 if end_page_val == -1 else end_page_val # Use large number for "infinity"
        analyzer_threads = self.analyzer_threads_spin.value(); ref_load_threads = self.ref_load_threads_spin.value()
        self.ref_load_threadpool.setMaxThreadCount(ref_load_threads) # Ensure ref pool size is current
        enc_model = self.get_encoding_model(); jitters = self.get_num_jitters(); max_up = self.get_max_upsample()
        det_prio = self.detection_priority_combo.currentIndex(); force_all = self.force_all_attempts_check.isChecked()
        use_mutex = self.use_face_mutex_check.isChecked()

        end_page_log = "∞" if end_page_val == -1 else str(end_page_val)
        self.log(f"Starting scrape: '{search_text}' Pages {start_page}-{end_page_log}... (AnalyzerThr: {analyzer_threads})")

        # Update UI state for running process
        self.status_label.setText("Initializing scraper..."); self.progress_bar.setMaximum(0); self.progress_bar.setValue(0) # Indeterminate progress initially
        self.start_button.setEnabled(False); self.pause_button.setEnabled(True); self.pause_button.setText("❚❚ Pause"); self.stop_button.setEnabled(True)
        self.clear_live_view_lanes(); self.no_lanes_label.setVisible(True); self._set_ref_placeholder() # Reset live view
        if self.processing_stats: self.processing_stats.update_video_queue(0, 0) # Reset video counts in stats

        # --- Create and Start Scraper Thread ---
        try:
            self.scraper_thread = ScraperThread( # Instantiate the thread
                search_text=search_text, start_page=start_page, end_page=end_page_for_thread,
                frames_for_recognition=frames_rec, frames_for_animation=self.get_frames_for_animation(),
                headless=self.headless_check.isChecked(), reference_encodings_all=self.reference_encodings_all, # Pass by reference
                threshold=self.get_threshold(), reference_pixmaps=self.reference_pixmaps, # Pass by reference
                known_results_urls=self.known_results_urls.copy(), # Pass a copy of known URLs
                analyzer_max_threads=analyzer_threads, detection_priority=det_prio,
                encoding_model=enc_model, num_jitters=jitters, max_upsample=max_up,
                force_all_attempts=force_all, use_mutex=use_mutex,
                parent=self, processing_stats_ref=self.processing_stats # Pass reference to self and stats
            )
            # --- Connect signals FROM scraper thread TO main window slots ---
            # Use QueuedConnection where UI updates or data access might cross threads
            self.scraper_thread.log_signal.connect(self.log) # Log messages
            self.scraper_thread.task_error_signal.connect(self.log) # Log errors from worker tasks
            self.scraper_thread.status_signal.connect(self.status_label.setText) # Update status bar
            self.scraper_thread.progress_signal.connect(self.update_progress) # Update progress bar
            self.scraper_thread.final_result_signal.connect(self.add_result, Qt.QueuedConnection) # Add found results
            self.scraper_thread.error_signal.connect(self.handle_scraper_error, Qt.QueuedConnection) # Handle critical errors from scraper
            self.scraper_thread.finished_signal.connect(self.scraping_finished, Qt.QueuedConnection) # Handle thread finish
            self.scraper_thread.current_reference_image_signal.connect(self.update_current_reference_image_display, Qt.QueuedConnection) # Update ref image display
            self.scraper_thread.processed_frame_score_signal.connect(self.handle_processed_frame_score, Qt.QueuedConnection) # Handle scores for lane updates
            self.scraper_thread.video_found_signal.connect(self.handle_video_found, Qt.QueuedConnection) # Handle newly found videos
            self.scraper_thread.video_processing_finished_signal.connect(self.handle_video_processing_finished, Qt.QueuedConnection) # Handle video completion
            # Connect signals for direct lane updates (relayed via main window signals)
            self.scraper_thread.frame_status_update_signal.connect(self.update_video_lane_frame_state, Qt.QueuedConnection)
            self.scraper_thread.frame_image_loaded_signal.connect(self.update_video_lane_frame_image, Qt.QueuedConnection)
            self.scraper_thread.lane_result_found_signal.connect(self.request_lane_result_found_effect, Qt.QueuedConnection)
            # Connect stats signal (forwarded from analysis task via scraper)
            self.scraper_thread.frame_processing_time_signal.connect(self.handle_frame_processing_time_signal, Qt.QueuedConnection)

            self.scraper_thread.start(); self.status_label.setText("Scraping started...") # Start the thread
        except Exception as e: # Catch errors during thread creation/start
            self.log(f"FATAL ERROR starting scraper thread: {e}"); logging.critical("Failed to create/start ScraperThread", exc_info=True)
            QMessageBox.critical(self, "Scraper Error", f"Failed to start scraping process:\n{e}"); self.scraping_finished(None, "Start Error") # Reset UI
            if self.stats_update_timer and self.stats_update_timer.isActive(): self.stats_update_timer.stop(); self.processing_stats = None # Stop stats if started

    def toggle_pause_scraping(self):
        """Pauses or resumes the scraper thread."""
        if not self.scraper_thread or not self.scraper_thread.isRunning(): return # Do nothing if not running
        # Check internal pause state of the thread
        if self.scraper_thread._is_paused: # If paused, resume
            self.scraper_thread.resume(); self.pause_button.setText("❚❚ Pause"); self.status_label.setText("Resuming..."); self.log("Resuming scraping.")
        else: # If running, pause
            self.scraper_thread.pause(); self.pause_button.setText("▶ Resume"); self.status_label.setText("Pausing..."); self.log("Pausing scraping.")

    def stop_scraping(self, confirm=True):
        """Stops the scraper thread, optionally with user confirmation."""
        # Check if thread exists and is running
        if not self.scraper_thread or not self.scraper_thread.isRunning():
            self.scraping_finished(None, "Not Running") # Reset UI if already stopped
            return
        # Ask for confirmation if requested
        if confirm:
            reply = QMessageBox.question(self, 'Confirm Stop', "Are you sure you want to stop the current scraping process?", QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
            if reply == QMessageBox.No: return # User cancelled
        # Proceed with stopping
        self.log("Stopping scraping process..."); self.status_label.setText("Stopping...")
        # Disable pause/stop buttons, update pause button text
        self.pause_button.setEnabled(False); self.pause_button.setText("❚❚ Pause"); self.stop_button.setEnabled(False)
        self.scraper_thread.stop() # Request the thread to stop

    @pyqtSlot(object, str) # Slot receives the thread object and a context string
    def scraping_finished(self, thread_obj=None, context=""):
        """Slot called when the scraper thread finishes or is stopped."""
        # Stop the stats update timer if it's running
        if self.stats_update_timer and self.stats_update_timer.isActive(): self.stats_update_timer.stop()

        # Determine if the thread was stopped manually or finished naturally
        was_stopped = False; check_thread = thread_obj if thread_obj else self.scraper_thread # Use provided thread or stored ref
        if check_thread and hasattr(check_thread, '_stop_requested') and hasattr(check_thread, 'mutex'):
             try: # Safely check the thread's internal stop flag
                 with QMutexLocker(check_thread.mutex): was_stopped = check_thread._stop_requested
             except Exception as e: was_stopped = True; logging.warning(f"Could not access ScraperThread state on finish: {e}") # Assume stopped if state inaccessible
        elif check_thread: was_stopped = True # Assume stopped if thread object exists but state is weird

        status_msg = "Stopped" if was_stopped else "Finished"
        self.log(f"Scraping process {status_msg.lower()}. Context: {context if context else 'N/A'}")
        if self.status_label: self.status_label.setText(f"Scraping {status_msg}")

        # Reset UI elements to idle state
        if self.start_button: self.start_button.setEnabled(True)
        if self.pause_button: self.pause_button.setEnabled(False); self.pause_button.setText("❚❚ Pause")
        if self.stop_button: self.stop_button.setEnabled(False)
        if self.progress_bar: self.progress_bar.setMaximum(100); self.progress_bar.setValue(0) # Reset progress bar
        self._set_ref_placeholder() # Reset reference image display

        # Clear stats object *after* potential final updates and reset display
        self.processing_stats = None; self.update_stats_display()

        # Clear thread reference if it matches the one that finished
        if check_thread == self.scraper_thread: self.scraper_thread = None; logging.info("Scraper thread reference cleared.")

    @pyqtSlot(str) # Slot receives error message string
    def handle_scraper_error(self, message):
        """Handles critical errors reported by the scraper thread."""
        self.log(f"Scraper FATAL Error: {message}"); logging.critical(f"Critical error from ScraperThread: {message}")
        # Optionally show a message box, but might be annoying if errors are frequent
        # QMessageBox.critical(self, "Scraping Error", f"Critical error during scraping:\n\n{message}\n\nCheck log file.")
        self.scraping_finished(None, "Critical Error") # Reset UI state

    @pyqtSlot(int, int) # Slot receives current value, total value
    def update_progress(self, current, total):
        """Updates the main progress bar (typically for page scraping progress)."""
        is_scraping = self.scraper_thread and self.scraper_thread.isRunning()
        if not self.progress_bar: return # Check if progress bar exists

        if is_scraping: # Only update if scraping is active
            if total > 0: # Definite progress (e.g., items on a page)
                if self.progress_bar.maximum() != total: self.progress_bar.setMaximum(total) # Update max if changed
                self.progress_bar.setValue(min(current, total)) # Set current value (ensure not over max)
            else: # Indeterminate progress (e.g., initializing, between pages)
                if self.progress_bar.maximum() != 0: self.progress_bar.setMaximum(0) # Set to indeterminate mode
                self.progress_bar.setValue(0)
        else: # Not scraping, reset bar to default state
             if self.progress_bar.maximum() != 100 or self.progress_bar.value() != 0:
                 self.progress_bar.setMaximum(100); self.progress_bar.setValue(0)

    # --- Result Handling ---
    def set_sort_order(self, key):
        """Sets the sorting key ('score' or 'timestamp_added') and updates UI."""
        reverse = True # Always sort descending for both score and date
        if key == self.current_sort_key and reverse == self.current_sort_reverse: return # No change needed
        self.current_sort_key = key; self.current_sort_reverse = reverse
        # Update button check states
        if self.sort_score_button: self.sort_score_button.setChecked(key == 'score')
        if self.sort_date_button: self.sort_date_button.setChecked(key == 'timestamp_added')
        self.log(f"Set results sort: {key} (Descending)")
        self.sort_results_view() # Re-sort the displayed widgets

    def sort_results_view(self):
        """Sorts the ResultWidgets currently displayed in the results layout."""
        widgets = []; spacer = None; layout = self.results_container_layout
        if not layout: return # Safety check
        # Extract existing ResultWidgets and the spacer/stretch item
        for i in reversed(range(layout.count())):
            item = layout.itemAt(i); widget = item.widget()
            if isinstance(widget, ResultWidget): widgets.append(layout.takeAt(i).widget()) # Remove and store widget
            elif isinstance(item, QSpacerItem): spacer = layout.takeAt(i) # Remove and store spacer
            elif widget: layout.takeAt(i); widget.deleteLater() # Remove and delete unexpected items
        widgets.reverse() # Widgets were extracted in reverse order

        # Define the sorting function based on the current key
        def sort_func(w):
            val = w.result_data.get(self.current_sort_key, 0) # Get value from result data
            try: return float(val) if val is not None else 0.0 # Convert to float for sorting, default 0
            except (ValueError, TypeError): return 0.0 # Handle non-numeric values
        # Sort the extracted widgets
        try: widgets.sort(key=sort_func, reverse=self.current_sort_reverse)
        except Exception as e: self.log(f"Error sorting results view: {e}"); logging.exception("Sorting error")

        # Re-add sorted widgets to the layout
        for widget in widgets: layout.addWidget(widget)
        # Re-add spacer or stretch at the end
        if spacer: layout.addSpacerItem(spacer)
        else: layout.addStretch(1)
        # self.log(f"Results view sorted.") # Optional log, can be verbose

    @pyqtSlot(dict) # Slot receives the result data dictionary
    def add_result(self, result_data):
        """Adds a new result to the UI (sorted) and internal data structures."""
        video_url = result_data.get('videoURL')
        if not video_url: self.log("Error: Received result without video URL."); logging.error("Result data missing 'videoURL'"); return
        # Check if result already exists (in data or UI)
        if video_url in self.current_results_data: self.log(f"Info: Result for ...{video_url[-20:]} already exists. Ignoring."); return

        title = result_data.get('videoTitle', 'Unknown')[:40]; score = result_data.get('score', 0.0)
        self.log(f"Result Found: '{title}' (Score: {score:.3f})")
        # Ensure timestamp exists
        if 'timestamp_added' not in result_data: result_data['timestamp_added'] = time.time()

        # --- Handle Thumbnail Saving ---
        thumbnail_qimage = result_data.pop("thumbnailQImageData", None) # Get QImage, remove from dict
        thumbnail_path = None # Path where thumbnail is saved (or None)
        if thumbnail_qimage and isinstance(thumbnail_qimage, QImage) and not thumbnail_qimage.isNull():
            try:
                # Create a safe filename based on URL and timestamp
                base_name = Path(video_url).name if video_url else "video"
                safe_slug = re.sub(r'[\\/*?:"<>|]+', "_", base_name) # Remove invalid chars
                ts_int = int(result_data['timestamp_added'])
                filename = f"thumb_{safe_slug[:50]}_{ts_int}.png" # Limit length
                THUMBNAILS_DIR.mkdir(parents=True, exist_ok=True) # Ensure dir exists
                save_path = THUMBNAILS_DIR / filename
                # Save QImage as PNG
                if thumbnail_qimage.save(str(save_path), "PNG"):
                    thumbnail_path = str(save_path); logging.info(f"Thumbnail saved: {filename}")
                else: self.log(f"ERROR: Failed to save thumbnail: {filename}"); logging.error(f"QImage.save() failed for path: {save_path}")
            except Exception as e: self.log(f"ERROR saving thumbnail: {e}"); logging.exception("Thumbnail save error")
        # Store the final path (string or None) back into the result data
        result_data['thumbnailPath'] = thumbnail_path

        # --- Update Data Structures ---
        self.current_results_data[video_url] = result_data # Add to main results dictionary
        self.known_results_urls.add(video_url) # Add URL to set of known results

        # --- Create and Add Widget to UI ---
        try:
            widget = ResultWidget(result_data, main_window=self) # Create the widget
            widget.delete_requested.connect(self.remove_result_widget) # Connect its delete signal
            self._insert_widget_sorted(widget) # Insert into layout maintaining sort order
        except Exception as e: # Handle errors creating the widget
             self.log(f"Error creating result widget for {title}: {e}"); logging.exception(f"Failed to create ResultWidget for {video_url}")
             # Clean up data if widget creation failed
             if video_url in self.current_results_data: del self.current_results_data[video_url]
             self.known_results_urls.discard(video_url); return

        # Ensure correct lane state (highlight best frame) if result found
        best_frame = result_data.get('frameNumberWithHighestScore')
        if best_frame is not None and best_frame > 0: self.request_lane_best_frame.emit(video_url, best_frame)
        # Save results to file
        self.save_results()

    def _insert_widget_sorted(self, widget_to_insert):
        """Inserts a ResultWidget into the results layout, maintaining the current sort order."""
        if not isinstance(widget_to_insert, ResultWidget): return # Only insert ResultWidgets
        # Define local sort function based on current settings
        def sort_func_local(w):
            val = w.result_data.get(self.current_sort_key, 0)
            try: return float(val) if val is not None else 0.0
            except: return 0.0
        # Get the value of the widget to insert
        new_value = sort_func_local(widget_to_insert); insert_index = 0
        layout = self.results_container_layout; num_items = layout.count()
        # Iterate through existing items to find the correct insertion point
        for i in range(num_items):
            item = layout.itemAt(i)
            # Check for spacer at the end (insert before it)
            if i == num_items - 1 and isinstance(item, QSpacerItem): insert_index = i; break
            existing_widget = item.widget()
            if isinstance(existing_widget, ResultWidget): # Compare with existing ResultWidgets
                existing_value = sort_func_local(existing_widget)
                # Determine if the new widget should be inserted before the current one based on sort order
                should_insert_before = (existing_value < new_value) if self.current_sort_reverse else (existing_value > new_value)
                if should_insert_before: insert_index = i; break # Found position, break loop
                else: insert_index = i + 1 # Continue checking next position
            elif item and not isinstance(item, QSpacerItem): # Insert before any unexpected non-spacer item
                insert_index = i; break
        # Insert the widget at the determined index
        layout.insertWidget(insert_index, widget_to_insert)

    @pyqtSlot(object) # Slot receives the ResultWidget instance that emitted the signal
    def remove_result_widget(self, widget_to_remove):
        """Removes a specific ResultWidget from the UI and internal data."""
        if not isinstance(widget_to_remove, ResultWidget): return # Ensure it's the correct type
        # Get data from the widget before removing it
        video_url = widget_to_remove.result_data.get('videoURL'); title = widget_to_remove.result_data.get('videoTitle', 'Unknown')
        thumbnail_path_str = widget_to_remove.result_data.get('thumbnailPath'); thumbnail_path = Path(thumbnail_path_str) if thumbnail_path_str else None
        if not video_url: logging.error("Cannot remove result: Missing video URL in widget data."); return

        self.log(f"Removing result: '{title}' (...{video_url[-10:]})")
        # Remove from data structures
        if video_url in self.current_results_data: del self.current_results_data[video_url]
        self.known_results_urls.discard(video_url)
        # Remove widget from layout
        try: self.results_container_layout.removeWidget(widget_to_remove)
        except Exception as e: logging.error(f"Error removing result widget from layout: {e}")
        widget_to_remove.cleanup() # Trigger widget's cleanup (calls deleteLater)

        # Delete associated thumbnail file if it exists
        if thumbnail_path and thumbnail_path.exists():
            try: thumbnail_path.unlink(); logging.info(f"Deleted thumbnail file: {thumbnail_path.name}")
            except OSError as e: self.log(f"Error deleting thumbnail file {thumbnail_path.name}: {e}"); logging.error(f"Error deleting thumbnail file: {thumbnail_path}", exc_info=True)
        # Save the updated results data
        self.save_results()

    def confirm_clear_results(self):
        """Confirms with the user before clearing all results."""
        if not self.current_results_data: QMessageBox.information(self, "Clear Results", "There are no results to clear."); return
        reply = QMessageBox.question(self, 'Confirm Clear All Results', f"This will permanently delete all {len(self.current_results_data)} results and their associated thumbnail files.\n\nAre you sure?", QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
        if reply == QMessageBox.Yes: self.clear_results()

    def clear_results(self):
        """Clears all results from UI, internal data, and deletes all thumbnail files."""
        self.log("Clearing all results...")
        widgets_to_remove = []; paths_to_delete = []
        layout = self.results_container_layout
        if not layout: return # Safety check
        # Iterate backwards through layout items
        for i in reversed(range(layout.count())):
            item = layout.itemAt(i); widget = item.widget()
            if isinstance(widget, ResultWidget): # If it's a result widget
                widgets_to_remove.append(widget) # Add to removal list
                thumb_path_str = widget.result_data.get('thumbnailPath') # Get thumbnail path
                if thumb_path_str:
                    try: paths_to_delete.append(Path(thumb_path_str)) # Add Path object to delete list
                    except Exception as path_err: logging.error(f"Bad thumbnail path during clear: {thumb_path_str} - {path_err}")
            elif not isinstance(item, QSpacerItem): # Remove unexpected non-spacer items
                layout.takeAt(i); widget.deleteLater()

        # Remove widgets from layout and trigger cleanup
        self.log(f"Removing {len(widgets_to_remove)} result widgets from UI...")
        for widget in widgets_to_remove: layout.removeWidget(widget); widget.cleanup()

        # Delete thumbnail files
        deleted_thumbs = 0; errors_thumbs = 0
        if paths_to_delete: self.log(f"Deleting {len(paths_to_delete)} thumbnail files...")
        for path in paths_to_delete:
             if path and path.exists(): # Check again if path exists before deleting
                 try: path.unlink(); deleted_thumbs += 1
                 except OSError as e: # Log errors but continue
                     self.log(f"Error deleting thumbnail file {path.name}: {e}")
                     logging.error(f"Error deleting thumbnail {path}: {e}", exc_info=True); errors_thumbs += 1
             elif path: logging.warning(f"Thumbnail path found in data but file doesn't exist during clear: {path}")

        if deleted_thumbs > 0: self.log(f"Deleted {deleted_thumbs} thumbnail files.")
        if errors_thumbs > 0: self.log(f"Failed to delete {errors_thumbs} thumbnail files (check permissions/log).")

        # --- Clear Data Structures ---
        self.current_results_data.clear()
        self.known_results_urls.clear()

        # --- Save Empty Results File ---
        self.save_results() # Save the now empty results data
        self.log("All results cleared.")

    def clear_results_ui_only(self):
         """Clears only the UI widgets from the results list, keeping data intact."""
         logging.debug("Clearing results UI only.")
         widgets_removed_count = 0; layout = self.results_container_layout
         if not layout: return # Guard against missing layout
         num_items = layout.count(); if num_items <= 1: return # Only spacer or empty
         # Iterate backwards, excluding the final spacer/stretch item
         for i in reversed(range(num_items - 1)):
             item = layout.itemAt(i); widget = item.widget()
             if isinstance(widget, ResultWidget): # If it's a result widget
                 taken_item = layout.takeAt(i) # Remove item from layout
                 if taken_item: widget.cleanup(); widgets_removed_count += 1 # Trigger cleanup
             elif widget: # Handle unexpected widgets
                  taken_item = layout.takeAt(i);
                  if taken_item: widget.deleteLater() # Remove and delete
         logging.debug(f"Removed {widgets_removed_count} result widgets from UI.")

    # --- Live View Methods ---
    @pyqtSlot(QPixmap, int) # Slot receives pixmap and reference index
    def update_current_reference_image_display(self, pixmap, ref_index):
        """Updates the display showing the last reference image that resulted in a high score."""
        if not self.ref_image_display_label: return # Check if label exists

        # Create tooltip text
        tooltip = f"Last Compared Ref: #{ref_index + 1}"
        if 0 <= ref_index < len(self.reference_image_paths): # Add filename if possible
            try: tooltip += f"\n{Path(self.reference_image_paths[ref_index]).name}"
            except Exception: tooltip += "\n(Error getting name)"
        elif ref_index != -1: tooltip += "\n(Unknown Path)" # Handle invalid index
        else: tooltip = "Last reference face compared" # Default tooltip

        # Handle visibility and content based on performance mode
        perf_mode = self.is_performance_mode()
        self.ref_image_display_label.setVisible(not perf_mode) # Hide in performance mode

        if not perf_mode: # Normal mode: Show image or placeholder
            if pixmap and not pixmap.isNull():
                # Scale pixmap to fit label, preserving aspect ratio
                scaled = pixmap.scaled(self.ref_image_display_label.size() - QSize(2,2), Qt.KeepAspectRatio, Qt.SmoothTransformation)
                self.ref_image_display_label.setPixmap(scaled); self.ref_image_display_label.setText("") # Clear text
            else: self._set_ref_placeholder() # Reset to placeholder if pixmap is invalid
        else: # Performance mode: Show text indication
            self.ref_image_display_label.clear(); self.ref_image_display_label.setText("Ref\n(Off)")

        self.ref_image_display_label.setToolTip(tooltip) # Update tooltip

    @pyqtSlot(str, dict) # Slot receives video_url and video_info dictionary
    def handle_video_found(self, video_url, video_info):
        """Handles newly found videos: Adds to pending queue or starts lane immediately."""
        # Check if already processing or in results (basic check, ScraperThread also checks)
        if video_url in self.active_lanes or video_url in self.current_results_data:
            logging.debug(f"Video ...{video_url[-10:]} already active/found. Ignoring.")
            return

        # Update stats for total videos found and queued count
        if self.processing_stats:
             self.processing_stats.increment_total_videos_found() # Increment total count
             # Update queued count (includes active lanes + pending queue + this new one)
             proc = self.processing_stats.processed_videos # Read needs lock? Assumed okay for now from GUI thread
             queued = len(self.active_lanes) + len(self.pending_lanes) + 1
             self.processing_stats.update_video_queue(proc, queued)

        # If space available in live view, add lane immediately
        if len(self.active_lanes) < MAX_ACTIVE_LANES:
            self.add_video_lane(video_url, video_info)
        else: # Otherwise, add to the pending queue
            self.pending_lanes.append((video_url, video_info))
            logging.debug(f"Max lanes ({MAX_ACTIVE_LANES}) reached. Added ...{video_url[-10:]} to pending queue (size: {len(self.pending_lanes)}).")
            if self.status_label: self.status_label.setText(f"Processing... ({len(self.pending_lanes)} pending)") # Update status

    @pyqtSlot(str, dict) # Slot receives video_url and video_info dictionary
    def add_video_lane(self, video_url, video_info):
        """Adds a new VideoProcessingLane widget to the live view and queues analysis."""
        if video_url in self.active_lanes: logging.warning(f"Attempted to add duplicate lane: {video_url}"); return

        title = video_info.get('videoTitle', 'Unknown Video')[:40] # Truncate title
        self.log(f"Live View: Adding lane for '{title}'...")

        # Handle UI changes when the first lane is added (remove placeholder/stretch)
        if not self.active_lanes:
            self.no_lanes_label.setVisible(False) # Hide placeholder text
            # Remove the final stretch item if it exists
            count = self.live_view_lanes_layout.count()
            if count > 0:
                last_item = self.live_view_lanes_layout.itemAt(count - 1)
                if isinstance(last_item, QSpacerItem): self.live_view_lanes_layout.removeItem(last_item)

        try: # Create and add the lane widget
            lane = VideoProcessingLane(video_url=video_url, video_info=video_info, frames_to_analyze=self.get_frames_for_recognition(),
                                       performance_mode=self.is_performance_mode(), parent=self.live_view_container, main_window_ref=self)
            self.live_view_lanes_layout.insertWidget(0, lane) # Insert new lane at the top
            self.active_lanes[video_url] = lane # Add to active lanes dictionary

            # Queue the analysis tasks for this new lane via the scraper thread
            if self.scraper_thread and self.scraper_thread.isRunning():
                # Use invokeMethod to safely call the slot in the ScraperThread's event loop
                QMetaObject.invokeMethod(self.scraper_thread, "queue_video_for_analysis", Qt.QueuedConnection,
                                         Q_ARG(str, video_url), Q_ARG(dict, video_info))
            else: logging.warning(f"Scraper thread not running, cannot queue analysis for {video_url}")

        except Exception as e: # Handle errors creating the lane widget
            self.log(f"ERROR creating video lane for ...{video_url[-10:]}: {e}")
            logging.critical(f"Failed to create VideoProcessingLane for {video_url}", exc_info=True)
            # Restore placeholder if no lanes ended up being added
            if not self.active_lanes:
                 self.no_lanes_label.setVisible(True)
                 count = self.live_view_lanes_layout.count()
                 if count == 0 or not isinstance(self.live_view_lanes_layout.itemAt(count - 1), QSpacerItem):
                      self.live_view_lanes_layout.addStretch(1)

    @pyqtSlot(str, int, int, float) # Receives video_url, frame_num, ref_idx, score
    def handle_processed_frame_score(self, video_url, frame_num, ref_idx, score):
        """Slot to receive frame scores from ScraperThread and relay to the appropriate lane."""
        # This signal originates from ScraperThread, runs in ScraperThread.
        # It emits another signal connected to a slot in the MainWindow (GUI thread).
        # logging.debug(f"Relaying score from scraper: {video_url[-5:]} F:{frame_num} S:{score:.3f}") # VERY Verbose
        self.request_lane_frame_score_update.emit(video_url, frame_num, score) # Emit signal for GUI thread

    @pyqtSlot(str, int, float) # Receives video_url, frame_number, score
    def handle_frame_score_for_lane(self, video_url, frame_number, score):
        """Updates the visual state (score display) of a specific frame within a lane. Runs in GUI thread."""
        if video_url in self.active_lanes: # Check if lane still exists
            self.active_lanes[video_url].update_frame_score(frame_number, score)
        # else: Lane might have been removed already, ignore score update

    @pyqtSlot(str, int, QByteArray) # Receives video_url, frame_number, image_data
    def update_video_lane_frame_image(self, video_url, frame_number, image_data):
        """Updates the image of a specific frame within a lane. Runs in GUI thread."""
        if video_url in self.active_lanes: # Check if lane still exists
            self.active_lanes[video_url].update_frame_image(frame_number, image_data)

    @pyqtSlot(str, int, int) # Receives video_url, frame_number, state constant
    def update_video_lane_frame_state(self, video_url, frame_number, state):
        """Updates the state (border/text) of a specific frame within a lane. Runs in GUI thread."""
        if video_url in self.active_lanes: # Check if lane still exists
            self.active_lanes[video_url].update_frame_state(frame_number, state)

    @pyqtSlot(str, int) # Receives video_url, best_frame_number
    def highlight_video_lane_best_frame(self, video_url, best_frame_number):
        """Highlights the best scoring frame in a specific lane. Runs in GUI thread."""
        if video_url in self.active_lanes: # Check if lane still exists
            self.active_lanes[video_url].highlight_best_frame(best_frame_number)

    @pyqtSlot(str, float) # Receives video_url, score
    def show_lane_result_found_effect(self, video_url, score):
        """Triggers the visual 'result found' flash effect on a lane. Runs in GUI thread."""
        if video_url in self.active_lanes: # Check if lane still exists
            self.active_lanes[video_url].show_result_found_effect(score)

    @pyqtSlot(str) # Receives video_url
    def handle_video_processing_finished(self, video_url):
        """Handles signal that video processing is complete. Updates stats, marks lane done. Runs in GUI thread."""
        # Update stats: Increment processed, decrement queued
        if self.processing_stats:
            # Access stats potentially needs locking if read elsewhere, but assumed okay from GUI thread
            proc = self.processing_stats.processed_videos + 1 # Increment processed count
            queued = len(self.active_lanes) + len(self.pending_lanes) - 1 # Decrement queued count (-1 for the one finishing)
            self.processing_stats.update_video_queue(proc, max(0, queued)) # Update stats object (ensure queued >= 0)

        # Mark the lane as done visually (starts its removal timer)
        self.mark_lane_done(video_url)

    @pyqtSlot(str) # Receives video_url
    def mark_lane_done(self, video_url):
        """Marks a lane as visually complete (style change) and schedules its removal. Runs in GUI thread."""
        if video_url in self.active_lanes: # Check if lane exists
            lane = self.active_lanes[video_url]
            if not lane.is_done(): # Only mark if not already marked
                try: lane.mark_as_done() # Calls method in VideoProcessingLane
                except Exception as e: # Handle errors during marking
                    logging.error(f"Error marking lane done {video_url}: {e}", exc_info=True); self.log(f"Error marking lane ...{video_url[-10:]} done.")
                    # Fallback: Request removal directly if marking fails
                    self.request_lane_removal.emit(video_url)
        # else: Lane already removed or never existed

    @pyqtSlot(str) # Receives video_url
    def remove_video_lane(self, video_url):
        """Removes a specific video processing lane from the UI and handles pending queue. Runs in GUI thread."""
        if video_url in self.active_lanes: # Check if lane exists in our active dictionary
            lane = self.active_lanes.pop(video_url) # Remove from dictionary
            title = lane.video_info.get('videoTitle', 'Unknown')[:40]
            self.log(f"Live View: Removing lane '{title}'")
            try: self.live_view_lanes_layout.removeWidget(lane) # Remove widget from layout
            except Exception as e: logging.error(f"Error removing lane widget from layout: {e}")
            lane.cleanup() # Trigger the lane's cleanup method (calls deleteLater)

            # --- Process Pending Queue ---
            # If there are videos waiting and space is now available, add the next one
            if self.pending_lanes and len(self.active_lanes) < MAX_ACTIVE_LANES:
                pending_url, pending_info = self.pending_lanes.popleft() # Get next video from queue
                logging.debug(f"Processing pending lane: ...{pending_url[-10:]}")
                self.add_video_lane(pending_url, pending_info) # Add it as a new lane
                if self.status_label: self.status_label.setText(f"Processing... ({len(self.pending_lanes)} pending)") # Update status

            # --- Restore Placeholder if No Lanes Left ---
            if not self.active_lanes and not self.pending_lanes: # If view is now empty
                self.no_lanes_label.setVisible(True) # Show placeholder text
                # Ensure stretch item is present at the end
                count = self.live_view_lanes_layout.count()
                if count <= 1: # Only label or empty
                     if count == 0 or not isinstance(self.live_view_lanes_layout.itemAt(count - 1), QSpacerItem):
                         self.live_view_lanes_layout.addStretch(1)
        # else: Lane already removed

    def clear_live_view_lanes(self):
        """Removes all active lanes from the UI and clears the pending queue."""
        self.log("Clearing all live view lanes...")
        urls_to_remove = list(self.active_lanes.keys()) # Get list of URLs to remove
        for url in urls_to_remove: self.remove_video_lane(url) # Use existing removal logic for each
        self.pending_lanes.clear(); self.active_lanes.clear() # Ensure dictionaries/queues are empty
        # Restore placeholder and stretch
        self.no_lanes_label.setVisible(True)
        count = self.live_view_lanes_layout.count()
        if count <= 1: # If only label or empty
             if count == 0 or not isinstance(self.live_view_lanes_layout.itemAt(count - 1), QSpacerItem):
                 self.live_view_lanes_layout.addStretch(1)

    @pyqtSlot(int) # Receives Qt.Checked or Qt.Unchecked state
    def toggle_performance_mode(self, state):
        """Toggles live view performance mode (disables image loading in lanes)."""
        enabled = (state == Qt.Checked); mode_text = "Enabled" if enabled else "Disabled"
        self.log(f"Live View Performance Mode {mode_text}.")
        # Update reference display visibility based on mode
        current_pixmap = self.ref_image_display_label.pixmap() if self.ref_image_display_label else QPixmap()
        self.update_current_reference_image_display(current_pixmap, -1) # Update display (will hide/show based on mode)
        # Propagate change to all currently active lanes
        for lane in self.active_lanes.values(): lane.update_performance_mode(enabled)
        # Save the setting
        self.settings.setValue("MainWindow/" + SETTING_LIVE_VIEW_PERF_MODE, enabled); self.settings.sync()

    # --- Saving/Loading ---
    def save_window_state(self):
        """Saves main window size and position to QSettings."""
        logging.debug("Saving window state.")
        self.settings.beginGroup("MainWindow")
        # Save size only if not minimized/maximized to avoid saving invalid sizes
        if not self.isMinimized() and not self.isMaximized(): self.settings.setValue(SETTING_WIN_SIZE, self.size())
        self.settings.setValue(SETTING_WIN_GEOM, self.saveGeometry()) # Save geometry (includes position and state)
        self.settings.endGroup(); self.settings.sync() # Ensure changes are written

    def load_window_state(self):
        """Restores main window size and position from QSettings."""
        logging.debug("Loading window state."); self.settings.beginGroup("MainWindow")
        try:
            geom = self.settings.value(SETTING_WIN_GEOM) # Load geometry
            if isinstance(geom,(QByteArray,bytes)): self.restoreGeometry(geom) # Restore if valid
            # Restore size separately if not maximized/minimized after restoring geometry
            if not self.isMaximized() and not self.isMinimized():
                 size = self.settings.value(SETTING_WIN_SIZE, QSize(1400, 980)) # Load size with default
                 if isinstance(size,QSize) and size.isValid(): self.resize(size) # Resize if valid
                 else: self.resize(1400, 980) # Fallback size
        except Exception as e: logging.error(f"Error loading window state: {e}", exc_info=True); self.setGeometry(100,100,1400,980) # Fallback geometry
        finally: self.settings.endGroup()

    def save_settings(self):
        """Saves application settings (controls, paths, etc.) to QSettings."""
        logging.debug("Saving settings...");
        try:
            self.settings.beginGroup("MainWindow") # Group settings related to the main window
            # Save values from UI controls if they exist
            if self.search_edit: self.settings.setValue(SETTING_SEARCH_TEXT, self.search_edit.text())
            if self.start_page_spin: self.settings.setValue(SETTING_START_PAGE, self.start_page_spin.value())
            if self.end_page_spin: end_val = -1 if self.end_page_spin.specialValueText()=="∞" and self.end_page_spin.value()==-1 else self.end_page_spin.value(); self.settings.setValue(SETTING_END_PAGE, end_val) # Handle infinity case
            if self.threshold_spin: self.settings.setValue(SETTING_THRESHOLD, self.get_threshold())
            if self.headless_check: self.settings.setValue(SETTING_HEADLESS, self.headless_check.isChecked())
            if self.frame_recognition_checkboxes: self.settings.setValue(SETTING_REC_FRAMES, [str(f) for f in self.get_frames_for_recognition()]) # Save as list of strings
            if self.frame_animation_checkboxes: self.settings.setValue(SETTING_ANIM_FRAMES, [str(f) for f in self.get_frames_for_animation()]) # Save as list of strings
            if self.reference_image_paths: self.settings.setValue(SETTING_REF_PATHS, self.reference_image_paths) # Save list of paths
            if self.ref_load_threads_spin: self.settings.setValue(SETTING_REF_LOAD_THREADS, self.ref_load_threads_spin.value())
            if self.analyzer_threads_spin: self.settings.setValue(SETTING_ANALYZER_THREADS, self.analyzer_threads_spin.value())
            if self.detection_priority_combo: self.settings.setValue(SETTING_DETECTION_PRIORITY, self.detection_priority_combo.currentIndex())
            if self.perf_mode_check: self.settings.setValue(SETTING_LIVE_VIEW_PERF_MODE, self.is_performance_mode())
            if self.encoding_model_combo: self.settings.setValue(SETTING_ENCODING_MODEL, self.get_encoding_model())
            if self.encoding_jitters_spin: self.settings.setValue(SETTING_ENCODING_JITTERS, self.get_num_jitters())
            if self.max_upsample_spin: self.settings.setValue(SETTING_MAX_DETECTION_UPSAMPLE, self.get_max_upsample())
            if self.force_all_attempts_check: self.settings.setValue(SETTING_FORCE_ALL_DETECTION_ATTEMPTS, self.force_all_attempts_check.isChecked())
            if self.use_face_mutex_check: self.settings.setValue(SETTING_USE_FACE_MUTEX, self.use_face_mutex_check.isChecked())
            self.settings.endGroup()
            # Save general settings (like last directory)
            self.settings.beginGroup("General"); self.settings.setValue(SETTING_LAST_DIR, self.last_dir); self.settings.endGroup()
            self.settings.sync(); logging.debug("Settings saved.") # Ensure changes are written
        except Exception as e: self.log(f"Error saving settings: {e}"); logging.exception("Error saving application settings")

    def load_settings_and_references(self):
        """Loads settings from QSettings and triggers loading for saved reference paths."""
        # Check if essential UI elements are initialized before proceeding
        if not hasattr(self, 'ref_progress_bar') or not self.ref_progress_bar: logging.warning("Skipping load_settings_and_references: UI not fully initialized (ref_progress_bar missing)."); return
        logging.debug("Loading settings and references..."); ui_available = bool(self.search_edit) # Simple check if UI seems ready

        try:
            self.settings.beginGroup("MainWindow") # Group for main window settings
            # Load values into UI controls if they exist
            if ui_available: self.search_edit.setText(self.settings.value(SETTING_SEARCH_TEXT, ""))
            start_page = self.settings.value(SETTING_START_PAGE, 1, type=int);
            if ui_available: self.start_page_spin.setValue(start_page)
            end_page_loaded = self.settings.value(SETTING_END_PAGE, 1, type=int) # Load end page value
            if ui_available: # Handle infinity case and ensure end >= start
                 if end_page_loaded == -1: self.end_page_spin.setValue(-1) # Set to infinity special value
                 else: self.end_page_spin.setValue(max(start_page, end_page_loaded)) # Ensure end >= start
                 self.update_end_page_tooltip() # Update tooltip after loading
            threshold_loaded = self.settings.value(SETTING_THRESHOLD, 0.75, type=float);
            if ui_available: self.threshold_spin.setValue(max(0.01, min(1.00, threshold_loaded))) # Clamp value
            if ui_available: self.headless_check.setChecked(self.settings.value(SETTING_HEADLESS, True, type=bool))
            det_prio_loaded = self.settings.value(SETTING_DETECTION_PRIORITY, DETECT_PRIO_ACCURACY, type=int);
            if ui_available: self.detection_priority_combo.setCurrentIndex(max(0, min(1, det_prio_loaded))) # Clamp index
            if ui_available: self.perf_mode_check.setChecked(self.settings.value(SETTING_LIVE_VIEW_PERF_MODE, False, type=bool))
            enc_model_loaded = self.settings.value(SETTING_ENCODING_MODEL, "small");
            if ui_available: enc_model_index = self.encoding_model_combo.findText(enc_model_loaded); self.encoding_model_combo.setCurrentIndex(enc_model_index if enc_model_index >= 0 else 0) # Find text, default to 0 if not found
            if ui_available: self.encoding_jitters_spin.setValue(self.settings.value(SETTING_ENCODING_JITTERS, 1, type=int))
            if ui_available: self.max_upsample_spin.setValue(self.settings.value(SETTING_MAX_DETECTION_UPSAMPLE, 1, type=int))
            if ui_available: self.force_all_attempts_check.setChecked(self.settings.value(SETTING_FORCE_ALL_DETECTION_ATTEMPTS, False, type=bool))
            if ui_available: self.use_face_mutex_check.setChecked(self.settings.value(SETTING_USE_FACE_MUTEX, True, type=bool))
            ref_threads_loaded = self.settings.value(SETTING_REF_LOAD_THREADS, DEFAULT_REF_LOAD_THREADS, type=int);
            if ui_available: self.ref_load_threads_spin.setValue(max(1, min(CPU_CORES*2, ref_threads_loaded))) # Clamp thread count
            analyzer_threads_loaded = self.settings.value(SETTING_ANALYZER_THREADS, DEFAULT_ANALYZER_THREADS, type=int);
            if ui_available: self.analyzer_threads_spin.setValue(max(1, min(max(4, CPU_CORES*2), analyzer_threads_loaded))) # Clamp thread count
            # Update thread pool size immediately after loading setting
            if ui_available: self.ref_load_threadpool.setMaxThreadCount(self.ref_load_threads_spin.value())

            # --- Load frame selections ---
            default_rec_set = {1, 5, 8, 12, 15}; default_anim_set = set(range(1, 16))
            if ui_available:
                 # Load Recognition Frames
                 rec_frames_str_list = self.settings.value(SETTING_REC_FRAMES, [str(f) for f in default_rec_set])
                 try: rec_frames_set = {int(f) for f in rec_frames_str_list if isinstance(f,str) and f.isdigit() and 1<=int(f)<=15} if isinstance(rec_frames_str_list, list) else default_rec_set
                 except: rec_frames_set = default_rec_set # Fallback on error
                 if not rec_frames_set: rec_frames_set = default_rec_set # Fallback if empty after load
                 for i, cb in enumerate(self.frame_recognition_checkboxes): cb.setChecked((i + 1) in rec_frames_set) # Update checkboxes
                 # Load Animation Frames
                 anim_frames_str_list = self.settings.value(SETTING_ANIM_FRAMES, [str(f) for f in default_anim_set])
                 try: anim_frames_set = {int(f) for f in anim_frames_str_list if isinstance(f,str) and f.isdigit() and 1<=int(f)<=15} if isinstance(anim_frames_str_list, list) else default_anim_set
                 except: anim_frames_set = default_anim_set # Fallback on error
                 if not anim_frames_set: anim_frames_set = default_anim_set # Fallback if empty
                 for i, cb in enumerate(self.frame_animation_checkboxes): cb.setChecked((i + 1) in anim_frames_set) # Update checkboxes

            # Load reference image paths (saved as list of strings)
            ref_paths_saved = self.settings.value(SETTING_REF_PATHS, [])
            if not isinstance(ref_paths_saved, list): ref_paths_saved = [] # Ensure it's a list
            self.settings.endGroup() # End MainWindow group

            # Load general settings
            self.settings.beginGroup("General"); self.last_dir = self.settings.value(SETTING_LAST_DIR, str(Path.home())); self.settings.endGroup()

            # --- Trigger Loading for Saved Reference Paths ---
            valid_paths_to_load = []; invalid_paths_logged = set()
            current_paths_str = set(self.reference_image_paths) # Use set for quick check
            # Validate saved paths
            for p_str in ref_paths_saved:
                if not isinstance(p_str, str): continue # Skip non-strings
                p_obj = Path(p_str)
                # Check if path exists, is a file, and not already loaded/queued
                if p_obj.exists() and p_obj.is_file() and p_str not in current_paths_str:
                    valid_paths_to_load.append(p_obj)
                elif p_str not in current_paths_str and p_str not in invalid_paths_logged: # Log invalid paths only once
                    msg = f"Warning: Saved reference path not found or invalid: '{p_str}'. Skipping."
                    self.log(msg); logging.warning(msg); invalid_paths_logged.add(p_str)

            # If valid paths found, start loading tasks
            if valid_paths_to_load:
                self.log(f"Loading/Verifying {len(valid_paths_to_load)} saved reference image(s)...")
                num_new = len(valid_paths_to_load)
                # Update progress bar max based on current + new tasks
                if self.reference_images_in_progress == 0: self.ref_progress_bar.setMaximum(num_new); self.ref_progress_bar.setValue(0)
                else: self.ref_progress_bar.setMaximum(self.reference_images_in_progress + num_new)
                if num_new > 0: self.ref_progress_bar.setVisible(True) # Show progress bar

                # Get current settings for the tasks
                det_p=self.detection_priority_combo.currentIndex(); enc_m=self.get_encoding_model(); jit=self.get_num_jitters(); max_u=self.get_max_upsample(); force_all=self.force_all_attempts_check.isChecked(); use_mutex_s = self.use_face_mutex_check.isChecked()
                # Create and start a task for each valid path
                for path_obj in valid_paths_to_load:
                    self.reference_images_in_progress += 1 # Increment counter
                    task = ReferenceImageLoaderTask(path_obj, True, self.encoding_cache, self.cache_mutex, det_p, enc_m, jit, max_u, force_all, use_mutex_s)
                    task.signals.log_signal.connect(self.log); task.signals.error.connect(self.handle_ref_image_error); task.signals.reference_image_loaded_signal.connect(self.handle_reference_image_loaded)
                    self.ref_load_threadpool.start(task)
                # Hide progress bar immediately if no tasks were actually queued but progress was 0
                if num_new == 0 and self.reference_images_in_progress == 0: self.ref_progress_bar.setVisible(False)
            # Hide progress bar if started with 0 refs in progress and none were loaded from settings
            elif self.reference_images_in_progress == 0: self.ref_progress_bar.setVisible(False)

        except Exception as e: self.log(f"CRITICAL Error loading settings: {e}"); logging.exception("CRITICAL: Error during load_settings_and_references")

    def save_encoding_cache(self):
        """Saves the face encoding cache to a JSON file (thread-safe)."""
        logging.debug(f"Saving encoding cache ({len(self.encoding_cache)} items)...")
        if not self.cache_mutex: logging.error("Cannot save cache: Mutex unavailable."); return

        cache_to_save = {}
        # Safely copy data from the shared cache
        with QMutexLocker(self.cache_mutex):
            for path, data in self.encoding_cache.items():
                # Basic validation before saving
                if isinstance(data, dict) and 'encodings' in data and 'timestamp' in data and isinstance(data['encodings'], list):
                     # Ensure encodings are lists (already done in task, but double-check)
                     try: enc_list = [list(e) if isinstance(e, np.ndarray) else e for e in data['encodings']]; data['encodings'] = enc_list
                     except: pass # Ignore conversion errors
                     cache_to_save[path] = data.copy() # Save a copy
                else: logging.warning(f"Skipping invalid cache entry during save for: '{Path(path).name}'.")

        # Handle empty cache: remove file if it exists
        if not cache_to_save:
            if ENCODING_CACHE_FILE.exists():
                try: ENCODING_CACHE_FILE.unlink(); logging.debug("Removed empty cache file.")
                except OSError as e: logging.warning(f"Could not remove empty cache file: {e}")
            return # Nothing more to do if cache is empty

        # Atomic write: write to temp file, then replace original
        temp_file = ENCODING_CACHE_FILE.with_suffix(".json.tmp")
        try:
            with temp_file.open('w', encoding='utf-8') as f: json.dump(cache_to_save, f, indent=2) # Use indent for readability
            os.replace(temp_file, ENCODING_CACHE_FILE); logging.debug(f"Encoding cache saved ({len(cache_to_save)} valid items).")
        except Exception as e: # Handle errors during save
            self.log(f"Error saving encoding cache: {e}"); logging.exception("Failed to save encoding cache")
            try: temp_file.unlink(missing_ok=True) # Try to remove temp file on error
            except OSError: pass

    def load_encoding_cache(self):
        """Loads the face encoding cache from JSON file (thread-safe)."""
        logging.debug("Loading encoding cache...")
        if not self.cache_mutex: logging.error("Cannot load cache: Mutex unavailable."); return

        loaded_cache_data = {}; valid_entries = 0; invalid_entries = 0
        if ENCODING_CACHE_FILE.exists(): # Check if file exists
            try:
                # Check for empty file
                if ENCODING_CACHE_FILE.stat().st_size == 0: logging.warning("Encoding cache file is empty."); cache_data = {}
                else: # Load JSON data
                    with ENCODING_CACHE_FILE.open('r', encoding='utf-8') as f: cache_data = json.load(f)
                if not isinstance(cache_data, dict): raise TypeError("Cache file content is not a dictionary.") # Validate type

                # Validate structure of each entry
                for path, data in cache_data.items():
                    if (isinstance(path, str) and isinstance(data, dict) and
                        'encodings' in data and 'timestamp' in data and isinstance(data['encodings'], list)):
                        loaded_cache_data[path] = data; valid_entries += 1
                    else: invalid_entries += 1; logging.warning(f"Invalid cache entry skipped during load for: {path}")
                # Log summary
                log_msg = f"Loaded {valid_entries} items from encoding cache."; log_msg += f" Skipped {invalid_entries} invalid entries." if invalid_entries else ""
                self.log(log_msg); logging.info(log_msg)
            except (json.JSONDecodeError, TypeError, Exception) as e: # Handle loading/parsing errors
                self.log(f"Error loading encoding cache file ({ENCODING_CACHE_FILE.name}): {e}. Starting with empty cache."); logging.error(f"Failed to load encoding cache: {e}", exc_info=True)
                self.rename_corrupted_file(ENCODING_CACHE_FILE); loaded_cache_data = {} # Rename bad file, use empty cache
        else: self.log("Encoding cache file not found. Starting with empty cache."); loaded_cache_data = {} # File doesn't exist

        # Update the shared cache dictionary (thread-safe)
        with QMutexLocker(self.cache_mutex): self.encoding_cache = loaded_cache_data

    def save_results(self):
        """Saves the current results data (self.current_results_data) to JSON file."""
        logging.debug(f"Saving results ({len(self.current_results_data)} items)...")
        # Handle empty results: remove file if it exists
        if not self.current_results_data:
            if RESULTS_FILE.exists():
                try: RESULTS_FILE.unlink(); logging.debug("Removed empty results file.")
                except OSError as e: logging.warning(f"Could not remove empty results file: {e}")
            return # Nothing more to do

        # Atomic write using temp file
        temp_file = RESULTS_FILE.with_suffix(".json.tmp")
        try:
            data_to_save = self.current_results_data.copy() # Work on a copy
            # Ensure thumbnailPath is stored as string (not Path object)
            for url, data in data_to_save.items():
                 if 'thumbnailPath' in data and isinstance(data['thumbnailPath'], Path): data['thumbnailPath'] = str(data['thumbnailPath'])
            # Write JSON to temp file
            with temp_file.open('w', encoding='utf-8') as f: json.dump(data_to_save, f, indent=2, ensure_ascii=False) # Use indent, allow non-ascii
            # Replace original file with temp file
            os.replace(temp_file, RESULTS_FILE); logging.debug("Results saved successfully.")
        except Exception as e: # Handle errors during save
            self.log(f"Error saving results: {e}"); logging.exception("Failed to save results")
            try: temp_file.unlink(missing_ok=True) # Clean up temp file on error
            except OSError: pass

    def load_results(self):
        """Loads previous results from JSON file and populates the UI."""
        logging.debug("Loading results..."); self.clear_results_ui_only(); loaded_results_data = {} # Clear UI first
        if RESULTS_FILE.exists(): # Check if file exists
            try:
                # Check for empty file
                if RESULTS_FILE.stat().st_size == 0: logging.warning("Results file is empty."); loaded_results_data = {}
                else: # Load JSON data
                     with RESULTS_FILE.open('r', encoding='utf-8') as f: loaded_results_data = json.load(f)
                if not isinstance(loaded_results_data, dict): raise TypeError("Results file content is not a dictionary.") # Validate type
            except (json.JSONDecodeError, TypeError, Exception) as e: # Handle loading/parsing errors
                self.log(f"Error loading results file ({RESULTS_FILE.name}): {e}. Starting fresh."); logging.error(f"Failed to load results: {e}", exc_info=True)
                self.rename_corrupted_file(RESULTS_FILE); loaded_results_data = {} # Rename bad file, use empty data
        else: self.log("Results file not found. Starting fresh."); loaded_results_data = {} # File doesn't exist

        # --- Update internal data structures ---
        self.current_results_data = loaded_results_data
        self.known_results_urls = set(self.current_results_data.keys()) # Update set of known URLs
        self.log(f"Loaded {len(self.current_results_data)} results.")

        # --- Validate and Create Widgets for Loaded Results ---
        widgets_to_add = []; removed_count = 0
        for url in list(self.current_results_data.keys()): # Iterate over copy of keys for safe removal
            data = self.current_results_data[url]
            # Basic validation of result entry structure
            if not isinstance(data, dict) or 'videoURL' not in data:
                self.log(f"Warning: Removing invalid result entry from loaded data: {url}"); del self.current_results_data[url]; self.known_results_urls.discard(url); removed_count += 1; continue
            # Ensure essential keys exist with defaults
            data.setdefault('timestamp_added', 0); data.setdefault('score', 0.0); data.setdefault('videoTitle', 'Unknown Title')
            # Validate thumbnail path and ensure it's stored as string or None
            thumb_path_str = data.get("thumbnailPath"); thumb_path_obj = Path(thumb_path_str) if isinstance(thumb_path_str, str) else None
            data['thumbnailPath'] = str(thumb_path_obj) if thumb_path_obj else None # Store string path or None
            # Log warning if thumbnail file is missing
            if thumb_path_str and (not thumb_path_obj or not thumb_path_obj.exists()): self.log(f"Warning: Thumbnail file missing for result '{data.get('videoTitle','?')}'")

            try: # Create ResultWidget for valid data
                widget = ResultWidget(data, main_window=self); widget.delete_requested.connect(self.remove_result_widget); widgets_to_add.append(widget)
            except Exception as create_err: # Handle errors creating widget
                self.log(f"Error creating widget for loaded result {url}: {create_err}. Removing entry."); del self.current_results_data[url]; self.known_results_urls.discard(url); removed_count += 1

        # Sort and Add Widgets to Layout if any were created
        if widgets_to_add: self.sort_widgets_and_add_to_layout(widgets_to_add)
        # Update Sort Button State to reflect current sort order
        if self.sort_score_button: self.sort_score_button.setChecked(self.current_sort_key == 'score')
        if self.sort_date_button: self.sort_date_button.setChecked(self.current_sort_key == 'timestamp_added')
        # Save results file again if invalid entries were removed during loading
        if removed_count > 0: self.log(f"Removed {removed_count} invalid entries during results load."); self.save_results()

    def sort_widgets_and_add_to_layout(self, widgets):
        """Sorts a list of ResultWidgets and adds them to the results layout efficiently."""
        # Define local sort function based on current settings
        def sort_func(w):
            val = w.result_data.get(self.current_sort_key, 0)
            try: return float(val) if val is not None else 0.0
            except: return 0.0
        # Sort the list of widgets
        try: widgets.sort(key=sort_func, reverse=self.current_sort_reverse)
        except Exception as e: self.log(f"Error sorting widgets before adding: {e}"); logging.exception("Widget sorting error")
        # Insert widgets into the layout before the final spacer/stretch item
        layout = self.results_container_layout; insert_pos = 0; num_items = layout.count()
        # Find position before the last item if it's a spacer
        if num_items > 0 and isinstance(layout.itemAt(num_items - 1), QSpacerItem): insert_pos = num_items - 1
        else: insert_pos = num_items # Otherwise, insert at the end
        # Insert widgets one by one at the calculated position
        for widget in widgets: layout.insertWidget(insert_pos, widget); insert_pos += 1

    def rename_corrupted_file(self, file_path: Path):
        """Renames a file suspected of being corrupted by adding a timestamped suffix."""
        if file_path.exists():
             try:
                 # Create a unique backup filename
                 backup_suffix = f"{file_path.suffix}.corrupted_{int(time.time())}"
                 backup_path = file_path.with_suffix(backup_suffix)
                 file_path.rename(backup_path) # Rename the original file
                 self.log(f"Renamed potentially corrupted file '{file_path.name}' to '{backup_path.name}'.")
                 logging.warning(f"Renamed potentially corrupted file '{file_path}' to '{backup_path}'")
             except OSError as e: self.log(f"Error renaming corrupted file '{file_path.name}': {e}"); logging.error(f"Failed to rename corrupted file {file_path}: {e}")

    # --- Misc Methods ---
    def open_url_safely(self, url_string):
        """Opens a URL using QDesktopServices with validation for http/https schemes."""
        logger_func = self.log # Use the instance's log method
        if url_string:
            try:
                qurl = QUrl(url_string)
                # Validate URL format and scheme
                if not qurl.isValid() or qurl.scheme() not in ['http', 'https']: raise ValueError(f"Invalid URL or scheme: '{qurl.scheme()}'")
                # Try opening the URL
                if not QDesktopServices.openUrl(qurl): raise RuntimeError("QDesktopServices.openUrl returned False")
                logger_func(f"Opening URL in default browser: {url_string}")
            except (ValueError, RuntimeError, Exception) as e: # Catch validation or opening errors
                 msg = f"Error opening URL {url_string}: {e}"; logger_func(msg)
                 QMessageBox.warning(self, "Error Opening URL", f"Could not open the requested URL:\n{url_string}\n\nError: {e}")
        else: logger_func("Attempted to open an empty or invalid URL.")

    def _handle_start_page_change(self, new_value):
        """Ensures end page is always >= start page (unless end page is set to infinity)."""
        if not self.end_page_spin: return # Guard against calls during init
        current_end_value = self.end_page_spin.value()
        # If end page is not infinity and is less than the new start page, update end page
        if current_end_value != -1 and current_end_value < new_value: self.end_page_spin.setValue(new_value)
        self.update_end_page_tooltip() # Update tooltip which depends on start page

    @pyqtSlot(str, int, QByteArray) # Slot receives video_url, frame_number, image_data
    def handle_frame_pixmap_updated(self, video_url, frame_number, image_data):
        """Slot to receive frame image data (QByteArray) and update FrameWidget in lane."""
        # This slot seems redundant as image data is now passed with frame_analysis_finished signal.
        # Kept for potential future refactoring or if separate image loading is re-introduced.
        # Currently, update_video_lane_frame_image connected directly to scraper's signal is preferred.
        logging.debug(f"handle_frame_pixmap_updated called for {video_url} F:{frame_number} (Likely redundant)")
        if video_url in self.active_lanes:
            lane = self.active_lanes[video_url]
            if frame_number in lane.frame_widgets:
                frame_widget = lane.frame_widgets[frame_number]
                frame_widget.set_image_data(image_data) # Call FrameWidget's slot to handle data

    def _handle_end_page_change(self, new_value):
        """Handles logic for end page spinbox, including infinity (-1) and ensuring end >= start."""
        if not self.start_page_spin or not self.end_page_spin: return # Guard against calls during init
        start_value = self.start_page_spin.value()
        self.end_page_spin.blockSignals(True) # Prevent recursive signals
        # If new value is less than start page AND not the infinity value (-1), force it to infinity
        if new_value < start_value and new_value != -1: self.end_page_spin.setValue(-1)
        self.update_end_page_tooltip() # Update tooltip based on the final value
        self.end_page_spin.blockSignals(False) # Re-enable signals

    def update_end_page_tooltip(self):
        """Updates the tooltip for the end page spinbox to explain infinity logic."""
        if not self.start_page_spin or not self.end_page_spin: return
        start_page = self.start_page_spin.value()
        # Set tooltip based on whether current value is infinity (-1)
        tooltip = f"Infinite (∞): Scrapes until no more pages are found.\nCurrent Start Page: {start_page}" if self.end_page_spin.value() == -1 else f"Set the last page number to scrape.\nSet to {start_page - 1} or lower for Infinite (∞).\nCurrent Start Page: {start_page}"
        self.end_page_spin.setToolTip(tooltip)

    @pyqtSlot(str, int, float) # Receives video_url, frame_number, processing_time_ms
    def handle_frame_processing_time_signal(self, video_url, frame_number, processing_time_ms):
        """Slot to receive frame processing time from ScraperThread (forwarded from task). Runs in GUI thread."""
        if self.processing_stats: self.processing_stats.add_frame_processing_time(processing_time_ms) # Add time to stats object
        # else: logging.warning(f"Received frame time for ...{video_url[-10:]} F:{frame_number} but stats obj is None.") # Optional log

    # --- Close Event ---
    def closeEvent(self, event):
        """Handles application closing: stops threads, saves data, ensures clean exit."""
        logging.info("Close event triggered. Starting shutdown sequence...")
        self.status_label.setText("Shutting down...") # Update status bar
        QApplication.processEvents() # Process UI events to show status update

        # --- Stop Scraper Thread Gracefully ---
        if self.scraper_thread and self.scraper_thread.isRunning():
            self.log("Stopping scraper thread..."); QApplication.processEvents()
            self.stop_scraping(confirm=False) # Request stop without confirmation
            # Wait for thread to finish, with timeout
            if not self.scraper_thread.wait(5000): # Wait up to 5 seconds
                self.log("Warning: Scraper thread did not finish gracefully within timeout."); logging.warning("ScraperThread did not finish gracefully on close.")
            else: self.log("Scraper thread stopped.")
            self.scraper_thread = None # Clear reference

        # --- Clear Thread Pools ---
        # Stop accepting new tasks and wait for active tasks (with timeout)
        self.log("Clearing worker thread pools..."); QApplication.processEvents()
        try:
            logging.debug("Clearing reference loader thread pool...")
            self.ref_load_threadpool.clear(); self.ref_load_threadpool.waitForDone(2000) # Wait 2s
            logging.debug("Clearing download thread pool...")
            self.download_threadpool.clear(); self.download_threadpool.waitForDone(2000) # Wait 2s
            self.log("Thread pools cleared.")
        except Exception as pool_err:
            self.log(f"Error clearing thread pools: {pool_err}"); logging.error("Error clearing pools on exit", exc_info=True)

        # --- Save Data and State ---
        self.log("Saving application state and data..."); QApplication.processEvents()
        try:
            if self.central_widget: # Check if UI was initialized before saving state
                 self.save_window_state(); self.save_settings()
            self.save_encoding_cache(); self.save_results(); self.settings.sync() # Ensure settings are written
            self.log("Saving complete.")
        except Exception as save_err: self.log(f"ERROR saving data on exit: {save_err}"); logging.error("Error saving data on application exit.", exc_info=True)

        self.log("Exiting application."); logging.info("--- Application End ---")
        event.accept() # Accept the close event

    # --- Helper for ResultWidget/Lane thumbnail download ---
    def _create_result_frame_downloader_task(self, url, index, target_widget, video_url_for_log):
         """Factory method to create a QRunnable for downloading thumbnail/anim frame."""
         class Downloader(QRunnable):
             """Minimal downloader task that signals the target widget upon completion."""
             def __init__(self, url, index, target_widget, video_url_for_log):
                 super().__init__(); self.url = url; self.index = index
                 self.widget_ref = weakref.ref(target_widget); self.video_url_for_log = video_url_for_log
                 self.setAutoDelete(True); # Auto-delete after run()
                 # Create log prefix for context
                 vid_short = self.video_url_for_log[-10:] if self.video_url_for_log else '?'
                 purpose = f"Anim{index}" if index != -1 else "Thumb"; self.log_prefix = f"[{purpose} Vid:{vid_short}]"

             def run(self):
                 """Downloads image and signals result widget if it still exists."""
                 logging.debug(f"{self.log_prefix} Starting DL: {self.url}"); pixmap = None; data = None; headers = {'User-Agent':'Mozilla/5.0'}
                 widget = self.widget_ref() # Get target widget from weakref
                 try:
                     if not widget: raise TaskStopException("Target widget destroyed before download")
                     # Perform download using requests
                     resp = requests.get(self.url, timeout=10, headers=headers); resp.raise_for_status(); data = resp.content
                     widget = self.widget_ref() # Re-check widget validity after download
                     if not widget: raise TaskStopException("Target widget destroyed after download")
                     # Load pixmap from downloaded data
                     if data:
                          loaded = QPixmap();
                          if loaded.loadFromData(data): pixmap = loaded.copy(); logging.debug(f"{self.log_prefix} Pixmap loaded successfully.")
                          else: logging.warning(f"{self.log_prefix} Failed QPixmap load from downloaded data.")
                     else: logging.warning(f"{self.log_prefix} Empty image data received.")
                 except TaskStopException as ts: logging.debug(f"{self.log_prefix} {ts}") # Controlled stop
                 except requests.exceptions.RequestException as e: logging.warning(f"{self.log_prefix} Download error: {e}") # Network errors
                 except Exception as e: logging.error(f"{self.log_prefix} Unexpected error during download/load: {e}", exc_info=True) # Other errors
                 finally:
                     widget = self.widget_ref(); slot_name = None; args = () # Reset vars
                     # Check if widget still exists, is valid, and visible before signaling
                     if widget and hasattr(widget, 'isWidgetType') and widget.isWidgetType() and widget.isVisible():
                         # Determine the correct slot and arguments based on target widget type and index
                         if isinstance(widget, ResultWidget) and self.index != -1: # Animation frame for ResultWidget
                             slot_name = "_animation_frame_loaded"; args = (Q_ARG(int, self.index), Q_ARG(QPixmap, pixmap or QPixmap())) # Send empty pixmap on failure
                         elif isinstance(widget, QLabel) and self.index == -1: # Thumbnail for VideoProcessingLane's QLabel
                             slot_name = "setPixmap"; args = (Q_ARG(QPixmap, pixmap or QPixmap()),) # Send pixmap (or empty)
                             # Also clear any text on the label when setting a pixmap
                             QMetaObject.invokeMethod(widget, "setText", Qt.QueuedConnection, Q_ARG(str, ""))
                         else: logging.warning(f"{self.log_prefix} Unknown target widget type ({type(widget)}) or index ({self.index}) for signal.")
                     else: logging.debug(f"{self.log_prefix} Target widget is invalid or no longer exists, skipping signal.")

                     # If a valid slot was determined, emit the signal using invokeMethod for thread safety
                     if slot_name:
                         logging.debug(f"{self.log_prefix} Signaling '{slot_name}' (Pixmap null: {pixmap is None or pixmap.isNull()}).")
                         QMetaObject.invokeMethod(widget, slot_name, Qt.QueuedConnection, *args)
                     del data # Explicitly delete potentially large byte data

         return Downloader(url, index, target_widget, video_url_for_log)

# =============================================================================
# Main Execution Block
# =============================================================================
if __name__ == '__main__':
    # --- Application Setup ---
    # Enable High DPI scaling BEFORE QApplication initialization for better results
    if hasattr(Qt, 'AA_EnableHighDpiScaling'): QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True)
    if hasattr(Qt, 'AA_UseHighDpiPixmaps'): QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True)

    # Create the application instance
    app = QApplication(sys.argv)
    app.setApplicationName(SETTINGS_APP); app.setOrganizationName(SETTINGS_ORG) # For QSettings

    # --- Initialize Global Placeholders ---
    # Create placeholder pixmaps AFTER QApplication exists
    PLACEHOLDER_REF_PIXMAP = create_placeholder_pixmap(PLACEHOLDER_REF_ICON_SIZE, "Ref")
    PLACEHOLDER_LANE_THUMB_PIXMAP = create_placeholder_pixmap(PLACEHOLDER_LANE_THUMB_SIZE, "Thumb")
    PLACEHOLDER_RESULT_THUMB_PIXMAP = create_placeholder_pixmap(PLACEHOLDER_RESULT_THUMB_SIZE, "Loading...")
    PLACEHOLDER_FRAME_PIXMAP = create_placeholder_pixmap(LANE_FRAME_WIDGET_SIZE, "") # Placeholder for individual frames in lane

    # Optional: Force a specific Qt style (e.g., "Fusion")
    # app.setStyle("Fusion")

    # --- Run Application ---
    try:
        window = MainWindow() # Create the main window instance
        window.show() # Show the window
        logging.info("Application initialized and window shown.")
        exit_code = app.exec_() # Start the Qt event loop
        logging.info(f"Application event loop finished. Exit code: {exit_code}.")
        sys.exit(exit_code) # Exit with the application's exit code
    except Exception as main_err: # Catch critical errors during initialization or execution
         logging.critical("FATAL ERROR during application initialization or execution:", exc_info=True)
         # Try showing a message box as a last resort
         try: QMessageBox.critical(None, f"{SETTINGS_APP} - Fatal Error", f"A fatal error occurred:\n\n{main_err}\n\nPlease check 'webvidscrap.log' for details.")
         except Exception as mb_err: # If even the message box fails
              logging.error(f"Failed to show fatal error message box: {mb_err}")
              print(f"FATAL APPLICATION ERROR: {main_err}\nPlease check 'webvidscrap.log'", file=sys.stderr)
         sys.exit(1) # Exit with error code 1