# 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