Initial commit

This commit is contained in:
2025-04-29 18:26:13 +02:00
commit 30b5b7ec23
2366 changed files with 6634852 additions and 0 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

1638
gui/main_window.py Normal file

File diff suppressed because it is too large Load Diff

232
gui/prediction_handler.py Normal file
View File

@@ -0,0 +1,232 @@
# gui/prediction_handler.py
import logging
from pathlib import Path
import time # For potential delays if needed
import os # For cpu_count
from concurrent.futures import ThreadPoolExecutor, as_completed # For parallel prediction
# --- PySide6 Imports ---
from PySide6.QtCore import QObject, Signal, QThread # Import QThread
# --- Backend Imports ---
# Adjust path to ensure modules can be found relative to this file's location
import sys
script_dir = Path(__file__).parent
project_root = script_dir.parent
if str(project_root) not in sys.path:
sys.path.insert(0, str(project_root))
try:
from configuration import Configuration, ConfigurationError
from asset_processor import AssetProcessor, AssetProcessingError
BACKEND_AVAILABLE = True
except ImportError as e:
print(f"ERROR (PredictionHandler): Failed to import backend modules: {e}")
# Define placeholders if imports fail
Configuration = None
AssetProcessor = None
ConfigurationError = Exception
AssetProcessingError = Exception
BACKEND_AVAILABLE = False
log = logging.getLogger(__name__)
# Basic config if logger hasn't been set up elsewhere
if not log.hasHandlers():
logging.basicConfig(level=logging.INFO, format='%(levelname)s (PredictHandler): %(message)s')
class PredictionHandler(QObject):
"""
Handles running predictions in a separate thread to avoid GUI freezes.
"""
# --- Signals ---
# Emits a list of dictionaries, each representing a file row for the table
# Dict format: {'original_path': str, 'predicted_asset_name': str | None, 'predicted_output_name': str | None, 'status': str, 'details': str | None, 'source_asset': str}
prediction_results_ready = Signal(list)
# Emitted when all predictions for a batch are done
prediction_finished = Signal()
# Emitted for status updates
status_message = Signal(str, int)
def __init__(self, parent=None):
super().__init__(parent)
self._is_running = False
# No explicit cancel needed for prediction for now, it should be fast per-item
@property
def is_running(self):
return self._is_running
def _predict_single_asset(self, input_path_str: str, config: Configuration) -> list[dict]:
"""
Helper method to predict a single asset. Runs within the ThreadPoolExecutor.
Returns a list of prediction dictionaries for the asset, or a single error dict.
"""
input_path = Path(input_path_str)
source_asset_name = input_path.name # For reference in the results
asset_results = []
try:
# Create AssetProcessor instance (needs dummy output path)
# Ensure AssetProcessor is thread-safe or create a new instance per thread.
# Based on its structure (using temp dirs), creating new instances should be safe.
processor = AssetProcessor(input_path, config, Path(".")) # Dummy output path
# Get detailed file predictions
detailed_predictions = processor.get_detailed_file_predictions()
if detailed_predictions is None:
log.error(f"Detailed prediction failed critically for {input_path_str}. Adding asset-level error.")
# Add a single error entry for the whole asset if the method returns None
asset_results.append({
'original_path': source_asset_name, # Use asset name as placeholder
'predicted_asset_name': None, # New key
'predicted_output_name': None, # New key
'status': 'Error',
'details': 'Critical prediction failure (check logs)',
'source_asset': source_asset_name
})
else:
log.debug(f"Received {len(detailed_predictions)} detailed predictions for {input_path_str}.")
# Add source_asset key and ensure correct keys exist
for prediction_dict in detailed_predictions:
# Ensure all expected keys are present, even if None
result_entry = {
'original_path': prediction_dict.get('original_path', '[Missing Path]'),
'predicted_asset_name': prediction_dict.get('predicted_asset_name'), # New key
'predicted_output_name': prediction_dict.get('predicted_output_name'), # New key
'status': prediction_dict.get('status', 'Error'),
'details': prediction_dict.get('details', '[Missing Details]'),
'source_asset': source_asset_name # Add the source asset identifier
}
asset_results.append(result_entry)
except AssetProcessingError as e: # Catch errors during processor instantiation or prediction setup
log.error(f"Asset processing error during prediction setup for {input_path_str}: {e}")
asset_results.append({
'original_path': source_asset_name,
'predicted_asset_name': None,
'predicted_output_name': None,
'status': 'Error',
'details': f'Asset Error: {e}',
'source_asset': source_asset_name
})
except Exception as e: # Catch unexpected errors
log.exception(f"Unexpected error during prediction for {input_path_str}: {e}")
asset_results.append({
'original_path': source_asset_name,
'predicted_asset_name': None,
'predicted_output_name': None,
'status': 'Error',
'details': f'Unexpected Error: {e}',
'source_asset': source_asset_name
})
finally:
# Cleanup for the single asset prediction if needed (AssetProcessor handles its own temp dir)
pass
return asset_results
def run_prediction(self, input_paths: list[str], preset_name: str):
"""
Runs the prediction logic for the given paths and preset using a ThreadPoolExecutor.
This method is intended to be run in a separate QThread.
"""
if self._is_running:
log.warning("Prediction is already running.")
return
if not BACKEND_AVAILABLE:
log.error("Backend modules not available. Cannot run prediction.")
self.status_message.emit("Error: Backend components missing.", 5000)
self.prediction_finished.emit()
return
if not preset_name:
log.warning("No preset selected for prediction.")
self.status_message.emit("No preset selected.", 3000)
self.prediction_finished.emit()
return
self._is_running = True
thread_id = QThread.currentThread() # Get current thread object
log.info(f"[{time.time():.4f}][T:{thread_id}] --> Entered PredictionHandler.run_prediction. Starting run for {len(input_paths)} items, Preset='{preset_name}'")
self.status_message.emit(f"Updating preview for {len(input_paths)} items...", 0)
config = None # Load config once if possible
try:
config = Configuration(preset_name)
except ConfigurationError as e:
log.error(f"Failed to load configuration for preset '{preset_name}': {e}")
self.status_message.emit(f"Error loading preset '{preset_name}': {e}", 5000)
# Emit error for all items? Or just finish? Finish for now.
self.prediction_finished.emit()
self._is_running = False
return
except Exception as e:
log.exception(f"Unexpected error loading configuration for preset '{preset_name}': {e}")
self.status_message.emit(f"Unexpected error loading preset '{preset_name}'.", 5000)
self.prediction_finished.emit()
return
all_file_results = [] # Accumulate results here
futures = []
# Determine number of workers - use half the cores, minimum 1, max 8?
max_workers = min(max(1, (os.cpu_count() or 1) // 2), 8)
log.info(f"Using ThreadPoolExecutor with max_workers={max_workers} for prediction.")
try:
with ThreadPoolExecutor(max_workers=max_workers) as executor:
# Submit tasks for each input path
for input_path_str in input_paths:
future = executor.submit(self._predict_single_asset, input_path_str, config)
futures.append(future)
# Process results as they complete
for future in as_completed(futures):
try:
# Result is a list of dicts for one asset
asset_result_list = future.result()
if asset_result_list: # Check if list is not empty
all_file_results.extend(asset_result_list)
except Exception as exc:
# This catches errors within the future execution itself if not handled by _predict_single_asset
log.error(f'Prediction task generated an exception: {exc}', exc_info=True)
# We might not know which input path failed here easily without more mapping
# Add a generic error?
all_file_results.append({
'original_path': '[Unknown Asset - Executor Error]',
'predicted_asset_name': None,
'predicted_output_name': None,
'status': 'Error',
'details': f'Executor Error: {exc}',
'source_asset': '[Unknown]'
})
except Exception as pool_exc:
log.exception(f"An error occurred with the prediction ThreadPoolExecutor: {pool_exc}")
self.status_message.emit(f"Error during prediction setup: {pool_exc}", 5000)
# Add a generic error if the pool fails
all_file_results.append({
'original_path': '[Prediction Pool Error]',
'predicted_asset_name': None,
'predicted_output_name': None,
'status': 'Error',
'details': f'Pool Error: {pool_exc}',
'source_asset': '[System]'
})
# Emit the combined list of detailed file results at the end
# Note: thread_id was already defined earlier in this function
log.info(f"[{time.time():.4f}][T:{thread_id}] Parallel prediction run finished. Preparing to emit {len(all_file_results)} file results.")
# <<< Add logging before emit >>>
log.debug(f"[{time.time():.4f}][T:{thread_id}] Type of all_file_results before emit: {type(all_file_results)}")
try:
log.debug(f"[{time.time():.4f}][T:{thread_id}] Content of all_file_results (first 5) before emit: {all_file_results[:5]}")
except Exception as e:
log.error(f"[{time.time():.4f}][T:{thread_id}] Error logging all_file_results content: {e}")
# <<< End added logging >>>
log.info(f"[{time.time():.4f}][T:{thread_id}] Emitting prediction_results_ready signal...")
self.prediction_results_ready.emit(all_file_results)
log.info(f"[{time.time():.4f}][T:{thread_id}] Emitted prediction_results_ready signal.")
self.status_message.emit("Preview update complete.", 3000)
self.prediction_finished.emit()
self._is_running = False
log.info(f"[{time.time():.4f}][T:{thread_id}] <-- Exiting PredictionHandler.run_prediction.")

469
gui/preview_table_model.py Normal file
View File

@@ -0,0 +1,469 @@
import logging # Import logging
import time # For logging timestamps
from PySide6.QtCore import QAbstractTableModel, Qt, QModelIndex, QSortFilterProxyModel, QThread # Import QThread
from PySide6.QtGui import QColor
log = logging.getLogger(__name__) # Get logger
# Define colors for alternating asset groups
COLOR_ASSET_GROUP_1 = QColor("#292929") # Dark grey 1
COLOR_ASSET_GROUP_2 = QColor("#343434") # Dark grey 2
# Define text colors for statuses
class PreviewTableModel(QAbstractTableModel):
"""
Custom table model for the GUI preview table.
Holds detailed file prediction results or a simple list of source assets.
"""
# Define text colors for statuses
STATUS_COLORS = {
"Mapped": QColor("#9dd9db"),
"Ignored": QColor("#c1753d"),
"Extra": QColor("#cfdca4"),
"Unrecognised": QColor("#92371f"),
"Model": QColor("#a4b8dc"),
"Unmatched Extra": QColor("#777777"),
"Error": QColor(Qt.GlobalColor.red),
"[No Status]": None # Use default color for no status
}
# Define column roles for clarity (Detailed Mode)
COL_STATUS = 0
COL_PREDICTED_ASSET = 1
COL_ORIGINAL_PATH = 2
COL_PREDICTED_OUTPUT = 3 # Kept for internal data access, but hidden in view
COL_DETAILS = 4
COL_ADDITIONAL_FILES = 5 # New column for ignored/extra files
# Define internal data roles for sorting/filtering
ROLE_RAW_STATUS = Qt.ItemDataRole.UserRole + 1
ROLE_SOURCE_ASSET = Qt.ItemDataRole.UserRole + 2
# Column for Simple Mode
COL_SIMPLE_PATH = 0
def __init__(self, data=None, parent=None):
super().__init__(parent)
log.debug("PreviewTableModel initialized.")
# Data format: List of dictionaries, each representing a file's details
# Example: {'original_path': '...', 'predicted_asset_name': '...', 'predicted_output_name': '...', 'status': '...', 'details': '...', 'source_asset': '...'}
self._data = [] # Keep the original flat data for reference if needed, but not for display
self._table_rows = [] # New structure for displaying rows
self._simple_data = [] # List of unique source asset paths for simple mode
self._simple_mode = False # Flag to toggle between detailed and simple view
self._headers_detailed = ["Status", "Predicted Asset", "Original Path", "Predicted Output", "Details", "Additional Files"] # Added new column header
self._sorted_unique_assets = [] # Store sorted unique asset names for coloring
self._headers_simple = ["Input Path"]
self.set_data(data or []) # Initialize data and simple_data
def set_simple_mode(self, enabled: bool):
"""Toggles the model between detailed and simple view modes."""
thread_id = QThread.currentThread() # Get current thread object
log.info(f"[{time.time():.4f}][T:{thread_id}] --> Entered PreviewTableModel.set_simple_mode(enabled={enabled}). Current mode: {self._simple_mode}")
if self._simple_mode != enabled:
log.info(f"[{time.time():.4f}][T:{thread_id}] Calling beginResetModel()...")
self.beginResetModel()
log.info(f"[{time.time():.4f}][T:{thread_id}] Returned from beginResetModel(). Setting mode.")
self._simple_mode = enabled
log.info(f"[{time.time():.4f}][T:{thread_id}] Mode changed to: {self._simple_mode}. Calling endResetModel()...")
self.endResetModel()
log.info(f"[{time.time():.4f}][T:{thread_id}] Returned from endResetModel().")
else:
log.info(f"[{time.time():.4f}][T:{thread_id}] PreviewTableModel mode is already as requested. No change.")
log.info(f"[{time.time():.4f}][T:{thread_id}] <-- Exiting PreviewTableModel.set_simple_mode.")
def rowCount(self, parent=QModelIndex()):
"""Returns the number of rows in the model."""
if parent.isValid():
return 0
row_count = len(self._simple_data) if self._simple_mode else len(self._table_rows) # Use _table_rows for detailed mode
# log.debug(f"PreviewTableModel.rowCount called. Mode: {self._simple_mode}, Row Count: {row_count}")
return row_count
def columnCount(self, parent=QModelIndex()):
"""Returns the number of columns in the model."""
if parent.isValid():
return 0
col_count = len(self._headers_simple) if self._simple_mode else len(self._headers_detailed) # Use updated headers_detailed
# log.debug(f"PreviewTableModel.columnCount called. Mode: {self._simple_mode}, Column Count: {col_count}")
return col_count
def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole):
"""Returns the data for a given index and role."""
if not index.isValid():
return None
row = index.row()
col = index.column()
# --- Simple Mode ---
if self._simple_mode:
if row >= len(self._simple_data):
# log.warning(f"data called with out of bounds row in simple mode: {row}/{len(self._simple_data)}")
return None # Bounds check
source_asset_path = self._simple_data[row]
if role == Qt.ItemDataRole.DisplayRole:
if col == self.COL_SIMPLE_PATH:
return source_asset_path
elif role == Qt.ItemDataRole.ToolTipRole:
if col == self.COL_SIMPLE_PATH:
return f"Input Asset: {source_asset_path}"
return None
# --- Detailed Mode ---
if row >= len(self._table_rows): # Use _table_rows
# log.warning(f"data called with out of bounds row in detailed mode: {row}/{len(self._table_rows)}")
return None # Bounds check
row_data = self._table_rows[row] # Get data from the structured row
# --- Handle Custom Internal Roles ---
# These roles are now handled by the proxy model based on the structured data
if role == self.ROLE_RAW_STATUS:
# Return status of the main file if it exists, otherwise a placeholder for additional rows
main_file = row_data.get('main_file')
return main_file.get('status', '[No Status]') if main_file else '[Additional]'
if role == self.ROLE_SOURCE_ASSET:
return row_data.get('source_asset', 'N/A')
# --- Handle Display Role ---
if role == Qt.ItemDataRole.DisplayRole:
if col == self.COL_STATUS:
main_file = row_data.get('main_file')
if main_file:
raw_status = main_file.get('status', '[No Status]')
details = main_file.get('details', '') # Get details for parsing
# Implement status text simplification
if raw_status == "Unmatched Extra":
if details and details.startswith("[Unmatched Extra (Regex match:"):
try:
pattern = details.split("match: '")[1].split("'")[0]
return f"[Extra={pattern}]"
except IndexError:
return "Extra" # Fallback if parsing fails
else:
return "Extra"
elif raw_status == "Ignored" and details and "Superseed by 16bit variant for" in details:
try:
filename = details.split("Superseed by 16bit variant for ")[1]
return f"Superseeded by 16bit {filename}"
except IndexError:
return raw_status # Fallback if parsing fails
else:
return raw_status # Return original status if no simplification applies
else:
return "" # Empty for additional-only rows
elif col == self.COL_PREDICTED_ASSET:
main_file = row_data.get('main_file')
return main_file.get('predicted_asset_name', 'N/A') if main_file else ""
elif col == self.COL_ORIGINAL_PATH:
main_file = row_data.get('main_file')
return main_file.get('original_path', '[Missing Path]') if main_file else ""
elif col == self.COL_PREDICTED_OUTPUT:
main_file = row_data.get('main_file')
return main_file.get('predicted_output_name', '') if main_file else ""
elif col == self.COL_DETAILS:
main_file = row_data.get('main_file')
return main_file.get('details', '') if main_file else ""
elif col == self.COL_ADDITIONAL_FILES:
return row_data.get('additional_file_path', '')
return None # Should not happen with defined columns
# --- Handle Tooltip Role ---
if role == Qt.ItemDataRole.ToolTipRole:
if col == self.COL_ORIGINAL_PATH:
main_file = row_data.get('main_file')
if main_file:
source_asset = row_data.get('source_asset', 'N/A')
original_path = main_file.get('original_path', '[Missing Path]')
return f"Source Asset: {source_asset}\nFull Path: {original_path}"
else:
return "" # No tooltip for empty cells
elif col == self.COL_STATUS:
main_file = row_data.get('main_file')
if main_file:
return main_file.get('details', main_file.get('status', '[No Status]'))
else:
return "" # No tooltip for empty cells
elif col == self.COL_PREDICTED_ASSET:
main_file = row_data.get('main_file')
if main_file:
predicted_asset_name = main_file.get('predicted_asset_name', 'None')
return f"Predicted Asset Name: {predicted_asset_name}"
else:
return "" # No tooltip for empty cells
elif col == self.COL_PREDICTED_OUTPUT:
main_file = row_data.get('main_file')
if main_file:
predicted_output_name = main_file.get('predicted_output_name', 'None')
return f"Predicted Output Name: {predicted_output_name}"
else:
return "" # No tooltip for empty cells
elif col == self.COL_DETAILS:
main_file = row_data.get('main_file')
if main_file:
return main_file.get('details', '')
else:
return "" # No tooltip for empty cells
elif col == self.COL_ADDITIONAL_FILES:
additional_file = row_data.get('additional_file_details')
if additional_file:
status = additional_file.get('status', '[No Status]')
details = additional_file.get('details', '')
return f"Status: {status}\nDetails: {details}"
else:
return "" # No tooltip if no additional file in this cell
return None
# --- Handle Foreground (Text Color) Role ---
if role == Qt.ItemDataRole.ForegroundRole:
row_data = self._table_rows[row] # Get data from the structured row
status = None
# Determine the relevant status based on column and row data
if col in [self.COL_STATUS, self.COL_PREDICTED_ASSET, self.COL_ORIGINAL_PATH, self.COL_PREDICTED_OUTPUT, self.COL_DETAILS]:
# These columns relate to the main file
main_file = row_data.get('main_file')
if main_file:
status = main_file.get('status', '[No Status]')
elif col == self.COL_ADDITIONAL_FILES:
# This column relates to the additional file
additional_file = row_data.get('additional_file_details')
if additional_file:
status = additional_file.get('status', '[No Status]')
# Look up color based on determined status
if status in self.STATUS_COLORS:
return self.STATUS_COLORS[status]
else:
return None # Use default text color if no specific status color or no relevant file data
# --- Handle Background Role ---
if role == Qt.ItemDataRole.BackgroundRole:
# Apply alternating background color based on asset group
source_asset = row_data.get('source_asset')
if source_asset and source_asset in self._sorted_unique_assets:
try:
asset_index = self._sorted_unique_assets.index(source_asset)
if asset_index % 2 == 0:
return COLOR_ASSET_GROUP_1
else:
return COLOR_ASSET_GROUP_2
except ValueError:
# Should not happen if logic is correct, but handle defensively
log.warning(f"Asset '{source_asset}' not found in _sorted_unique_assets.")
return None # Use default background
return None # Use default background for rows without a source asset
# --- Handle Text Alignment Role ---
if role == Qt.ItemDataRole.TextAlignmentRole:
if col == self.COL_ORIGINAL_PATH:
return int(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
elif col == self.COL_ADDITIONAL_FILES:
return int(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
# For other columns, return default alignment (or None)
return None
# --- Handle Text Alignment Role ---
if role == Qt.ItemDataRole.TextAlignmentRole:
if col == self.COL_ORIGINAL_PATH:
return int(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
elif col == self.COL_ADDITIONAL_FILES:
return int(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
# For other columns, return default alignment (or None)
return None
return None
def headerData(self, section: int, orientation: Qt.Orientation, role: int = Qt.ItemDataRole.DisplayRole):
"""Returns the header data for a given section, orientation, and role."""
if role == Qt.ItemDataRole.DisplayRole and orientation == Qt.Orientation.Horizontal:
headers = self._headers_simple if self._simple_mode else self._headers_detailed
if 0 <= section < len(headers):
return headers[section]
return None
def set_data(self, data: list):
"""Sets the model's data, extracts simple data, and emits signals."""
# Removed diagnostic import here
thread_id = QThread.currentThread() # Get current thread object
log.info(f"[{time.time():.4f}][T:{thread_id}] --> Entered PreviewTableModel.set_data. Received {len(data)} items.")
log.info(f"[{time.time():.4f}][T:{thread_id}] Calling beginResetModel()...")
self.beginResetModel()
log.info(f"[{time.time():.4f}][T:{thread_id}] Returned from beginResetModel(). Processing data...")
self._data = data or [] # Keep original data for reference if needed
self._table_rows = [] # Clear previous structured data
# Group files by source asset
grouped_data = {}
unique_sources = set()
if data and isinstance(data[0], dict): # Ensure data is in detailed format
for file_details in data:
source_asset = file_details.get('source_asset')
if source_asset:
if source_asset not in grouped_data:
grouped_data[source_asset] = {'main_files': [], 'additional_files': []}
unique_sources.add(source_asset)
status = file_details.get('status')
# Separate into main and additional files based on status
if status in ["Mapped", "Model", "Error"]:
grouped_data[source_asset]['main_files'].append(file_details)
else: # Ignored, Extra, Unrecognised, Unmatched Extra
grouped_data[source_asset]['additional_files'].append(file_details)
# Sort main and additional files within each group (e.g., by original_path)
for asset_data in grouped_data.values():
asset_data['main_files'].sort(key=lambda x: x.get('original_path', ''))
asset_data['additional_files'].sort(key=lambda x: x.get('original_path', '')) # Sort additional by their path
# Build the _table_rows structure
sorted_assets = sorted(list(unique_sources)) # Sort assets alphabetically
for asset_name in sorted_assets:
asset_data = grouped_data[asset_name]
main_files = asset_data['main_files']
additional_files = asset_data['additional_files']
max_rows = max(len(main_files), len(additional_files))
for i in range(max_rows):
main_file = main_files[i] if i < len(main_files) else None
additional_file = additional_files[i] if i < len(additional_files) else None
row_data = {
'source_asset': asset_name,
'main_file': main_file, # Store the full dict for easy access
'additional_file_path': additional_file.get('original_path', '') if additional_file else '',
'additional_file_details': additional_file, # Store full dict for tooltip
'is_main_row': main_file is not None # True if this row has a main file
}
self._table_rows.append(row_data)
# Store sorted unique asset paths for simple mode and coloring
self._sorted_unique_assets = sorted(list(unique_sources))
self._simple_data = self._sorted_unique_assets # Simple data is just the sorted unique assets
log.info(f"[{time.time():.4f}][T:{thread_id}] Structured data built: {len(self._table_rows)} rows.")
log.info(f"[{time.time():.4f}][T:{thread_id}] Simple data extracted: {len(self._simple_data)} unique sources.")
log.info(f"[{time.time():.4f}][T:{thread_id}] Calling endResetModel()...")
self.endResetModel()
log.info(f"[{time.time():.4f}][T:{thread_id}] Returned from endResetModel().")
log.info(f"[{time.time():.4f}][T:{thread_id}] <-- Exiting PreviewTableModel.set_data.")
def clear_data(self):
"""Clears the model's data."""
thread_id = QThread.currentThread() # Get current thread object
log.info(f"[{time.time():.4f}][T:{thread_id}] PreviewTableModel.clear_data called.")
self.set_data([])
class PreviewSortFilterProxyModel(QSortFilterProxyModel):
"""
Custom proxy model for sorting the preview table.
Implements multi-level sorting and custom status order.
"""
# Define the desired status priority for sorting
# Lower numbers sort first. Mapped/Model have same priority.
STATUS_PRIORITY = {
"Error": 0,
"Mapped": 1,
"Model": 1,
"Ignored": 2,
"Extra": 3,
"Unrecognised": 3, # Treat as Extra
"Unmatched Extra": 3, # Treat as Extra
"[No Status]": 99 # Lowest priority
}
def __init__(self, parent=None):
super().__init__(parent)
log.debug("PreviewSortFilterProxyModel initialized.")
# Set default sort column and order (Status column, Ascending)
# This will be overridden by the custom lessThan logic
self.setSortRole(PreviewTableModel.ROLE_RAW_STATUS) # Sort using the raw status role
self.sort(PreviewTableModel.COL_STATUS, Qt.SortOrder.AscendingOrder) # Apply initial sort
def lessThan(self, left: QModelIndex, right: QModelIndex):
"""
Custom comparison logic for multi-level sorting.
Sorts by:
1. Source Asset (Ascending)
2. Status (Custom Order: Error > Mapped/Model > Ignored > Extra)
3. Original Path (Ascending)
"""
model = self.sourceModel()
if not model:
# log.debug("ProxyModel.lessThan: No source model.")
return super().lessThan(left, right) # Fallback if no source model
# If in simple mode, sort by the simple path column
if isinstance(model, PreviewTableModel) and model._simple_mode:
left_path = model.data(left.siblingAtColumn(model.COL_SIMPLE_PATH), Qt.ItemDataRole.DisplayRole)
right_path = model.data(right.siblingAtColumn(model.COL_SIMPLE_PATH), Qt.ItemDataRole.DisplayRole)
# log.debug(f"ProxyModel.lessThan (Simple Mode): Comparing '{left_path}' < '{right_path}'")
if not left_path: return True
if not right_path: return False
return left_path < right_path
# --- Detailed Mode Sorting ---
# log.debug("ProxyModel.lessThan (Detailed Mode).")
# Get the full row data from the source model's _table_rows
left_row_data = model._table_rows[left.row()]
right_row_data = model._table_rows[right.row()]
# --- Level 1: Sort by Source Asset ---
left_asset = left_row_data.get('source_asset', 'N/A')
right_asset = right_row_data.get('source_asset', 'N/A')
if left_asset != right_asset:
# Handle None/empty strings for consistent sorting
if not left_asset or left_asset == 'N/A': return True # Empty asset comes first
if not right_asset or right_asset == 'N/A': return False # Non-empty asset comes first
return left_asset < right_asset # Alphabetical sort for assets
# --- Level 2: Sort by Row Type (Main vs Additional-only) ---
# Main rows (is_main_row == True) should come before additional-only rows
left_is_main = left_row_data.get('is_main_row', False)
right_is_main = right_row_data.get('is_main_row', False)
if left_is_main != right_is_main:
return left_is_main > right_is_main # True > False
# --- Level 3: Sort within the row type ---
if left_is_main: # Both are main rows
# Sort by Original Path (Alphabetical)
left_path = left_row_data.get('main_file', {}).get('original_path', '')
right_path = right_row_data.get('main_file', {}).get('original_path', '')
if not left_path: return True
if not right_path: return False
return left_path < right_path
else: # Both are additional-only rows
# Sort by Additional File Path (Alphabetical)
left_additional_path = left_row_data.get('additional_file_path', '')
right_additional_path = right_row_data.get('additional_file_path', '')
if not left_additional_path: return True
if not right_additional_path: return False
return left_additional_path < right_additional_path
# Should not reach here if logic is correct, but include a fallback
return super().lessThan(left, right)
# Override sort method to ensure custom sorting is used
def sort(self, column: int, order: Qt.SortOrder = Qt.SortOrder.AscendingOrder):
# We ignore the column and order here and rely on lessThan for multi-level sort
# However, calling this method is necessary to trigger the proxy model's sorting mechanism.
# We can potentially use the column/order to toggle ascending/descending within each level in lessThan,
# but for now, we'll stick to the defined order.
log.debug(f"ProxyModel.sort called with column {column}, order {order}. Triggering lessThan.")
# Call base class sort to trigger update. Pass a valid column, e.g., COL_STATUS,
# as the actual sorting logic is in lessThan.
super().sort(PreviewTableModel.COL_STATUS, Qt.SortOrder.AscendingOrder)

345
gui/processing_handler.py Normal file
View File

@@ -0,0 +1,345 @@
# gui/processing_handler.py
import logging
from pathlib import Path
from concurrent.futures import ProcessPoolExecutor, as_completed
import time # For potential delays if needed
import subprocess # <<< ADDED IMPORT
import shutil # <<< ADDED IMPORT
from typing import Optional # <<< ADDED IMPORT
# --- PySide6 Imports ---
# Inherit from QObject to support signals/slots for thread communication
from PySide6.QtCore import QObject, Signal
# --- Backend Imports ---
# Need to import the worker function and potentially config/processor if needed directly
# Adjust path to ensure modules can be found relative to this file's location
import sys
script_dir = Path(__file__).parent
project_root = script_dir.parent
if str(project_root) not in sys.path:
sys.path.insert(0, str(project_root))
try:
# Import the worker function from main.py
from main import process_single_asset_wrapper
# Import exceptions if needed for type hinting or specific handling
from configuration import ConfigurationError
from asset_processor import AssetProcessingError
import config as core_config # <<< ADDED IMPORT
BACKEND_AVAILABLE = True
except ImportError as e:
print(f"ERROR (ProcessingHandler): Failed to import backend modules/worker: {e}")
# Define placeholders if imports fail, so the GUI doesn't crash immediately
process_single_asset_wrapper = None
ConfigurationError = Exception
AssetProcessingError = Exception
BACKEND_AVAILABLE = False
log = logging.getLogger(__name__)
# Basic config if logger hasn't been set up elsewhere
if not log.hasHandlers():
logging.basicConfig(level=logging.INFO, format='%(levelname)s (Handler): %(message)s')
class ProcessingHandler(QObject):
"""
Handles the execution of the asset processing pipeline in a way that
can be run in a separate thread and communicate progress via signals.
"""
# --- Signals ---
# Emitted for overall progress bar update
progress_updated = Signal(int, int) # current_count, total_count
# Emitted for updating status of individual files in the list
file_status_updated = Signal(str, str, str) # input_path_str, status ("processing", "processed", "skipped", "failed"), message
# Emitted when the entire batch processing is finished
processing_finished = Signal(int, int, int) # processed_count, skipped_count, failed_count
# Emitted for general status messages to the status bar
status_message = Signal(str, int) # message, timeout_ms
def __init__(self, parent=None):
super().__init__(parent)
self._executor = None
self._futures = {} # Store future->input_path mapping
self._is_running = False
self._cancel_requested = False
@property
def is_running(self):
return self._is_running
def run_processing(self, input_paths: list[str], preset_name: str, output_dir_str: str, overwrite: bool, num_workers: int,
run_blender: bool, nodegroup_blend_path: str, materials_blend_path: str, verbose: bool): # <<< ADDED verbose PARAM
"""
Starts the asset processing task and optionally runs Blender scripts afterwards.
This method should be called when the handler is moved to a separate thread.
"""
if self._is_running:
log.warning("Processing is already running.")
self.status_message.emit("Processing already in progress.", 3000)
return
if not BACKEND_AVAILABLE or not process_single_asset_wrapper:
log.error("Backend modules or worker function not available. Cannot start processing.")
self.status_message.emit("Error: Backend components missing. Cannot process.", 5000)
self.processing_finished.emit(0, 0, len(input_paths)) # Emit finished with all failed
return
self._is_running = True
self._cancel_requested = False
self._futures = {} # Reset futures
total_files = len(input_paths)
processed_count = 0
skipped_count = 0
failed_count = 0
completed_count = 0
log.info(f"Starting processing run: {total_files} assets, Preset='{preset_name}', Workers={num_workers}, Overwrite={overwrite}")
self.status_message.emit(f"Starting processing for {total_files} items...", 0) # Persistent message
try:
# Use 'with' statement for ProcessPoolExecutor for cleanup
with ProcessPoolExecutor(max_workers=num_workers) as executor:
self._executor = executor # Store for potential cancellation
# Submit tasks
for input_path in input_paths:
if self._cancel_requested: break # Check before submitting more
log.debug(f"Submitting task for: {input_path}")
future = executor.submit(process_single_asset_wrapper, input_path, preset_name, output_dir_str, overwrite, verbose=verbose) # Pass verbose flag from GUI
self._futures[future] = input_path # Map future back to input path
# Optionally emit "processing" status here
self.file_status_updated.emit(input_path, "processing", "")
if self._cancel_requested:
log.info("Processing cancelled during task submission.")
# Count remaining unsubmitted tasks as failed/cancelled
failed_count = total_files - len(self._futures)
# Process completed futures
for future in as_completed(self._futures):
completed_count += 1
input_path = self._futures[future] # Get original path
asset_name = Path(input_path).name
status = "failed" # Default status
error_message = "Unknown error"
if self._cancel_requested:
# If cancelled after submission, try to get result but count as failed
status = "failed"
error_message = "Cancelled"
failed_count += 1
# Don't try future.result() if cancelled, it might raise CancelledError
else:
try:
# Get result tuple: (input_path_str, status_string, error_message_or_None)
result_tuple = future.result()
_, status, error_message = result_tuple
error_message = error_message or "" # Ensure it's a string
# Increment counters based on status
if status == "processed":
processed_count += 1
elif status == "skipped":
skipped_count += 1
elif status == "failed":
failed_count += 1
else:
log.warning(f"Unknown status '{status}' received for {asset_name}. Counting as failed.")
failed_count += 1
error_message = f"Unknown status: {status}"
except Exception as e:
# Catch errors if the future itself fails (e.g., worker process crashed hard)
log.exception(f"Critical worker failure for {asset_name}: {e}")
failed_count += 1 # Count crashes as failures
status = "failed"
error_message = f"Worker process crashed: {e}"
# Emit progress signals
self.progress_updated.emit(completed_count, total_files)
self.file_status_updated.emit(input_path, status, error_message)
# Check for cancellation again after processing each result
if self._cancel_requested:
log.info("Cancellation detected after processing a result.")
# Count remaining unprocessed futures as failed/cancelled
remaining_futures = total_files - completed_count
failed_count += remaining_futures
break # Exit the as_completed loop
except Exception as pool_exc:
log.exception(f"An error occurred with the process pool: {pool_exc}")
self.status_message.emit(f"Error during processing: {pool_exc}", 5000)
# Mark all remaining as failed
failed_count = total_files - processed_count - skipped_count
finally:
# --- Blender Script Execution (Optional) ---
if run_blender and not self._cancel_requested:
log.info("Asset processing complete. Checking for Blender script execution.")
self.status_message.emit("Asset processing complete. Starting Blender scripts...", 0)
blender_exe = self._find_blender_executable()
if blender_exe:
script_dir = Path(__file__).parent.parent / "blenderscripts" # Go up one level from gui/
nodegroup_script_path = script_dir / "create_nodegroups.py"
materials_script_path = script_dir / "create_materials.py"
asset_output_root = output_dir_str # Use the same output dir
# Run Nodegroup Script
if nodegroup_blend_path and Path(nodegroup_blend_path).is_file():
if nodegroup_script_path.is_file():
log.info("-" * 20 + " Running Nodegroup Script " + "-" * 20)
self.status_message.emit(f"Running Blender nodegroup script on {Path(nodegroup_blend_path).name}...", 0)
success_ng = self._run_blender_script_subprocess(
blender_exe_path=blender_exe,
blend_file_path=nodegroup_blend_path,
python_script_path=str(nodegroup_script_path),
asset_root_dir=asset_output_root
)
if not success_ng:
log.error("Blender node group script execution failed.")
self.status_message.emit("Blender nodegroup script failed.", 5000)
else:
log.info("Blender nodegroup script finished successfully.")
self.status_message.emit("Blender nodegroup script finished.", 3000)
else:
log.error(f"Node group script not found: {nodegroup_script_path}")
self.status_message.emit(f"Error: Nodegroup script not found.", 5000)
elif run_blender and nodegroup_blend_path: # Log if path was provided but invalid
log.warning(f"Nodegroup blend path provided but invalid: {nodegroup_blend_path}")
self.status_message.emit(f"Warning: Invalid Nodegroup .blend path.", 5000)
# Run Materials Script (only if nodegroup script was attempted or not needed)
if materials_blend_path and Path(materials_blend_path).is_file():
if materials_script_path.is_file():
log.info("-" * 20 + " Running Materials Script " + "-" * 20)
self.status_message.emit(f"Running Blender materials script on {Path(materials_blend_path).name}...", 0)
# Pass the nodegroup blend path as the second argument to the script
success_mat = self._run_blender_script_subprocess(
blender_exe_path=blender_exe,
blend_file_path=materials_blend_path,
python_script_path=str(materials_script_path),
asset_root_dir=asset_output_root,
nodegroup_blend_file_path_arg=nodegroup_blend_path # Pass the nodegroup path
)
if not success_mat:
log.error("Blender material script execution failed.")
self.status_message.emit("Blender material script failed.", 5000)
else:
log.info("Blender material script finished successfully.")
self.status_message.emit("Blender material script finished.", 3000)
else:
log.error(f"Material script not found: {materials_script_path}")
self.status_message.emit(f"Error: Material script not found.", 5000)
elif run_blender and materials_blend_path: # Log if path was provided but invalid
log.warning(f"Materials blend path provided but invalid: {materials_blend_path}")
self.status_message.emit(f"Warning: Invalid Materials .blend path.", 5000)
else:
log.warning("Blender executable not found. Skipping Blender script execution.")
self.status_message.emit("Warning: Blender executable not found. Skipping scripts.", 5000)
elif self._cancel_requested:
log.info("Processing was cancelled. Skipping Blender script execution.")
# --- End Blender Script Execution ---
final_message = f"Finished. Processed: {processed_count}, Skipped: {skipped_count}, Failed: {failed_count}"
log.info(final_message)
self.status_message.emit(final_message, 5000) # Show final summary
self.processing_finished.emit(processed_count, skipped_count, failed_count)
self._is_running = False
self._executor = None
self._futures = {} # Clear futures
def request_cancel(self):
"""Requests cancellation of the ongoing processing task."""
if not self._is_running:
log.warning("Cancel requested but no processing is running.")
return
if self._cancel_requested:
log.warning("Cancellation already requested.")
return
log.info("Cancellation requested.")
self.status_message.emit("Cancellation requested...", 3000)
self._cancel_requested = True
# Attempt to shutdown the executor - this might cancel pending tasks
# but won't forcefully stop running ones. `cancel_futures=True` is Python 3.9+
if self._executor:
log.debug("Requesting executor shutdown...")
# For Python 3.9+: self._executor.shutdown(wait=False, cancel_futures=True)
# For older Python:
self._executor.shutdown(wait=False)
# Manually try cancelling futures that haven't started
for future in self._futures:
if not future.running() and not future.done():
future.cancel()
log.debug("Executor shutdown requested.")
# Note: True cancellation of running ProcessPoolExecutor tasks is complex.
# This implementation primarily prevents processing further results and
# attempts to cancel pending/unstarted tasks.
def _find_blender_executable(self) -> Optional[str]:
"""Finds the Blender executable path from config or system PATH."""
try:
blender_exe_config = getattr(core_config, 'BLENDER_EXECUTABLE_PATH', None)
if blender_exe_config:
p = Path(blender_exe_config)
if p.is_file():
log.info(f"Using Blender executable from config: {p}")
return str(p.resolve())
else:
log.warning(f"Blender path in config not found: '{blender_exe_config}'. Trying PATH.")
else:
log.info("BLENDER_EXECUTABLE_PATH not set in config. Trying PATH.")
blender_exe = shutil.which("blender")
if blender_exe:
log.info(f"Found Blender executable in PATH: {blender_exe}")
return blender_exe
else:
log.warning("Could not find 'blender' in system PATH.")
return None
except Exception as e:
log.error(f"Error checking Blender executable path: {e}")
return None
def _run_blender_script_subprocess(self, blender_exe_path: str, blend_file_path: str, python_script_path: str, asset_root_dir: str, nodegroup_blend_file_path_arg: Optional[str] = None) -> bool:
"""Internal helper to run a single Blender script via subprocess."""
command_base = [
blender_exe_path,
"--factory-startup",
"-b",
blend_file_path,
"--log", "*", # <<< ADDED BLENDER LOGGING FLAG
"--python", python_script_path,
"--",
asset_root_dir,
]
# Add nodegroup blend file path if provided (for create_materials script)
if nodegroup_blend_file_path_arg:
command = command_base + [nodegroup_blend_file_path_arg]
else:
command = command_base
log.debug(f"Executing Blender command: {' '.join(map(str, command))}") # Ensure all parts are strings for join
try:
# Ensure all parts of the command are strings for subprocess
str_command = [str(part) for part in command]
result = subprocess.run(str_command, capture_output=True, text=True, check=False, encoding='utf-8') # Specify encoding
log.info(f"Blender script '{Path(python_script_path).name}' finished with exit code: {result.returncode}")
if result.stdout: log.debug(f"Blender stdout:\n{result.stdout.strip()}")
if result.stderr:
if result.returncode != 0: log.error(f"Blender stderr:\n{result.stderr.strip()}")
else: log.warning(f"Blender stderr (RC=0):\n{result.stderr.strip()}")
return result.returncode == 0
except FileNotFoundError:
log.error(f"Blender executable not found at: {blender_exe_path}")
return False
except Exception as e:
log.exception(f"Error running Blender script '{Path(python_script_path).name}': {e}")
return False