Initial Work on data-transfer refactor

This commit is contained in:
2025-04-29 23:11:44 +02:00
parent f66971f3fd
commit a6dc49ebb1
16 changed files with 1092 additions and 246 deletions

View File

@@ -10,18 +10,23 @@ log = logging.getLogger(__name__)
log.info(f"sys.path: {sys.path}")
from PySide6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QSplitter, # Added QSplitter
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QSplitter, QTableView, # Added QSplitter, QTableView
QPushButton, QComboBox, QTableWidget, QTableWidgetItem, QHeaderView,
QProgressBar, QLabel, QFrame, QCheckBox, QSpinBox, QListWidget, QTextEdit, # Added QListWidget, QTextEdit
QLineEdit, QMessageBox, QFileDialog, QInputDialog, QListWidgetItem, QTabWidget, # Added more widgets
QFormLayout, QGroupBox, QAbstractItemView, QSizePolicy, # Added more layout/widget items
QMenuBar, QMenu # Added for menu
)
from PySide6.QtCore import Qt, QThread, Slot, Signal, QObject # Added Signal, QObject
from PySide6.QtCore import Qt, QThread, Slot, Signal, QObject, QModelIndex # Added Signal, QObject, QModelIndex
from PySide6.QtGui import QColor, QAction, QPalette # Add QColor import, QAction, QPalette
# --- Backend Imports for Data Structures ---
from rule_structure import SourceRule, AssetRule, FileRule # Import Rule Structures
from gui.rule_editor_widget import RuleEditorWidget # Import the new rule editor widget
# --- GUI Model Imports ---
from gui.preview_table_model import PreviewTableModel, PreviewSortFilterProxyModel
from gui.rule_hierarchy_model import RuleHierarchyModel # Import the new hierarchy model
# --- Backend Imports ---
script_dir = Path(__file__).parent
@@ -150,6 +155,8 @@ def setup_table_widget_with_controls(parent_layout, label_text, attribute_name,
class MainWindow(QMainWindow):
# Signal emitted when presets change in the editor panel
presets_changed_signal = Signal()
# Signal to trigger prediction handler in its thread
start_prediction_signal = Signal(list, str, object) # input_paths, preset_name, rules
def __init__(self):
super().__init__()
@@ -159,6 +166,8 @@ class MainWindow(QMainWindow):
# --- Internal State ---
self.current_asset_paths = set() # Store unique paths of assets added
self.rule_hierarchy_model = RuleHierarchyModel() # Instantiate the hierarchy model
self._current_source_rule = None # Store the current SourceRule object
# --- Editor State ---
self.current_editing_preset_path = None
@@ -172,6 +181,27 @@ class MainWindow(QMainWindow):
self.prediction_handler = None
self.setup_threads()
# --- Preview Area (Table) Setup ---
# Initialize models
self.preview_model = PreviewTableModel()
self.preview_proxy_model = PreviewSortFilterProxyModel()
self.preview_proxy_model.setSourceModel(self.preview_model)
# Initialize table view and placeholder
self.preview_table_view = QTableView()
self.preview_table_view.setModel(self.preview_proxy_model)
self.preview_placeholder_label = QLabel("Please select a preset to view file predictions")
self.preview_placeholder_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.preview_placeholder_label.setStyleSheet("QLabel { font-size: 16px; color: grey; }")
# Initially hide the table view and show the placeholder
self.preview_table_view.setVisible(False)
self.preview_placeholder_label.setVisible(True)
# Apply style sheet to remove borders and rounded corners
self.preview_table_view.setStyleSheet("QTableView { border: none; }")
# --- Main Layout with Splitter ---
self.splitter = QSplitter(Qt.Orientation.Horizontal)
self.setCentralWidget(self.splitter)
@@ -361,7 +391,6 @@ class MainWindow(QMainWindow):
self.output_path_edit.setText("") # Clear on error
self.statusBar().showMessage(f"Error setting default output path: {e}", 5000)
# --- Drag and Drop Area ---
self.drag_drop_area = QFrame()
self.drag_drop_area.setFrameShape(QFrame.Shape.StyledPanel)
@@ -375,27 +404,31 @@ class MainWindow(QMainWindow):
main_layout.addWidget(self.drag_drop_area)
self.drag_drop_area.setVisible(False) # Hide the specific visual drag/drop area
# --- Preview Area (Table) ---
self.preview_label = QLabel("File Preview:") # Updated Label
self.preview_table = QTableWidget() # Keep QTableWidget for now, will replace with QTableView later
# Initialize models
self.preview_model = PreviewTableModel()
self.preview_proxy_model = PreviewSortFilterProxyModel()
self.preview_proxy_model.setSourceModel(self.preview_model)
# --- Hierarchy and Rule Editor Splitter ---
self.hierarchy_rule_splitter = QSplitter(Qt.Orientation.Vertical)
main_layout.addWidget(self.hierarchy_rule_splitter, 1) # Give it stretch factor
# Use the proxy model for the table view
# NOTE: QTableWidget is simpler but less flexible with models.
# For full model/view benefits (like multi-column sorting via proxy),
# we should ideally switch to QTableView. Sticking with QTableWidget for minimal change first.
# However, QTableWidget doesn't fully support QSortFilterProxyModel for sorting.
# Let's switch to QTableView now for proper model/proxy integration.
from PySide6.QtWidgets import QTableView # Import QTableView
# --- Hierarchy Tree View ---
from PySide6.QtWidgets import QTreeView # Import QTreeView
self.hierarchy_tree_view = QTreeView()
self.hierarchy_tree_view.setHeaderHidden(True) # Hide header for simple hierarchy display
self.hierarchy_tree_view.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) # Make items non-editable
self.hierarchy_tree_view.setModel(self.rule_hierarchy_model) # Set the hierarchy model
self.hierarchy_tree_view.clicked.connect(self._on_hierarchy_item_clicked) # Connect click signal
self.hierarchy_rule_splitter.addWidget(self.hierarchy_tree_view)
self.preview_table_view = QTableView() # Use QTableView instead of QTableWidget
self.preview_table_view.setModel(self.preview_proxy_model) # Set the proxy model
# --- Rule Editor Widget ---
self.rule_editor_widget = RuleEditorWidget()
self.rule_editor_widget.rule_updated.connect(self._on_rule_updated) # Connect rule updated signal
self.hierarchy_rule_splitter.addWidget(self.rule_editor_widget)
# Set initial sizes for the splitter
self.hierarchy_rule_splitter.setSizes([200, 400]) # Adjust sizes as needed
# --- Preview Area (Table) - Moved into the splitter ---
# The preview table view will now be used to display files for the selected asset/source
# Set headers and resize modes using the model's headerData
# The model defines the columns and headers
header = self.preview_table_view.horizontalHeader()
# Set resize modes for detailed columns
header.setSectionResizeMode(self.preview_model.COL_STATUS, QHeaderView.ResizeMode.ResizeToContents)
@@ -436,24 +469,29 @@ class MainWindow(QMainWindow):
header.moveSection(header.visualIndex(self.preview_model.COL_ADDITIONAL_FILES), 4)
# Current visual: [0, 1, 4, 2, 5, 3] - This looks correct.
main_layout.addWidget(self.preview_label)
# Add placeholder label for the preview area (already done, just referencing)
# self.preview_placeholder_label = QLabel("Please select a preset to view file predictions") # Already initialized in __init__
# self.preview_placeholder_label.setAlignment(Qt.AlignmentFlag.AlignCenter) # Already done
# self.preview_placeholder_label.setStyleSheet("QLabel { font-size: 16px; color: grey; }") # Optional styling # Already done
# Add placeholder label for the preview area
self.preview_placeholder_label = QLabel("Please select a preset to view file predictions")
self.preview_placeholder_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.preview_placeholder_label.setStyleSheet("QLabel { font-size: 16px; color: grey; }") # Optional styling
# Add both the table view and the placeholder label to the layout (already done, just referencing)
# We will manage their visibility later (already done, just referencing)
# main_layout.addWidget(self.preview_placeholder_label, 1) # Give it stretch factor # REMOVED - Now managed by splitter
# main_layout.addWidget(self.preview_table_view, 1) # Give it stretch factor # REMOVED - Now managed by splitter
# Add both the table view and the placeholder label to the layout
# We will manage their visibility later
main_layout.addWidget(self.preview_placeholder_label, 1) # Give it stretch factor
main_layout.addWidget(self.preview_table_view, 1) # Give it stretch factor
# Initially hide the table view and show the placeholder (already done, just referencing)
# self.preview_table_view.setVisible(False) # Already done
# self.preview_placeholder_label.setVisible(True) # Already done
# Initially hide the table view and show the placeholder
self.preview_table_view.setVisible(False)
self.preview_placeholder_label.setVisible(True)
# Apply style sheet to remove borders and rounded corners (already done, just referencing)
# self.preview_table_view.setStyleSheet("QTableView { border: none; }") # Already done
# Apply style sheet to remove borders and rounded corners
self.preview_table_view.setStyleSheet("QTableView { border: none; }")
# --- Add Preview Table View to Splitter ---
# The preview table view will now be placed below the hierarchy tree view in the splitter
# It will display the files associated with the selected item in the hierarchy
self.hierarchy_rule_splitter.addWidget(self.preview_table_view)
# Set initial sizes for the splitter (adjusting to include the table view)
self.hierarchy_rule_splitter.setSizes([200, 200, 400]) # Hierarchy, Rule Editor, File Preview
# --- Progress Bar ---
self.progress_bar = QProgressBar()
@@ -751,10 +789,22 @@ class MainWindow(QMainWindow):
if self.processing_thread and self.processing_handler:
try: self.processing_thread.started.disconnect()
except RuntimeError: pass
# Use the current SourceRule from the hierarchy model
if self._current_source_rule is None:
log.error("Cannot start processing: No rule hierarchy available.")
self.statusBar().showMessage("Error: No rule hierarchy available. Run preview first.", 5000)
self.set_controls_enabled(True)
self.cancel_button.setEnabled(False)
self.start_button.setText("Start Processing")
return
log.debug(f"Using SourceRule '{self._current_source_rule.name}' for processing.")
self.processing_thread.started.connect(
lambda: self.processing_handler.run_processing(
input_paths, selected_preset, output_dir_str, overwrite, num_workers,
# Pass Blender integration settings
rules=self._current_source_rule, # Pass the current SourceRule
run_blender=self.blender_integration_checkbox.isChecked(),
nodegroup_blend_path=self.nodegroup_blend_path_input.text(),
materials_blend_path=self.materials_blend_path_input.text(),
@@ -969,17 +1019,25 @@ class MainWindow(QMainWindow):
log.info(f"[{time.time():.4f}] Requesting background preview update for {len(input_paths)} items, Preset='{selected_preset}'")
self.statusBar().showMessage(f"Updating preview for '{selected_preset}'...", 0)
# Clearing is handled by model's set_data now, no need to clear table view directly
self.setup_threads() # Ensure threads are ready
if self.prediction_thread and self.prediction_handler:
try: self.prediction_thread.started.disconnect() # Disconnect previous lambda if any
except RuntimeError: pass
# Connect the lambda to start the prediction
self.prediction_thread.started.connect(
lambda: self.prediction_handler.run_prediction(input_paths, selected_preset)
)
# Create a placeholder SourceRule instance (replace with actual rule loading later)
placeholder_rules = SourceRule() # Temporary rule for passing data
log.debug(f"Created placeholder SourceRule for prediction.")
# Create a placeholder SourceRule instance (replace with actual rule loading later)
placeholder_rules = SourceRule() # Temporary rule for passing data
log.debug(f"Created placeholder SourceRule for prediction.")
# Start the prediction thread
log.debug(f"[{time.time():.4f}] Starting prediction thread...")
self.prediction_thread.start()
log.debug(f"[{time.time():.4f}] Prediction thread start requested.")
# Emit the signal to trigger run_prediction in the prediction thread
log.debug(f"[{time.time():.4f}] Emitting start_prediction_signal...")
self.start_prediction_signal.emit(input_paths, selected_preset, placeholder_rules)
log.debug(f"[{time.time():.4f}] start_prediction_signal emitted.")
else:
log.error(f"[{time.time():.4f}][T:{thread_id}] Failed to start prediction: Thread or handler not initialized.")
self.statusBar().showMessage("Error: Failed to initialize prediction thread.", 5000)
@@ -1013,7 +1071,10 @@ class MainWindow(QMainWindow):
self.prediction_thread = QThread(self)
self.prediction_handler = PredictionHandler()
self.prediction_handler.moveToThread(self.prediction_thread)
self.prediction_handler.prediction_results_ready.connect(self.on_prediction_results_ready) # Updated slot below
# Connect the new signal to the handler's run_prediction slot using QueuedConnection
self.start_prediction_signal.connect(self.prediction_handler.run_prediction, Qt.ConnectionType.QueuedConnection)
self.prediction_handler.prediction_results_ready.connect(self.on_prediction_results_ready) # Connect the file list signal
self.prediction_handler.rule_hierarchy_ready.connect(self._on_rule_hierarchy_ready) # Connect the new hierarchy signal
self.prediction_handler.prediction_finished.connect(self.on_prediction_finished)
self.prediction_handler.status_message.connect(self.show_status_message)
self.prediction_handler.prediction_finished.connect(self.prediction_thread.quit)
@@ -1613,6 +1674,84 @@ class MainWindow(QMainWindow):
else:
event.accept() # Accept close event
# --- Slots for Hierarchy and Rule Editor ---
@Slot(QModelIndex)
def _on_hierarchy_item_clicked(self, index: QModelIndex):
"""Loads the selected rule item into the rule editor and filters the preview table."""
if index.isValid():
rule_item = self.rule_hierarchy_model.get_item_from_index(index)
if rule_item:
rule_type_name = type(rule_item).__name__
log.debug(f"Hierarchy item clicked: {rule_type_name} - {getattr(rule_item, 'name', 'N/A')}")
self.rule_editor_widget.load_rule(rule_item, rule_type_name)
# Filter the preview table based on the selected item
if isinstance(rule_item, SourceRule):
# Show all files for the source
self.preview_proxy_model.setFilterRegularExpression("") # Clear filter
self.preview_proxy_model.setFilterKeyColumn(-1) # Apply to all columns (effectively no column filter)
log.debug("Filtering preview table: Showing all files for Source.")
elif isinstance(rule_item, AssetRule):
# Show files belonging to this asset
# Filter by the 'source_asset' column (which stores the asset name/path)
# Need to escape potential regex special characters in the asset name/path
filter_string = "^" + rule_item.asset_name.replace('\\', '\\\\').replace('.', '\\.') + "$"
self.preview_proxy_model.setFilterRegularExpression(filter_string)
self.preview_proxy_model.setFilterKeyColumn(self.preview_model.ROLE_SOURCE_ASSET) # Filter by source_asset column
log.debug(f"Filtering preview table: Showing files for Asset '{rule_item.name}'. Filter: '{filter_string}' on column {self.preview_model.COL_SOURCE_ASSET}")
elif isinstance(rule_item, FileRule):
# Show only this specific file
# Filter by the 'original_path' column
filter_string = "^" + rule_item.file_path.replace('\\', '\\\\').replace('.', '\\.') + "$"
self.preview_proxy_model.setFilterRegularExpression(filter_string)
self.preview_proxy_model.setFilterKeyColumn(self.preview_model.COL_ORIGINAL_PATH) # Filter by original_path column
log.debug(f"Filtering preview table: Showing file '{rule_item.file_path}'. Filter: '{filter_string}' on column {self.preview_model.COL_ORIGINAL_PATH}")
else:
# Clear filter for unknown types
self.preview_proxy_model.setFilterRegularExpression("")
self.preview_proxy_model.setFilterKeyColumn(-1)
log.warning(f"Clicked item has unknown type {type(rule_item)}. Clearing preview filter.")
else:
log.warning("Clicked item has no associated rule object. Clearing editor and preview filter.")
self.rule_editor_widget.clear_editor()
self.preview_proxy_model.setFilterRegularExpression("") # Clear filter
self.preview_proxy_model.setFilterKeyColumn(-1)
else:
log.debug("Clicked item index is invalid. Clearing editor and preview filter.")
self.rule_editor_widget.clear_editor()
self.preview_proxy_model.setFilterRegularExpression("") # Clear filter
self.preview_proxy_model.setFilterKeyColumn(-1)
@Slot(object)
def _on_rule_updated(self, rule_object):
"""Handles the signal when a rule is updated in the editor."""
# This slot is called when an attribute is changed in the RuleEditorWidget.
# The rule_object passed is the actual object instance from the hierarchy model.
# Since the RuleEditorWidget modifies the object in place, we don't need to
# explicitly update the model's data structure here.
# However, if the change affects the display in the hierarchy tree or preview table,
# we might need to emit dataChanged signals or trigger updates.
# For now, just log the update.
log.debug(f"Rule object updated in editor: {type(rule_object).__name__} - {getattr(rule_object, 'name', 'N/A')}")
# TODO: Consider if any UI updates are needed based on the rule change.
# E.g., if a rule name changes, the hierarchy tree might need a dataChanged signal.
# If a rule affects file output names, the preview table might need updating.
# This is complex and depends on which rule attributes are editable and their impact.
@Slot(object)
def _on_rule_hierarchy_ready(self, source_rule: SourceRule):
"""Receives the generated SourceRule hierarchy and updates the tree view model."""
log.info(f"Received rule hierarchy ready signal for input: {source_rule.input_path}")
self._current_source_rule = source_rule # Store the generated rule hierarchy
self.rule_hierarchy_model.set_root_rule(source_rule) # Update the tree view model
self.hierarchy_tree_view.expandToDepth(0) # Expand the first level (Source and Assets)
log.debug("Rule hierarchy model updated and tree view expanded.")
# --- Main Execution ---
def run_gui():
"""Initializes and runs the Qt application."""

View File

@@ -1,12 +1,14 @@
from rule_structure import SourceRule, AssetRule, FileRule
# 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
from collections import defaultdict
# --- PySide6 Imports ---
from PySide6.QtCore import QObject, Signal, QThread # Import QThread
from PySide6.QtCore import QObject, Signal, QThread, Slot # Import QThread and Slot
# --- Backend Imports ---
# Adjust path to ensure modules can be found relative to this file's location
@@ -43,6 +45,8 @@ class PredictionHandler(QObject):
# 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 the hierarchical rule structure is ready
rule_hierarchy_ready = Signal(object) # Emits a SourceRule object
# Emitted when all predictions for a batch are done
prediction_finished = Signal()
# Emitted for status updates
@@ -57,78 +61,72 @@ class PredictionHandler(QObject):
def is_running(self):
return self._is_running
def _predict_single_asset(self, input_path_str: str, config: Configuration) -> list[dict]:
def _predict_single_asset(self, input_path_str: str, config: Configuration, rules: SourceRule) -> list[dict] | 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.
Helper method to run detailed file prediction for a single input path.
Runs within the ThreadPoolExecutor.
Returns a list of file prediction dictionaries for the input, or a dictionary representing an error.
"""
input_path = Path(input_path_str)
source_asset_name = input_path.name # For reference in the results
asset_results = []
source_asset_name = input_path.name # For reference in error reporting
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.
# Create AssetProcessor instance (needs dummy output path for prediction)
# The detailed prediction method handles its own workspace setup/cleanup
processor = AssetProcessor(input_path, config, Path(".")) # Dummy output path
# Get detailed file predictions
detailed_predictions = processor.get_detailed_file_predictions()
# Get the detailed file predictions
# This method returns a list of dictionaries
detailed_predictions = processor.get_detailed_file_predictions(rules)
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)
log.error(f"AssetProcessor.get_detailed_file_predictions returned None for {input_path_str}.")
# Return a list containing a single error entry for consistency
return [{
'original_path': source_asset_name,
'predicted_asset_name': None,
'predicted_output_name': None,
'status': 'Error',
'details': 'Prediction returned no results',
'source_asset': source_asset_name
}]
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({
# Add the source_asset name to each prediction result for grouping later
for prediction in detailed_predictions:
prediction['source_asset'] = source_asset_name
log.debug(f"Generated {len(detailed_predictions)} detailed predictions for {input_path_str}.")
return detailed_predictions # Return the list of dictionaries
except AssetProcessingError as e:
log.error(f"Asset processing error during prediction for {input_path_str}: {e}")
# Return a list containing a single error entry for consistency
return [{
'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
}]
except Exception as e:
log.exception(f"Unexpected error during prediction for {input_path_str}: {e}")
asset_results.append({
# Return a list containing a single error entry for consistency
return [{
'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):
@Slot()
def run_prediction(self, input_paths: list[str], preset_name: str, rules: SourceRule):
"""
Runs the prediction logic for the given paths and preset using a ThreadPoolExecutor.
Generates the hierarchical rule structure and detailed file predictions.
This method is intended to be run in a separate QThread.
"""
if self._is_running:
@@ -156,7 +154,6 @@ class PredictionHandler(QObject):
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
@@ -166,9 +163,15 @@ class PredictionHandler(QObject):
self.prediction_finished.emit()
return
all_file_results = [] # Accumulate results here
# Create the root SourceRule object
# For now, use a generic name. Later, this might be derived from input paths.
source_rule = SourceRule()
log.debug(f"Created root SourceRule object.")
# Collect all detailed file prediction results from completed futures
all_file_prediction_results = []
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.")
@@ -176,22 +179,34 @@ class PredictionHandler(QObject):
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)
# _predict_single_asset now returns a list of file prediction dicts or an error dict list
future = executor.submit(self._predict_single_asset, input_path_str, config, rules)
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)
result = future.result()
if isinstance(result, list):
# Extend the main list with results from this asset
all_file_prediction_results.extend(result)
elif isinstance(result, dict) and result.get('status') == 'Error':
# Handle error dictionaries returned by _predict_single_asset (should be in a list now, but handle single dict for safety)
all_file_prediction_results.append(result)
else:
log.error(f'Prediction task returned unexpected result type: {type(result)}')
all_file_prediction_results.append({
'original_path': '[Unknown Asset - Unexpected Result]',
'predicted_asset_name': None,
'predicted_output_name': None,
'status': 'Error',
'details': f'Unexpected result type: {type(result)}',
'source_asset': '[Unknown]'
})
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({
all_file_prediction_results.append({
'original_path': '[Unknown Asset - Executor Error]',
'predicted_asset_name': None,
'predicted_output_name': None,
@@ -203,8 +218,7 @@ class PredictionHandler(QObject):
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({
all_file_prediction_results.append({
'original_path': '[Prediction Pool Error]',
'predicted_asset_name': None,
'predicted_output_name': None,
@@ -213,19 +227,61 @@ class PredictionHandler(QObject):
'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)}")
# --- Build the hierarchical rule structure (SourceRule -> AssetRule -> FileRule) ---
# Group file prediction results by predicted_asset_name
grouped_by_asset = defaultdict(list)
for file_pred in all_file_prediction_results:
# Group by predicted_asset_name, handle None or errors
asset_name = file_pred.get('predicted_asset_name')
if asset_name is None:
# Group files without a predicted asset name under a special key or ignore for hierarchy?
# Let's group them under their source_asset name for now, but mark them clearly.
asset_name = f"[{file_pred.get('source_asset', 'UnknownSource')}]" # Use source asset name as a fallback identifier
log.debug(f"File '{file_pred.get('original_path', 'UnknownPath')}' has no predicted asset name, grouping under '{asset_name}' for hierarchy.")
grouped_by_asset[asset_name].append(file_pred)
# Create AssetRule objects from the grouped results
asset_rules = []
for asset_name, file_preds in grouped_by_asset.items():
# Determine the source_path for the AssetRule (use the source_asset from the first file in the group)
source_asset_path = file_preds[0].get('source_asset', asset_name) # Fallback to asset_name if source_asset is missing
asset_rule = AssetRule(asset_name=asset_name)
# Create FileRule objects from the file prediction dictionaries
for file_pred in file_preds:
file_rule = FileRule(
file_path=file_pred.get('original_path', 'UnknownPath'),
map_type_override=None, # Assuming these are not predicted here
resolution_override=None, # Assuming these are not predicted here
channel_merge_instructions={}, # Assuming these are not predicted here
output_format_override=None # Assuming these are not predicted here
)
asset_rule.files.append(file_rule)
asset_rules.append(asset_rule)
# Populate the SourceRule with the collected AssetRules
source_rule.assets = asset_rules
log.debug(f"Built SourceRule with {len(asset_rules)} AssetRule(s).")
# Emit the hierarchical rule structure
log.info(f"[{time.time():.4f}][T:{thread_id}] Parallel prediction run finished. Preparing to emit rule hierarchy.")
self.rule_hierarchy_ready.emit(source_rule)
log.info(f"[{time.time():.4f}][T:{thread_id}] Emitted rule_hierarchy_ready signal.")
# Emit the combined list of detailed file results for the table view
log.info(f"[{time.time():.4f}][T:{thread_id}] Preparing to emit {len(all_file_prediction_results)} file results for table view.")
log.debug(f"[{time.time():.4f}][T:{thread_id}] Type of all_file_prediction_results before emit: {type(all_file_prediction_results)}")
try:
log.debug(f"[{time.time():.4f}][T:{thread_id}] Content of all_file_results (first 5) before emit: {all_file_results[:5]}")
log.debug(f"[{time.time():.4f}][T:{thread_id}] Content of all_file_prediction_results (first 5) before emit: {all_file_prediction_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.error(f"[{time.time():.4f}][T:{thread_id}] Error logging all_file_prediction_results content: {e}")
log.info(f"[{time.time():.4f}][T:{thread_id}] Emitting prediction_results_ready signal...")
self.prediction_results_ready.emit(all_file_results)
self.prediction_results_ready.emit(all_file_prediction_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

View File

@@ -7,6 +7,7 @@ import time # For potential delays if needed
import subprocess # <<< ADDED IMPORT
import shutil # <<< ADDED IMPORT
from typing import Optional # <<< ADDED IMPORT
from rule_structure import SourceRule # Import SourceRule
# --- PySide6 Imports ---
# Inherit from QObject to support signals/slots for thread communication
@@ -70,7 +71,7 @@ class ProcessingHandler(QObject):
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
run_blender: bool, nodegroup_blend_path: str, materials_blend_path: str, verbose: bool, rules: SourceRule): # <<< 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.
@@ -107,7 +108,7 @@ class ProcessingHandler(QObject):
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
future = executor.submit(process_single_asset_wrapper, input_path, preset_name, output_dir_str, overwrite, verbose=verbose, rules=rules) # Pass verbose flag from GUI and rules
self._futures[future] = input_path # Map future back to input path
# Optionally emit "processing" status here
self.file_status_updated.emit(input_path, "processing", "")

180
gui/rule_editor_widget.py Normal file
View File

@@ -0,0 +1,180 @@
import sys
from PySide6.QtWidgets import (QApplication, QWidget, QVBoxLayout, QLabel, QLineEdit,
QFormLayout, QComboBox, QCheckBox, QSpinBox, QDoubleSpinBox)
from PySide6.QtCore import Signal, Slot, QObject
# Assuming rule_structure.py is in the parent directory or accessible via PYTHONPATH
# from ..rule_structure import SourceRule, AssetRule, FileRule # Adjust import based on actual structure
# For now, we'll use placeholder classes or assume rule_structure is directly importable
# from rule_structure import SourceRule, AssetRule, FileRule # Assuming direct import is possible
class RuleEditorWidget(QWidget):
"""
A widget to display and edit hierarchical processing rules (Source, Asset, File).
"""
rule_updated = Signal(object) # Signal emitted when a rule is updated
def __init__(self, parent=None):
super().__init__(parent)
self.current_rule_type = None
self.current_rule_object = None
self.layout = QVBoxLayout(self)
self.rule_type_label = QLabel("Select an item in the hierarchy to view/edit rules.")
self.layout.addWidget(self.rule_type_label)
self.form_layout = QFormLayout()
self.layout.addLayout(self.form_layout)
self.layout.addStretch() # Add stretch to push content to the top
self.setLayout(self.layout)
self.clear_editor()
@Slot(object, str)
def load_rule(self, rule_object, rule_type_name):
"""
Loads a rule object into the editor.
Args:
rule_object: The SourceRule, AssetRule, or FileRule object.
rule_type_name: The name of the rule type ('SourceRule', 'AssetRule', 'FileRule').
"""
self.clear_editor()
self.current_rule_object = rule_object
self.current_rule_type = rule_type_name
self.rule_type_label.setText(f"Editing: {rule_type_name}")
if rule_object:
# Dynamically create form fields based on rule object attributes
for attr_name, attr_value in vars(rule_object).items():
if attr_name.startswith('_'): # Skip private attributes
continue
label = QLabel(attr_name.replace('_', ' ').title() + ":")
editor_widget = self._create_editor_widget(attr_name, attr_value)
if editor_widget:
self.form_layout.addRow(label, editor_widget)
# Connect signal to update rule object
self._connect_editor_signal(editor_widget, attr_name)
def _create_editor_widget(self, attr_name, attr_value):
"""
Creates an appropriate editor widget based on the attribute type.
"""
if isinstance(attr_value, bool):
widget = QCheckBox()
widget.setChecked(attr_value)
return widget
elif isinstance(attr_value, int):
widget = QSpinBox()
widget.setRange(-2147483648, 2147483647) # Default integer range
widget.setValue(attr_value)
return widget
elif isinstance(attr_value, float):
widget = QDoubleSpinBox()
widget.setRange(-sys.float_info.max, sys.float_info.max) # Default float range
widget.setValue(attr_value)
return widget
elif isinstance(attr_value, (str, type(None))): # Handle None for strings
widget = QLineEdit()
widget.setText(str(attr_value) if attr_value is not None else "")
return widget
# Add more types as needed (e.g., dropdowns for enums/choices)
# elif isinstance(attr_value, list):
# # Example for a simple list of strings
# widget = QLineEdit()
# widget.setText(", ".join(map(str, attr_value)))
# return widget
else:
# For unsupported types, just display the value
label = QLabel(str(attr_value))
return label
def _connect_editor_signal(self, editor_widget, attr_name):
"""
Connects the appropriate signal of the editor widget to the update logic.
"""
if isinstance(editor_widget, QLineEdit):
editor_widget.textChanged.connect(lambda text: self._update_rule_attribute(attr_name, text))
elif isinstance(editor_widget, QCheckBox):
editor_widget.toggled.connect(lambda checked: self._update_rule_attribute(attr_name, checked))
elif isinstance(editor_widget, QSpinBox):
editor_widget.valueChanged.connect(lambda value: self._update_rule_attribute(attr_name, value))
elif isinstance(editor_widget, QDoubleSpinBox):
editor_widget.valueChanged.connect(lambda value: self._update_rule_attribute(attr_name, value))
# Add connections for other widget types
def _update_rule_attribute(self, attr_name, value):
"""
Updates the attribute of the current rule object and emits the signal.
"""
if self.current_rule_object:
# Basic type conversion based on the original attribute type
original_value = getattr(self.current_rule_object, attr_name)
try:
if isinstance(original_value, bool):
converted_value = bool(value)
elif isinstance(original_value, int):
converted_value = int(value)
elif isinstance(original_value, float):
converted_value = float(value)
elif isinstance(original_value, (str, type(None))):
converted_value = str(value) if value != "" else None # Convert empty string to None for original None types
else:
converted_value = value # Fallback for other types
setattr(self.current_rule_object, attr_name, converted_value)
self.rule_updated.emit(self.current_rule_object)
# print(f"Updated {attr_name} to {converted_value} in {self.current_rule_type}") # Debugging
except ValueError:
# Handle potential conversion errors (e.g., non-numeric input for int/float)
print(f"Error converting value '{value}' for attribute '{attr_name}'")
# Optionally, revert the editor widget to the original value or show an error indicator
def clear_editor(self):
"""
Clears the form layout.
"""
self.current_rule_object = None
self.current_rule_type = None
self.rule_type_label.setText("Select an item in the hierarchy to view/edit rules.")
while self.form_layout.rowCount() > 0:
self.form_layout.removeRow(0)
if __name__ == '__main__':
app = QApplication(sys.argv)
# Placeholder Rule Classes for testing
from dataclasses import dataclass, field
@dataclass
class SourceRule:
source_setting_1: str = "default_source_string"
source_setting_2: int = 123
source_setting_3: bool = True
@dataclass
class AssetRule:
asset_setting_a: float = 4.56
asset_setting_b: str = None
asset_setting_c: bool = False
@dataclass
class FileRule:
file_setting_x: int = 789
file_setting_y: str = "default_file_string"
editor = RuleEditorWidget()
# Test loading different rule types
source_rule = SourceRule()
asset_rule = AssetRule()
file_rule = FileRule()
editor.load_rule(source_rule, "SourceRule")
# editor.load_rule(asset_rule, "AssetRule")
# editor.load_rule(file_rule, "FileRule")
editor.show()
sys.exit(app.exec())

184
gui/rule_hierarchy_model.py Normal file
View File

@@ -0,0 +1,184 @@
from PySide6.QtCore import QAbstractItemModel, QModelIndex, Qt, Signal, Slot
from PySide6.QtGui import QIcon # Assuming we might want icons later
from rule_structure import SourceRule, AssetRule, FileRule # Import rule structures
class RuleHierarchyModel(QAbstractItemModel):
"""
A custom model for displaying the hierarchical structure of SourceRule,
AssetRule, and FileRule objects in a QTreeView.
"""
def __init__(self, root_rule: SourceRule = None, parent=None):
super().__init__(parent)
self._root_rule = root_rule
def set_root_rule(self, root_rule: SourceRule):
"""Sets the root SourceRule for the model and resets the model."""
self.beginResetModel()
self._root_rule = root_rule
self.endResetModel()
def rowCount(self, parent: QModelIndex = QModelIndex()):
"""Returns the number of rows (children) for the given parent index."""
if not parent.isValid():
# Root item (SourceRule)
return 1 if self._root_rule else 0
else:
parent_item = parent.internalPointer()
if isinstance(parent_item, SourceRule):
# Children of SourceRule are AssetRules
return len(parent_item.assets)
elif isinstance(parent_item, AssetRule):
# Children of AssetRule are FileRules
return len(parent_item.files)
elif isinstance(parent_item, FileRule):
# FileRules have no children
return 0
else:
return 0
def columnCount(self, parent: QModelIndex = QModelIndex()):
"""Returns the number of columns."""
return 1 # We only need one column for the hierarchy name
def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole):
"""Returns the data for the given index and role."""
if not index.isValid():
return None
item = index.internalPointer()
if role == Qt.ItemDataRole.DisplayRole:
if isinstance(item, SourceRule):
return f"Source: {item.input_path}" # Or some other identifier
elif isinstance(item, AssetRule):
return f"Asset: {item.asset_name}" # Or some other identifier
elif isinstance(item, FileRule):
return f"File: {item.file_path}" # Or some other identifier
else:
return None
# Add other roles as needed (e.g., Qt.ItemDataRole.DecorationRole for icons)
# elif role == Qt.ItemDataRole.DecorationRole:
# if isinstance(item, SourceRule):
# return QIcon("icons/source.png") # Placeholder icon
# elif isinstance(item, AssetRule):
# return QIcon("icons/asset.png") # Placeholder icon
# elif isinstance(item, FileRule):
# return QIcon("icons/file.png") # Placeholder icon
# else:
# return None
return None
def index(self, row: int, column: int, parent: QModelIndex = QModelIndex()):
"""Returns the model index for the given row, column, and parent index."""
if not self.hasIndex(row, column, parent):
return QModelIndex()
if not parent.isValid():
# Requesting index for the root item (SourceRule)
if self._root_rule and row == 0:
return self.createIndex(row, column, self._root_rule)
else:
return QModelIndex()
else:
parent_item = parent.internalPointer()
if isinstance(parent_item, SourceRule):
# Children are AssetRules
if 0 <= row < len(parent_item.assets):
child_item = parent_item.assets[row]
return self.createIndex(row, column, child_item)
else:
return QModelIndex()
elif isinstance(parent_item, AssetRule):
# Children are FileRules
if 0 <= row < len(parent_item.files):
child_item = parent_item.files[row]
return self.createIndex(row, column, child_item)
else:
return QModelIndex()
else:
return QModelIndex() # Should not happen for FileRule parents
def parent(self, index: QModelIndex):
"""Returns the parent index for the given index."""
if not index.isValid():
return QModelIndex()
child_item = index.internalPointer()
if isinstance(child_item, SourceRule):
# SourceRule is the root, has no parent in the model hierarchy
return QModelIndex()
elif isinstance(child_item, AssetRule):
# Find the SourceRule that contains this AssetRule
if self._root_rule and child_item in self._root_rule.assets:
# The row of the SourceRule is always 0 in this model
return self.createIndex(0, 0, self._root_rule)
else:
return QModelIndex() # Should not happen if data is consistent
elif isinstance(child_item, FileRule):
# Find the AssetRule that contains this FileRule
if self._root_rule:
for asset_row, asset_rule in enumerate(self._root_rule.assets):
if child_item in asset_rule.files:
# The row of the parent AssetRule within the SourceRule's children
return self.createIndex(asset_row, 0, asset_rule)
return QModelIndex() # Should not happen if data is consistent
else:
return QModelIndex() # Unknown item type
def headerData(self, section: int, orientation: Qt.Orientation, role: int = Qt.ItemDataRole.DisplayRole):
"""Returns the data for the header."""
if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole:
if section == 0:
return "Hierarchy"
return None
def get_item_from_index(self, index: QModelIndex):
"""Helper to get the underlying rule object from a model index."""
if index.isValid():
return index.internalPointer()
return None
if __name__ == '__main__':
# Example Usage (for testing the model)
from PySide6.QtWidgets import QApplication, QTreeView
from dataclasses import dataclass, field
# Define placeholder rule structures if not imported
@dataclass
class FileRule:
name: str = "file"
setting_f1: str = "value1"
setting_f2: int = 10
@dataclass
class AssetRule:
name: str = "asset"
files: list[FileRule] = field(default_factory=list)
setting_a1: bool = True
setting_a2: float = 3.14
@dataclass
class SourceRule:
name: str = "source"
assets: list[AssetRule] = field(default_factory=list)
setting_s1: str = "hello"
# Create a sample hierarchical structure
file1 = FileRule(name="texture_diffuse.png")
file2 = FileRule(name="texture_normal.png")
file3 = FileRule(name="model.obj")
asset1 = AssetRule(name="Material_01", files=[file1, file2])
asset2 = AssetRule(name="Model_01", files=[file3])
source_rule_instance = SourceRule(name="Input_Archive", assets=[asset1, asset2])
app = QApplication([])
tree_view = QTreeView()
model = RuleHierarchyModel(source_rule_instance)
tree_view.setModel(model)
tree_view.setWindowTitle("Rule Hierarchy Example")
tree_view.show()
app.exec()