From e0c098eadd601cb6e97f03eb99c365869299ee7c Mon Sep 17 00:00:00 2001 From: jockeyboy <33347307+jockeyboy@users.noreply.github.com> Date: Sat, 23 Aug 2025 09:24:48 +0100 Subject: [PATCH] Update bms-substance-exporter.py Update exporter to fix Substance V11 Crash at the end of the Export Add a Log creation and also the ability to delete the substance output Textures --- bms-substance-exporter.py | 474 +++++++++++++++++++++++++++++--------- 1 file changed, 370 insertions(+), 104 deletions(-) diff --git a/bms-substance-exporter.py b/bms-substance-exporter.py index 935f0a4..a005006 100644 --- a/bms-substance-exporter.py +++ b/bms-substance-exporter.py @@ -1,143 +1,409 @@ +# ============================================================================== +# File: bms-substance-exporter.py +# Author: Lee Waterall +# Description: This script is a plugin for Substance Painter that automatically +# converts exported textures to the DDS format using the 'texconv.exe' utility. +# It is specifically configured for BMS (Falcon BMS) texture naming conventions. +# The script runs conversions in a separate thread pool to prevent the UI from +# freezing and provides a responsive progress bar. +# ============================================================================== + import os +import shutil +import subprocess +import concurrent +import threading +import logging +import datetime +import json +import webbrowser # New import for opening web links +from concurrent.futures import ThreadPoolExecutor as Pool, as_completed +# Substance Painter API imports for UI, export events, etc. import substance_painter.ui import substance_painter.export import substance_painter.project import substance_painter.textureset import substance_painter.event -import subprocess -import concurrent -import threading -from concurrent.futures import ThreadPoolExecutor as Pool - -from PySide6 import QtWidgets +# PySide6 imports for UI elements and threading +from PySide6 import QtWidgets, QtCore, QtGui +# A global list to keep track of plugin widgets so they can be closed gracefully plugin_widgets = [] +# Define default settings for the plugin. +# 'texconv_path': The name of the executable. Assumed to be in the same folder as the script. +# 'texture_patterns': A dictionary of texture types and the naming patterns to identify them. +DEFAULT_SETTINGS = { + "texconv_path": "texconv.exe", + "texture_patterns": { + "albedo": ["_albedo"], + "armw": ["_armw"], + "emission": ["_emission"], + "normal": ["_normal"] + } +} + +# ============================================================================== +# Settings and Log Management +# ============================================================================== + +def get_script_path(): + """Returns the absolute path of the directory containing this script.""" + return os.path.dirname(os.path.abspath(__file__)) + +def setup_logging(): + """ + Configures and returns a logger to write a log file. + The log file name is timestamped to avoid overwriting previous logs. + """ + log_file_name = f"BMS_conversion_log_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.txt" + log_file_path = os.path.join(get_script_path(), log_file_name) + logging.basicConfig( + filename=log_file_path, + filemode='w', + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' + ) + return log_file_path + +# ============================================================================== +# DDS Exporter Class - handles conversion in a separate thread +# ============================================================================== + +# By inheriting from QtCore.QObject, this class can use Qt's signal/slot mechanism +# to communicate with the main UI thread, which is essential for thread safety. +class DDSExporter(QtCore.QObject): + # Define a signal that will be emitted when a conversion is completed. + # It carries the file path and a boolean indicating success. + conversion_completed = QtCore.Signal(str, bool) + + def __init__(self, texconv_path): + """Initializes the exporter with the path to texconv.exe.""" + super().__init__() + self.texconv_path = texconv_path + self.startupinfo = None + # This setup hides the command prompt window that would otherwise appear + # when subprocess.run is called on Windows. + if os.name == 'nt': + self.startupinfo = subprocess.STARTUPINFO() + self.startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW + self.startupinfo.wShowWindow = subprocess.SW_HIDE + + def convert_file(self, file_path, output_dir, texture_type): + """ + Builds the texconv command and executes it. This method is designed to + be run in a background thread by the ThreadPoolExecutor. + """ + # Builds the list of command-line arguments for texconv.exe + # Using a list is more robust than a single string, as it handles + # special characters and spaces in file paths correctly. + texconv_args = [ + self.texconv_path, + "-nologo", # Suppresses the logo + "-y", # Overwrites existing files + "-o", output_dir, # Specifies the output directory + file_path # The input file + ] + + # Appends format-specific arguments based on the texture type. + if texture_type == "albedo": + texconv_args.extend(["-f", "BC7_UNORM_SRGB", "-srgb"]) + elif texture_type == "armw": + texconv_args.extend(["-f", "BC7_UNORM"]) + elif texture_type == "emission": + texconv_args.extend(["-f", "BC7_UNORM"]) + elif texture_type == "normal": + texconv_args.extend(["-f", "BC5_UNORM"]) + + success = False + try: + # Executes the command. check=True will raise an exception if the command fails. + result = subprocess.run( + texconv_args, + capture_output=True, # Captures stdout and stderr + text=True, # Decodes stdout/stderr as text + check=True, + startupinfo=self.startupinfo + ) + # Log successful conversion details + if result.stdout: + logging.info(f"Texconv stdout: {result.stdout}") + if result.stderr: + logging.warning(f"Texconv stderr: {result.stderr}") + success = True + except subprocess.CalledProcessError as e: + # Log the specific error from texconv's stderr + logging.error(f"Failed to convert {file_path}. Error: {e.stderr}") + except FileNotFoundError: + logging.error(f"Texconv not found at {self.texconv_path}") + + # Emits the signal to notify the main thread that this conversion is done. + self.conversion_completed.emit(file_path, success) + +# ============================================================================== +# UI Class for user settings +# ============================================================================== +class SettingsDialog(QtWidgets.QDialog): + def __init__(self, parent=None, default_dir=""): + super(SettingsDialog, self).__init__(parent) + self.setWindowTitle("BMS Exporter Settings") + self.setWindowFlags(self.windowFlags() | QtCore.Qt.WindowStaysOnTopHint) + self.output_dir = default_dir + + main_layout = QtWidgets.QVBoxLayout(self) + + # Output Folder selection + output_group = QtWidgets.QGroupBox("Output Folder", self) + output_layout = QtWidgets.QVBoxLayout(output_group) + self.output_path_line = QtWidgets.QLineEdit(default_dir) + self.output_path_line.setReadOnly(True) + self.browse_button = QtWidgets.QPushButton("Browse") + self.browse_button.clicked.connect(self.select_folder) + + path_layout = QtWidgets.QHBoxLayout() + path_layout.addWidget(self.output_path_line) + path_layout.addWidget(self.browse_button) + output_layout.addLayout(path_layout) + + main_layout.addWidget(output_group) + + # Options + options_group = QtWidgets.QGroupBox("Options", self) + options_layout = QtWidgets.QVBoxLayout(options_group) + + self.delete_source_checkbox = QtWidgets.QCheckBox("Delete source files after successful conversion") + options_layout.addWidget(self.delete_source_checkbox) + + main_layout.addWidget(options_group) + + # Dialog buttons + button_box = QtWidgets.QDialogButtonBox( + QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel, + self) + button_box.accepted.connect(self.accept) + button_box.rejected.connect(self.reject) + main_layout.addWidget(button_box) + + def select_folder(self): + folder = QtWidgets.QFileDialog.getExistingDirectory(self, "Select Output Directory", self.output_dir) + if folder: + self.output_dir = folder + self.output_path_line.setText(self.output_dir) + + def get_settings(self): + return (self.output_path_line.text(), self.delete_source_checkbox.isChecked()) + + +# ============================================================================== +# Main export and conversion logic - runs on the UI thread +# ============================================================================== def export_dds_textures(export_result): - print(f"export_dds_textures" - f"{export_result}") - total_files_to_process = 0 - + """ + This is the main function that is triggered by Substance Painter's + 'ExportTexturesEnded' event. It orchestrates the entire conversion process. + """ + print("BMS Substance Exporter: Project exported event triggered.") + + # Setup logging for this specific run. + log_file_path = setup_logging() + + # Check for texconv.exe before proceeding --- + texconv_path = os.path.join(get_script_path(), DEFAULT_SETTINGS["texconv_path"]) + if not os.path.exists(texconv_path): + logging.error(f"Error: texconv.exe not found at {texconv_path}") + print(f"BMS Substance Exporter: Error: texconv.exe not found at {texconv_path}") + + msg_box = QtWidgets.QMessageBox() + msg_box.setWindowTitle("Error: texconv.exe not found") + msg_box.setText("The 'texconv.exe' utility was not found in the script's directory.") + msg_box.setInformativeText("Please download 'texconv.exe' from the DirectXTex GitHub repository and place it in the same folder as this script.") + + # Add a button to open the download page. + directxtex_link = "https://github.com/microsoft/DirectXTex/releases" + link_button = msg_box.addButton("Open Download Page", QtWidgets.QMessageBox.AcceptRole) + msg_box.addButton("Close", QtWidgets.QMessageBox.RejectRole) + + msg_box.exec() + if msg_box.clickedButton() == link_button: + webbrowser.open(directxtex_link) + + return # Abort the conversion process + + + # Check if the Substance Painter export was successful. if export_result.status != substance_painter.export.ExportStatus.Success: + logging.error("Export failed, skipping conversion.") + print("BMS Substance Exporter: Export failed, skipping conversion.") return - albedo_files = [] - armw_files = [] - emission_files = [] - normal_files = [] - + # Collects all exported file paths. + all_files = [] for stack, files in export_result.textures.items(): - for exported_filename in files: - if os.path.splitext(exported_filename)[0].endswith("_Albedo"): - albedo_files.append(os.path.abspath(exported_filename)) - elif os.path.splitext(exported_filename)[0].endswith("_ARMW"): - armw_files.append(os.path.abspath(exported_filename)) - elif os.path.splitext(exported_filename)[0].endswith("_Emission"): - emission_files.append(os.path.abspath(exported_filename)) - elif os.path.splitext(exported_filename)[0].endswith("_Normal"): - normal_files.append(os.path.abspath(exported_filename)) - - else: - print(f"Warning: unknown file pattern {exported_filename}, will not be converted to DDS") - - texconv_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "texconv.exe") - if not os.path.exists(texconv_path): - print(f"Error: texconv.exe not found in {texconv_path}") + all_files.extend(files) + + if not all_files: + logging.info("No files to process.") + print("BMS Substance Exporter: No files to process.") + return + + # Get a default output path based on the project file name. + project_path = substance_painter.project.file_path() + project_name = os.path.splitext(os.path.basename(project_path))[0] if project_path else "exported_textures" + initial_dir = os.path.join(os.path.dirname(os.path.abspath(all_files[0])), project_name) + + # Show the settings dialog to the user. + dialog = SettingsDialog(default_dir=initial_dir) + if not dialog.exec(): + print("BMS Substance Exporter: Conversion cancelled by user.") return - total_files_to_process = len(albedo_files) + len(armw_files) + len(emission_files) + len(normal_files) - files_completed = 0 - pool = Pool(max_workers=2) - futures = [] - semaphore = threading.Semaphore() + # Retrieve the user's choices from the dialog. + output_directory, delete_source = dialog.get_settings() + + if not output_directory: + logging.error("Output directory not selected, conversion aborted.") + print("BMS Substance Exporter: Output directory not selected, conversion aborted.") + return + + if not os.path.exists(output_directory): + os.makedirs(output_directory) + + # Filters the files based on the predefined naming patterns. + relevant_files = [] + for exported_filename in all_files: + base = os.path.splitext(exported_filename)[0] + lower_base = base.lower() + + found_match = False + for texture_type, patterns in DEFAULT_SETTINGS.get("texture_patterns", {}).items(): + for pattern in patterns: + # Compare the end of the filename (case-insensitive) to find a match. + if lower_base.endswith(pattern.lower()): + # Use absolute path to avoid any issues with relative paths. + relevant_files.append((os.path.abspath(exported_filename), texture_type)) + found_match = True + break + if found_match: + break + + if not found_match: + logging.warning(f"Unknown file pattern for {exported_filename}, will not be converted to DDS") + print(f"BMS Substance Exporter: Warning: unknown file pattern for {exported_filename}, will not be converted to DDS") + + total_files_to_process = len(relevant_files) + + if total_files_to_process == 0: + logging.info("No relevant files to process.") + print("BMS Substance Exporter: No relevant files to process.") + return + # Initialize the progress bar UI element. progress_widget = QtWidgets.QProgressDialog("Converting to DDS...", "Cancel", 0, total_files_to_process) - plugin_widgets.append(progress_widget) + progress_widget.setWindowFlags(progress_widget.windowFlags() | QtCore.Qt.WindowStaysOnTopHint) + plugin_widgets.append(progress_widget) # Keep a reference to close later progress_widget.setValue(0) - progress_widget.setCancelButton(None) + progress_widget.setCancelButton(None) # Disable the cancel button progress_widget.show() - - def _worker_callback(future): - nonlocal total_files_to_process + + # Initialize variables for tracking conversion progress. + files_completed = 0 + failed_conversions = [] + + # Create a ThreadPoolExecutor to run conversions in parallel. + # This prevents the main UI thread from freezing. + pool = Pool(max_workers=4) + # Instantiate the DDSExporter to handle the conversion logic. + exporter = DDSExporter(texconv_path) + + # Keep a list of files that were successfully converted to be deleted later if the user opts to. + successfully_converted_files = [] + + def on_conversion_completed(file_path, success): + """ + This is a 'slot' function that receives the signal from the worker threads. + Because it's connected via a signal, it is executed on the main UI thread, + making it safe to update the UI elements. + """ nonlocal files_completed - nonlocal progress_widget - nonlocal semaphore - - semaphore.acquire() files_completed += 1 + + # Update the progress bar and label. progress_widget.setValue(files_completed) - print(f"{files_completed} / {total_files_to_process} files exported") + progress_widget.setLabelText(f"Converting to DDS... ({files_completed}/{total_files_to_process}) - {os.path.basename(file_path)}") + + if success: + successfully_converted_files.append(file_path) + else: + failed_conversions.append(file_path) + + # Check if all files have been processed. + if files_completed == total_files_to_process: + progress_widget.close() # Close the progress bar + + # Delete source files if the option was selected + if delete_source: + print("BMS Substance Exporter: Deleting source files...") + for file_to_delete in successfully_converted_files: + try: + os.remove(file_to_delete) + logging.info(f"Deleted source file: {file_to_delete}") + except OSError as e: + logging.warning(f"Failed to delete {file_to_delete}. Error: {e}") + + final_message = "" + # Display a summary message to the user. + if failed_conversions: + final_message = f"Conversion completed with {len(failed_conversions)} failure(s)." + final_message += "\n\nSee the log file for details." + # Show an error message box with an option to open the log file. + msg_box = QtWidgets.QMessageBox() + msg_box.setWindowTitle("Conversion Completed with Errors") + msg_box.setText("BMS Substance Exporter: " + final_message) + msg_box.setInformativeText("Would you like to open the log file?") + open_log_button = msg_box.addButton("Open Log", QtWidgets.QMessageBox.AcceptRole) + msg_box.addButton("Close", QtWidgets.QMessageBox.RejectRole) + msg_box.exec() + if msg_box.clickedButton() == open_log_button: + os.startfile(log_file_path) + else: + final_message = f"All {total_files_to_process} files converted successfully." + QtWidgets.QMessageBox.information(None, "Conversion Completed", "BMS Substance Exporter: " + final_message) + print("BMS Substance Exporter: " + final_message) - if future.exception() is not None: - print("got exception: %s" % future.exception()) + # Connect the signal from the exporter to the slot function. + exporter.conversion_completed.connect(on_conversion_completed) - if files_completed == total_files_to_process: - print("Export completed") - progress_widget.close() - - semaphore.release() - - for albedo_file in albedo_files: - print(f"processing albedo file: {albedo_file}: \n" - f'"{texconv_path}" -nologo -y -f BC7_UNORM_SRGB -srgb "{albedo_file}"') - output_directory = os.path.dirname(albedo_file) - - future = pool.submit(subprocess.call, - f'"{texconv_path}" -nologo -y -f BC7_UNORM_SRGB -srgb -o "{output_directory}" "{albedo_file}"', - shell=True) - future.add_done_callback(_worker_callback) - futures.append(future) - # texconv.exe -nologo -y -f BC7_UNORM_SRGB -srgb PreviewSphere_Sphere_Albedo.tif - - for armw_file in armw_files: - print(f"processing armw file: {armw_file}") - output_directory = os.path.dirname(albedo_file) - - future = pool.submit(subprocess.call, - f'"{texconv_path}" -nologo -y -f BC7_UNORM -o "{output_directory}" "{armw_file}"', - shell=True) - future.add_done_callback(_worker_callback) - futures.append(future) - # texconv.exe -nologo -y -f BC7_UNORM PreviewSphere_Sphere_ARMW.tif - - for emission_file in emission_files: - print(f"processing emission file: {emission_file}") - output_directory = os.path.dirname(albedo_file) - - future = pool.submit(subprocess.call, - f'"{texconv_path}" -nologo -y -f BC7_UNORM -o "{output_directory}" "{emission_file}"', - shell=True) - future.add_done_callback(_worker_callback) - futures.append(future) - # texconv.exe -nologo -y -f BC7_UNORM PreviewSphere_Sphere_ARMW.tif - - for normal_file in normal_files: - print(f"processing normal_file file: {normal_file}") - output_directory = os.path.dirname(albedo_file) - - future = pool.submit(subprocess.call, - f'"{texconv_path}" -nologo -y -f BC5_UNORM -o "{output_directory}" "{normal_file}"', - shell=True) - future.add_done_callback(_worker_callback) - futures.append(future) - # texconv.exe -nologo -y -f BC5_UNORM PreviewSphere_Sphere_Normal.tif + # Submit each file conversion task to the thread pool. + # This is non-blocking, so the UI remains responsive. + for file_path, texture_type in relevant_files: + logging.info(f"Submitting {texture_type} file for conversion...") + pool.submit(exporter.convert_file, file_path, output_directory, texture_type) +# ============================================================================== +# Plugin Lifecycle Functions +# ============================================================================== def start_plugin(): + """Called when the plugin is loaded by Substance Painter.""" + print("BMS Substance Exporter: Plugin is starting...") + # Connect the main function to the Substance Painter export event. substance_painter.event.DISPATCHER.connect(substance_painter.event.ExportTexturesEnded, export_dds_textures) - def close_plugin(): + """Called when the plugin is unloaded.""" + print("BMS Substance Exporter: Plugin is closing.") + # Close any open UI widgets created by the plugin. for widget in plugin_widgets: - substance_painter.ui.delete_ui_element(widget) - + if widget and widget.isVisible(): + widget.close() plugin_widgets.clear() + # Safely shut down the logging system. + logging.shutdown() - +# This is the entry point for the plugin script. +# When the script is executed, it calls start_plugin. if __name__ == "__main__": start_plugin() - -