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}")

code
# 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)

code
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

code
        # 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]);

code
    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

code
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

code
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

code
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)

code
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)

code
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}")

code
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)

code
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

code
        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

code
@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

code
@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)

code
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

code
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

code
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

code
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

code
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...
code
    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)

code
# 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