Compare commits

8 Commits

53 changed files with 4712 additions and 1616 deletions

46
.roo/mcp.json Normal file
View File

@@ -0,0 +1,46 @@
{
"mcpServers": {
"conport": {
"command": "C:\\Users\\theis\\context-portal\\.venv\\Scripts\\python.exe",
"args": [
"C:\\Users\\theis\\context-portal\\src\\context_portal_mcp\\main.py",
"--mode",
"stdio",
"--workspace_id",
"${workspaceFolder}"
],
"alwaysAllow": [
"get_product_context",
"update_product_context",
"get_active_context",
"update_active_context",
"log_decision",
"get_decisions",
"search_decisions_fts",
"log_progress",
"get_progress",
"update_progress",
"delete_progress_by_id",
"log_system_pattern",
"get_system_patterns",
"log_custom_data",
"get_custom_data",
"delete_custom_data",
"search_project_glossary_fts",
"export_conport_to_markdown",
"import_markdown_to_conport",
"link_conport_items",
"search_custom_data_value_fts",
"get_linked_items",
"batch_log_items",
"get_item_history",
"delete_decision_by_id",
"delete_system_pattern_by_id",
"get_conport_schema",
"get_recent_activity_summary",
"semantic_search_conport",
"search_system_patterns_fts"
]
}
}
}

View File

15
.roomodes Normal file
View File

@@ -0,0 +1,15 @@
{
"customModes": [
{
"slug": "Task-Initiator",
"name": "Task Initiator",
"roleDefinition": "You are Task Initiator. Your exclusive function is comprehensive initial context gathering, focusing solely on ConPort data. Do NOT perform other tasks or use direct file system tools for context gathering.",
"customInstructions": "1. First, execute standard initial context setup procedures (as per global ConPort strategy).\n2. Next, if a specific user request is pending, YOU, as Task Initiator, should analyze it and proactively gather relevant information, strictly by querying ConPort. Your process for this is:\n a. Identify the key subject(s) of the request.\n b. Loosely search relevant ConPort data for information or summaries related to these identified subject(s).\n3. After completing both standard setup AND any ConPort-based task-specific gathering, briefly report the overall context status. This report must cover ConPort initialization and summarize any specific information found (or explicitly not found) within ConPort relevant to the user's request.\n4. Then, output `[TASK_INITIATOR_COMPLETE]`.\n5. Finally, to address the user's main request with the context you've gathered (or confirmed is missing from ConPort), use the `switch_mode` tool to transition to the determined most appropriate mode by analysing the initial request. you should ALWAYS finish context-gathering before switching modes.",
"groups": [
"mcp",
"read"
],
"source": "project"
}
]
}

View File

@@ -16,6 +16,7 @@ This document outlines the key features of the Asset Processor Tool.
* Saves maps in appropriate formats (JPG, PNG, EXR) based on complex rules involving map type (`FORCE_LOSSLESS_MAP_TYPES`), resolution (`RESOLUTION_THRESHOLD_FOR_JPG`), bit depth, and source format. * Saves maps in appropriate formats (JPG, PNG, EXR) based on complex rules involving map type (`FORCE_LOSSLESS_MAP_TYPES`), resolution (`RESOLUTION_THRESHOLD_FOR_JPG`), bit depth, and source format.
* Calculates basic image statistics (Min/Max/Mean) for a reference resolution. * Calculates basic image statistics (Min/Max/Mean) for a reference resolution.
* Calculates and stores the relative aspect ratio change string in metadata (e.g., `EVEN`, `X150`, `Y125`). * Calculates and stores the relative aspect ratio change string in metadata (e.g., `EVEN`, `X150`, `Y125`).
* **Low-Resolution Fallback:** If enabled (`ENABLE_LOW_RESOLUTION_FALLBACK`), automatically saves an additional "LOWRES" variant of source images if their largest dimension is below a configurable threshold (`LOW_RESOLUTION_THRESHOLD`). This "LOWRES" variant uses the original image dimensions and is saved in addition to any standard resolution outputs.
* **Channel Merging:** Combines channels from different maps into packed textures (e.g., NRMRGH) based on preset rules (`MAP_MERGE_RULES` in `config.py`). * **Channel Merging:** Combines channels from different maps into packed textures (e.g., NRMRGH) based on preset rules (`MAP_MERGE_RULES` in `config.py`).
* **Metadata Generation:** Creates a `metadata.json` file for each asset containing details about maps, category, archetype, aspect ratio change, processing settings, etc. * **Metadata Generation:** Creates a `metadata.json` file for each asset containing details about maps, category, archetype, aspect ratio change, processing settings, etc.
* **Output Organization:** Creates a clean, structured output directory (`<output_base>/<supplier>/<asset_name>/`). * **Output Organization:** Creates a clean, structured output directory (`<output_base>/<supplier>/<asset_name>/`).

View File

@@ -13,9 +13,21 @@ The `app_settings.json` file is structured into several key sections, including:
* `ASSET_TYPE_DEFINITIONS`: Defines known asset types (like Surface, Model, Decal) and their properties. * `ASSET_TYPE_DEFINITIONS`: Defines known asset types (like Surface, Model, Decal) and their properties.
* `MAP_MERGE_RULES`: Defines how multiple input maps can be merged into a single output map (e.g., combining Normal and Roughness into one). * `MAP_MERGE_RULES`: Defines how multiple input maps can be merged into a single output map (e.g., combining Normal and Roughness into one).
### Low-Resolution Fallback Settings
These settings control the generation of low-resolution "fallback" variants for source images:
* `ENABLE_LOW_RESOLUTION_FALLBACK` (boolean, default: `true`):
* If `true`, the tool will generate an additional "LOWRES" variant for source images whose largest dimension is smaller than the `LOW_RESOLUTION_THRESHOLD`.
* This "LOWRES" variant uses the original dimensions of the source image and is saved in addition to any other standard resolution outputs (e.g., 1K, PREVIEW).
* If `false`, this feature is disabled.
* `LOW_RESOLUTION_THRESHOLD` (integer, default: `512`):
* Defines the pixel dimension (for the largest side of an image) below which the "LOWRES" fallback variant will be generated (if enabled).
* For example, if set to `512`, any source image smaller than 512x512 (e.g., 256x512, 128x128) will have a "LOWRES" variant created.
### LLM Predictor Settings ### LLM Predictor Settings
For users who wish to utilize the experimental LLM Predictor feature, the following settings are available in `config/app_settings.json`: For users who wish to utilize the experimental LLM Predictor feature, the following settings are available in `config/llm_settings.json`:
* `llm_endpoint_url`: The URL of the LLM API endpoint. For local LLMs like LM Studio or Ollama, this will typically be `http://localhost:<port>/v1`. Consult your LLM server documentation for the exact endpoint. * `llm_endpoint_url`: The URL of the LLM API endpoint. For local LLMs like LM Studio or Ollama, this will typically be `http://localhost:<port>/v1`. Consult your LLM server documentation for the exact endpoint.
* `llm_api_key`: The API key required to access the LLM endpoint. Some local LLM servers may not require a key, in which case this can be left empty. * `llm_api_key`: The API key required to access the LLM endpoint. Some local LLM servers may not require a key, in which case this can be left empty.
@@ -23,15 +35,39 @@ For users who wish to utilize the experimental LLM Predictor feature, the follow
* `llm_temperature`: Controls the randomness of the LLM's output. Lower values (e.g., 0.1-0.5) make the output more deterministic and focused, while higher values (e.g., 0.6-1.0) make it more creative and varied. For prediction tasks, lower temperatures are generally recommended. * `llm_temperature`: Controls the randomness of the LLM's output. Lower values (e.g., 0.1-0.5) make the output more deterministic and focused, while higher values (e.g., 0.6-1.0) make it more creative and varied. For prediction tasks, lower temperatures are generally recommended.
* `llm_request_timeout`: The maximum time (in seconds) to wait for a response from the LLM API. Adjust this based on the performance of your LLM server and the complexity of the requests. * `llm_request_timeout`: The maximum time (in seconds) to wait for a response from the LLM API. Adjust this based on the performance of your LLM server and the complexity of the requests.
Note that the `llm_predictor_prompt` and `llm_predictor_examples` settings are also present in `app_settings.json`. These define the instructions and examples provided to the LLM for prediction. While they can be viewed here, they are primarily intended for developer reference and tuning the LLM's behavior, and most users will not need to modify them. Note that the `llm_predictor_prompt` and `llm_predictor_examples` settings are also present in `config/llm_settings.json`. These define the instructions and examples provided to the LLM for prediction. While they can be viewed here, they are primarily intended for developer reference and tuning the LLM's behavior, and most users will not need to modify them directly via the file. These settings are editable via the LLM Editor panel in the main GUI when the LLM interpretation mode is selected.
## GUI Configuration Editor ## Application Preferences (`config/app_settings.json` overrides)
You can modify the `app_settings.json` file using the built-in GUI editor. Access it via the **Edit** -> **Preferences...** menu. You can modify user-overridable application settings using the built-in GUI editor. These settings are loaded from `config/app_settings.json` and saved as overrides in `config/user_settings.json`. Access it via the **Edit** -> **Preferences...** menu.
This editor provides a tabbed interface (e.g., "General", "Output & Naming") to view and change the core application settings defined in `app_settings.json`. Settings in the editor directly correspond to the structure and values within the JSON file. Note that any changes made through the GUI editor require an application restart to take effect. This editor provides a tabbed interface to view and change various application behaviors. The tabs include:
* **General:** Basic settings like output base directory and temporary file prefix.
* **Output & Naming:** Settings controlling output directory and filename patterns, and how variants are handled.
* **Image Processing:** Settings related to image resolution definitions, compression levels, and format choices.
* **Map Merging:** Configuration for how multiple input maps are combined into single output maps.
* **Postprocess Scripts:** Paths to default Blender files for post-processing.
*(Ideally, a screenshot of the GUI Configuration Editor would be included here.)* Note that this editor focuses on user-specific overrides of core application settings. **Asset Type Definitions, File Type Definitions, and Supplier Settings are managed in a separate Definitions Editor.**
Any changes made through the Preferences editor require an application restart to take effect.
*(Ideally, a screenshot of the Application Preferences editor would be included here.)*
## Definitions Editor (`config/asset_type_definitions.json`, `config/file_type_definitions.json`, `config/suppliers.json`)
Core application definitions that are separate from general user preferences are managed in the dedicated Definitions Editor. This includes defining known asset types, file types, and configuring settings specific to different suppliers. Access it via the **Edit** -> **Edit Definitions...** menu.
The editor is organized into three tabs:
* **Asset Type Definitions:** Define the different categories of assets (e.g., Surface, Model, Decal). For each asset type, you can configure its description, a color for UI representation, and example usage strings.
* **File Type Definitions:** Define the specific types of files the tool recognizes (e.g., MAP_COL, MAP_NRM, MODEL). For each file type, you can configure its description, a color, example keywords/patterns, a standard type alias, bit depth handling rules, whether it's grayscale, and an optional keybind for quick assignment in the GUI.
* **Supplier Settings:** Configure settings that are specific to assets originating from different suppliers. Currently, this includes the "Normal Map Type" (OpenGL or DirectX) used for normal maps from that supplier.
Each tab presents a list of the defined items on the left (Asset Types, File Types, or Suppliers). Selecting an item in the list displays its configurable details on the right. Buttons are provided to add new definitions or remove existing ones.
Changes made in the Definitions Editor are saved directly to their respective configuration files (`config/asset_type_definitions.json`, `config/file_type_definitions.json`, and `config/suppliers.json`). Some changes may require an application restart to take full effect in processing logic.
*(Ideally, screenshots of the Definitions Editor tabs would be included here.)*
## Preset Files (`presets/*.json`) ## Preset Files (`presets/*.json`)

View File

@@ -12,7 +12,10 @@ python -m gui.main_window
## Interface Overview ## Interface Overview
* **Menu Bar:** The "Edit" menu contains the "Preferences..." option to open the GUI Configuration Editor. The "View" menu allows you to toggle the visibility of the Log Console and the Detailed File Preview. * **Menu Bar:** The "Edit" menu contains options to configure application settings and definitions:
* **Preferences...:** Opens the Application Preferences editor for user-overridable settings (saved to `config/user_settings.json`).
* **Edit Definitions...:** Opens the Definitions Editor for managing Asset Type Definitions, File Type Definitions, and Supplier Settings (saved to their respective files).
The "View" menu allows you to toggle the visibility of the Log Console and the Detailed File Preview.
* **Preset Editor Panel (Left):** * **Preset Editor Panel (Left):**
* **Optional Log Console:** Displays application logs (toggle via View menu). * **Optional Log Console:** Displays application logs (toggle via View menu).
* **Preset List:** Create, delete, load, edit, and save presets. On startup, the "-- Select a Preset --" item is explicitly selected. You must select a specific preset from this list to load it into the editor below, enable the detailed file preview, and enable the "Start Processing" button. * **Preset List:** Create, delete, load, edit, and save presets. On startup, the "-- Select a Preset --" item is explicitly selected. You must select a specific preset from this list to load it into the editor below, enable the detailed file preview, and enable the "Start Processing" button.

View File

@@ -2,7 +2,7 @@
This document describes the directory structure and contents of the processed assets generated by the Asset Processor Tool. This document describes the directory structure and contents of the processed assets generated by the Asset Processor Tool.
Processed assets are saved to a location determined by two global settings defined in `config/app_settings.json`: Processed assets are saved to a location determined by two global settings, `OUTPUT_DIRECTORY_PATTERN` and `OUTPUT_FILENAME_PATTERN`, defined in `config/app_settings.json`. These settings can be overridden by the user via `config/user_settings.json`.
* `OUTPUT_DIRECTORY_PATTERN`: Defines the directory structure *within* the Base Output Directory. * `OUTPUT_DIRECTORY_PATTERN`: Defines the directory structure *within* the Base Output Directory.
* `OUTPUT_FILENAME_PATTERN`: Defines the naming convention for individual files *within* the directory created by `OUTPUT_DIRECTORY_PATTERN`. * `OUTPUT_FILENAME_PATTERN`: Defines the naming convention for individual files *within* the directory created by `OUTPUT_DIRECTORY_PATTERN`.
@@ -23,7 +23,7 @@ The following tokens can be used in both `OUTPUT_DIRECTORY_PATTERN` and `OUTPUT_
* `[Time]`: Current time (`HHMMSS`). * `[Time]`: Current time (`HHMMSS`).
* `[Sha5]`: The first 5 characters of the SHA-256 hash of the original input source file (e.g., the source zip archive). * `[Sha5]`: The first 5 characters of the SHA-256 hash of the original input source file (e.g., the source zip archive).
* `[ApplicationPath]`: Absolute path to the application directory. * `[ApplicationPath]`: Absolute path to the application directory.
* `[maptype]`: The standardized map type identifier (e.g., `COL` for Color/Albedo, `NRM` for Normal, `RGH` for Roughness). This is derived from the `standard_type` defined in the application's `FILE_TYPE_DEFINITIONS` (see `config/app_settings.json`) and may include a variant suffix if applicable. (Primarily for filename pattern) * `[maptype]`: The standardized map type identifier (e.g., `COL` for Color/Albedo, `NRM` for Normal, `RGH` for Roughness). This is derived from the `standard_type` defined in the application's `FILE_TYPE_DEFINITIONS` (managed in `config/file_type_definitions.json` via the Definitions Editor) and may include a variant suffix if applicable. (Primarily for filename pattern)
* `[dimensions]`: Pixel dimensions (e.g., `2048x2048`). * `[dimensions]`: Pixel dimensions (e.g., `2048x2048`).
* `[bitdepth]`: Output bit depth (e.g., `8bit`, `16bit`). * `[bitdepth]`: Output bit depth (e.g., `8bit`, `16bit`).
* `[category]`: Asset category determined by preset rules. * `[category]`: Asset category determined by preset rules.
@@ -51,13 +51,14 @@ The final output path is constructed by combining the Base Output Directory (set
* `OUTPUT_FILENAME_PATTERN`: `[maptype].[ext]` * `OUTPUT_FILENAME_PATTERN`: `[maptype].[ext]`
* Resulting Path for a Normal map: `Output/Texture/Wood/WoodFloor001/Normal.exr` * Resulting Path for a Normal map: `Output/Texture/Wood/WoodFloor001/Normal.exr`
The `<output_base_directory>` (the root folder where processing output starts) is configured separately via the GUI (**Edit** -> **Preferences...** -> **Output & Naming** tab -> **Base Output Directory**) or the `--output` CLI argument. The `OUTPUT_DIRECTORY_PATTERN` defines the structure *within* this base directory, and `OUTPUT_FILENAME_PATTERN` defines the filenames within that structure. The `<output_base_directory>` (the root folder where processing output starts) is configured separately via the GUI (**Edit** -> **Preferences...** -> **General** tab -> **Output Base Directory**) or the `--output` CLI argument. The `OUTPUT_DIRECTORY_PATTERN` defines the structure *within* this base directory, and `OUTPUT_FILENAME_PATTERN` defines the filenames within that structure.
## Contents of Each Asset Directory ## Contents of Each Asset Directory
Each asset directory contains the following: Each asset directory contains the following:
* Processed texture maps (e.g., `WoodFloor_Albedo_4k.png`, `MetalPanel_Normal_2k.exr`). The exact filenames depend on the `OUTPUT_FILENAME_PATTERN`. These are the resized, format-converted, and bit-depth adjusted texture files. * Processed texture maps (e.g., `WoodFloor_Albedo_4k.png`, `MetalPanel_Normal_2k.exr`). The exact filenames depend on the `OUTPUT_FILENAME_PATTERN`. These are the resized, format-converted, and bit-depth adjusted texture files.
* **LOWRES Variants:** If the "Low-Resolution Fallback" feature is enabled and a source image's dimensions are below the configured threshold, an additional variant with "LOWRES" as its resolution token (e.g., `MyTexture_COL_LOWRES.png`) will be saved. This variant uses the original dimensions of the source image.
* Merged texture maps (e.g., `WoodFloor_Combined_4k.png`). The exact filenames depend on the `OUTPUT_FILENAME_PATTERN`. These are maps created by combining channels from different source maps based on the configured merge rules. * Merged texture maps (e.g., `WoodFloor_Combined_4k.png`). The exact filenames depend on the `OUTPUT_FILENAME_PATTERN`. These are maps created by combining channels from different source maps based on the configured merge rules.
* Model files (if present in the source asset). * Model files (if present in the source asset).
* `metadata.json`: A JSON file containing detailed information about the asset and the processing that was performed. This includes details about the maps (resolutions, formats, bit depths, and for roughness maps, a `derived_from_gloss_filename: true` flag if it was inverted from an original gloss map), merged map details, calculated image statistics, aspect ratio change information, asset category and archetype, the source preset used, and a list of ignored source files. This file is intended for use by downstream tools or scripts (like the Blender integration scripts). * `metadata.json`: A JSON file containing detailed information about the asset and the processing that was performed. This includes details about the maps (resolutions, formats, bit depths, and for roughness maps, a `derived_from_gloss_filename: true` flag if it was inverted from an original gloss map), merged map details, calculated image statistics, aspect ratio change information, asset category and archetype, the source preset used, and a list of ignored source files. This file is intended for use by downstream tools or scripts (like the Blender integration scripts).

View File

@@ -34,7 +34,7 @@ The script accepts several command-line arguments to configure the test run. If
* A string to search for within the application logs generated during the test run. If found, matching log lines (with context) will be highlighted. * A string to search for within the application logs generated during the test run. If found, matching log lines (with context) will be highlighted.
* Default: None * Default: None
* `--additional-lines NUM_LINES` (optional): * `--additional-lines NUM_LINES` (optional):
* When using `--search`, this specifies how many lines of context before and after each matching log line should be displayed. * When using `--search`, this specifies how many lines of context before and after each matching log line should be displayed. A good non-zero value is 1-2.
* Default: `0` * Default: `0`
**Example Usage:** **Example Usage:**
@@ -81,3 +81,5 @@ When executed, `autotest.py` performs the following steps:
* **Output Directory:** Inspect the contents of the specified output directory to manually verify the processed assets if needed. * **Output Directory:** Inspect the contents of the specified output directory to manually verify the processed assets if needed.
This automated test helps ensure the stability of the core processing logic when driven by GUI-equivalent actions. This automated test helps ensure the stability of the core processing logic when driven by GUI-equivalent actions.
Note: Under some conditions, the autotest will exit with errorcode "3221226505". This has no consequence and can therefor be ignore.

View File

@@ -2,43 +2,144 @@
This document provides technical details about the configuration system and the structure of preset files for developers working on the Asset Processor Tool. This document provides technical details about the configuration system and the structure of preset files for developers working on the Asset Processor Tool.
## Configuration Flow ## Configuration System Overview
The tool utilizes a two-tiered configuration system managed by the `configuration.py` module: The tool's configuration is managed by the `configuration.py` module and loaded from several JSON files, providing a layered approach for defaults, user overrides, definitions, and source-specific presets.
1. **Application Settings (`config/app_settings.json`):** This JSON file defines the core global default settings, constants, and rules that apply generally across different asset sources (e.g., the global `OUTPUT_DIRECTORY_PATTERN` and `OUTPUT_FILENAME_PATTERN`, standard image resolutions, map merge rules, output format rules, Blender paths, `FILE_TYPE_DEFINITIONS`, `ASSET_TYPE_DEFINITIONS`). See the [User Guide: Output Structure](../01_User_Guide/09_Output_Structure.md#available-tokens) for a list of available tokens for these patterns. ### Configuration Files
* **`FILE_TYPE_DEFINITIONS` Enhancements:**
* **`keybind` Property:** Each file type object within `FILE_TYPE_DEFINITIONS` can now optionally include a `keybind` property. This property accepts a single character string (e.g., `"C"`, `"R"`) representing the keyboard key. In the GUI, this key (typically combined with `Ctrl`, or standalone like `F2` for asset naming) is used as a shortcut to set or toggle the corresponding file type for selected items in the Preview Table. The tool's configuration is loaded from several JSON files, providing a layered approach for defaults, user overrides, definitions, and source-specific presets.
1. **Application Settings (`config/app_settings.json`):** This JSON file defines the core global default settings, constants, and rules that apply generally across different asset sources (e.g., the global `OUTPUT_DIRECTORY_PATTERN` and `OUTPUT_FILENAME_PATTERN`, standard image resolutions, map merge rules, output format rules, Blender paths, temporary directory prefix, initial scaling mode, merge dimension mismatch strategy). See the [User Guide: Output Structure](../01_User_Guide/09_Output_Structure.md#available-tokens) for a list of available tokens for these patterns.
* *Note:* `ASSET_TYPE_DEFINITIONS` and `FILE_TYPE_DEFINITIONS` are no longer stored here; they have been moved to dedicated files.
* It also includes settings for new features like the "Low-Resolution Fallback":
* `ENABLE_LOW_RESOLUTION_FALLBACK` (boolean): Enables or disables the generation of "LOWRES" variants for small source images. Defaults to `true`.
* `LOW_RESOLUTION_THRESHOLD` (integer): The pixel dimension threshold (largest side) below which a "LOWRES" variant is created if the feature is enabled. Defaults to `512`.
2. **User Settings (`config/user_settings.json`):** This optional JSON file allows users to override specific settings defined in `config/app_settings.json`. If this file exists, its values for corresponding keys will take precedence over the base application settings. This file is primarily managed through the GUI's Application Preferences Editor.
3. **Asset Type Definitions (`config/asset_type_definitions.json`):** This dedicated JSON file contains the definitions for different asset types (e.g., Surface, Model, Decal), including their descriptions, colors for UI representation, and example usage strings.
4. **File Type Definitions (`config/file_type_definitions.json`):** This dedicated JSON file contains the definitions for different file types (specifically texture maps and models), including descriptions, colors for UI representation, examples of keywords/patterns, a standard alias (`standard_type`), bit depth handling rules (`bit_depth_rule`), a grayscale flag (`is_grayscale`), and an optional GUI keybind (`keybind`).
* **`keybind` Property:** Each file type object within `FILE_TYPE_DEFINITIONS` can optionally include a `keybind` property. This property accepts a single character string (e.g., `"C"`, `"R"`) representing the keyboard key. In the GUI, this key (typically combined with `Ctrl`) is used as a shortcut to set or toggle the corresponding file type for selected items in the Preview Table.
*Example:* *Example:*
```json ```json
"MAP_COL": { "MAP_COL": {
"description": "Color/Albedo Map", "description": "Color/Albedo Map",
"color": [200, 200, 200], "color": "#ffaa00",
"examples": ["albedo", "col", "basecolor"], "examples": ["_col.", "_basecolor.", "albedo", "diffuse"],
"standard_type": "COL", "standard_type": "COL",
"bit_depth_rule": "respect", "bit_depth_rule": "force_8bit",
"is_grayscale": false, "is_grayscale": false,
"keybind": "C" "keybind": "C"
}, },
``` ```
* **New File Type `MAP_GLOSS`:** A new standard file type, `MAP_GLOSS`, has been added. It is typically configured as follows: Note: The `bit_depth_rule` property in `FILE_TYPE_DEFINITIONS` is the primary source for determining bit depth handling for a given map type.
*Example:*
5. **Supplier Settings (`config/suppliers.json`):** This JSON file stores settings specific to different asset suppliers. It is now structured as a dictionary where keys are supplier names and values are objects containing supplier-specific configurations.
* **Structure:**
```json ```json
"MAP_GLOSS": { {
"description": "Glossiness Map", "SupplierName1": {
"color": [180, 180, 220], "setting_key1": "value",
"examples": ["gloss", "gls"], "setting_key2": "value"
"standard_type": "GLOSS", },
"bit_depth_rule": "respect", "SupplierName2": {
"is_grayscale": true, "setting_key1": "value"
"keybind": "R" }
}
```
* **`normal_map_type` Property:** A key setting within each supplier's object is `normal_map_type`, specifying whether normal maps from this supplier use "OpenGL" or "DirectX" conventions.
*Example:*
```json
{
"Poliigon": {
"normal_map_type": "DirectX"
},
"Dimensiva": {
"normal_map_type": "OpenGL"
}
} }
``` ```
Note: The `keybind` "R" for `MAP_GLOSS` is often shared with `MAP_ROUGH` to allow toggling between them.
2. **LLM Settings (`config/llm_settings.json`):** This JSON file contains settings specifically related to the LLM predictor, such as the API endpoint, model name, prompt template, and examples. These settings can be edited through the GUI using the `LLMEditorWidget`.
3. **Preset Files (`Presets/*.json`):** These JSON files define supplier-specific rules and overrides. They contain patterns to interpret filenames, classify map types, handle variants, define naming conventions, and specify other source-specific behaviors.
The `configuration.py` module contains the `Configuration` class (for loading/merging settings for processing) and standalone functions like `load_base_config()` (for accessing `app_settings.json` directly) and `save_llm_config()` / `save_base_config()` (for writing settings back to files). Note that the old `config.py` file has been deleted. 6. **LLM Settings (`config/llm_settings.json`):** This JSON file contains settings specifically related to the LLM predictor, such as the API endpoint, model name, prompt template, and examples. These settings are managed through the GUI using the `LLMEditorWidget`.
7. **Preset Files (`Presets/*.json`):** These JSON files define source-specific rules and overrides. They contain patterns to interpret filenames, classify map types, handle variants, define naming conventions, and specify other source-specific behaviors. Preset settings override values from `app_settings.json` and `user_settings.json` where applicable.
### Configuration Loading and Access
The `configuration.py` module contains the `Configuration` class and standalone functions for loading and saving settings.
* **`Configuration` Class:** This is the primary class used by the processing engine and other core components. When initialized with a `preset_name`, it loads settings in the following order, with later files overriding earlier ones for shared keys:
1. `config/app_settings.json` (Base Defaults)
2. `config/user_settings.json` (User Overrides - if exists)
3. `config/asset_type_definitions.json` (Asset Type Definitions)
4. `config/file_type_definitions.json` (File Type Definitions)
5. `config/llm_settings.json` (LLM Settings)
6. `Presets/{preset_name}.json` (Preset Overrides)
The loaded settings are merged into internal dictionaries, and most are accessible via instance properties (e.g., `config.output_base_dir`, `config.llm_endpoint_url`, `config.get_asset_type_definitions()`). Regex patterns defined in the merged configuration are pre-compiled for performance.
* **`load_base_config()` function:** This standalone function is primarily used by the GUI for initial setup and displaying default/user-overridden settings before a specific preset is selected. It loads and merges the following files:
1. `config/app_settings.json`
2. `config/user_settings.json` (if exists)
3. `config/asset_type_definitions.json`
4. `config/file_type_definitions.json`
It returns a single dictionary containing the combined settings and definitions.
* **Saving Functions:**
* `save_base_config(settings_dict)`: Saves the provided dictionary to `config/app_settings.json`. (Used less frequently now for user-driven saves).
* `save_user_config(settings_dict)`: Saves the provided dictionary to `config/user_settings.json`. Used by `ConfigEditorDialog`.
* `save_llm_config(settings_dict)`: Saves the provided dictionary to `config/llm_settings.json`. Used by `LLMEditorWidget`.
## Supplier Management (`config/suppliers.json`)
A file, `config/suppliers.json`, is used to store a persistent list of known supplier names. This file is a simple JSON array of strings.
* **Purpose:** Provides a list of suggestions for the "Supplier" field in the GUI's Unified View, enabling auto-completion.
* **Management:** The GUI's `SupplierSearchDelegate` is responsible for loading this list on startup, adding new, unique supplier names entered by the user, and saving the updated list back to the file.
## GUI Configuration Editors
The GUI provides dedicated editors for modifying configuration files:
* **`ConfigEditorDialog` (`gui/config_editor_dialog.py`):** Edits user-configurable application settings.
* **`LLMEditorWidget` (`gui/llm_editor_widget.py`):** Edits the LLM-specific settings.
### `ConfigEditorDialog` (`gui/config_editor_dialog.py`)
The GUI includes a dedicated editor for modifying user-configurable settings. This is implemented in `gui/config_editor_dialog.py`.
* **Purpose:** Provides a user-friendly interface for viewing the effective application settings (defaults + user overrides + definitions) and editing the user-specific overrides.
* **Implementation:** The dialog loads the effective settings using `load_base_config()`. It presents relevant settings in a tabbed layout ("General", "Output & Naming", etc.). When saving, it now performs a **granular save**: it loads the current content of `config/user_settings.json`, identifies only the settings that were changed by the user during the current dialog session (by comparing against the initial state), updates only those specific values in the loaded `user_settings.json` content, and saves the modified content back to `config/user_settings.json` using `save_user_config()`. This preserves any other settings in `user_settings.json` that were not touched. The dialog displays definitions from `asset_type_definitions.json` and `file_type_definitions.json` but does not save changes to these files.
* **Limitations:** Currently, editing complex fields like `IMAGE_RESOLUTIONS` or the full details of `MAP_MERGE_RULES` via the UI is not fully supported for saving to `user_settings.json`.
### `LLMEditorWidget` (`gui/llm_editor_widget.py`)
* **Purpose:** Provides a user-friendly interface for viewing and editing the LLM settings defined in `config/llm_settings.json`.
* **Implementation:** Uses tabs for "Prompt Settings" and "API Settings". Allows editing the prompt, managing examples, and configuring API details. When saving, it also performs a **granular save**: it loads the current content of `config/llm_settings.json`, identifies only the settings changed by the user in the current session, updates only those values, and saves the modified content back to `config/llm_settings.json` using `configuration.save_llm_config()`.
## Preset File Structure (`Presets/*.json`)
Preset files are the primary way to adapt the tool to new asset sources. Developers should use `Presets/_template.json` as a starting point. Key fields include:
* `supplier_name`: The name of the asset source (e.g., `"Poliigon"`). Used for output directory naming.
* `map_type_mapping`: A list of dictionaries, each mapping source filename patterns/keywords to a specific file type. The `target_type` for this mapping **must** be a key from the `FILE_TYPE_DEFINITIONS` now located in `config/file_type_definitions.json`.
* `target_type`: The specific file type key from `FILE_TYPE_DEFINITIONS` (e.g., `"MAP_COL"`, `"MAP_NORM_GL"`, `"MAP_RGH"`). This replaces previous alias-based systems. The common aliases like "COL" or "NRM" are now derived from the `standard_type` property within `FILE_TYPE_DEFINITIONS` but are not used directly for `target_type`.
* `keywords`: A list of filename patterns (regex or fnmatch-style wildcards) used to identify this map type. The order of keywords within this list, and the order of dictionaries in the `map_type_mapping` list, determines the priority for assigning variant suffixes (`-1`, `-2`, etc.) when multiple files match the same `target_type`.
* `bit_depth_variants`: A dictionary mapping standard map types (e.g., `"NRM"`) to a pattern identifying its high bit-depth variant (e.g., `"*_NRM16*.tif"`). Files matching these patterns are prioritized over their standard counterparts.
* `map_bit_depth_rules`: Defines how to handle the bit depth of source maps. Can specify a default behavior (`"respect"` or `"force_8bit"`) and overrides for specific map types.
* `model_patterns`: A list of regex patterns to identify model files (e.g., `".*\\.fbx"`, `".*\\.obj"`).
* `move_to_extra_patterns`: A list of regex patterns for files that should be moved directly to the `Extra/` output subdirectory without further processing.
* `source_naming_convention`: Rules for extracting the base asset name and potentially the archetype from source filenames or directory structures (e.g., using separators and indices).
* `asset_category_rules`: Keywords or patterns used to determine the asset category (e.g., identifying `"Decal"` based on keywords).
* `archetype_rules`: Keywords or patterns used to determine the asset archetype (e.g., identifying `"Wood"` or `"Metal"`).
Careful definition of these patterns and rules, especially the regex in `map_type_mapping`, `bit_depth_variants`, `model_patterns`, and `move_to_extra_patterns`, is essential for correct asset processing.
**Note on Data Passing:** As mentioned in the Architecture documentation, major changes to the data passing mechanisms between the GUI, Main (CLI orchestration), and `AssetProcessor` modules are currently being planned. The descriptions of how configuration data is handled and passed within this document reflect the current state and will require review and updates once the plan for these changes is finalized.
## Supplier Management (`config/suppliers.json`) ## Supplier Management (`config/suppliers.json`)

View File

@@ -50,27 +50,44 @@ These stages are executed sequentially once for each asset before the core item
### Core Item Processing Loop ### Core Item Processing Loop
The [`PipelineOrchestrator`](processing/pipeline/orchestrator.py:36) iterates through the `context.processing_items` list (populated by the [`PrepareProcessingItemsStage`](processing/pipeline/stages/prepare_processing_items.py:10)). For each item (either a [`FileRule`](rule_structure.py:5) for a regular map or a [`MergeTaskDefinition`](processing/pipeline/asset_context.py:16) for a merged map), the following stages are executed sequentially: The [`PipelineOrchestrator`](processing/pipeline/orchestrator.py:36) iterates through the `context.processing_items` list (populated by the [`PrepareProcessingItemsStage`](processing/pipeline/stages/prepare_processing_items.py:10)). Each `item` in this list is now either a [`ProcessingItem`](rule_structure.py:0) (representing a specific variant of a source map, e.g., Color at 1K, or Color at LOWRES) or a [`MergeTaskDefinition`](processing/pipeline/asset_context.py:16).
1. **[`PrepareProcessingItemsStage`](processing/pipeline/stages/prepare_processing_items.py:10)** (`processing/pipeline/stages/prepare_processing_items.py`): 1. **[`PrepareProcessingItemsStage`](processing/pipeline/stages/prepare_processing_items.py:10)** (`processing/pipeline/stages/prepare_processing_items.py`):
* **Responsibility**: (Executed once before the loop) Creates the `context.processing_items` list by combining [`FileRule`](rule_structure.py:5)s from `context.files_to_process` and [`MergeTaskDefinition`](processing/pipeline/asset_context.py:16)s derived from the global `map_merge_rules` configuration. It correctly accesses `map_merge_rules` from `context.config_obj` and validates each merge rule for the presence of `output_map_type` and a dictionary for `inputs`. Initializes `context.intermediate_results`. * **Responsibility**: (Executed once before the loop) This stage is now responsible for "exploding" each relevant [`FileRule`](rule_structure.py:5) into one or more [`ProcessingItem`](rule_structure.py:0) objects.
* **Context Interaction**: Reads from `context.files_to_process` and `context.config_obj` (accessing `map_merge_rules`). Populates `context.processing_items` and initializes `context.intermediate_results`. * For each [`FileRule`](rule_structure.py:5) that represents an image map:
* It loads the source image data and determines its original dimensions and bit depth.
* It creates standard [`ProcessingItem`](rule_structure.py:0)s for each required output resolution (e.g., "1K", "PREVIEW"), populating them with a copy of the source image data and the respective `resolution_key`.
* If the "Low-Resolution Fallback" feature is enabled (`ENABLE_LOW_RESOLUTION_FALLBACK` in config) and the source image's largest dimension is below `LOW_RESOLUTION_THRESHOLD`, it creates an additional [`ProcessingItem`](rule_structure.py:0) with `resolution_key="LOWRES"`, using the original image data and dimensions.
* It also adds [`MergeTaskDefinition`](processing/pipeline/asset_context.py:16)s derived from global `map_merge_rules`.
* **Context Interaction**: Reads `context.files_to_process` and `context.config_obj`. Populates `context.processing_items` with a list of [`ProcessingItem`](rule_structure.py:0) and [`MergeTaskDefinition`](processing/pipeline/asset_context.py:16) objects. Initializes `context.intermediate_results`.
2. **[`RegularMapProcessorStage`](processing/pipeline/stages/regular_map_processor.py:18)** (`processing/pipeline/stages/regular_map_processor.py`): For each `item` in `context.processing_items`:
* **Responsibility**: (Executed per [`FileRule`](rule_structure.py:5) item) Checks if the `FileRule.item_type` starts with "MAP_". If not, the item is skipped. Otherwise, it loads the image data for the file, determines its potentially suffixed internal map type (e.g., "MAP_COL-1"), applies in-memory transformations (Gloss-to-Rough, Normal Green Invert) using the shared utility function [`apply_common_map_transformations`](processing/utils/image_processing_utils.py), and returns the processed image data and details in a [`ProcessedRegularMapData`](processing/pipeline/asset_context.py:23) object. The `internal_map_type` in the output reflects any transformations (e.g., "MAP_GLOSS" becomes "MAP_ROUGH").
* **Context Interaction**: Reads from the input [`FileRule`](rule_structure.py:5) (checking `item_type`) and [`Configuration`](configuration.py:68). Returns a [`ProcessedRegularMapData`](processing/pipeline/asset_context.py:23) object which is stored in `context.intermediate_results`. 2. **Transformations (Implicit or via a dedicated stage - formerly `RegularMapProcessorStage` logic):**
* **Responsibility**: If the `item` is a [`ProcessingItem`](rule_structure.py:0), its `image_data` (loaded by `PrepareProcessingItemsStage`) may need transformations (Gloss-to-Rough, Normal Green Invert). This logic, previously in `RegularMapProcessorStage`, might be integrated into `PrepareProcessingItemsStage` before `ProcessingItem` creation, or handled by a new dedicated transformation stage that operates on `ProcessingItem.image_data`. The `item.map_type_identifier` would be updated if a transformation like Gloss-to-Rough occurs.
* **Context Interaction**: Modifies `item.image_data` and `item.map_type_identifier` within the [`ProcessingItem`](rule_structure.py:0) object.
3. **[`MergedTaskProcessorStage`](processing/pipeline/stages/merged_task_processor.py:68)** (`processing/pipeline/stages/merged_task_processor.py`): 3. **[`MergedTaskProcessorStage`](processing/pipeline/stages/merged_task_processor.py:68)** (`processing/pipeline/stages/merged_task_processor.py`):
* **Responsibility**: (Executed per [`MergeTaskDefinition`](processing/pipeline/asset_context.py:16) item) Validates that all input map types specified in the merge rule start with "MAP_". If not, the task is failed. It dynamically loads input images by looking up the required input map types (e.g., "MAP_NRM") in `context.processed_maps_details` and using the temporary file paths from their `saved_files_info`. It applies in-memory transformations to inputs using [`apply_common_map_transformations`](processing/utils/image_processing_utils.py), handles dimension mismatches (with fallback creation if configured and `source_dimensions` are available), performs the channel merging operation, and returns the merged image data and details in a [`ProcessedMergedMapData`](processing/pipeline/asset_context.py:35) object. The `output_map_type` of the merged map must also be "MAP_" prefixed in the configuration. * **Responsibility**: (Executed if `item` is a [`MergeTaskDefinition`](processing/pipeline/asset_context.py:16)) Same as before: validates inputs, loads source map data (likely from `ProcessingItem`s in `context.processing_items` or a cache populated from them), applies transformations, merges channels, and returns [`ProcessedMergedMapData`](processing/pipeline/asset_context.py:35).
* **Context Interaction**: Reads from the input [`MergeTaskDefinition`](processing/pipeline/asset_context.py:16) (checking input map types), `context.workspace_path`, `context.processed_maps_details` (for input image data), and [`Configuration`](configuration.py:68). Returns a [`ProcessedMergedMapData`](processing/pipeline/asset_context.py:35) object which is stored in `context.intermediate_results`. * **Context Interaction**: Reads [`MergeTaskDefinition`](processing/pipeline/asset_context.py:16), potentially `context.processing_items` (or a cache derived from it) for input image data. Returns [`ProcessedMergedMapData`](processing/pipeline/asset_context.py:35).
4. **[`InitialScalingStage`](processing/pipeline/stages/initial_scaling.py:14)** (`processing/pipeline/stages/initial_scaling.py`): 4. **[`InitialScalingStage`](processing/pipeline/stages/initial_scaling.py:14)** (`processing/pipeline/stages/initial_scaling.py`):
* **Responsibility**: (Executed per item) Applies initial scaling (e.g., Power-of-Two downscaling) to the image data from the previous processing stage based on the `initial_scaling_mode` configuration. * **Responsibility**: (Executed per item)
* **Context Interaction**: Takes a [`InitialScalingInput`](processing/pipeline/asset_context.py:46) (containing image data and config) and returns an [`InitialScalingOutput`](processing/pipeline/asset_context.py:54) object, which updates the item's entry in `context.intermediate_results`. * If `item` is a [`ProcessingItem`](rule_structure.py:0): Takes `item.image_data`, `item.current_dimensions`, and `item.resolution_key` as input. If `item.resolution_key` is "LOWRES", POT scaling is skipped. Otherwise, applies POT scaling if configured.
* If `item` is from a `MergeTaskDefinition` (i.e., `processed_data` from `MergedTaskProcessorStage`): Applies POT scaling as before.
* **Context Interaction**: Takes [`InitialScalingInput`](processing/pipeline/asset_context.py:46) (now including `resolution_key`). Returns [`InitialScalingOutput`](processing/pipeline/asset_context.py:54) (also including `resolution_key`), which updates `context.intermediate_results`. The `current_image_data` and `current_dimensions` for saving are taken from this output.
5. **[`SaveVariantsStage`](processing/pipeline/stages/save_variants.py:15)** (`processing/pipeline/stages/save_variants.py`): 5. **[`SaveVariantsStage`](processing/pipeline/stages/save_variants.py:15)** (`processing/pipeline/stages/save_variants.py`):
* **Responsibility**: (Executed per item) Takes the final processed image data (potentially scaled) and configuration, and calls a utility to save the image to temporary files in various resolutions and formats as defined by the configuration. * **Responsibility**: (Executed per item) Saves the (potentially scaled) `current_image_data`.
* **Context Interaction**: Takes a [`SaveVariantsInput`](processing/pipeline/asset_context.py:61) object (which includes the "MAP_" prefixed `internal_map_type`). It uses the `get_filename_friendly_map_type` utility to convert this to a "standard type" (e.g., "COL") for output naming. Returns a [`SaveVariantsOutput`](processing/pipeline/asset_context.py:79) object containing details about the saved temporary files. The orchestrator stores these details, including the original "MAP_" prefixed `internal_map_type`, in `context.processed_maps_details` for the item. * **Context Interaction**:
* Takes [`SaveVariantsInput`](processing/pipeline/asset_context.py:61).
* `internal_map_type` is set from `item.map_type_identifier` (for `ProcessingItem`) or `processed_data.output_map_type` (for merged).
* `output_filename_pattern_tokens['resolution']` is set to the `resolution_key` obtained from `scaled_data_output.resolution_key` (which originates from `item.resolution_key` for `ProcessingItem`s, or is `None` for merged items that get all standard resolutions).
* `image_resolutions` argument for `SaveVariantsInput`:
* If `resolution_key == "LOWRES"`: Set to `{"LOWRES": width_of_lowres_data}`.
* If `resolution_key` is a standard key (e.g., "1K"): Set to `{resolution_key: configured_dimension}`.
* For merged items (where `resolution_key` from scaling is likely `None`): Set to the full `config.image_resolutions` map to generate all applicable standard sizes.
* Returns [`SaveVariantsOutput`](processing/pipeline/asset_context.py:79). Orchestrator stores details in `context.processed_maps_details`.
### Post-Item Stages ### Post-Item Stages

View File

@@ -10,13 +10,13 @@ The GUI is built using `PySide6`, which provides Python bindings for the Qt fram
The `MainWindow` class acts as the central **coordinator** for the GUI application. It is responsible for: The `MainWindow` class acts as the central **coordinator** for the GUI application. It is responsible for:
* Setting up the main application window structure and menu bar. * Setting up the main application window structure and menu bar, including actions to launch configuration and definition editors.
* **Layout:** Arranging the main GUI components using a `QSplitter`. * **Layout:** Arranging the main GUI components using a `QSplitter`.
* **Left Pane:** Contains the preset selection controls (from `PresetEditorWidget`) permanently displayed at the top. Below this, a `QStackedWidget` switches between the preset JSON editor (also from `PresetEditorWidget`) and the `LLMEditorWidget`. * **Left Pane:** Contains the preset selection controls (from `PresetEditorWidget`) permanently displayed at the top. Below this, a `QStackedWidget` switches between the preset JSON editor (also from `PresetEditorWidget`) and the `LLMEditorWidget`.
* **Right Pane:** Contains the `MainPanelWidget`. * **Right Pane:** Contains the `MainPanelWidget`.
* Instantiating and managing the major GUI widgets: * Instantiating and managing the major GUI widgets:
* `PresetEditorWidget` (`gui/preset_editor_widget.py`): Provides the preset selector and the JSON editor parts. * `PresetEditorWidget` (`gui/preset_editor_widget.py`): Provides the preset selector and the JSON editor parts.
* `LLMEditorWidget` (`gui/llm_editor_widget.py`): Provides the editor for LLM settings. * `LLMEditorWidget` (`gui/llm_editor_widget.py`): Provides the editor for LLM settings (from `config/llm_settings.json`).
* `MainPanelWidget` (`gui/main_panel_widget.py`): Contains the rule hierarchy view and processing controls. * `MainPanelWidget` (`gui/main_panel_widget.py`): Contains the rule hierarchy view and processing controls.
* `LogConsoleWidget` (`gui/log_console_widget.py`): Displays application logs. * `LogConsoleWidget` (`gui/log_console_widget.py`): Displays application logs.
* Instantiating key models and handlers: * Instantiating key models and handlers:
@@ -198,13 +198,24 @@ The `LogConsoleWidget` displays logs captured by a custom `QtLogHandler` from Py
The GUI provides a "Cancel" button. Cancellation logic for the actual processing is now likely handled within the `main.ProcessingTask` or the code that manages it, as the `ProcessingHandler` has been removed. The GUI button would signal this external task manager. The GUI provides a "Cancel" button. Cancellation logic for the actual processing is now likely handled within the `main.ProcessingTask` or the code that manages it, as the `ProcessingHandler` has been removed. The GUI button would signal this external task manager.
## GUI Configuration Editor (`gui/config_editor_dialog.py`) ## Application Preferences Editor (`gui/config_editor_dialog.py`)
A dedicated dialog for editing `config/app_settings.json`. A dedicated dialog for editing user-overridable application settings. It loads base settings from `config/app_settings.json` and saves user overrides to `config/user_settings.json`.
* **Functionality:** Loads `config/app_settings.json`, presents in tabs, allows editing basic fields, definitions tables (with color editing), and merge rules list/detail. * **Functionality:** Provides a tabbed interface to edit various application settings, including general paths, output/naming patterns, image processing options (like resolutions and compression), and map merging rules. It no longer includes editors for Asset Type or File Type Definitions.
* **Limitations:** Editing complex fields like `IMAGE_RESOLUTIONS` or full `MAP_MERGE_RULES` details might still be limited. * **Integration:** Launched by `MainWindow` via the "Edit" -> "Preferences..." menu.
* **Integration:** Launched by `MainWindow` ("Edit" -> "Preferences..."). * **Persistence:** Saves changes to `config/user_settings.json`. Changes require an application restart to take effect in processing logic.
* **Persistence:** Saves changes to `config/app_settings.json`. Requires application restart for changes to affect processing logic loaded by the `Configuration` class.
The refactored GUI separates concerns into distinct widgets and handlers, coordinated by the `MainWindow`. Background tasks use `QThreadPool` and `QRunnable`. The `UnifiedViewModel` focuses on data presentation and simple edits, delegating complex restructuring to the `AssetRestructureHandler`. The refactored GUI separates concerns into distinct widgets and handlers, coordinated by the `MainWindow`. Background tasks use `QThreadPool` and `QRunnable`. The `UnifiedViewModel` focuses on data presentation and simple edits, delegating complex restructuring to the `AssetRestructureHandler`.
## Definitions Editor (`gui/definitions_editor_dialog.py`)
A new dedicated dialog for managing core application definitions that are separate from general user preferences.
* **Purpose:** Provides a structured UI for editing Asset Type Definitions, File Type Definitions, and Supplier Settings.
* **Structure:** Uses a `QTabWidget` with three tabs:
* **Asset Type Definitions:** Manages definitions from `config/asset_type_definitions.json`. Presents a list of asset types and allows editing their description, color, and examples.
* **File Type Definitions:** Manages definitions from `config/file_type_definitions.json`. Presents a list of file types and allows editing their description, color, examples, standard type, bit depth rule, grayscale status, and keybind.
* **Supplier Settings:** Manages settings from `config/suppliers.json`. Presents a list of suppliers and allows editing supplier-specific settings (e.g., Normal Map Type).
* **Integration:** Launched by `MainWindow` via the "Edit" -> "Edit Definitions..." menu.
* **Persistence:** Saves changes directly to the respective configuration files (`config/asset_type_definitions.json`, `config/file_type_definitions.json`, `config/suppliers.json`). Some changes may require an application restart.

View File

@@ -1,5 +1,5 @@
{ {
"preset_name": "Dinesen Custom", "preset_name": "Dinesen",
"supplier_name": "Dinesen", "supplier_name": "Dinesen",
"notes": "Preset for standard Poliigon downloads. Prioritizes _xxx16 files. Moves previews etc. to Extra/. Assumes Metal/Rough workflow.", "notes": "Preset for standard Poliigon downloads. Prioritizes _xxx16 files. Moves previews etc. to Extra/. Assumes Metal/Rough workflow.",
"source_naming": { "source_naming": {
@@ -10,11 +10,7 @@
}, },
"glossiness_keywords": [ "glossiness_keywords": [
"GLOSS" "GLOSS"
], ]
"bit_depth_variants": {
"NRM": "*_NRM16*",
"DISP": "*_DISP16*"
}
}, },
"move_to_extra_patterns": [ "move_to_extra_patterns": [
"*_Preview*", "*_Preview*",
@@ -25,7 +21,8 @@
"*.pdf", "*.pdf",
"*.url", "*.url",
"*.htm*", "*.htm*",
"*_Fabric.*" "*_Fabric.*",
"*_DISP_*METALNESS*"
], ],
"map_type_mapping": [ "map_type_mapping": [
{ {
@@ -46,6 +43,11 @@
"NORM*", "NORM*",
"NRM*", "NRM*",
"N" "N"
],
"priority_keywords": [
"*_NRM16*",
"*_NM16*",
"*Normal16*"
] ]
}, },
{ {
@@ -75,6 +77,14 @@
"DISP", "DISP",
"HEIGHT", "HEIGHT",
"BUMP" "BUMP"
],
"priority_keywords": [
"*_DISP16*",
"*_DSP16*",
"*DSP16*",
"*DISP16*",
"*Displacement16*",
"*Height16*"
] ]
}, },
{ {

View File

@@ -10,11 +10,7 @@
}, },
"glossiness_keywords": [ "glossiness_keywords": [
"GLOSS" "GLOSS"
], ]
"bit_depth_variants": {
"NRM": "*_NRM16*",
"DISP": "*_DISP16*"
}
}, },
"move_to_extra_patterns": [ "move_to_extra_patterns": [
"*_Preview*", "*_Preview*",
@@ -25,7 +21,8 @@
"*.pdf", "*.pdf",
"*.url", "*.url",
"*.htm*", "*.htm*",
"*_Fabric.*" "*_Fabric.*",
"*_Albedo*"
], ],
"map_type_mapping": [ "map_type_mapping": [
{ {
@@ -33,6 +30,7 @@
"keywords": [ "keywords": [
"COLOR*", "COLOR*",
"COL", "COL",
"COL-*",
"DIFFUSE", "DIFFUSE",
"DIF", "DIF",
"ALBEDO" "ALBEDO"
@@ -43,7 +41,13 @@
"keywords": [ "keywords": [
"NORMAL*", "NORMAL*",
"NORM*", "NORM*",
"NRM*" "NRM*",
"N"
],
"priority_keywords": [
"*_NRM16*",
"*_NM16*",
"*Normal16*"
] ]
}, },
{ {
@@ -57,8 +61,7 @@
"target_type": "MAP_GLOSS", "target_type": "MAP_GLOSS",
"keywords": [ "keywords": [
"GLOSS" "GLOSS"
], ]
"is_gloss_source": true
}, },
{ {
"target_type": "MAP_AO", "target_type": "MAP_AO",
@@ -74,6 +77,14 @@
"DISP", "DISP",
"HEIGHT", "HEIGHT",
"BUMP" "BUMP"
],
"priority_keywords": [
"*_DISP16*",
"*_DSP16*",
"*DSP16*",
"*DISP16*",
"*Displacement16*",
"*Height16*"
] ]
}, },
{ {

View File

@@ -0,0 +1,107 @@
# Configuration System Refactoring Plan
This document outlines the plan for refactoring the configuration system of the Asset Processor Tool.
## Overall Goals
1. **Decouple Definitions:** Separate `ASSET_TYPE_DEFINITIONS` and `FILE_TYPE_DEFINITIONS` from the main `config/app_settings.json` into dedicated files.
2. **Introduce User Overrides:** Allow users to override base settings via a new `config/user_settings.json` file.
3. **Improve GUI Saving:** (Lower Priority) Make GUI configuration saving more targeted to avoid overwriting unrelated settings when saving changes from `ConfigEditorDialog` or `LLMEditorWidget`.
## Proposed Plan Phases
**Phase 1: Decouple Definitions**
1. **Create New Definition Files:**
* Create `config/asset_type_definitions.json`.
* Create `config/file_type_definitions.json`.
2. **Migrate Content:**
* Move `ASSET_TYPE_DEFINITIONS` object from `config/app_settings.json` to `config/asset_type_definitions.json`.
* Move `FILE_TYPE_DEFINITIONS` object from `config/app_settings.json` to `config/file_type_definitions.json`.
3. **Update `configuration.py`:**
* Add constants for new definition file paths.
* Modify `Configuration` class to load these new files.
* Update property methods (e.g., `get_asset_type_definitions`, `get_file_type_definitions_with_examples`) to use data from the new definition dictionaries.
* Adjust validation (`_validate_configs`) as needed.
4. **Update GUI & `load_base_config()`:**
* Modify `load_base_config()` to load and return a combined dictionary including `app_settings.json` and the two new definition files.
* Update GUI components relying on `load_base_config()` to ensure they receive the necessary definition data.
**Phase 2: Implement User Overrides**
1. **Define `user_settings.json`:**
* Establish `config/user_settings.json` for user-specific overrides, mirroring parts of `app_settings.json`.
2. **Update `configuration.py` Loading:**
* In `Configuration.__init__`, load `app_settings.json`, then definition files, then attempt to load and deep merge `user_settings.json` (user settings override base).
* Load presets *after* the base+user merge (presets override combined base+user).
* Modify `load_base_config()` to also load and merge `user_settings.json` after `app_settings.json`.
3. **Update GUI Editors:**
* Modify `ConfigEditorDialog` to load the effective settings (base+user) but save changes *only* to `config/user_settings.json`.
* `LLMEditorWidget` continues targeting `llm_settings.json`.
**Phase 3: Granular GUI Saving (Lower Priority)**
1. **Refactor Saving Logic:**
* In `ConfigEditorDialog` and `LLMEditorWidget`:
* Load the current target file (`user_settings.json` or `llm_settings.json`).
* Identify specific setting(s) changed by the user in the GUI session.
* Update only those specific key(s) in the loaded dictionary.
* Write the entire modified dictionary back to the target file, preserving untouched settings.
## Proposed File Structure & Loading Flow
```mermaid
graph LR
subgraph Config Files
A[config/asset_type_definitions.json]
B[config/file_type_definitions.json]
C[config/app_settings.json (Base Defaults)]
D[config/user_settings.json (User Overrides)]
E[config/llm_settings.json]
F[config/suppliers.json]
G[Presets/*.json]
end
subgraph Code
H[configuration.py]
I[GUI]
J[Processing Engine / Pipeline]
K[LLM Handlers]
end
subgraph Loading Flow (Configuration Class)
L(Load Asset Types) --> H
M(Load File Types) --> H
N(Load Base Settings) --> P(Merge Base + User)
O(Load User Settings) --> P
P --> R(Merge Preset Overrides)
Q(Load LLM Settings) --> H
R --> T(Final Config Object)
G -- Load Preset --> R
H -- Contains --> T
end
subgraph Loading Flow (GUI - load_base_config)
L2(Load Asset Types) --> U(Return Merged Defaults + Defs)
M2(Load File Types) --> U
N2(Load Base Settings) --> V(Merge Base + User)
O2(Load User Settings) --> V
V --> U
I -- Calls --> U
end
T -- Used by --> J
T -- Used by --> K
I -- Edits --> D
I -- Edits --> E
I -- Manages --> F
style A fill:#f9f,stroke:#333,stroke-width:2px
style B fill:#f9f,stroke:#333,stroke-width:2px
style C fill:#ccf,stroke:#333,stroke-width:2px
style D fill:#9cf,stroke:#333,stroke-width:2px
style E fill:#ccf,stroke:#333,stroke-width:2px
style F fill:#9cf,stroke:#333,stroke-width:2px
style G fill:#ffc,stroke:#333,stroke-width:2px

View File

@@ -1,96 +0,0 @@
# Plan: Enforcing "MAP_" Prefix for Internal Processing and Standard Type for Output Naming
**Date:** 2025-05-13
**I. Goal:**
The primary goal is to ensure that for all internal processing, the system *exclusively* uses `FileRule.item_type` values that start with the "MAP_" prefix (e.g., "MAP_COL", "MAP_NRM"). The "standard type" (e.g., "COL", "NRM") associated with these "MAP_" types (as defined in `config/app_settings.json`) should *only* be used during the file saving stages for output naming. Any `FileRule` whose `item_type` does not start with "MAP_" (and isn't a special type like "EXTRA" or "MODEL") should be skipped by the relevant map processing stages.
**II. Current State Analysis Summary:**
* **Output Naming:** The use of "standard type" for output filenames via the `get_filename_friendly_map_type` utility in `SaveVariantsStage` and `OutputOrganizationStage` is **correct** and already meets the requirement.
* **Internal "MAP_" Prefix Usage:**
* Some stages like `GlossToRoughConversionStage` correctly check for "MAP_" prefixes (e.g., `processing_map_type.startswith("MAP_GLOSS")`).
* Other stages like `RegularMapProcessorStage` and `MergedTaskProcessorStage` (and its helpers) implicitly expect "MAP_" prefixed types for their internal regex-based logic but lack explicit checks to skip items if the prefix is missing.
* Stages like `AlphaExtractionToMaskStage` and `NormalMapGreenChannelStage` currently use non-"MAP_" prefixed "standard types" (e.g., "NORMAL", "ALBEDO") when reading from `context.processed_maps_details` for their decision-making logic.
* The `PrepareProcessingItemsStage` adds `FileRule`s to the processing queue without filtering based on the "MAP_" prefix in `item_type`.
* **Data Consistency in `AssetProcessingContext`:**
* `FileRule.item_type` is the field that should hold the "MAP_" prefixed type from the initial rule generation.
* `context.processed_maps_details` entries can contain various map type representations:
* `map_type`: Often stores the "standard type" (e.g., "Roughness", "MASK", "NORMAL").
* `processing_map_type` / `internal_map_type`: Generally seem to store the "MAP_" prefixed type. This needs to be consistent.
* **Configuration (`config/app_settings.json`):**
* `FILE_TYPE_DEFINITIONS` correctly use "MAP_" prefixed keys.
* `MAP_MERGE_RULES` need to be reviewed to ensure their `output_map_type` and input map types are "MAP_" prefixed.
**III. Proposed Changes (Code Identification & Recommendations):**
**A. Enforce "MAP_" Prefix for Processing Items (Skipping Logic):**
The core requirement is that processing stages should skip `FileRule` items if their `item_type` doesn't start with "MAP_".
1. **`RegularMapProcessorStage` (`processing/pipeline/stages/regular_map_processor.py`):**
* **Identify:** In the `execute` method, `initial_internal_map_type` is derived from `file_rule.item_type_override` or `file_rule.item_type`.
* **Recommend:** Add an explicit check after determining `initial_internal_map_type`. If `initial_internal_map_type` does not start with `"MAP_"`, the stage should log a warning, set the `result.status` to "Skipped (Invalid Type)" or similar, and return `result` early, effectively skipping processing for this item.
2. **`MergedTaskProcessorStage` (`processing/pipeline/stages/merged_task_processor.py`):**
* **Identify:** This stage processes `MergeTaskDefinition`s. The definitions for these tasks (input types, output type) come from `MAP_MERGE_RULES` in `config/app_settings.json`. The stage uses `required_map_type_from_rule` for its inputs.
* **Recommend:**
* **Configuration First:** Review all entries in `MAP_MERGE_RULES` in `config/app_settings.json`.
* Ensure the `output_map_type` for each rule (e.g., "MAP_NRMRGH") starts with "MAP_".
* Ensure all map type values within the `inputs` dictionary (e.g., `"R": "MAP_NRM"`) start with "MAP_".
* **Stage Logic:** In the `execute` method, when iterating through `merge_inputs_config.items()`, check if `required_map_type_from_rule` starts with `"MAP_"`. If not, log a warning and either:
* Skip loading/processing this specific input channel (potentially using its fallback if the overall merge can still proceed).
* Or, if a non-"MAP_" input is critical, fail the entire merge task for this asset.
* The helper `_apply_in_memory_transformations` already uses regex expecting "MAP_" prefixes; this will naturally fail or misbehave if inputs are not "MAP_" prefixed, reinforcing the need for the check above.
**B. Standardize Map Type Fields and Usage in `context.processed_maps_details`:**
Ensure consistency in how "MAP_" prefixed types are stored and accessed within `context.processed_maps_details` for internal logic (not naming).
1. **Recommendation:** Establish a single, consistent field name within `context.processed_maps_details` to store the definitive "MAP_" prefixed internal map type (e.g., `internal_map_type` or `processing_map_type`). All stages that perform logic based on the specific *kind* of map (e.g., transformations, source selection) should read from this standardized field. The `map_type` field can continue to store the "standard type" (e.g., "Roughness") primarily for informational/metadata purposes if needed, but not for core processing logic.
2. **`AlphaExtractionToMaskStage` (`processing/pipeline/stages/alpha_extraction_to_mask.py`):**
* **Identify:**
* Checks for existing MASK map using `file_rule.map_type == "MASK"`. (Discrepancy: `FileRule` uses `item_type`).
* Searches for suitable source maps using `details.get('map_type') in self.SUITABLE_SOURCE_MAP_TYPES` where `SUITABLE_SOURCE_MAP_TYPES` are standard types like "ALBEDO".
* When adding new details, it sets `map_type: "MASK"` and the new `FileRule` gets `item_type="MAP_MASK"`.
* **Recommend:**
* Change the check for an existing MASK map to `file_rule.item_type == "MAP_MASK"`.
* Modify the source map search to use the standardized "MAP_" prefixed field from `details` (e.g., `details.get('internal_map_type')`) and update `SUITABLE_SOURCE_MAP_TYPES` to be "MAP_" prefixed (e.g., "MAP_COL", "MAP_ALBEDO").
* When adding new details for the created MASK map to `context.processed_maps_details`, ensure the standardized "MAP_" prefixed field is set to "MAP_MASK", and `map_type` (if kept) is "MASK".
3. **`NormalMapGreenChannelStage` (`processing/pipeline/stages/normal_map_green_channel.py`):**
* **Identify:** Checks `map_details.get('map_type') == "NORMAL"`.
* **Recommend:** Change this check to use the standardized "MAP_" prefixed field from `map_details` (e.g., `map_details.get('internal_map_type')`) and verify if it `startswith("MAP_NRM")`.
4. **`GlossToRoughConversionStage` (`processing/pipeline/stages/gloss_to_rough_conversion.py`):**
* **Identify:** This stage already uses `processing_map_type.startswith("MAP_GLOSS")` and updates `processing_map_type` to "MAP_ROUGH" in `map_details`. It also updates the `FileRule.item_type` correctly.
* **Recommend:** This stage is largely consistent. Ensure the field it reads/writes (`processing_map_type`) aligns with the chosen standardized "MAP_" prefixed field for `processed_maps_details`.
**C. Review Orchestration Logic (Conceptual):**
* When the orchestrator populates `context.processed_maps_details` after stages like `SaveVariantsStage`, ensure it stores the "MAP_" prefixed `internal_map_type` (from `SaveVariantsInput`) into the chosen standardized field in `processed_maps_details`.
**IV. Testing Recommendations:**
* Create test cases with `AssetRule`s containing `FileRule`s where `item_type` is intentionally set to a non-"MAP_" prefixed value (e.g., "COLOR_MAP", "TEXTURE_ROUGH"). Verify that `RegularMapProcessorStage` skips these.
* Modify `MAP_MERGE_RULES` in a test configuration:
* Set an `output_map_type` to a non-"MAP_" value.
* Set an input map type (e.g., for channel "R") to a non-"MAP_" value.
* Verify that `MergedTaskProcessorStage` correctly handles these (e.g., fails the task, skips the input, logs warnings).
* Test `AlphaExtractionToMaskStage`:
* With an existing `FileRule` having `item_type="MAP_MASK"` to ensure extraction is skipped.
* With source maps having "MAP_COL" (with alpha) as their `internal_map_type` in `processed_maps_details` to ensure they are correctly identified as sources.
* Test `NormalMapGreenChannelStage` with a normal map having "MAP_NRM" as its `internal_map_type` in `processed_maps_details` to ensure it's processed.
* Verify that output filenames continue to use the "standard type" (e.g., "COL", "ROUGH", "NRM") correctly.
**V. Mermaid Diagram (Illustrative Flow for `FileRule` Processing):**
```mermaid
graph TD
A[AssetRule with FileRules] --> B{FileRuleFilterStage};
B -- files_to_process --> C{PrepareProcessingItemsStage};
C -- processing_items (FileRule) --> D{PipelineOrchestrator};
D -- FileRule --> E(RegularMapProcessorStage);
E --> F{Check FileRule.item_type};
F -- Starts with "MAP_"? --> G[Process Map];
F -- No --> H[Skip Map / Log Warning];
G --> I[...subsequent stages...];
H --> I;

View File

@@ -1,72 +0,0 @@
# Processing Pipeline Refactoring Plan
## 1. Problem Summary
The current processing pipeline, particularly the `IndividualMapProcessingStage`, exhibits maintainability challenges:
* **High Complexity:** The stage handles too many responsibilities (loading, merging, transformations, scaling, saving).
* **Duplicated Logic:** Image transformations (Gloss-to-Rough, Normal Green Invert) are duplicated within the stage instead of relying solely on dedicated stages or being handled consistently.
* **Tight Coupling:** Heavy reliance on the large, mutable `AssetProcessingContext` object creates implicit dependencies and makes isolated testing difficult.
## 2. Refactoring Goals
* Improve code readability and understanding.
* Enhance maintainability by localizing changes and removing duplication.
* Increase testability through smaller, focused components with clear interfaces.
* Clarify data dependencies between pipeline stages.
* Adhere more closely to the Single Responsibility Principle (SRP).
## 3. Proposed New Pipeline Stages
Replace the existing `IndividualMapProcessingStage` with the following sequence of smaller, focused stages, executed by the `PipelineOrchestrator` for each processing item:
1. **`PrepareProcessingItemsStage`:**
* **Responsibility:** Identifies and lists all items (`FileRule`, `MergeTaskDefinition`) to be processed from the main context.
* **Output:** Updates `context.processing_items`.
2. **`RegularMapProcessorStage`:** (Handles `FileRule` items)
* **Responsibility:** Loads source image, determines internal map type (with suffix), applies relevant transformations (Gloss-to-Rough, Normal Green Invert), determines original metadata.
* **Output:** `ProcessedRegularMapData` object containing transformed image data and metadata.
3. **`MergedTaskProcessorStage`:** (Handles `MergeTaskDefinition` items)
* **Responsibility:** Loads input images, applies transformations to inputs, handles fallbacks/resizing, performs merge operation.
* **Output:** `ProcessedMergedMapData` object containing merged image data and metadata.
4. **`InitialScalingStage`:** (Optional)
* **Responsibility:** Applies configured scaling (e.g., POT downscale) to the processed image data received from the previous stage.
* **Output:** Scaled image data.
5. **`SaveVariantsStage`:**
* **Responsibility:** Takes the final processed (and potentially scaled) image data and orchestrates saving variants using the `save_image_variants` utility.
* **Output:** List of saved file details (`saved_files_details`).
## 4. Proposed Data Flow
* **Input/Output Objects:** Key stages (`RegularMapProcessor`, `MergedTaskProcessor`, `InitialScaling`, `SaveVariants`) will use specific Input and Output dataclasses for clearer interfaces.
* **Orchestrator Role:** The `PipelineOrchestrator` manages the overall flow. It calls stages, passes necessary data (extracting image data references and metadata from previous stage outputs to create inputs for the next), receives output objects, and integrates final results (like saved file details) back into the main `AssetProcessingContext`.
* **Image Data Handling:** Large image arrays (`np.ndarray`) are passed primarily via stage return values (Output objects) and used as inputs to subsequent stages, managed by the Orchestrator. They are not stored long-term in the main `AssetProcessingContext`.
* **Main Context:** The `AssetProcessingContext` remains for overall state (rules, paths, configuration access, final status tracking) and potentially for simpler stages with minimal side effects.
## 5. Visualization (Conceptual)
```mermaid
graph TD
subgraph Proposed Pipeline Stages
Start --> Prep[PrepareProcessingItemsStage]
Prep --> ItemLoop{Loop per Item}
ItemLoop -- FileRule --> RegProc[RegularMapProcessorStage]
ItemLoop -- MergeTask --> MergeProc[MergedTaskProcessorStage]
RegProc --> Scale(InitialScalingStage)
MergeProc --> Scale
Scale --> Save[SaveVariantsStage]
Save --> UpdateContext[Update Main Context w/ Results]
UpdateContext --> ItemLoop
end
```
## 6. Benefits
* Improved Readability & Understanding.
* Enhanced Maintainability & Reduced Risk.
* Better Testability.
* Clearer Dependencies.

View File

@@ -0,0 +1,62 @@
# Issue: List item selection not working in Definitions Editor
**Date:** 2025-05-13
**Affected File:** [`gui/definitions_editor_dialog.py`](gui/definitions_editor_dialog.py)
**Problem Description:**
User mouse clicks on items within the `QListWidget` instances (for Asset Types, File Types, and Suppliers) in the Definitions Editor dialog do not trigger item selection or the `currentItemChanged` signal. The first item is selected by default and its details are displayed correctly. Programmatic selection of items (e.g., via a diagnostic button) *does* correctly trigger the `currentItemChanged` signal and updates the UI detail views. The issue is specific to user-initiated mouse clicks for selection after the initial load.
**Debugging Steps Taken & Findings:**
1. **Initial Analysis:**
* Reviewed GUI internals documentation ([`Documentation/02_Developer_Guide/06_GUI_Internals.md`](Documentation/02_Developer_Guide/06_GUI_Internals.md)) and [`gui/definitions_editor_dialog.py`](gui/definitions_editor_dialog.py) source code.
* Confirmed signal connections (`currentItemChanged` to display slots) are made.
2. **Logging in Display Slots (`_display_*_details`):**
* Added logging to display slots. Confirmed they are called for the initial (default) item selection.
* No further calls to these slots occur on user clicks, indicating `currentItemChanged` is not firing.
3. **Color Swatch Palette Role:**
* Investigated and corrected `QPalette.ColorRole` for color swatches (reverted from `Background` to `Window`). This fixed an `AttributeError` but did not resolve the selection issue.
4. **Robust Error Handling in Display Slots:**
* Wrapped display slot logic in `try...finally` blocks with detailed logging. Confirmed slots complete without error for initial selection and signals for detail widgets are reconnected.
5. **Diagnostic Lambda for `currentItemChanged`:**
* Added a lambda logger to `currentItemChanged` alongside the main display slot.
* Confirmed both lambda and display slot fire for initial programmatic selection.
* Neither fires for subsequent user clicks. This proved the `QListWidget` itself was not emitting the signal.
6. **Explicit `setEnabled` and `setSelectionMode` on `QListWidget`:**
* Explicitly set these properties. No change in behavior.
7. **Explicit `setEnabled` and `setFocusPolicy(Qt.ClickFocus)` on `tab_page` (parent of `QListWidget` layout):**
* This change **allowed programmatic selection via a diagnostic button to correctly fire `currentItemChanged` and update the UI**.
* However, user mouse clicks still did not work and did not fire the signal.
8. **Event Filter Investigation:**
* **Filter on `QListWidget`:** Did NOT receive mouse press/release events from user clicks.
* **Filter on `tab_page` (parent of `QListWidget`'s layout):** Did NOT receive mouse press/release events.
* **Filter on `self.tab_widget` (QTabWidget):** DID receive mouse press/release events.
* Modified `self.tab_widget`'s event filter to return `False` for events over the current page, attempting to ensure propagation.
* **Result:** With the modified `tab_widget` filter, an event filter re-added to `asset_type_list_widget` *did* start receiving mouse press/release events. **However, `asset_type_list_widget` still did not emit `currentItemChanged` from these user clicks.**
9. **`DebugListWidget` (Subclassing `QListWidget`):**
* Created `DebugListWidget` overriding `mousePressEvent` with logging.
* Used `DebugListWidget` for `asset_type_list_widget`.
* **Initial user report indicated that `DebugListWidget.mousePressEvent` logs were NOT appearing for user clicks.** This means that even with the `QTabWidget` event filter attempting to propagate events, and the `asset_type_list_widget`'s filter (from step 8) confirming it received them, the `mousePressEvent` of the `QListWidget` itself was not being triggered by those propagated events. This is the current mystery.
**Current Status:**
- Programmatic selection works and fires signals.
- User clicks are received by an event filter on `asset_type_list_widget` (after `QTabWidget` filter modification) but do not result in `mousePressEvent` being called on the `QListWidget` (or `DebugListWidget`) itself, and thus no `currentItemChanged` signal is emitted.
- The issue seems to be a very low-level event processing problem specifically for user mouse clicks within the `QListWidget` instances when they are children of the `QTabWidget` pages, even when events appear to reach the list widget via an event filter.
**Next Steps (When Resuming):**
1. Re-verify the logs from the `DebugListWidget.mousePressEvent` test. If it's truly not being called despite its event filter seeing events, this is extremely unusual.
2. Simplify the `_create_tab_pane` method drastically for one tab:
* Remove the right-hand pane.
* Add the `DebugListWidget` directly to the `tab_page`'s layout without the intermediate `left_pane_layout`.
3. Consider if any styles applied to `QListWidget` or its parents via stylesheets could be interfering with hit testing or event processing (unlikely for this specific symptom, but possible).
4. Explore alternative ways to populate/manage the `QListWidget` or its items if a subtle corruption is occurring.
5. If all else fails, consider replacing the `QListWidget` with a `QListView` and a `QStringListModel` as a more fundamental change to see if the issue is specific to `QListWidget` in this context.

View File

@@ -1,9 +1,9 @@
{ {
"source_rules": [ "source_rules": [
{ {
"input_path": "BoucleChunky001.zip", "input_path": "BoucleChunky001.zip",
"supplier_identifier": "Dinesen", "supplier_identifier": "Dinesen",
"preset_name": null, "preset_name": "Dinesen",
"assets": [ "assets": [
{ {
"asset_name": "BoucleChunky001", "asset_name": "BoucleChunky001",
@@ -26,7 +26,7 @@
}, },
{ {
"file_path": "BoucleChunky001_DISP_1K_METALNESS.png", "file_path": "BoucleChunky001_DISP_1K_METALNESS.png",
"item_type": "MAP_DISP", "item_type": "EXTRA",
"target_asset_name_override": "BoucleChunky001" "target_asset_name_override": "BoucleChunky001"
}, },
{ {

View File

@@ -96,6 +96,8 @@ class InfoSummaryFilter(logging.Filter):
"verify: processingengine.process called", "verify: processingengine.process called",
": effective supplier set to", ": effective supplier set to",
": metadata initialized.", ": metadata initialized.",
"path",
"\\asset_processor",
": file rules queued for processing", ": file rules queued for processing",
"successfully loaded base application settings", "successfully loaded base application settings",
"successfully loaded and merged asset_type_definitions", "successfully loaded and merged asset_type_definitions",
@@ -108,12 +110,6 @@ class InfoSummaryFilter(logging.Filter):
"worker thread: finished processing for rule:", "worker thread: finished processing for rule:",
"task finished signal received for", "task finished signal received for",
# Autotest step markers (not global summaries) # Autotest step markers (not global summaries)
"step 1: loading zip file:",
"step 2: selecting preset:",
"step 4: retrieving and comparing rules...",
"step 5: starting processing...",
"step 7: checking output path:",
"output path check completed.",
] ]
def filter(self, record): def filter(self, record):
@@ -302,15 +298,32 @@ class AutoTester(QObject):
def run_test(self) -> None: def run_test(self) -> None:
"""Orchestrates the test steps.""" """Orchestrates the test steps."""
logger.info("Starting test run...") # Load expected rules first to potentially get the preset name
self._load_expected_rules() # Moved here
if not self.expected_rules_data: # Ensure rules were loaded if not self.expected_rules_data: # Ensure rules were loaded
logger.error("Expected rules not loaded. Aborting test.") logger.error("Expected rules not loaded. Aborting test.")
self.cleanup_and_exit(success=False) self.cleanup_and_exit(success=False)
return return
# Determine preset to use: from expected rules if available, else from CLI args
preset_to_use = self.cli_args.preset # Default
if self.expected_rules_data.get("source_rules") and \
isinstance(self.expected_rules_data["source_rules"], list) and \
len(self.expected_rules_data["source_rules"]) > 0 and \
isinstance(self.expected_rules_data["source_rules"][0], dict) and \
self.expected_rules_data["source_rules"][0].get("preset_name"):
preset_to_use = self.expected_rules_data["source_rules"][0]["preset_name"]
logger.info(f"Overriding preset with value from expected_rules.json: '{preset_to_use}'")
else:
logger.info(f"Using preset from CLI arguments: '{preset_to_use}' (this was self.cli_args.preset)")
# If preset_to_use is still self.cli_args.preset, ensure it's logged correctly
# The variable preset_to_use will hold the correct value to be used throughout.
logger.info("Starting test run...") # Moved after preset_to_use definition
# Add a specific summary log for essential context # Add a specific summary log for essential context
logger.info(f"Autotest Context: Input='{self.cli_args.zipfile.name}', Preset='{self.cli_args.preset}', Output='{self.cli_args.outputdir}'") # This now correctly uses preset_to_use
logger.info(f"Autotest Context: Input='{self.cli_args.zipfile.name}', Preset='{preset_to_use}', Output='{self.cli_args.outputdir}'")
# Step 1: Load ZIP # Step 1: Load ZIP
self.test_step = "LOADING_ZIP" self.test_step = "LOADING_ZIP"
@@ -330,20 +343,25 @@ class AutoTester(QObject):
# Step 2: Select Preset # Step 2: Select Preset
self.test_step = "SELECTING_PRESET" self.test_step = "SELECTING_PRESET"
logger.info(f"Step 2: Selecting preset: {self.cli_args.preset}") # KEEP INFO - Passes filter # Use preset_to_use (which is now correctly defined earlier)
logger.info(f"Step 2: Selecting preset: {preset_to_use}") # KEEP INFO - Passes filter
# The print statement below already uses preset_to_use, which is good.
print(f"DEBUG: Attempting to select preset: '{preset_to_use}' (derived from expected: {preset_to_use == self.expected_rules_data.get('source_rules',[{}])[0].get('preset_name') if self.expected_rules_data.get('source_rules') else 'N/A'}, cli_arg: {self.cli_args.preset})")
preset_found = False preset_found = False
preset_list_widget = self.main_window.preset_editor_widget.editor_preset_list preset_list_widget = self.main_window.preset_editor_widget.editor_preset_list
for i in range(preset_list_widget.count()): for i in range(preset_list_widget.count()):
item = preset_list_widget.item(i) item = preset_list_widget.item(i)
if item and item.text() == self.cli_args.preset: if item and item.text() == preset_to_use: # Use preset_to_use
preset_list_widget.setCurrentItem(item) preset_list_widget.setCurrentItem(item)
logger.debug(f"Preset '{self.cli_args.preset}' selected.") logger.debug(f"Preset '{preset_to_use}' selected.")
print(f"DEBUG: Successfully selected preset '{item.text()}' in GUI.")
preset_found = True preset_found = True
break break
if not preset_found: if not preset_found:
logger.error(f"Preset '{self.cli_args.preset}' not found in the list.") logger.error(f"Preset '{preset_to_use}' not found in the list.")
available_presets = [preset_list_widget.item(i).text() for i in range(preset_list_widget.count())] available_presets = [preset_list_widget.item(i).text() for i in range(preset_list_widget.count())]
logger.debug(f"Available presets: {available_presets}") logger.debug(f"Available presets: {available_presets}")
print(f"DEBUG: Failed to find preset '{preset_to_use}'. Available: {available_presets}")
self.cleanup_and_exit(success=False) self.cleanup_and_exit(success=False)
return return
@@ -453,8 +471,6 @@ class AutoTester(QObject):
else: else:
logger.warning("Log console or output widget not found. Cannot retrieve logs.") logger.warning("Log console or output widget not found. Cannot retrieve logs.")
self._process_and_display_logs(all_logs_text)
logger.info("Log analysis completed.")
# Final Step # Final Step
logger.info("Test run completed successfully.") # KEEP INFO - Passes filter logger.info("Test run completed successfully.") # KEEP INFO - Passes filter
@@ -527,7 +543,7 @@ class AutoTester(QObject):
comparable_sources_list.append({ comparable_sources_list.append({
"input_path": Path(source_rule_obj.input_path).name, # Use only the filename "input_path": Path(source_rule_obj.input_path).name, # Use only the filename
"supplier_identifier": source_rule_obj.supplier_identifier, "supplier_identifier": source_rule_obj.supplier_identifier,
"preset_name": source_rule_obj.preset_name, "preset_name": source_rule_obj.preset_name, # This is the actual preset name from the SourceRule object
"assets": comparable_asset_list "assets": comparable_asset_list
}) })
logger.debug("Conversion to comparable dictionary finished.") logger.debug("Conversion to comparable dictionary finished.")
@@ -573,6 +589,8 @@ class AutoTester(QObject):
if not self._compare_list_of_rules(actual_value, expected_value, "FileRule", current_context, "file_path"): if not self._compare_list_of_rules(actual_value, expected_value, "FileRule", current_context, "file_path"):
item_match = False item_match = False
else: # Regular field comparison else: # Regular field comparison
if key == "preset_name":
print(f"DEBUG: Comparing preset_name: Actual='{actual_value}', Expected='{expected_value}' for {item_type_name} ({current_context})")
if actual_value != expected_value: if actual_value != expected_value:
# Handle None vs "None" string for preset_name specifically if it's a common issue # Handle None vs "None" string for preset_name specifically if it's a common issue
if key == "preset_name" and actual_value is None and expected_value == "None": if key == "preset_name" and actual_value is None and expected_value == "None":
@@ -596,7 +614,7 @@ class AutoTester(QObject):
Items are matched by a key field (e.g., 'asset_name' or 'file_path'). Items are matched by a key field (e.g., 'asset_name' or 'file_path').
Order independent for matching, but logs count mismatches. Order independent for matching, but logs count mismatches.
""" """
list_match = True # Corrected indentation list_match = True
if not isinstance(actual_list, list) or not isinstance(expected_list, list): if not isinstance(actual_list, list) or not isinstance(expected_list, list):
logger.error(f"Type mismatch for list of {item_type_name}s in {parent_context}. Expected lists.") logger.error(f"Type mismatch for list of {item_type_name}s in {parent_context}. Expected lists.")
return False return False
@@ -606,6 +624,11 @@ class AutoTester(QObject):
list_match = False # Count mismatch is an error list_match = False # Count mismatch is an error
# If counts differ, we still try to match what we can to provide more detailed feedback, # If counts differ, we still try to match what we can to provide more detailed feedback,
# but the overall list_match will remain False. # but the overall list_match will remain False.
if item_type_name == "FileRule":
print(f"DEBUG: FileRule count mismatch for {parent_context}. Actual: {len(actual_list)}, Expected: {len(expected_list)}")
print(f"DEBUG: Actual FileRule paths: {[item.get(item_key_field) for item in actual_list]}")
print(f"DEBUG: Expected FileRule paths: {[item.get(item_key_field) for item in expected_list]}")
actual_items_map = {item.get(item_key_field): item for item in actual_list if item.get(item_key_field) is not None} actual_items_map = {item.get(item_key_field): item for item in actual_list if item.get(item_key_field) is not None}
@@ -639,12 +662,8 @@ class AutoTester(QObject):
list_match = False list_match = False
return list_match # Corrected indentation return list_match
def _compare_rules(self, actual_rules_data: Dict[str, Any], expected_rules_data: Dict[str, Any]) -> bool: # Corrected structure: moved out
item_match = False
return item_match
def _compare_rules(self, actual_rules_data: Dict[str, Any], expected_rules_data: Dict[str, Any]) -> bool: def _compare_rules(self, actual_rules_data: Dict[str, Any], expected_rules_data: Dict[str, Any]) -> bool:
""" """
@@ -773,6 +792,10 @@ class AutoTester(QObject):
def cleanup_and_exit(self, success: bool = True) -> None: def cleanup_and_exit(self, success: bool = True) -> None:
"""Cleans up and exits the application.""" """Cleans up and exits the application."""
# Retrieve logs before clearing the handler
all_logs_text = "" # This variable is not used by _process_and_display_logs anymore, but kept for signature compatibility if needed elsewhere.
self._process_and_display_logs(all_logs_text) # Process and display logs BEFORE clearing the buffer
global autotest_memory_handler global autotest_memory_handler
if autotest_memory_handler: if autotest_memory_handler:
logger.debug("Clearing memory log handler buffer and removing handler.") logger.debug("Clearing memory log handler buffer and removing handler.")

View File

@@ -1,245 +1,4 @@
{ {
"ASSET_TYPE_DEFINITIONS": {
"Surface": {
"description": "A single Standard PBR material set for a surface.",
"color": "#1f3e5d",
"examples": [
"Set: Wood01_COL + Wood01_NRM + WOOD01_ROUGH",
"Set: Dif_Concrete + Normal_Concrete + Refl_Concrete"
]
},
"Model": {
"description": "A set that contains models, can include PBR textureset",
"color": "#b67300",
"examples": [
"Single = Chair.fbx",
"Set = Plant02.fbx + Plant02_col + Plant02_SSS"
]
},
"Decal": {
"description": "A alphamasked textureset",
"color": "#68ac68",
"examples": [
"Set = DecalGraffiti01_Col + DecalGraffiti01_Alpha",
"Single = DecalLeakStain03"
]
},
"Atlas": {
"description": "A texture, name usually hints that it's an atlas",
"color": "#955b8b",
"examples": [
"Set = FoliageAtlas01_col + FoliageAtlas01_nrm"
]
},
"UtilityMap": {
"description": "A useful image-asset consisting of only a single texture. Therefor each Utilitymap can only contain a single item.",
"color": "#706b87",
"examples": [
"Single = imperfection.png",
"Single = smudges.png",
"Single = scratches.tif"
]
}
},
"FILE_TYPE_DEFINITIONS": {
"MAP_COL": {
"description": "Color/Albedo Map",
"color": "#ffaa00",
"examples": [
"_col.",
"_basecolor.",
"albedo",
"diffuse"
],
"standard_type": "COL",
"bit_depth_rule": "force_8bit",
"is_grayscale": false,
"keybind": "C"
},
"MAP_NRM": {
"description": "Normal Map",
"color": "#cca2f1",
"examples": [
"_nrm.",
"_normal."
],
"standard_type": "NRM",
"bit_depth_rule": "respect",
"is_grayscale": false,
"keybind": "N"
},
"MAP_METAL": {
"description": "Metalness Map",
"color": "#dcf4f2",
"examples": [
"_metal.",
"_met."
],
"standard_type": "METAL",
"bit_depth_rule": "force_8bit",
"is_grayscale": true,
"keybind": "M"
},
"MAP_ROUGH": {
"description": "Roughness Map",
"color": "#bfd6bf",
"examples": [
"_rough.",
"_rgh.",
"_gloss"
],
"standard_type": "ROUGH",
"bit_depth_rule": "force_8bit",
"is_grayscale": true,
"keybind": "R"
},
"MAP_GLOSS": {
"description": "Glossiness Map",
"color": "#d6bfd6",
"examples": [
"_gloss.",
"_gls."
],
"standard_type": "GLOSS",
"bit_depth_rule": "force_8bit",
"is_grayscale": true,
"keybind": "R"
},
"MAP_AO": {
"description": "Ambient Occlusion Map",
"color": "#e3c7c7",
"examples": [
"_ao.",
"_ambientocclusion."
],
"standard_type": "AO",
"bit_depth_rule": "force_8bit",
"is_grayscale": true
},
"MAP_DISP": {
"description": "Displacement/Height Map",
"color": "#c6ddd5",
"examples": [
"_disp.",
"_height."
],
"standard_type": "DISP",
"bit_depth_rule": "respect",
"is_grayscale": true,
"keybind": "D"
},
"MAP_REFL": {
"description": "Reflection/Specular Map",
"color": "#c2c2b9",
"examples": [
"_refl.",
"_specular."
],
"standard_type": "REFL",
"bit_depth_rule": "force_8bit",
"is_grayscale": true,
"keybind": "M"
},
"MAP_SSS": {
"description": "Subsurface Scattering Map",
"color": "#a0d394",
"examples": [
"_sss.",
"_subsurface."
],
"standard_type": "SSS",
"bit_depth_rule": "respect",
"is_grayscale": true
},
"MAP_FUZZ": {
"description": "Fuzz/Sheen Map",
"color": "#a2d1da",
"examples": [
"_fuzz.",
"_sheen."
],
"standard_type": "FUZZ",
"bit_depth_rule": "force_8bit",
"is_grayscale": true
},
"MAP_IDMAP": {
"description": "ID Map (for masking)",
"color": "#ca8fb4",
"examples": [
"_id.",
"_matid."
],
"standard_type": "IDMAP",
"bit_depth_rule": "force_8bit",
"is_grayscale": false
},
"MAP_MASK": {
"description": "Generic Mask Map",
"color": "#c6e2bf",
"examples": [
"_mask."
],
"standard_type": "MASK",
"bit_depth_rule": "force_8bit",
"is_grayscale": true
},
"MAP_IMPERFECTION": {
"description": "Imperfection Map (scratches, dust)",
"color": "#e6d1a6",
"examples": [
"_imp.",
"_imperfection.",
"splatter",
"scratches",
"smudges",
"hairs",
"fingerprints"
],
"standard_type": "IMPERFECTION",
"bit_depth_rule": "force_8bit",
"is_grayscale": true
},
"MODEL": {
"description": "3D Model File",
"color": "#3db2bd",
"examples": [
".fbx",
".obj"
],
"standard_type": "",
"bit_depth_rule": "",
"is_grayscale": false
},
"EXTRA": {
"description": "asset previews or metadata",
"color": "#8c8c8c",
"examples": [
".txt",
".zip",
"preview.",
"_flat.",
"_sphere.",
"_Cube.",
"thumb"
],
"standard_type": "",
"bit_depth_rule": "",
"is_grayscale": false,
"keybind": "E"
},
"FILE_IGNORE": {
"description": "File to be ignored",
"color": "#673d35",
"examples": [
"Thumbs.db",
".DS_Store"
],
"standard_type": "",
"bit_depth_rule": "",
"is_grayscale": false,
"keybind": "X"
}
},
"TARGET_FILENAME_PATTERN": "{base_name}_{map_type}_{resolution}.{ext}", "TARGET_FILENAME_PATTERN": "{base_name}_{map_type}_{resolution}.{ext}",
"RESPECT_VARIANT_MAP_TYPES": [ "RESPECT_VARIANT_MAP_TYPES": [
"COL" "COL"
@@ -287,7 +46,10 @@
"TEMP_DIR_PREFIX": "_PROCESS_ASSET_", "TEMP_DIR_PREFIX": "_PROCESS_ASSET_",
"INITIAL_SCALING_MODE": "POT_DOWNSCALE", "INITIAL_SCALING_MODE": "POT_DOWNSCALE",
"MERGE_DIMENSION_MISMATCH_STRATEGY": "USE_LARGEST", "MERGE_DIMENSION_MISMATCH_STRATEGY": "USE_LARGEST",
"ENABLE_LOW_RESOLUTION_FALLBACK": true,
"LOW_RESOLUTION_THRESHOLD": 512,
"general_settings": { "general_settings": {
"invert_normal_map_green_channel_globally": false "invert_normal_map_green_channel_globally": false,
"app_version": "Pre-Alpha"
} }
} }

View File

@@ -0,0 +1,44 @@
{
"ASSET_TYPE_DEFINITIONS": {
"Surface": {
"color": "#1f3e5d",
"description": "A single Standard PBR material set for a surface.",
"examples": [
"Set: Wood01_COL + Wood01_NRM + WOOD01_ROUGH",
"Set: Dif_Concrete + Normal_Concrete + Refl_Concrete"
]
},
"Model": {
"color": "#b67300",
"description": "A set that contains models, can include PBR textureset",
"examples": [
"Single = Chair.fbx",
"Set = Plant02.fbx + Plant02_col + Plant02_SSS"
]
},
"Decal": {
"color": "#68ac68",
"description": "A alphamasked textureset",
"examples": [
"Set = DecalGraffiti01_Col + DecalGraffiti01_Alpha",
"Single = DecalLeakStain03"
]
},
"Atlas": {
"color": "#955b8b",
"description": "A texture, name usually hints that it's an atlas",
"examples": [
"Set = FoliageAtlas01_col + FoliageAtlas01_nrm"
]
},
"UtilityMap": {
"color": "#706b87",
"description": "A useful image-asset consisting of only a single texture. Therefor each Utilitymap can only contain a single item.",
"examples": [
"Single = imperfection.png",
"Single = smudges.png",
"Single = scratches.tif"
]
}
}
}

View File

@@ -0,0 +1,210 @@
{
"FILE_TYPE_DEFINITIONS": {
"MAP_COL": {
"bit_depth_rule": "force_8bit",
"color": "#ffaa00",
"description": "Color/Albedo Map",
"examples": [
"_col.",
"_basecolor.",
"albedo",
"diffuse"
],
"is_grayscale": false,
"keybind": "C",
"standard_type": "COL"
},
"MAP_NRM": {
"bit_depth_rule": "respect",
"color": "#cca2f1",
"description": "Normal Map",
"examples": [
"_nrm.",
"_normal."
],
"is_grayscale": false,
"keybind": "N",
"standard_type": "NRM"
},
"MAP_METAL": {
"bit_depth_rule": "force_8bit",
"color": "#dcf4f2",
"description": "Metalness Map",
"examples": [
"_metal.",
"_met."
],
"is_grayscale": true,
"keybind": "M",
"standard_type": "METAL"
},
"MAP_ROUGH": {
"bit_depth_rule": "force_8bit",
"color": "#bfd6bf",
"description": "Roughness Map",
"examples": [
"_rough.",
"_rgh.",
"_gloss"
],
"is_grayscale": true,
"keybind": "R",
"standard_type": "ROUGH"
},
"MAP_GLOSS": {
"bit_depth_rule": "force_8bit",
"color": "#d6bfd6",
"description": "Glossiness Map",
"examples": [
"_gloss.",
"_gls."
],
"is_grayscale": true,
"keybind": "R",
"standard_type": "GLOSS"
},
"MAP_AO": {
"bit_depth_rule": "force_8bit",
"color": "#e3c7c7",
"description": "Ambient Occlusion Map",
"examples": [
"_ao.",
"_ambientocclusion."
],
"is_grayscale": true,
"keybind": "",
"standard_type": "AO"
},
"MAP_DISP": {
"bit_depth_rule": "respect",
"color": "#c6ddd5",
"description": "Displacement/Height Map",
"examples": [
"_disp.",
"_height."
],
"is_grayscale": true,
"keybind": "D",
"standard_type": "DISP"
},
"MAP_REFL": {
"bit_depth_rule": "force_8bit",
"color": "#c2c2b9",
"description": "Reflection/Specular Map",
"examples": [
"_refl.",
"_specular."
],
"is_grayscale": true,
"keybind": "M",
"standard_type": "REFL"
},
"MAP_SSS": {
"bit_depth_rule": "respect",
"color": "#a0d394",
"description": "Subsurface Scattering Map",
"examples": [
"_sss.",
"_subsurface."
],
"is_grayscale": true,
"keybind": "",
"standard_type": "SSS"
},
"MAP_FUZZ": {
"bit_depth_rule": "force_8bit",
"color": "#a2d1da",
"description": "Fuzz/Sheen Map",
"examples": [
"_fuzz.",
"_sheen."
],
"is_grayscale": true,
"keybind": "",
"standard_type": "FUZZ"
},
"MAP_IDMAP": {
"bit_depth_rule": "force_8bit",
"color": "#ca8fb4",
"description": "ID Map (for masking)",
"examples": [
"_id.",
"_matid."
],
"is_grayscale": false,
"keybind": "",
"standard_type": "IDMAP"
},
"MAP_MASK": {
"bit_depth_rule": "force_8bit",
"color": "#c6e2bf",
"description": "Generic Mask Map",
"examples": [
"_mask."
],
"is_grayscale": true,
"keybind": "",
"standard_type": "MASK"
},
"MAP_IMPERFECTION": {
"bit_depth_rule": "force_8bit",
"color": "#e6d1a6",
"description": "Imperfection Map (scratches, dust)",
"examples": [
"_imp.",
"_imperfection.",
"splatter",
"scratches",
"smudges",
"hairs",
"fingerprints"
],
"is_grayscale": true,
"keybind": "",
"standard_type": "IMPERFECTION"
},
"MODEL": {
"bit_depth_rule": "",
"color": "#3db2bd",
"description": "3D Model File",
"examples": [
".fbx",
".obj"
],
"is_grayscale": false,
"keybind": "",
"standard_type": ""
},
"EXTRA": {
"bit_depth_rule": "",
"color": "#8c8c8c",
"description": "asset previews or metadata",
"examples": [
".txt",
".zip",
"preview.",
"_flat.",
"_sphere.",
"_Cube.",
"thumb"
],
"is_grayscale": false,
"keybind": "E",
"standard_type": "EXTRA"
},
"FILE_IGNORE": {
"bit_depth_rule": "",
"color": "#673d35",
"description": "File identified to be ignored due to prioritization rules (e.g., a lower bit-depth version when a higher one is present).",
"category": "Ignored",
"examples": [
"Thumbs.db",
".DS_Store"
],
"is_grayscale": false,
"keybind": "X",
"standard_type": "",
"details": {}
}
}
}

View File

@@ -1,5 +1,11 @@
[ {
"Dimensiva", "Dimensiva": {
"Dinesen", "normal_map_type": "OpenGL"
"Poliigon" },
] "Dinesen": {
"normal_map_type": "OpenGL"
},
"Poliigon": {
"normal_map_type": "OpenGL"
}
}

View File

@@ -3,12 +3,18 @@ import os
from pathlib import Path from pathlib import Path
import logging import logging
import re import re
import collections.abc
from typing import Optional
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
BASE_DIR = Path(__file__).parent BASE_DIR = Path(__file__).parent
APP_SETTINGS_PATH = BASE_DIR / "config" / "app_settings.json" APP_SETTINGS_PATH = BASE_DIR / "config" / "app_settings.json"
LLM_SETTINGS_PATH = BASE_DIR / "config" / "llm_settings.json" LLM_SETTINGS_PATH = BASE_DIR / "config" / "llm_settings.json"
ASSET_TYPE_DEFINITIONS_PATH = BASE_DIR / "config" / "asset_type_definitions.json"
FILE_TYPE_DEFINITIONS_PATH = BASE_DIR / "config" / "file_type_definitions.json"
USER_SETTINGS_PATH = BASE_DIR / "config" / "user_settings.json"
SUPPLIERS_CONFIG_PATH = BASE_DIR / "config" / "suppliers.json"
PRESETS_DIR = BASE_DIR / "Presets" PRESETS_DIR = BASE_DIR / "Presets"
class ConfigurationError(Exception): class ConfigurationError(Exception):
@@ -64,6 +70,25 @@ def _fnmatch_to_regex(pattern: str) -> str:
# For filename matching, we usually want to find the pattern, not match the whole string. # For filename matching, we usually want to find the pattern, not match the whole string.
return res return res
def _deep_merge_dicts(base_dict: dict, override_dict: dict) -> dict:
"""
Recursively merges override_dict into base_dict.
If a key exists in both and both values are dicts, it recursively merges them.
Otherwise, the value from override_dict takes precedence.
Modifies base_dict in place and returns it.
"""
for key, value in override_dict.items():
if isinstance(value, collections.abc.Mapping):
node = base_dict.get(key) # Use .get() to avoid creating empty dicts if not needed for override
if isinstance(node, collections.abc.Mapping):
_deep_merge_dicts(node, value) # node is base_dict[key], modified in place
else:
# If base_dict[key] is not a dict or doesn't exist, override it
base_dict[key] = value
else:
base_dict[key] = value
return base_dict
class Configuration: class Configuration:
""" """
@@ -71,7 +96,7 @@ class Configuration:
""" """
def __init__(self, preset_name: str): def __init__(self, preset_name: str):
""" """
Loads core config and the specified preset file. Loads core config, user overrides, and the specified preset file.
Args: Args:
preset_name: The name of the preset (without .json extension). preset_name: The name of the preset (without .json extension).
@@ -79,14 +104,41 @@ class Configuration:
Raises: Raises:
ConfigurationError: If core config or preset cannot be loaded/validated. ConfigurationError: If core config or preset cannot be loaded/validated.
""" """
log.debug(f"Initializing Configuration with preset: '{preset_name}'") log.debug(f"Initializing Configuration with preset filename stem: '{preset_name}'")
self.preset_name = preset_name self._preset_filename_stem = preset_name # Store the stem used for loading
# 1. Load core settings
self._core_settings: dict = self._load_core_config() self._core_settings: dict = self._load_core_config()
# 2. Load asset type definitions
self._asset_type_definitions: dict = self._load_asset_type_definitions()
# 3. Load file type definitions
self._file_type_definitions: dict = self._load_file_type_definitions()
# 4. Load user settings
user_settings_overrides: dict = self._load_user_settings()
# 5. Deep merge user settings onto core settings
if user_settings_overrides:
log.info("Applying user setting overrides to core settings.")
# _deep_merge_dicts modifies self._core_settings in place
_deep_merge_dicts(self._core_settings, user_settings_overrides)
# 6. Load LLM settings
self._llm_settings: dict = self._load_llm_config() self._llm_settings: dict = self._load_llm_config()
self._preset_settings: dict = self._load_preset(preset_name)
# 7. Load preset settings (conceptually overrides combined base + user for shared keys)
self._preset_settings: dict = self._load_preset(self._preset_filename_stem) # Use the stored stem
# Store the actual preset name read from the file content
self.actual_internal_preset_name = self._preset_settings.get("preset_name", self._preset_filename_stem)
log.info(f"Configuration instance: Loaded preset file '{self._preset_filename_stem}.json', internal preset_name is '{self.actual_internal_preset_name}'")
# 8. Validate and compile (after all base/user/preset settings are established)
self._validate_configs() self._validate_configs()
self._compile_regex_patterns() self._compile_regex_patterns()
log.info(f"Configuration loaded successfully using preset: '{self.preset_name}'") log.info(f"Configuration loaded successfully using preset: '{self.actual_internal_preset_name}'") # Changed self.preset_name to self.actual_internal_preset_name
def _compile_regex_patterns(self): def _compile_regex_patterns(self):
@@ -95,8 +147,8 @@ class Configuration:
self.compiled_extra_regex: list[re.Pattern] = [] self.compiled_extra_regex: list[re.Pattern] = []
self.compiled_model_regex: list[re.Pattern] = [] self.compiled_model_regex: list[re.Pattern] = []
self.compiled_bit_depth_regex_map: dict[str, re.Pattern] = {} self.compiled_bit_depth_regex_map: dict[str, re.Pattern] = {}
# Map: base_map_type -> list of tuples: (compiled_regex, original_keyword, rule_index) # Map: base_map_type -> list of tuples: (compiled_regex, original_keyword, rule_index, is_priority)
self.compiled_map_keyword_regex: dict[str, list[tuple[re.Pattern, str, int]]] = {} self.compiled_map_keyword_regex: dict[str, list[tuple[re.Pattern, str, int, bool]]] = {}
for pattern in self.move_to_extra_patterns: for pattern in self.move_to_extra_patterns:
try: try:
@@ -131,28 +183,53 @@ class Configuration:
for rule_index, mapping_rule in enumerate(self.map_type_mapping): for rule_index, mapping_rule in enumerate(self.map_type_mapping):
if not isinstance(mapping_rule, dict) or \ if not isinstance(mapping_rule, dict) or \
'target_type' not in mapping_rule or \ 'target_type' not in mapping_rule: # Removed 'keywords' check here as it's handled below
'keywords' not in mapping_rule or \ log.warning(f"Skipping invalid map_type_mapping rule at index {rule_index}: {mapping_rule}. Expected dict with 'target_type'.")
not isinstance(mapping_rule['keywords'], list):
log.warning(f"Skipping invalid map_type_mapping rule at index {rule_index}: {mapping_rule}. Expected dict with 'target_type' and 'keywords' list.")
continue continue
target_type = mapping_rule['target_type'].upper() target_type = mapping_rule['target_type'].upper()
source_keywords = mapping_rule['keywords']
# Ensure 'keywords' exists and is a list, default to empty list if not found or not a list
regular_keywords = mapping_rule.get('keywords', [])
if not isinstance(regular_keywords, list):
log.warning(f"Rule {rule_index} for target '{target_type}' has 'keywords' but it's not a list. Treating as empty.")
regular_keywords = []
for keyword in source_keywords: priority_keywords = mapping_rule.get('priority_keywords', []) # Optional, defaults to empty list
if not isinstance(priority_keywords, list):
log.warning(f"Rule {rule_index} for target '{target_type}' has 'priority_keywords' but it's not a list. Treating as empty.")
priority_keywords = []
# Process regular keywords
for keyword in regular_keywords:
if not isinstance(keyword, str): if not isinstance(keyword, str):
log.warning(f"Skipping non-string keyword '{keyword}' in rule {rule_index} for target '{target_type}'.") log.warning(f"Skipping non-string regular keyword '{keyword}' in rule {rule_index} for target '{target_type}'.")
continue
try:
kw_regex_part = _fnmatch_to_regex(keyword)
# Ensure the keyword is treated as a whole word or is at the start/end of a segment
regex_str = rf"(?:^|{separator})({kw_regex_part})(?:$|{separator})"
compiled_regex = re.compile(regex_str, re.IGNORECASE)
# Add False for is_priority
temp_compiled_map_regex[target_type].append((compiled_regex, keyword, rule_index, False))
log.debug(f" Compiled regular keyword '{keyword}' (rule {rule_index}) for target '{target_type}' as regex: {regex_str}")
except re.error as e:
log.warning(f"Failed to compile regular map keyword regex '{keyword}' for target type '{target_type}': {e}. Skipping keyword.")
# Process priority keywords
for keyword in priority_keywords:
if not isinstance(keyword, str):
log.warning(f"Skipping non-string priority keyword '{keyword}' in rule {rule_index} for target '{target_type}'.")
continue continue
try: try:
kw_regex_part = _fnmatch_to_regex(keyword) kw_regex_part = _fnmatch_to_regex(keyword)
regex_str = rf"(?:^|{separator})({kw_regex_part})(?:$|{separator})" regex_str = rf"(?:^|{separator})({kw_regex_part})(?:$|{separator})"
compiled_regex = re.compile(regex_str, re.IGNORECASE) compiled_regex = re.compile(regex_str, re.IGNORECASE)
temp_compiled_map_regex[target_type].append((compiled_regex, keyword, rule_index)) # Add True for is_priority
log.debug(f" Compiled keyword '{keyword}' (rule {rule_index}) for target '{target_type}' as regex: {regex_str}") temp_compiled_map_regex[target_type].append((compiled_regex, keyword, rule_index, True))
log.debug(f" Compiled priority keyword '{keyword}' (rule {rule_index}) for target '{target_type}' as regex: {regex_str}")
except re.error as e: except re.error as e:
log.warning(f"Failed to compile map keyword regex '{keyword}' for target type '{target_type}': {e}. Skipping keyword.") log.warning(f"Failed to compile priority map keyword regex '{keyword}' for target type '{target_type}': {e}. Skipping keyword.")
self.compiled_map_keyword_regex = dict(temp_compiled_map_regex) self.compiled_map_keyword_regex = dict(temp_compiled_map_regex)
log.debug(f"Compiled map keyword regex keys: {list(self.compiled_map_keyword_regex.keys())}") log.debug(f"Compiled map keyword regex keys: {list(self.compiled_map_keyword_regex.keys())}")
@@ -215,9 +292,79 @@ class Configuration:
except Exception as e: except Exception as e:
raise ConfigurationError(f"Failed to read preset file {preset_file}: {e}") raise ConfigurationError(f"Failed to read preset file {preset_file}: {e}")
def _load_asset_type_definitions(self) -> dict:
"""Loads asset type definitions from the asset_type_definitions.json file."""
log.debug(f"Loading asset type definitions from: {ASSET_TYPE_DEFINITIONS_PATH}")
if not ASSET_TYPE_DEFINITIONS_PATH.is_file():
raise ConfigurationError(f"Asset type definitions file not found: {ASSET_TYPE_DEFINITIONS_PATH}")
try:
with open(ASSET_TYPE_DEFINITIONS_PATH, 'r', encoding='utf-8') as f:
data = json.load(f)
if "ASSET_TYPE_DEFINITIONS" not in data:
raise ConfigurationError(f"Key 'ASSET_TYPE_DEFINITIONS' not found in {ASSET_TYPE_DEFINITIONS_PATH}")
settings = data["ASSET_TYPE_DEFINITIONS"]
if not isinstance(settings, dict):
raise ConfigurationError(f"'ASSET_TYPE_DEFINITIONS' in {ASSET_TYPE_DEFINITIONS_PATH} must be a dictionary.")
log.debug(f"Asset type definitions loaded successfully.")
return settings
except json.JSONDecodeError as e:
raise ConfigurationError(f"Failed to parse asset type definitions file {ASSET_TYPE_DEFINITIONS_PATH}: Invalid JSON - {e}")
except Exception as e:
raise ConfigurationError(f"Failed to read asset type definitions file {ASSET_TYPE_DEFINITIONS_PATH}: {e}")
def _load_file_type_definitions(self) -> dict:
"""Loads file type definitions from the file_type_definitions.json file."""
log.debug(f"Loading file type definitions from: {FILE_TYPE_DEFINITIONS_PATH}")
if not FILE_TYPE_DEFINITIONS_PATH.is_file():
raise ConfigurationError(f"File type definitions file not found: {FILE_TYPE_DEFINITIONS_PATH}")
try:
with open(FILE_TYPE_DEFINITIONS_PATH, 'r', encoding='utf-8') as f:
data = json.load(f)
if "FILE_TYPE_DEFINITIONS" not in data:
raise ConfigurationError(f"Key 'FILE_TYPE_DEFINITIONS' not found in {FILE_TYPE_DEFINITIONS_PATH}")
settings = data["FILE_TYPE_DEFINITIONS"]
if not isinstance(settings, dict):
raise ConfigurationError(f"'FILE_TYPE_DEFINITIONS' in {FILE_TYPE_DEFINITIONS_PATH} must be a dictionary.")
log.debug(f"File type definitions loaded successfully.")
return settings
except json.JSONDecodeError as e:
raise ConfigurationError(f"Failed to parse file type definitions file {FILE_TYPE_DEFINITIONS_PATH}: Invalid JSON - {e}")
except Exception as e:
raise ConfigurationError(f"Failed to read file type definitions file {FILE_TYPE_DEFINITIONS_PATH}: {e}")
def _load_user_settings(self) -> dict:
"""Loads user override settings from config/user_settings.json."""
log.debug(f"Attempting to load user settings from: {USER_SETTINGS_PATH}")
if not USER_SETTINGS_PATH.is_file():
log.info(f"User settings file not found: {USER_SETTINGS_PATH}. Proceeding without user overrides.")
return {}
try:
with open(USER_SETTINGS_PATH, 'r', encoding='utf-8') as f:
settings = json.load(f)
log.info(f"User settings loaded successfully from {USER_SETTINGS_PATH}.")
return settings
except json.JSONDecodeError as e:
log.warning(f"Failed to parse user settings file {USER_SETTINGS_PATH}: Invalid JSON - {e}. Using empty user settings.")
return {}
except Exception as e:
log.warning(f"Failed to read user settings file {USER_SETTINGS_PATH}: {e}. Using empty user settings.")
return {}
def _validate_configs(self): def _validate_configs(self):
"""Performs basic validation checks on loaded settings.""" """Performs basic validation checks on loaded settings."""
log.debug("Validating loaded configurations...") log.debug("Validating loaded configurations...")
# Validate new definition files first
if not isinstance(self._asset_type_definitions, dict):
raise ConfigurationError("Asset type definitions were not loaded correctly or are not a dictionary.")
if not self._asset_type_definitions: # Check if empty
raise ConfigurationError("Asset type definitions are empty.")
if not isinstance(self._file_type_definitions, dict):
raise ConfigurationError("File type definitions were not loaded correctly or are not a dictionary.")
if not self._file_type_definitions: # Check if empty
raise ConfigurationError("File type definitions are empty.")
# Preset validation # Preset validation
required_preset_keys = [ required_preset_keys = [
"preset_name", "supplier_name", "source_naming", "map_type_mapping", "preset_name", "supplier_name", "source_naming", "map_type_mapping",
@@ -225,31 +372,43 @@ class Configuration:
] ]
for key in required_preset_keys: for key in required_preset_keys:
if key not in self._preset_settings: if key not in self._preset_settings:
raise ConfigurationError(f"Preset '{self.preset_name}' is missing required key: '{key}'.") raise ConfigurationError(f"Preset file '{self._preset_filename_stem}.json' (internal name: '{self.actual_internal_preset_name}') is missing required key: '{key}'.")
# Validate map_type_mapping structure (new format) # Validate map_type_mapping structure (new format)
if not isinstance(self._preset_settings['map_type_mapping'], list): if not isinstance(self._preset_settings['map_type_mapping'], list):
raise ConfigurationError(f"Preset '{self.preset_name}': 'map_type_mapping' must be a list.") raise ConfigurationError(f"Preset file '{self._preset_filename_stem}.json': 'map_type_mapping' must be a list.")
for index, rule in enumerate(self._preset_settings['map_type_mapping']): for index, rule in enumerate(self._preset_settings['map_type_mapping']):
if not isinstance(rule, dict): if not isinstance(rule, dict):
raise ConfigurationError(f"Preset '{self.preset_name}': Rule at index {index} in 'map_type_mapping' must be a dictionary.") raise ConfigurationError(f"Preset file '{self._preset_filename_stem}.json': Rule at index {index} in 'map_type_mapping' must be a dictionary.")
if 'target_type' not in rule or not isinstance(rule['target_type'], str): if 'target_type' not in rule or not isinstance(rule['target_type'], str):
raise ConfigurationError(f"Preset '{self.preset_name}': Rule at index {index} in 'map_type_mapping' is missing 'target_type' string.") raise ConfigurationError(f"Preset file '{self._preset_filename_stem}.json': Rule at index {index} in 'map_type_mapping' is missing 'target_type' string.")
valid_file_type_keys = self._core_settings.get('FILE_TYPE_DEFINITIONS', {}).keys() valid_file_type_keys = self._file_type_definitions.keys()
if rule['target_type'] not in valid_file_type_keys: if rule['target_type'] not in valid_file_type_keys:
raise ConfigurationError( raise ConfigurationError(
f"Preset '{self.preset_name}': Rule at index {index} in 'map_type_mapping' " f"Preset file '{self._preset_filename_stem}.json': Rule at index {index} in 'map_type_mapping' "
f"has an invalid 'target_type': '{rule['target_type']}'. " f"has an invalid 'target_type': '{rule['target_type']}'. "
f"Must be one of {list(valid_file_type_keys)}." f"Must be one of {list(valid_file_type_keys)}."
) )
if 'keywords' not in rule or not isinstance(rule['keywords'], list): # 'keywords' is optional if 'priority_keywords' is present and not empty,
raise ConfigurationError(f"Preset '{self.preset_name}': Rule at index {index} in 'map_type_mapping' is missing 'keywords' list.") # but if 'keywords' IS present, it must be a list of strings.
if 'keywords' in rule:
if not isinstance(rule['keywords'], list):
raise ConfigurationError(f"Preset file '{self._preset_filename_stem}.json': Rule at index {index} in 'map_type_mapping' has 'keywords' but it's not a list.")
for kw_index, keyword in enumerate(rule['keywords']): for kw_index, keyword in enumerate(rule['keywords']):
if not isinstance(keyword, str): if not isinstance(keyword, str):
raise ConfigurationError(f"Preset '{self.preset_name}': Keyword at index {kw_index} in rule {index} ('{rule['target_type']}') must be a string.") raise ConfigurationError(f"Preset file '{self._preset_filename_stem}.json': Keyword at index {kw_index} in rule {index} ('{rule['target_type']}') must be a string.")
elif not ('priority_keywords' in rule and rule['priority_keywords']): # if 'keywords' is not present, 'priority_keywords' must be
raise ConfigurationError(f"Preset file '{self._preset_filename_stem}.json': Rule at index {index} in 'map_type_mapping' must have 'keywords' or non-empty 'priority_keywords'.")
# Validate priority_keywords if present
if 'priority_keywords' in rule:
if not isinstance(rule['priority_keywords'], list):
raise ConfigurationError(f"Preset file '{self._preset_filename_stem}.json': Rule at index {index} in 'map_type_mapping' has 'priority_keywords' but it's not a list.")
for prio_kw_index, prio_keyword in enumerate(rule['priority_keywords']):
if not isinstance(prio_keyword, str):
raise ConfigurationError(f"Preset file '{self._preset_filename_stem}.json': Priority keyword at index {prio_kw_index} in rule {index} ('{rule['target_type']}') must be a string.")
if not isinstance(self._core_settings.get('TARGET_FILENAME_PATTERN'), str): if not isinstance(self._core_settings.get('TARGET_FILENAME_PATTERN'), str):
raise ConfigurationError("Core config 'TARGET_FILENAME_PATTERN' must be a string.") raise ConfigurationError("Core config 'TARGET_FILENAME_PATTERN' must be a string.")
@@ -261,7 +420,7 @@ class Configuration:
raise ConfigurationError("Core config 'IMAGE_RESOLUTIONS' must be a dictionary.") raise ConfigurationError("Core config 'IMAGE_RESOLUTIONS' must be a dictionary.")
# Validate DEFAULT_ASSET_CATEGORY # Validate DEFAULT_ASSET_CATEGORY
valid_asset_type_keys = self._core_settings.get('ASSET_TYPE_DEFINITIONS', {}).keys() valid_asset_type_keys = self._asset_type_definitions.keys()
default_asset_category_value = self._core_settings.get('DEFAULT_ASSET_CATEGORY') default_asset_category_value = self._core_settings.get('DEFAULT_ASSET_CATEGORY')
if not default_asset_category_value: if not default_asset_category_value:
raise ConfigurationError("Core config 'DEFAULT_ASSET_CATEGORY' is missing.") raise ConfigurationError("Core config 'DEFAULT_ASSET_CATEGORY' is missing.")
@@ -289,6 +448,12 @@ class Configuration:
def supplier_name(self) -> str: def supplier_name(self) -> str:
return self._preset_settings.get('supplier_name', 'DefaultSupplier') return self._preset_settings.get('supplier_name', 'DefaultSupplier')
@property
def internal_display_preset_name(self) -> str:
"""Returns the 'preset_name' field from within the loaded preset JSON,
or falls back to the filename stem if not present."""
return self.actual_internal_preset_name
@property @property
def default_asset_category(self) -> str: def default_asset_category(self) -> str:
"""Gets the default asset category from core settings.""" """Gets the default asset category from core settings."""
@@ -423,11 +588,11 @@ class Configuration:
Gets the bit depth rule ('respect', 'force_8bit', 'force_16bit') for a given map type identifier. Gets the bit depth rule ('respect', 'force_8bit', 'force_16bit') for a given map type identifier.
The map_type_input can be an FTD key (e.g., "MAP_COL") or a suffixed FTD key (e.g., "MAP_COL-1"). The map_type_input can be an FTD key (e.g., "MAP_COL") or a suffixed FTD key (e.g., "MAP_COL-1").
""" """
if not self._core_settings or 'FILE_TYPE_DEFINITIONS' not in self._core_settings: if not self._file_type_definitions: # Check if the attribute exists and is not empty
log.warning("FILE_TYPE_DEFINITIONS not found in core settings. Cannot determine bit depth rule.") log.warning("File type definitions not loaded. Cannot determine bit depth rule.")
return "respect" return "respect"
file_type_definitions = self._core_settings['FILE_TYPE_DEFINITIONS'] file_type_definitions = self._file_type_definitions
# 1. Try direct match with map_type_input as FTD key # 1. Try direct match with map_type_input as FTD key
definition = file_type_definitions.get(map_type_input) definition = file_type_definitions.get(map_type_input)
@@ -473,8 +638,8 @@ class Configuration:
from FILE_TYPE_DEFINITIONS. from FILE_TYPE_DEFINITIONS.
""" """
aliases = set() aliases = set()
file_type_definitions = self._core_settings.get('FILE_TYPE_DEFINITIONS', {}) # _file_type_definitions is guaranteed to be a dict by the loader
for _key, definition in file_type_definitions.items(): for _key, definition in self._file_type_definitions.items():
if isinstance(definition, dict): if isinstance(definition, dict):
standard_type = definition.get('standard_type') standard_type = definition.get('standard_type')
if standard_type and isinstance(standard_type, str) and standard_type.strip(): if standard_type and isinstance(standard_type, str) and standard_type.strip():
@@ -482,16 +647,16 @@ class Configuration:
return sorted(list(aliases)) return sorted(list(aliases))
def get_asset_type_definitions(self) -> dict: def get_asset_type_definitions(self) -> dict:
"""Returns the ASSET_TYPE_DEFINITIONS dictionary from core settings.""" """Returns the _asset_type_definitions dictionary."""
return self._core_settings.get('ASSET_TYPE_DEFINITIONS', {}) return self._asset_type_definitions
def get_asset_type_keys(self) -> list: def get_asset_type_keys(self) -> list:
"""Returns a list of valid asset type keys from core settings.""" """Returns a list of valid asset type keys from core settings."""
return list(self.get_asset_type_definitions().keys()) return list(self.get_asset_type_definitions().keys())
def get_file_type_definitions_with_examples(self) -> dict: def get_file_type_definitions_with_examples(self) -> dict:
"""Returns the FILE_TYPE_DEFINITIONS dictionary (including descriptions and examples) from core settings.""" """Returns the _file_type_definitions dictionary (including descriptions and examples)."""
return self._core_settings.get('FILE_TYPE_DEFINITIONS', {}) return self._file_type_definitions
def get_file_type_keys(self) -> list: def get_file_type_keys(self) -> list:
"""Returns a list of valid file type keys from core settings.""" """Returns a list of valid file type keys from core settings."""
@@ -532,9 +697,27 @@ class Configuration:
"""Returns the LLM request timeout in seconds from LLM settings.""" """Returns the LLM request timeout in seconds from LLM settings."""
return self._llm_settings.get('llm_request_timeout', 120) return self._llm_settings.get('llm_request_timeout', 120)
@property
def app_version(self) -> Optional[str]:
"""Returns the application version from general_settings."""
gs = self._core_settings.get('general_settings')
if isinstance(gs, dict):
return gs.get('app_version')
return None
@property
def enable_low_resolution_fallback(self) -> bool:
"""Gets the setting for enabling low-resolution fallback."""
return self._core_settings.get('ENABLE_LOW_RESOLUTION_FALLBACK', True)
@property
def low_resolution_threshold(self) -> int:
"""Gets the pixel dimension threshold for low-resolution fallback."""
return self._core_settings.get('LOW_RESOLUTION_THRESHOLD', 512)
@property @property
def FILE_TYPE_DEFINITIONS(self) -> dict: def FILE_TYPE_DEFINITIONS(self) -> dict:
return self._core_settings.get('FILE_TYPE_DEFINITIONS', {}) return self._file_type_definitions
@property @property
def keybind_config(self) -> dict[str, list[str]]: def keybind_config(self) -> dict[str, list[str]]:
@@ -544,8 +727,8 @@ class Configuration:
Example: {'C': ['MAP_COL'], 'R': ['MAP_ROUGH', 'MAP_GLOSS']} Example: {'C': ['MAP_COL'], 'R': ['MAP_ROUGH', 'MAP_GLOSS']}
""" """
keybinds = {} keybinds = {}
file_type_defs = self._core_settings.get('FILE_TYPE_DEFINITIONS', {}) # _file_type_definitions is guaranteed to be a dict by the loader
for ftd_key, ftd_value in file_type_defs.items(): for ftd_key, ftd_value in self._file_type_definitions.items():
if isinstance(ftd_value, dict) and 'keybind' in ftd_value: if isinstance(ftd_value, dict) and 'keybind' in ftd_value:
key = ftd_value['keybind'] key = ftd_value['keybind']
if key not in keybinds: if key not in keybinds:
@@ -561,25 +744,92 @@ class Configuration:
def load_base_config() -> dict: def load_base_config() -> dict:
""" """
Loads only the base configuration from app_settings.json. Loads base configuration by merging app_settings.json, user_settings.json (if exists),
Does not load presets or perform merging/validation. asset_type_definitions.json, and file_type_definitions.json.
Does not load presets or perform full validation beyond basic file loading.
Returns a dictionary containing the merged settings. If app_settings.json
fails to load, an empty dictionary is returned. If other files
fail, errors are logged, and the function proceeds with what has been loaded.
""" """
base_settings = {}
# 1. Load app_settings.json (critical)
if not APP_SETTINGS_PATH.is_file(): if not APP_SETTINGS_PATH.is_file():
log.error(f"Base configuration file not found: {APP_SETTINGS_PATH}") log.error(f"Critical: Base application settings file not found: {APP_SETTINGS_PATH}. Returning empty configuration.")
# Return empty dict or raise a specific error if preferred
# For now, return empty dict to allow GUI to potentially start with defaults
return {} return {}
try: try:
with open(APP_SETTINGS_PATH, 'r', encoding='utf-8') as f: with open(APP_SETTINGS_PATH, 'r', encoding='utf-8') as f:
settings = json.load(f) base_settings = json.load(f)
return settings log.info(f"Successfully loaded base application settings from: {APP_SETTINGS_PATH}")
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
log.error(f"Failed to parse base configuration file {APP_SETTINGS_PATH}: Invalid JSON - {e}") log.error(f"Critical: Failed to parse base application settings file {APP_SETTINGS_PATH}: Invalid JSON - {e}. Returning empty configuration.")
return {} return {}
except Exception as e: except Exception as e:
log.error(f"Failed to read base configuration file {APP_SETTINGS_PATH}: {e}") log.error(f"Critical: Failed to read base application settings file {APP_SETTINGS_PATH}: {e}. Returning empty configuration.")
return {} return {}
# 2. Attempt to load user_settings.json
user_settings_overrides = {}
if USER_SETTINGS_PATH.is_file():
try:
with open(USER_SETTINGS_PATH, 'r', encoding='utf-8') as f:
user_settings_overrides = json.load(f)
log.info(f"User settings loaded successfully for base_config from {USER_SETTINGS_PATH}.")
except json.JSONDecodeError as e:
log.warning(f"Failed to parse user settings file {USER_SETTINGS_PATH} for base_config: Invalid JSON - {e}. Proceeding without these user overrides.")
except Exception as e:
log.warning(f"Failed to read user settings file {USER_SETTINGS_PATH} for base_config: {e}. Proceeding without these user overrides.")
# 3. Deep merge user settings onto base_settings
if user_settings_overrides:
log.info("Applying user setting overrides to base_settings in load_base_config.")
# _deep_merge_dicts modifies base_settings in place
_deep_merge_dicts(base_settings, user_settings_overrides)
# 4. Load asset_type_definitions.json (non-critical, merge if successful)
if not ASSET_TYPE_DEFINITIONS_PATH.is_file():
log.error(f"Asset type definitions file not found: {ASSET_TYPE_DEFINITIONS_PATH}. Proceeding without it.")
else:
try:
with open(ASSET_TYPE_DEFINITIONS_PATH, 'r', encoding='utf-8') as f:
asset_defs_data = json.load(f)
if "ASSET_TYPE_DEFINITIONS" in asset_defs_data:
if isinstance(asset_defs_data["ASSET_TYPE_DEFINITIONS"], dict):
# Merge into base_settings, which might already contain user overrides
base_settings['ASSET_TYPE_DEFINITIONS'] = asset_defs_data["ASSET_TYPE_DEFINITIONS"]
log.info(f"Successfully loaded and merged ASSET_TYPE_DEFINITIONS from: {ASSET_TYPE_DEFINITIONS_PATH}")
else:
log.error(f"Value under 'ASSET_TYPE_DEFINITIONS' in {ASSET_TYPE_DEFINITIONS_PATH} is not a dictionary. Skipping merge.")
else:
log.error(f"Key 'ASSET_TYPE_DEFINITIONS' not found in {ASSET_TYPE_DEFINITIONS_PATH}. Skipping merge.")
except json.JSONDecodeError as e:
log.error(f"Failed to parse asset type definitions file {ASSET_TYPE_DEFINITIONS_PATH}: Invalid JSON - {e}. Skipping merge.")
except Exception as e:
log.error(f"Failed to read asset type definitions file {ASSET_TYPE_DEFINITIONS_PATH}: {e}. Skipping merge.")
# 5. Load file_type_definitions.json (non-critical, merge if successful)
if not FILE_TYPE_DEFINITIONS_PATH.is_file():
log.error(f"File type definitions file not found: {FILE_TYPE_DEFINITIONS_PATH}. Proceeding without it.")
else:
try:
with open(FILE_TYPE_DEFINITIONS_PATH, 'r', encoding='utf-8') as f:
file_defs_data = json.load(f)
if "FILE_TYPE_DEFINITIONS" in file_defs_data:
if isinstance(file_defs_data["FILE_TYPE_DEFINITIONS"], dict):
# Merge into base_settings
base_settings['FILE_TYPE_DEFINITIONS'] = file_defs_data["FILE_TYPE_DEFINITIONS"]
log.info(f"Successfully loaded and merged FILE_TYPE_DEFINITIONS from: {FILE_TYPE_DEFINITIONS_PATH}")
else:
log.error(f"Value under 'FILE_TYPE_DEFINITIONS' in {FILE_TYPE_DEFINITIONS_PATH} is not a dictionary. Skipping merge.")
else:
log.error(f"Key 'FILE_TYPE_DEFINITIONS' not found in {FILE_TYPE_DEFINITIONS_PATH}. Skipping merge.")
except json.JSONDecodeError as e:
log.error(f"Failed to parse file type definitions file {FILE_TYPE_DEFINITIONS_PATH}: Invalid JSON - {e}. Skipping merge.")
except Exception as e:
log.error(f"Failed to read file type definitions file {FILE_TYPE_DEFINITIONS_PATH}: {e}. Skipping merge.")
return base_settings
def save_llm_config(settings_dict: dict): def save_llm_config(settings_dict: dict):
""" """
Saves the provided LLM settings dictionary to llm_settings.json. Saves the provided LLM settings dictionary to llm_settings.json.
@@ -594,6 +844,18 @@ def save_llm_config(settings_dict: dict):
log.error(f"Failed to save LLM configuration file {LLM_SETTINGS_PATH}: {e}") log.error(f"Failed to save LLM configuration file {LLM_SETTINGS_PATH}: {e}")
# Re-raise as ConfigurationError to signal failure upstream # Re-raise as ConfigurationError to signal failure upstream
raise ConfigurationError(f"Failed to save LLM configuration: {e}") raise ConfigurationError(f"Failed to save LLM configuration: {e}")
def save_user_config(settings_dict: dict):
"""Saves the provided settings dictionary to user_settings.json."""
log.debug(f"Saving user config to: {USER_SETTINGS_PATH}")
try:
# Ensure parent directory exists (though 'config/' should always exist)
USER_SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True)
with open(USER_SETTINGS_PATH, 'w', encoding='utf-8') as f:
json.dump(settings_dict, f, indent=4)
log.info(f"User config saved successfully to {USER_SETTINGS_PATH}")
except Exception as e:
log.error(f"Failed to save user configuration file {USER_SETTINGS_PATH}: {e}")
raise ConfigurationError(f"Failed to save user configuration: {e}")
def save_base_config(settings_dict: dict): def save_base_config(settings_dict: dict):
""" """
Saves the provided settings dictionary to app_settings.json. Saves the provided settings dictionary to app_settings.json.
@@ -606,3 +868,149 @@ def save_base_config(settings_dict: dict):
except Exception as e: except Exception as e:
log.error(f"Failed to save base configuration file {APP_SETTINGS_PATH}: {e}") log.error(f"Failed to save base configuration file {APP_SETTINGS_PATH}: {e}")
raise ConfigurationError(f"Failed to save configuration: {e}") raise ConfigurationError(f"Failed to save configuration: {e}")
def load_asset_definitions() -> dict:
"""
Reads config/asset_type_definitions.json.
Returns the dictionary under the "ASSET_TYPE_DEFINITIONS" key.
Handles file not found or JSON errors gracefully (e.g., return empty dict, log error).
"""
log.debug(f"Loading asset type definitions from: {ASSET_TYPE_DEFINITIONS_PATH}")
if not ASSET_TYPE_DEFINITIONS_PATH.is_file():
log.error(f"Asset type definitions file not found: {ASSET_TYPE_DEFINITIONS_PATH}")
return {}
try:
with open(ASSET_TYPE_DEFINITIONS_PATH, 'r', encoding='utf-8') as f:
data = json.load(f)
if "ASSET_TYPE_DEFINITIONS" not in data:
log.error(f"Key 'ASSET_TYPE_DEFINITIONS' not found in {ASSET_TYPE_DEFINITIONS_PATH}")
return {}
settings = data["ASSET_TYPE_DEFINITIONS"]
if not isinstance(settings, dict):
log.error(f"'ASSET_TYPE_DEFINITIONS' in {ASSET_TYPE_DEFINITIONS_PATH} must be a dictionary.")
return {}
log.debug(f"Asset type definitions loaded successfully.")
return settings
except json.JSONDecodeError as e:
log.error(f"Failed to parse asset type definitions file {ASSET_TYPE_DEFINITIONS_PATH}: Invalid JSON - {e}")
return {}
except Exception as e:
log.error(f"Failed to read asset type definitions file {ASSET_TYPE_DEFINITIONS_PATH}: {e}")
return {}
def save_asset_definitions(data: dict):
"""
Takes a dictionary (representing the content for the "ASSET_TYPE_DEFINITIONS" key).
Writes it to config/asset_type_definitions.json under the root key "ASSET_TYPE_DEFINITIONS".
Handles potential I/O errors.
"""
log.debug(f"Saving asset type definitions to: {ASSET_TYPE_DEFINITIONS_PATH}")
try:
with open(ASSET_TYPE_DEFINITIONS_PATH, 'w', encoding='utf-8') as f:
json.dump({"ASSET_TYPE_DEFINITIONS": data}, f, indent=4)
log.info(f"Asset type definitions saved successfully to {ASSET_TYPE_DEFINITIONS_PATH}")
except Exception as e:
log.error(f"Failed to save asset type definitions file {ASSET_TYPE_DEFINITIONS_PATH}: {e}")
raise ConfigurationError(f"Failed to save asset type definitions: {e}")
def load_file_type_definitions() -> dict:
"""
Reads config/file_type_definitions.json.
Returns the dictionary under the "FILE_TYPE_DEFINITIONS" key.
Handles errors gracefully.
"""
log.debug(f"Loading file type definitions from: {FILE_TYPE_DEFINITIONS_PATH}")
if not FILE_TYPE_DEFINITIONS_PATH.is_file():
log.error(f"File type definitions file not found: {FILE_TYPE_DEFINITIONS_PATH}")
return {}
try:
with open(FILE_TYPE_DEFINITIONS_PATH, 'r', encoding='utf-8') as f:
data = json.load(f)
if "FILE_TYPE_DEFINITIONS" not in data:
log.error(f"Key 'FILE_TYPE_DEFINITIONS' not found in {FILE_TYPE_DEFINITIONS_PATH}")
return {}
settings = data["FILE_TYPE_DEFINITIONS"]
if not isinstance(settings, dict):
log.error(f"'FILE_TYPE_DEFINITIONS' in {FILE_TYPE_DEFINITIONS_PATH} must be a dictionary.")
return {}
log.debug(f"File type definitions loaded successfully.")
return settings
except json.JSONDecodeError as e:
log.error(f"Failed to parse file type definitions file {FILE_TYPE_DEFINITIONS_PATH}: Invalid JSON - {e}")
return {}
except Exception as e:
log.error(f"Failed to read file type definitions file {FILE_TYPE_DEFINITIONS_PATH}: {e}")
return {}
def save_file_type_definitions(data: dict):
"""
Takes a dictionary (representing content for "FILE_TYPE_DEFINITIONS" key).
Writes it to config/file_type_definitions.json under the root key "FILE_TYPE_DEFINITIONS".
Handles errors.
"""
log.debug(f"Saving file type definitions to: {FILE_TYPE_DEFINITIONS_PATH}")
try:
with open(FILE_TYPE_DEFINITIONS_PATH, 'w', encoding='utf-8') as f:
json.dump({"FILE_TYPE_DEFINITIONS": data}, f, indent=4)
log.info(f"File type definitions saved successfully to {FILE_TYPE_DEFINITIONS_PATH}")
except Exception as e:
log.error(f"Failed to save file type definitions file {FILE_TYPE_DEFINITIONS_PATH}: {e}")
raise ConfigurationError(f"Failed to save file type definitions: {e}")
def load_supplier_settings() -> dict:
"""
Reads config/suppliers.json.
Returns the entire dictionary.
Handles file not found (return empty dict) or JSON errors.
If the loaded data is a list (old format), convert it in memory to the new
dictionary format, defaulting normal_map_type to "OpenGL" for each supplier.
"""
log.debug(f"Loading supplier settings from: {SUPPLIERS_CONFIG_PATH}")
if not SUPPLIERS_CONFIG_PATH.is_file():
log.warning(f"Supplier settings file not found: {SUPPLIERS_CONFIG_PATH}. Returning empty dict.")
return {}
try:
with open(SUPPLIERS_CONFIG_PATH, 'r', encoding='utf-8') as f:
data = json.load(f)
if isinstance(data, list):
log.warning(f"Supplier settings in {SUPPLIERS_CONFIG_PATH} is in the old list format. Converting to new dictionary format.")
new_data = {}
for supplier_name in data:
if isinstance(supplier_name, str):
new_data[supplier_name] = {"normal_map_type": "OpenGL"}
else:
log.warning(f"Skipping non-string item '{supplier_name}' during old format conversion of supplier settings.")
log.info(f"Supplier settings converted to new format: {new_data}")
return new_data
if not isinstance(data, dict):
log.error(f"Supplier settings in {SUPPLIERS_CONFIG_PATH} must be a dictionary. Found {type(data)}. Returning empty dict.")
return {}
log.debug(f"Supplier settings loaded successfully.")
return data
except json.JSONDecodeError as e:
log.error(f"Failed to parse supplier settings file {SUPPLIERS_CONFIG_PATH}: Invalid JSON - {e}. Returning empty dict.")
return {}
except Exception as e:
log.error(f"Failed to read supplier settings file {SUPPLIERS_CONFIG_PATH}: {e}. Returning empty dict.")
return {}
def save_supplier_settings(data: dict):
"""
Takes a dictionary (in the new format).
Writes it directly to config/suppliers.json.
Handles errors.
"""
log.debug(f"Saving supplier settings to: {SUPPLIERS_CONFIG_PATH}")
if not isinstance(data, dict):
log.error(f"Data for save_supplier_settings must be a dictionary. Got {type(data)}.")
raise ConfigurationError(f"Invalid data type for saving supplier settings: {type(data)}")
try:
with open(SUPPLIERS_CONFIG_PATH, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2) # Using indent=2 as per the example for suppliers.json
log.info(f"Supplier settings saved successfully to {SUPPLIERS_CONFIG_PATH}")
except Exception as e:
log.error(f"Failed to save supplier settings file {SUPPLIERS_CONFIG_PATH}: {e}")
raise ConfigurationError(f"Failed to save supplier settings: {e}")

Binary file not shown.

BIN
context_portal/context.db Normal file

Binary file not shown.

View File

@@ -0,0 +1,137 @@
# Plan for New Definitions Editor UI
## 1. Overview
This document outlines the plan to create a new, dedicated UI for managing "Asset Type Definitions", "File Type Definitions", and "Supplier Settings". This editor will provide a more structured and user-friendly way to manage these core application configurations, which are currently stored in separate JSON files.
## 2. General Design Principles
* **Dedicated Dialog:** The editor will be a new `QDialog` (e.g., `DefinitionsEditorDialog`).
* **Access Point:** Launched from the `MainWindow` menu bar (e.g., under a "Definitions" menu or "Edit" -> "Edit Definitions...").
* **Tabbed Interface:** The dialog will use a `QTabWidget` to separate the management of different definition types.
* **List/Details View:** Each tab will generally follow a two-pane layout:
* **Left Pane:** A `QListWidget` displaying the primary keys or names of the definitions (e.g., asset type names, file type IDs, supplier names). Includes "Add" and "Remove" buttons for managing these primary entries.
* **Right Pane:** A details area (e.g., `QGroupBox` with a `QFormLayout`) that shows the specific settings for the item selected in the left-pane list.
* **Data Persistence:** The dialog will load from and save to the respective JSON configuration files:
* Asset Types: `config/asset_type_definitions.json`
* File Types: `config/file_type_definitions.json`
* Supplier Settings: `config/suppliers.json` (This file will be refactored from a simple list to a dictionary of supplier objects).
* **User Experience:** Standard "Save" and "Cancel" buttons, with a check for unsaved changes.
## 3. Tab-Specific Plans
### 3.1. Asset Type Definitions Tab
* **Manages:** `config/asset_type_definitions.json`
* **UI Sketch:**
```mermaid
graph LR
subgraph AssetTypeTab [Asset Type Definitions Tab]
direction LR
AssetList[QListWidget (Asset Type Keys e.g., "Surface")] --> AssetDetailsGroup{Details for Selected Asset Type};
end
subgraph AssetDetailsGroup
direction TB
Desc[Description: QTextEdit]
Color[Color: QPushButton ("Choose Color...") + Color Swatch Display]
Examples[Examples: QListWidget + Add/Remove Example Buttons]
end
AssetActions["Add Asset Type (Prompt for Name)\nRemove Selected Asset Type"] --> AssetList
```
* **Details:**
* **Left Pane:** `QListWidget` for asset type names. "Add Asset Type" (prompts for new key) and "Remove Selected Asset Type" buttons.
* **Right Pane (Details):**
* `description`: `QTextEdit`.
* `color`: `QPushButton` opening `QColorDialog`, with an adjacent `QLabel` to display the color swatch.
* `examples`: `QListWidget` with "Add Example" (`QInputDialog.getText`) and "Remove Selected Example" buttons.
### 3.2. File Type Definitions Tab
* **Manages:** `config/file_type_definitions.json`
* **UI Sketch:**
```mermaid
graph LR
subgraph FileTypeTab [File Type Definitions Tab]
direction LR
FileList[QListWidget (File Type Keys e.g., "MAP_COL")] --> FileDetailsGroup{Details for Selected File Type};
end
subgraph FileDetailsGroup
direction TB
DescF[Description: QTextEdit]
ColorF[Color: QPushButton ("Choose Color...") + Color Swatch Display]
ExamplesF[Examples: QListWidget + Add/Remove Example Buttons]
StdType[Standard Type: QLineEdit]
BitDepth[Bit Depth Rule: QComboBox ("respect", "force_8bit", "force_16bit")]
IsGrayscale[Is Grayscale: QCheckBox]
Keybind[Keybind: QLineEdit (1 char)]
end
FileActions["Add File Type (Prompt for ID)\nRemove Selected File Type"] --> FileList
```
* **Details:**
* **Left Pane:** `QListWidget` for file type IDs. "Add File Type" (prompts for new key) and "Remove Selected File Type" buttons.
* **Right Pane (Details):**
* `description`: `QTextEdit`.
* `color`: `QPushButton` opening `QColorDialog`, with an adjacent `QLabel` for color swatch.
* `examples`: `QListWidget` with "Add Example" and "Remove Selected Example" buttons.
* `standard_type`: `QLineEdit`.
* `bit_depth_rule`: `QComboBox` (options: "respect", "force_8bit", "force_16bit").
* `is_grayscale`: `QCheckBox`.
* `keybind`: `QLineEdit` (validation for single character recommended).
### 3.3. Supplier Settings Tab
* **Manages:** `config/suppliers.json` (This file will be refactored to a dictionary structure, e.g., `{"SupplierName": {"normal_map_type": "OpenGL", ...}}`).
* **UI Sketch:**
```mermaid
graph LR
subgraph SupplierTab [Supplier Settings Tab]
direction LR
SupplierList[QListWidget (Supplier Names)] --> SupplierDetailsGroup{Details for Selected Supplier};
end
subgraph SupplierDetailsGroup
direction TB
NormalMapType[Normal Map Type: QComboBox ("OpenGL", "DirectX")]
%% Future supplier-specific settings can be added here
end
SupplierActions["Add Supplier (Prompt for Name)\nRemove Selected Supplier"] --> SupplierList
```
* **Details:**
* **Left Pane:** `QListWidget` for supplier names. "Add Supplier" (prompts for new name) and "Remove Selected Supplier" buttons.
* **Right Pane (Details):**
* `normal_map_type`: `QComboBox` (options: "OpenGL", "DirectX"). Default for new suppliers: "OpenGL".
* *(Space for future supplier-specific settings).*
* **Data Handling Note for `config/suppliers.json`:**
* The editor will load from and save to `config/suppliers.json` using the new dictionary format (supplier name as key, object of settings as value).
* Initial implementation might require `config/suppliers.json` to be manually updated to this new format if it currently exists as a simple list. Alternatively, the editor could attempt an automatic conversion on first load if the old list format is detected, or prompt the user. For the first pass, assuming the editor works with the new format is simpler.
## 4. Implementation Steps (High-Level)
1. **(Potentially Manual First Step) Refactor `config/suppliers.json`:** If `config/suppliers.json` exists as a list, manually convert it to the new dictionary structure (e.g., `{"SupplierName": {"normal_map_type": "OpenGL"}}`) before starting UI development for this tab, or plan for the editor to handle this conversion.
2. **Create `DefinitionsEditorDialog` Class:** Inherit from `QDialog`.
3. **Implement UI Structure:** Main `QTabWidget`, and for each tab, the two-pane layout with `QListWidget`, `QGroupBox` for details, and relevant input widgets (`QLineEdit`, `QTextEdit`, `QComboBox`, `QCheckBox`, `QPushButton`).
4. **Implement Loading Logic:**
* For each tab, read data from its corresponding JSON file.
* Populate the left-pane `QListWidget` with the primary keys/names.
* Store the full data structure internally (e.g., in dictionaries within the dialog instance).
5. **Implement Display Logic:**
* When an item is selected in a `QListWidget`, populate the right-pane detail fields with the data for that item.
6. **Implement Editing Logic:**
* Ensure that changes made in the detail fields (text edits, combobox selections, checkbox states, color choices, list example modifications) update the corresponding internal data structure for the currently selected item.
7. **Implement Add/Remove Functionality:**
* For each definition type (Asset Type, File Type, Supplier), implement the "Add" and "Remove" buttons.
* "Add": Prompt for a unique key/name, create a new default entry in the internal data, and add it to the `QListWidget`.
* "Remove": Remove the selected item from the `QListWidget` and the internal data.
* For "examples" lists within Asset and File types, implement their "Add Example" and "Remove Selected Example" buttons.
8. **Implement Saving Logic:**
* When the main "Save" button is clicked:
* Write the (potentially modified) Asset Type definitions data structure to `config/asset_type_definitions.json`.
* Write File Type definitions to `config/file_type_definitions.json`.
* Write Supplier settings (in the new dictionary format) to `config/suppliers.json`.
* Consider creating new dedicated save functions in `configuration.py` for each of these files if they don't already exist or if existing ones are not suitable.
9. **Implement Unsaved Changes Check & Cancel Logic.**
10. **Integrate Dialog Launch:** Add a menu action in `MainWindow.py` to open the `DefinitionsEditorDialog`.
This plan provides a comprehensive approach to creating a dedicated editor for these crucial application definitions.

View File

@@ -0,0 +1,113 @@
# Refactoring Plan for Preferences Window (ConfigEditorDialog)
## 1. Overview
This document outlines the plan to refactor the preferences window (`gui/config_editor_dialog.py`). The primary goal is to address issues related to misaligned scope, poor user experience for certain data types, and incomplete interactivity. The refactoring will focus on making the `ConfigEditorDialog` a robust editor for settings in `config/app_settings.json` that are intended to be overridden by the user via `config/user_settings.json`.
## 2. Assessment Summary
* **Misaligned Scope:** The dialog currently includes UI for "Asset Type Definitions" and "File Type Definitions". However, these are managed in separate dedicated JSON files ([`config/asset_type_definitions.json`](config/asset_type_definitions.json) and [`config/file_type_definitions.json`](config/file_type_definitions.json)) and are not saved by this dialog (which targets `config/user_settings.json`).
* **Poor UX for Data Types:**
* Lists (e.g., `RESPECT_VARIANT_MAP_TYPES`) are edited as comma-separated strings.
* Dictionary-like structures (e.g., `IMAGE_RESOLUTIONS`) are handled inconsistently (JSON defines as dict, UI attempts list-of-pairs).
* Editing complex list-of-objects (e.g., `MAP_MERGE_RULES`) is functionally incomplete.
* **Incomplete Interactivity:** Many table-based editors lack "Add/Remove Row" functionality and proper cell delegates for intuitive editing.
* **LLM Settings:** Confirmed to be correctly managed by the separate `LLMEditorWidget` and `config/llm_settings.json`, so they are out of scope for this specific dialog refactor.
## 3. Refactoring Phases and Plan Details
```mermaid
graph TD
A[Start: Current State] --> B{Phase 1: Correct Scope & Critical UX/Data Fixes};
B --> C{Phase 2: Enhance MAP_MERGE_RULES Editor};
C --> D{Phase 3: General UX & Table Interactivity};
D --> E[End: Refactored Preferences Window];
subgraph "Phase 1: Correct Scope & Critical UX/Data Fixes"
B1[Remove Definitions Editing from ConfigEditorDialog]
B2[Improve List Editing for RESPECT_VARIANT_MAP_TYPES]
B3[Fix IMAGE_RESOLUTIONS Handling (Dictionary)]
B4[Handle Simple Nested Settings (e.g., general_settings)]
end
subgraph "Phase 2: Enhance MAP_MERGE_RULES Editor"
C1[Implement Add/Remove for Merge Rules]
C2[Improve Rule Detail Editing (ComboBoxes, SpinBoxes)]
end
subgraph "Phase 3: General UX & Table Interactivity"
D1[Implement IMAGE_RESOLUTIONS Table Add/Remove Buttons]
D2[Implement Necessary Table Cell Delegates (e.g., for IMAGE_RESOLUTIONS values)]
D3[Review/Refine Tab Layout & Widget Grouping]
end
B --> B1; B --> B2; B --> B3; B --> B4;
C --> C1; C --> C2;
D --> D1; D --> D2; D --> D3;
```
### Phase 1: Correct Scope & Critical UX/Data Fixes (in `gui/config_editor_dialog.py`)
1. **Remove Definitions Editing:**
* **Action:** In `populate_definitions_tab`, remove the inner `QTabWidget` and the code that creates/populates the "Asset Types" and "File Types" tables.
* The `DEFAULT_ASSET_CATEGORY` `QComboBox` (for the setting from `app_settings.json`) should remain. Its items should be populated using keys obtained from the `Configuration` class (which loads the actual `ASSET_TYPE_DEFINITIONS` from its dedicated file).
* **Rationale:** Simplifies the dialog to settings managed via `user_settings.json`. Editing of the full definition files requires dedicated UI (see Future Enhancements note).
2. **Improve `RESPECT_VARIANT_MAP_TYPES` Editing:**
* **Action:** In `populate_output_naming_tab`, replace the `QLineEdit` for `RESPECT_VARIANT_MAP_TYPES` with a `QListWidget` and "Add"/"Remove" buttons.
* "Add" button: Use `QInputDialog.getItem` with items populated from `Configuration.get_file_type_keys()` (or similar method accessing loaded `FILE_TYPE_DEFINITIONS`) to allow users to select a valid file type key.
* "Remove" button: Remove the selected item from the `QListWidget`.
* Update `save_settings` to read the list of strings from this `QListWidget`.
* Update `populate_widgets_from_settings` to populate this `QListWidget`.
3. **Fix `IMAGE_RESOLUTIONS` Handling:**
* **Action:** In `populate_image_processing_tab`:
* The `QTableWidget` for `IMAGE_RESOLUTIONS` should have two columns: "Name" (string, for the dictionary key) and "Resolution (px)" (integer, for the dictionary value).
* In `populate_image_resolutions_table`, ensure it correctly populates from the dictionary structure in `self.settings['IMAGE_RESOLUTIONS']` (from `app_settings.json`).
* In `save_settings`, ensure it correctly reads data from the table and reconstructs the `IMAGE_RESOLUTIONS` dictionary (e.g., `{"4K": 4096, "2K": 2048}`) when saving to `user_settings.json`.
* ComboBoxes `CALCULATE_STATS_RESOLUTION` and `RESOLUTION_THRESHOLD_FOR_JPG` should be populated with the *keys* (names like "4K", "2K") from the `IMAGE_RESOLUTIONS` dictionary. `RESOLUTION_THRESHOLD_FOR_JPG` should also include "Never" and "Always" options. The `save_settings` method needs to correctly map these special ComboBox values back to appropriate storable values if necessary (e.g., sentinel numbers or specific strings if the backend configuration expects them for "Never"/"Always").
4. **Handle Simple Nested Settings (e.g., `general_settings`):**
* **Action:** For `general_settings.invert_normal_map_green_channel_globally` (from `config/app_settings.json`):
* Add a `QCheckBox` labeled "Invert Normal Map Green Channel Globally" to an appropriate tab (e.g., "Image Processing" or a "General" tab after layout review).
* Update `populate_widgets_from_settings` to read `self.settings.get('general_settings', {}).get('invert_normal_map_green_channel_globally', False)`.
* Update `save_settings` to write this value back to `target_file_content.setdefault('general_settings', {})['invert_normal_map_green_channel_globally'] = widget.isChecked()`.
### Phase 2: Enhance `MAP_MERGE_RULES` Editor (in `gui/config_editor_dialog.py`)
1. **Rule Management:**
* **Action:** In `populate_map_merging_tab`:
* Connect the "Add Rule" button:
* Create a default new rule dictionary (e.g., `{"output_map_type": "NEW_RULE", "inputs": {}, "defaults": {}, "output_bit_depth": "respect_inputs"}`).
* Add it to the internal list of rules that will be saved (e.g., a copy of `self.settings['MAP_MERGE_RULES']` that gets modified).
* Add a new `QListWidgetItem` for it and select it to display its details.
* Connect the "Remove Rule" button:
* Remove the selected rule from the internal list and the `QListWidget`.
* Clear the details panel.
2. **Rule Details Panel Improvements (`display_merge_rule_details`):**
* **`output_map_type`:** Change the `QLineEdit` to a `QComboBox`. Populate its items from `Configuration.get_file_type_keys()`.
* **`inputs` Table:** The "Input Map Type" column cells should use a `QComboBox` delegate, populated with `Configuration.get_file_type_keys()` plus an empty/None option.
* **`defaults` Table:** The "Default Value" column cells should use a `QDoubleSpinBox` delegate (e.g., range 0.0 to 1.0, or 0-255 if appropriate for specific channel types).
* Ensure changes in these detail editors update the underlying rule data associated with the selected `QListWidgetItem` and the internal list of rules.
### Phase 3: General UX & Table Interactivity (in `gui/config_editor_dialog.py`)
1. **Implement `IMAGE_RESOLUTIONS` Table Add/Remove Buttons:**
* **Action:** In `populate_image_processing_tab`, connect the "Add Row" and "Remove Row" buttons for the `IMAGE_RESOLUTIONS` table.
* "Add Row": Prompt for "Name" (string) and "Resolution (px)" (integer).
* "Remove Row": Remove the selected row from the table and the underlying data.
2. **Implement Necessary Table Cell Delegates:**
* **Action:** For the `IMAGE_RESOLUTIONS` table, the "Resolution (px)" column should use a `QSpinBox` delegate or a `QLineEdit` with integer validation to ensure correct data input.
3. **Review/Refine Tab Layout & Widget Grouping:**
* **Action:** After the functional changes, review the overall layout of tabs and the grouping of settings within `gui/config_editor_dialog.py`.
* Ensure settings from `config/app_settings.json` are logically placed and clearly labeled.
* Verify widget labels are descriptive and tooltips are helpful where needed.
* Confirm correct mapping between UI widgets and the keys in `app_settings.json` (e.g., `OUTPUT_FILENAME_PATTERN` vs. `TARGET_FILENAME_PATTERN`).
## 4. Future Enhancements (Out of Scope for this Refactor)
* **Dedicated Editors for Definitions:** As per user feedback, if `ASSET_TYPE_DEFINITIONS` and `FILE_TYPE_DEFINITIONS` require UI-based editing, dedicated dialogs/widgets should be created. These would read from and save to their respective files ([`config/asset_type_definitions.json`](config/asset_type_definitions.json) and [`config/file_type_definitions.json`](config/file_type_definitions.json)) and could adopt a list/details UI similar to the `MAP_MERGE_RULES` editor.
* **Live Updates:** Consider mechanisms for applying some settings without requiring an application restart, if feasible for specific settings.
This plan aims to create a more focused, usable, and correct preferences window.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -126,12 +126,15 @@ class SupplierSearchDelegate(QStyledItemDelegate):
"""Loads the list of known suppliers from the JSON config file.""" """Loads the list of known suppliers from the JSON config file."""
try: try:
with open(SUPPLIERS_CONFIG_PATH, 'r') as f: with open(SUPPLIERS_CONFIG_PATH, 'r') as f:
suppliers = json.load(f) suppliers_data = json.load(f) # Renamed variable for clarity
if isinstance(suppliers, list): if isinstance(suppliers_data, list):
# Ensure all items are strings # Ensure all items are strings
return sorted([str(s) for s in suppliers if isinstance(s, str)]) return sorted([str(s) for s in suppliers_data if isinstance(s, str)])
else: elif isinstance(suppliers_data, dict): # ADDED: Handle dictionary case
log.warning(f"'{SUPPLIERS_CONFIG_PATH}' does not contain a valid list. Starting fresh.") # If it's a dictionary, extract keys as supplier names
return sorted([str(key) for key in suppliers_data.keys() if isinstance(key, str)])
else: # MODIFIED: Updated warning message
log.warning(f"'{SUPPLIERS_CONFIG_PATH}' does not contain a valid list or dictionary of suppliers. Starting fresh.")
return [] return []
except FileNotFoundError: except FileNotFoundError:
log.info(f"'{SUPPLIERS_CONFIG_PATH}' not found. Starting with an empty supplier list.") log.info(f"'{SUPPLIERS_CONFIG_PATH}' not found. Starting with an empty supplier list.")

View File

@@ -1,6 +1,7 @@
# gui/llm_editor_widget.py # gui/llm_editor_widget.py
import json import json
import logging import logging
import copy # Added for deepcopy
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QTabWidget, QPlainTextEdit, QGroupBox, QWidget, QVBoxLayout, QTabWidget, QPlainTextEdit, QGroupBox,
QHBoxLayout, QPushButton, QFormLayout, QLineEdit, QDoubleSpinBox, QHBoxLayout, QPushButton, QFormLayout, QLineEdit, QDoubleSpinBox,
@@ -24,6 +25,7 @@ class LLMEditorWidget(QWidget):
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
self._unsaved_changes = False self._unsaved_changes = False
self.original_llm_settings = {} # Initialize original_llm_settings
self._init_ui() self._init_ui()
self._connect_signals() self._connect_signals()
self.save_button.setEnabled(False) # Initially disabled self.save_button.setEnabled(False) # Initially disabled
@@ -131,6 +133,7 @@ class LLMEditorWidget(QWidget):
try: try:
with open(LLM_CONFIG_PATH, 'r', encoding='utf-8') as f: with open(LLM_CONFIG_PATH, 'r', encoding='utf-8') as f:
settings = json.load(f) settings = json.load(f)
self.original_llm_settings = copy.deepcopy(settings) # Store a deep copy
# Populate Prompt Settings # Populate Prompt Settings
self.prompt_editor.setPlainText(settings.get("llm_predictor_prompt", "")) self.prompt_editor.setPlainText(settings.get("llm_predictor_prompt", ""))
@@ -159,9 +162,9 @@ class LLMEditorWidget(QWidget):
logger.info("LLM settings loaded successfully.") logger.info("LLM settings loaded successfully.")
except FileNotFoundError: except FileNotFoundError:
logger.warning(f"LLM settings file not found: {LLM_CONFIG_PATH}. Using defaults and disabling editor.") logger.warning(f"LLM settings file not found: {LLM_CONFIG_PATH}. Using defaults.")
QMessageBox.warning(self, "Load Error", QMessageBox.warning(self, "Load Error",
f"LLM settings file not found:\n{LLM_CONFIG_PATH}\n\nPlease ensure the file exists. Using default values.") f"LLM settings file not found:\n{LLM_CONFIG_PATH}\n\nNew settings will be created if you save.")
# Reset to defaults (optional, or leave fields empty) # Reset to defaults (optional, or leave fields empty)
self.prompt_editor.clear() self.prompt_editor.clear()
self.endpoint_url_edit.clear() self.endpoint_url_edit.clear()
@@ -169,19 +172,21 @@ class LLMEditorWidget(QWidget):
self.model_name_edit.clear() self.model_name_edit.clear()
self.temperature_spinbox.setValue(0.7) self.temperature_spinbox.setValue(0.7)
self.timeout_spinbox.setValue(120) self.timeout_spinbox.setValue(120)
# self.setEnabled(False) # Disabling might be too harsh if user wants to create settings self.original_llm_settings = {} # Start with empty original settings if file not found
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
logger.error(f"Error decoding JSON from {LLM_CONFIG_PATH}: {e}") logger.error(f"Error decoding JSON from {LLM_CONFIG_PATH}: {e}")
QMessageBox.critical(self, "Load Error", QMessageBox.critical(self, "Load Error",
f"Failed to parse LLM settings file:\n{LLM_CONFIG_PATH}\n\nError: {e}\n\nPlease check the file for syntax errors. Editor will be disabled.") f"Failed to parse LLM settings file:\n{LLM_CONFIG_PATH}\n\nError: {e}\n\nPlease check the file for syntax errors. Editor will be disabled.")
self.setEnabled(False) # Disable editor on critical load error self.setEnabled(False) # Disable editor on critical load error
self.original_llm_settings = {} # Reset original settings on JSON error
except Exception as e: # Catch other potential errors during loading/populating except Exception as e: # Catch other potential errors during loading/populating
logger.error(f"An unexpected error occurred loading LLM settings: {e}", exc_info=True) logger.error(f"An unexpected error occurred loading LLM settings: {e}", exc_info=True)
QMessageBox.critical(self, "Load Error", QMessageBox.critical(self, "Load Error",
f"An unexpected error occurred while loading settings:\n{e}\n\nEditor will be disabled.") f"An unexpected error occurred while loading settings:\n{e}\n\nEditor will be disabled.")
self.setEnabled(False) self.setEnabled(False)
self.original_llm_settings = {} # Reset original settings on other errors
# Reset unsaved changes flag and disable save button after loading # Reset unsaved changes flag and disable save button after loading
@@ -201,26 +206,38 @@ class LLMEditorWidget(QWidget):
"""Gather data from UI, save to JSON file, and handle errors.""" """Gather data from UI, save to JSON file, and handle errors."""
logger.info("Attempting to save LLM settings...") logger.info("Attempting to save LLM settings...")
settings_dict = {} # 1.a. Load Current Target File
target_file_content = {}
try:
with open(LLM_CONFIG_PATH, 'r', encoding='utf-8') as f:
target_file_content = json.load(f)
except FileNotFoundError:
logger.info(f"{LLM_CONFIG_PATH} not found. Will create a new one.")
target_file_content = {} # Start with an empty dict if file doesn't exist
except json.JSONDecodeError as e:
logger.error(f"Error decoding existing {LLM_CONFIG_PATH}: {e}. Starting with an empty config for save.")
QMessageBox.warning(self, "Warning",
f"Could not parse existing LLM settings file ({LLM_CONFIG_PATH}).\n"
f"Any pre-existing settings in that file might be overwritten if you save now.\nError: {e}")
target_file_content = {} # Start fresh if current file is corrupt
# 1.b. Gather current UI settings into current_llm_settings
current_llm_settings = {}
parsed_examples = [] parsed_examples = []
has_errors = False has_errors = False # For example parsing
# Gather API Settings current_llm_settings["llm_endpoint_url"] = self.endpoint_url_edit.text().strip()
settings_dict["llm_endpoint_url"] = self.endpoint_url_edit.text().strip() current_llm_settings["llm_api_key"] = self.api_key_edit.text() # Keep as is
settings_dict["llm_api_key"] = self.api_key_edit.text() # Keep as is, don't strip current_llm_settings["llm_model_name"] = self.model_name_edit.text().strip()
settings_dict["llm_model_name"] = self.model_name_edit.text().strip() current_llm_settings["llm_temperature"] = self.temperature_spinbox.value()
settings_dict["llm_temperature"] = self.temperature_spinbox.value() current_llm_settings["llm_request_timeout"] = self.timeout_spinbox.value()
settings_dict["llm_request_timeout"] = self.timeout_spinbox.value() current_llm_settings["llm_predictor_prompt"] = self.prompt_editor.toPlainText().strip()
# Gather Prompt Settings
settings_dict["llm_predictor_prompt"] = self.prompt_editor.toPlainText().strip()
# Gather and Parse Examples
for i in range(self.examples_tab_widget.count()): for i in range(self.examples_tab_widget.count()):
example_editor = self.examples_tab_widget.widget(i) example_editor = self.examples_tab_widget.widget(i)
if isinstance(example_editor, QTextEdit): if isinstance(example_editor, QTextEdit):
example_text = example_editor.toPlainText().strip() example_text = example_editor.toPlainText().strip()
if not example_text: # Skip empty examples silently if not example_text:
continue continue
try: try:
parsed_example = json.loads(example_text) parsed_example = json.loads(example_text)
@@ -231,40 +248,58 @@ class LLMEditorWidget(QWidget):
logger.warning(f"Invalid JSON in '{tab_name}': {e}. Skipping example.") logger.warning(f"Invalid JSON in '{tab_name}': {e}. Skipping example.")
QMessageBox.warning(self, "Invalid Example", QMessageBox.warning(self, "Invalid Example",
f"The content in '{tab_name}' is not valid JSON and will not be saved.\n\nError: {e}\n\nPlease correct it or remove the tab.") f"The content in '{tab_name}' is not valid JSON and will not be saved.\n\nError: {e}\n\nPlease correct it or remove the tab.")
# Optionally switch to the tab with the error:
# self.examples_tab_widget.setCurrentIndex(i)
else: else:
logger.warning(f"Widget at index {i} in examples tab is not a QTextEdit. Skipping.") logger.warning(f"Widget at index {i} in examples tab is not a QTextEdit. Skipping.")
if has_errors: if has_errors:
logger.warning("LLM settings not saved due to invalid JSON in examples.") logger.warning("LLM settings not saved due to invalid JSON in examples.")
# Keep save button enabled if there were errors, allowing user to fix and retry return
# self.save_button.setEnabled(True)
# self._unsaved_changes = True
return # Stop saving process
settings_dict["llm_predictor_examples"] = parsed_examples current_llm_settings["llm_predictor_examples"] = parsed_examples
# Save the dictionary to file # 1.c. Identify Changes and Update Target File Content
changed_settings_count = 0
for key, current_value in current_llm_settings.items():
original_value = self.original_llm_settings.get(key)
# Special handling for lists (e.g., examples) - direct comparison works
# For other types, direct comparison also works.
# This includes new keys present in current_llm_settings but not in original_llm_settings
if key not in self.original_llm_settings or current_value != original_value:
target_file_content[key] = current_value
logger.debug(f"Setting '{key}' changed or added. Old: '{original_value}', New: '{current_value}'")
changed_settings_count +=1
if changed_settings_count == 0 and self._unsaved_changes:
logger.info("Save called, but no actual changes detected compared to original loaded settings.")
# If _unsaved_changes was true, it means UI interaction happened,
# but values might have been reverted to original.
# We still proceed to save target_file_content as it might contain
# values from a file that was modified externally since last load.
# Or, if the file didn't exist, it will now be created with current UI values.
# 1.d. Save Updated Content
try: try:
save_llm_config(settings_dict) save_llm_config(target_file_content) # Save the potentially modified target_file_content
QMessageBox.information(self, "Save Successful", f"LLM settings saved to:\n{LLM_CONFIG_PATH}") QMessageBox.information(self, "Save Successful", f"LLM settings saved to:\n{LLM_CONFIG_PATH}")
# Update original_llm_settings to reflect the newly saved state
self.original_llm_settings = copy.deepcopy(target_file_content)
self.save_button.setEnabled(False) self.save_button.setEnabled(False)
self._unsaved_changes = False self._unsaved_changes = False
self.settings_saved.emit() # Notify MainWindow or others self.settings_saved.emit()
logger.info("LLM settings saved successfully.") logger.info("LLM settings saved successfully.")
except ConfigurationError as e: except ConfigurationError as e:
logger.error(f"Failed to save LLM settings: {e}") logger.error(f"Failed to save LLM settings: {e}")
QMessageBox.critical(self, "Save Error", f"Could not save LLM settings.\n\nError: {e}") QMessageBox.critical(self, "Save Error", f"Could not save LLM settings.\n\nError: {e}")
# Keep save button enabled as save failed self.save_button.setEnabled(True) # Keep save enabled
self.save_button.setEnabled(True)
self._unsaved_changes = True self._unsaved_changes = True
except Exception as e: # Catch unexpected errors during save except Exception as e:
logger.error(f"An unexpected error occurred during LLM settings save: {e}", exc_info=True) logger.error(f"An unexpected error occurred during LLM settings save: {e}", exc_info=True)
QMessageBox.critical(self, "Save Error", f"An unexpected error occurred while saving settings:\n{e}") QMessageBox.critical(self, "Save Error", f"An unexpected error occurred while saving settings:\n{e}")
self.save_button.setEnabled(True) self.save_button.setEnabled(True) # Keep save enabled
self._unsaved_changes = True self._unsaved_changes = True
# --- Example Management Slots --- # --- Example Management Slots ---

View File

@@ -27,6 +27,7 @@ from .llm_editor_widget import LLMEditorWidget
from .log_console_widget import LogConsoleWidget from .log_console_widget import LogConsoleWidget
from .main_panel_widget import MainPanelWidget from .main_panel_widget import MainPanelWidget
from .definitions_editor_dialog import DefinitionsEditorDialog
# --- Backend Imports for Data Structures --- # --- Backend Imports for Data Structures ---
from rule_structure import SourceRule, AssetRule, FileRule from rule_structure import SourceRule, AssetRule, FileRule
@@ -310,7 +311,7 @@ class MainWindow(QMainWindow):
log.info(f"Added {added_count} new asset paths: {newly_added_paths}") log.info(f"Added {added_count} new asset paths: {newly_added_paths}")
self.statusBar().showMessage(f"Added {added_count} asset(s). Updating preview...", 3000) self.statusBar().showMessage(f"Added {added_count} asset(s). Updating preview...", 3000)
mode, selected_preset_text = self.preset_editor_widget.get_selected_preset_mode() mode, selected_display_name, preset_file_path = self.preset_editor_widget.get_selected_preset_mode()
if mode == "llm": if mode == "llm":
log.info(f"LLM Interpretation selected. Preparing LLM prediction for {len(newly_added_paths)} new paths.") log.info(f"LLM Interpretation selected. Preparing LLM prediction for {len(newly_added_paths)} new paths.")
@@ -329,8 +330,9 @@ class MainWindow(QMainWindow):
log.info(f"Delegating {len(llm_requests_to_queue)} LLM requests to the handler.") log.info(f"Delegating {len(llm_requests_to_queue)} LLM requests to the handler.")
self.llm_interaction_handler.queue_llm_requests_batch(llm_requests_to_queue) self.llm_interaction_handler.queue_llm_requests_batch(llm_requests_to_queue)
# The handler manages starting its own processing internally. # The handler manages starting its own processing internally.
elif mode == "preset" and selected_preset_text: elif mode == "preset" and selected_display_name and preset_file_path:
log.info(f"Preset '{selected_preset_text}' selected. Triggering prediction for {len(newly_added_paths)} new paths.") preset_name_for_loading = preset_file_path.stem
log.info(f"Preset '{selected_display_name}' (file: {preset_name_for_loading}.json) selected. Triggering prediction for {len(newly_added_paths)} new paths.")
if self.prediction_thread and not self.prediction_thread.isRunning(): if self.prediction_thread and not self.prediction_thread.isRunning():
log.debug("Starting prediction thread from add_input_paths.") log.debug("Starting prediction thread from add_input_paths.")
self.prediction_thread.start() self.prediction_thread.start()
@@ -342,7 +344,8 @@ class MainWindow(QMainWindow):
self._source_file_lists[input_path_str] = file_list self._source_file_lists[input_path_str] = file_list
self._pending_predictions.add(input_path_str) self._pending_predictions.add(input_path_str)
log.debug(f"Added '{input_path_str}' to pending predictions. Current pending: {self._pending_predictions}") log.debug(f"Added '{input_path_str}' to pending predictions. Current pending: {self._pending_predictions}")
self.start_prediction_signal.emit(input_path_str, file_list, selected_preset_text) # Pass the filename stem for loading, not the display name
self.start_prediction_signal.emit(input_path_str, file_list, preset_name_for_loading)
else: else:
log.warning(f"Skipping prediction for {input_path_str} due to extraction error.") log.warning(f"Skipping prediction for {input_path_str} due to extraction error.")
elif mode == "placeholder": elif mode == "placeholder":
@@ -445,7 +448,12 @@ class MainWindow(QMainWindow):
self.statusBar().showMessage("No assets added to process.", 3000) self.statusBar().showMessage("No assets added to process.", 3000)
return return
mode, selected_preset_name = self.preset_editor_widget.get_selected_preset_mode() # mode, selected_preset_name, preset_file_path are relevant here if processing depends on the *loaded* preset's config
# For now, _on_process_requested uses the rules already in unified_model, which should have been generated
# using the correct preset context. The preset name itself isn't directly used by the processing engine,
# as the SourceRule object already contains the necessary preset-derived information or the preset name string.
# We'll rely on the SourceRule objects in unified_model.get_all_source_rules() to be correct.
# mode, selected_display_name, preset_file_path = self.preset_editor_widget.get_selected_preset_mode()
output_dir_str = settings.get("output_dir") output_dir_str = settings.get("output_dir")
@@ -693,7 +701,7 @@ class MainWindow(QMainWindow):
log.error("RuleBasedPredictionHandler not loaded. Cannot update preview.") log.error("RuleBasedPredictionHandler not loaded. Cannot update preview.")
self.statusBar().showMessage("Error: Prediction components not loaded.", 5000) self.statusBar().showMessage("Error: Prediction components not loaded.", 5000)
return return
mode, selected_preset_name = self.preset_editor_widget.get_selected_preset_mode() mode, selected_display_name, preset_file_path = self.preset_editor_widget.get_selected_preset_mode()
if mode == "placeholder": if mode == "placeholder":
log.debug("Update preview called with placeholder preset selected. Showing existing raw inputs (detailed view).") log.debug("Update preview called with placeholder preset selected. Showing existing raw inputs (detailed view).")
@@ -748,9 +756,10 @@ class MainWindow(QMainWindow):
# Do not return here; let the function exit normally after handling LLM case. # Do not return here; let the function exit normally after handling LLM case.
# The standard prediction path below will be skipped because mode is 'llm'. # The standard prediction path below will be skipped because mode is 'llm'.
elif mode == "preset" and selected_preset_name: elif mode == "preset" and selected_display_name and preset_file_path:
log.info(f"[{time.time():.4f}] Requesting background preview update for {len(input_paths)} items using Preset='{selected_preset_name}'") preset_name_for_loading = preset_file_path.stem
self.statusBar().showMessage(f"Updating preview for '{selected_preset_name}'...", 0) log.info(f"[{time.time():.4f}] Requesting background preview update for {len(input_paths)} items using Preset Display='{selected_display_name}' (File Stem='{preset_name_for_loading}')")
self.statusBar().showMessage(f"Updating preview for '{selected_display_name}'...", 0)
log.debug("Clearing accumulated rules for new standard preview batch.") log.debug("Clearing accumulated rules for new standard preview batch.")
self._accumulated_rules.clear() self._accumulated_rules.clear()
@@ -763,8 +772,8 @@ class MainWindow(QMainWindow):
for input_path_str in input_paths: for input_path_str in input_paths:
file_list = self._extract_file_list(input_path_str) file_list = self._extract_file_list(input_path_str)
if file_list is not None: if file_list is not None:
log.debug(f"[{time.time():.4f}] Emitting start_prediction_signal for: {input_path_str} with {len(file_list)} files.") log.debug(f"[{time.time():.4f}] Emitting start_prediction_signal for: {input_path_str} with {len(file_list)} files, using preset file stem: {preset_name_for_loading}.")
self.start_prediction_signal.emit(input_path_str, file_list, selected_preset_name) self.start_prediction_signal.emit(input_path_str, file_list, preset_name_for_loading) # Pass stem for loading
else: else:
log.warning(f"[{time.time():.4f}] Skipping standard prediction signal for {input_path_str} due to extraction error.") log.warning(f"[{time.time():.4f}] Skipping standard prediction signal for {input_path_str} due to extraction error.")
else: else:
@@ -861,6 +870,11 @@ class MainWindow(QMainWindow):
self.preferences_action = QAction("&Preferences...", self) self.preferences_action = QAction("&Preferences...", self)
self.preferences_action.triggered.connect(self._open_config_editor) self.preferences_action.triggered.connect(self._open_config_editor)
edit_menu.addAction(self.preferences_action) edit_menu.addAction(self.preferences_action)
edit_menu.addSeparator()
self.definitions_editor_action = QAction("Edit Definitions...", self)
self.definitions_editor_action.triggered.connect(self._open_definitions_editor)
edit_menu.addAction(self.definitions_editor_action)
view_menu = self.menu_bar.addMenu("&View") view_menu = self.menu_bar.addMenu("&View")
@@ -904,6 +918,17 @@ class MainWindow(QMainWindow):
log.exception(f"Error opening configuration editor dialog: {e}") log.exception(f"Error opening configuration editor dialog: {e}")
QMessageBox.critical(self, "Error", f"An error occurred while opening the configuration editor:\n{e}") QMessageBox.critical(self, "Error", f"An error occurred while opening the configuration editor:\n{e}")
@Slot() # PySide6.QtCore.Slot
def _open_definitions_editor(self):
log.debug("Opening Definitions Editor dialog.")
try:
# DefinitionsEditorDialog is imported at the top of the file
dialog = DefinitionsEditorDialog(self)
dialog.exec_() # Use exec_() for modal dialog
log.debug("Definitions Editor dialog closed.")
except Exception as e:
log.exception(f"Error opening Definitions Editor dialog: {e}")
QMessageBox.critical(self, "Error", f"An error occurred while opening the Definitions Editor:\n{e}")
@Slot(bool) @Slot(bool)
def _toggle_log_console_visibility(self, checked): def _toggle_log_console_visibility(self, checked):
@@ -1049,13 +1074,13 @@ class MainWindow(QMainWindow):
log.debug(f"<-- Exiting _handle_prediction_completion for '{input_path}'") log.debug(f"<-- Exiting _handle_prediction_completion for '{input_path}'")
@Slot(str, str) @Slot(str, str, Path) # mode, display_name, file_path (Path can be None)
def _on_preset_selection_changed(self, mode: str, preset_name: str | None): def _on_preset_selection_changed(self, mode: str, display_name: str | None, file_path: Path | None ):
""" """
Handles changes in the preset editor selection (preset, LLM, placeholder). Handles changes in the preset editor selection (preset, LLM, placeholder).
Switches between PresetEditorWidget and LLMEditorWidget. Switches between PresetEditorWidget and LLMEditorWidget.
""" """
log.info(f"Preset selection changed: mode='{mode}', preset_name='{preset_name}'") log.info(f"Preset selection changed: mode='{mode}', display_name='{display_name}', file_path='{file_path}'")
if mode == "llm": if mode == "llm":
log.debug("Switching editor stack to LLM Editor Widget.") log.debug("Switching editor stack to LLM Editor Widget.")
@@ -1077,11 +1102,11 @@ class MainWindow(QMainWindow):
self.editor_stack.setCurrentWidget(self.preset_editor_widget.json_editor_container) self.editor_stack.setCurrentWidget(self.preset_editor_widget.json_editor_container)
# The PresetEditorWidget's internal logic handles disabling/clearing the editor fields. # The PresetEditorWidget's internal logic handles disabling/clearing the editor fields.
if mode == "preset" and preset_name: if mode == "preset" and display_name: # Use display_name for window title
# This might be redundant if the editor handles its own title updates on save/load # This might be redundant if the editor handles its own title updates on save/load
# but good for consistency. # but good for consistency.
unsaved = self.preset_editor_widget.editor_unsaved_changes unsaved = self.preset_editor_widget.editor_unsaved_changes
self.setWindowTitle(f"Asset Processor Tool - {preset_name}{'*' if unsaved else ''}") self.setWindowTitle(f"Asset Processor Tool - {display_name}{'*' if unsaved else ''}")
elif mode == "llm": elif mode == "llm":
self.setWindowTitle("Asset Processor Tool - LLM Interpretation") self.setWindowTitle("Asset Processor Tool - LLM Interpretation")
else: else:

View File

@@ -39,10 +39,9 @@ if not log.hasHandlers():
def classify_files(file_list: List[str], config: Configuration) -> Dict[str, List[Dict[str, Any]]]: def classify_files(file_list: List[str], config: Configuration) -> Dict[str, List[Dict[str, Any]]]:
""" """
Analyzes a list of files based on configuration rules using a two-pass approach Analyzes a list of files based on configuration rules to group them by asset
to group them by asset and determine initial file properties. and determine initial file properties, applying prioritization based on
Pass 1: Identifies and classifies prioritized bit depth variants. 'priority_keywords' in map_type_mapping.
Pass 2: Classifies extras, general maps (downgrading if primary exists), and ignores.
Args: Args:
file_list: List of absolute file paths. file_list: List of absolute file paths.
@@ -53,19 +52,21 @@ def classify_files(file_list: List[str], config: Configuration) -> Dict[str, Lis
Example: Example:
{ {
'AssetName1': [ 'AssetName1': [
{'file_path': '/path/to/AssetName1_DISP16.png', 'item_type': 'DISP', 'asset_name': 'AssetName1'}, {'file_path': '/path/to/AssetName1_DISP16.png', 'item_type': 'MAP_DISP', 'asset_name': 'AssetName1'},
{'file_path': '/path/to/AssetName1_DISP.png', 'item_type': 'EXTRA', 'asset_name': 'AssetName1'}, {'file_path': '/path/to/AssetName1_Color.png', 'item_type': 'MAP_COL', 'asset_name': 'AssetName1'}
{'file_path': '/path/to/AssetName1_Color.png', 'item_type': 'COL', 'asset_name': 'AssetName1'}
], ],
# ... other assets # ... other assets
} }
Files marked as "FILE_IGNORE" will also be included in the output.
Returns an empty dict if classification fails or no files are provided. Returns an empty dict if classification fails or no files are provided.
""" """
temp_grouped_files = defaultdict(list) classified_files_info: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
extra_files_to_associate = [] file_matches: Dict[str, List[Tuple[str, int, bool]]] = defaultdict(list) # {file_path: [(target_type, rule_index, is_priority), ...]}
primary_asset_names = set() files_to_ignore: Set[str] = set()
primary_assignments = set()
processed_in_pass1 = set() # --- DEBUG: Log the input file_list ---
log.info(f"DEBUG_ROO_CLASSIFY_INPUT: classify_files received file_list (len={len(file_list)}): {file_list}")
# --- END DEBUG ---
# --- Validation --- # --- Validation ---
if not file_list or not config: if not file_list or not config:
@@ -73,20 +74,20 @@ def classify_files(file_list: List[str], config: Configuration) -> Dict[str, Lis
return {} return {}
if not hasattr(config, 'compiled_map_keyword_regex') or not config.compiled_map_keyword_regex: if not hasattr(config, 'compiled_map_keyword_regex') or not config.compiled_map_keyword_regex:
log.warning("Classification skipped: Missing compiled map keyword regex in config.") log.warning("Classification skipped: Missing compiled map keyword regex in config.")
# Proceeding might still classify EXTRA/FILE_IGNORE if those rules exist
if not hasattr(config, 'compiled_extra_regex'): if not hasattr(config, 'compiled_extra_regex'):
log.warning("Configuration object missing 'compiled_extra_regex'. Cannot classify extra files.") log.warning("Configuration object missing 'compiled_extra_regex'. Cannot classify extra files.")
if not hasattr(config, 'compiled_bit_depth_regex_map'): compiled_extra_regex = [] # Provide default to avoid errors
log.warning("Configuration object missing 'compiled_bit_depth_regex_map'. Cannot prioritize bit depth variants.") else:
compiled_extra_regex = getattr(config, 'compiled_extra_regex', [])
compiled_map_regex = getattr(config, 'compiled_map_keyword_regex', {}) compiled_map_regex = getattr(config, 'compiled_map_keyword_regex', {})
compiled_extra_regex = getattr(config, 'compiled_extra_regex', []) # Note: compiled_bit_depth_regex_map is no longer used for primary classification logic here
compiled_bit_depth_regex_map = getattr(config, 'compiled_bit_depth_regex_map', {})
num_map_rules = sum(len(patterns) for patterns in compiled_map_regex.values()) num_map_rules = sum(len(patterns) for patterns in compiled_map_regex.values())
num_extra_rules = len(compiled_extra_regex) num_extra_rules = len(compiled_extra_regex)
num_bit_depth_rules = len(compiled_bit_depth_regex_map)
log.debug(f"Starting classification for {len(file_list)} files using {num_map_rules} map keyword patterns, {num_bit_depth_rules} bit depth patterns, and {num_extra_rules} extra patterns.") log.debug(f"Starting classification for {len(file_list)} files using {num_map_rules} map keyword patterns and {num_extra_rules} extra patterns.")
# --- Asset Name Extraction Helper --- # --- Asset Name Extraction Helper ---
def get_asset_name(f_path: Path, cfg: Configuration) -> str: def get_asset_name(f_path: Path, cfg: Configuration) -> str:
@@ -120,155 +121,179 @@ def classify_files(file_list: List[str], config: Configuration) -> Dict[str, Lis
log.warning(f"Asset name extraction resulted in empty string for '{filename}'. Using stem: '{asset_name}'.") log.warning(f"Asset name extraction resulted in empty string for '{filename}'. Using stem: '{asset_name}'.")
return asset_name return asset_name
# --- Pass 1: Prioritized Bit Depth Variants --- # --- Pass 1: Collect all potential matches for each file ---
log.debug("--- Starting Classification Pass 1: Prioritized Variants ---") # For each file, find all map_type_mapping rules it matches (both regular and priority keywords).
# Store the target_type, original rule_index, and whether it was a priority match.
log.debug("--- Starting Classification Pass 1: Collect Potential Matches ---")
file_matches: Dict[str, List[Tuple[str, int, bool]]] = defaultdict(list) # {file_path: [(target_type, rule_index, is_priority), ...]}
files_classified_as_extra: Set[str] = set() # Files already classified as EXTRA
compiled_map_regex = getattr(config, 'compiled_map_keyword_regex', {})
compiled_extra_regex = getattr(config, 'compiled_extra_regex', [])
for file_path_str in file_list: for file_path_str in file_list:
file_path = Path(file_path_str) file_path = Path(file_path_str)
filename = file_path.name filename = file_path.name
asset_name = get_asset_name(file_path, config) asset_name = get_asset_name(file_path, config)
processed = False
for target_type, variant_regex in compiled_bit_depth_regex_map.items(): if "BoucleChunky001" in file_path_str:
match = variant_regex.search(filename) log.info(f"DEBUG_ROO: Processing file: {file_path_str}")
if match:
log.debug(f"PASS 1: File '{filename}' matched PRIORITIZED bit depth variant for type '{target_type}'.")
matched_item_type = target_type
if (asset_name, matched_item_type) in primary_assignments: # Check for EXTRA files first
log.warning(f"PASS 1: Primary assignment ({asset_name}, {matched_item_type}) already exists. File '{filename}' will be handled in Pass 2.")
else:
primary_assignments.add((asset_name, matched_item_type))
log.debug(f" PASS 1: Added primary assignment: ({asset_name}, {matched_item_type})")
primary_asset_names.add(asset_name)
temp_grouped_files[asset_name].append({
'file_path': file_path_str,
'item_type': matched_item_type,
'asset_name': asset_name
})
processed_in_pass1.add(file_path_str)
processed = True
break # Stop checking other variant patterns for this file
log.debug(f"--- Finished Pass 1. Primary assignments made: {primary_assignments} ---")
# --- Pass 2: Extras, General Maps, Ignores ---
log.debug("--- Starting Classification Pass 2: Extras, General Maps, Ignores ---")
for file_path_str in file_list:
if file_path_str in processed_in_pass1:
log.debug(f"PASS 2: Skipping '{Path(file_path_str).name}' (processed in Pass 1).")
continue
file_path = Path(file_path_str)
filename = file_path.name
asset_name = get_asset_name(file_path, config)
is_extra = False is_extra = False
is_map = False
# 1. Check for Extra Files FIRST in Pass 2
for extra_pattern in compiled_extra_regex: for extra_pattern in compiled_extra_regex:
if extra_pattern.search(filename): if extra_pattern.search(filename):
log.debug(f"PASS 2: File '{filename}' matched EXTRA pattern: {extra_pattern.pattern}") if "BoucleChunky001_DISP_1K_METALNESS.png" in filename and extra_pattern.search(filename):
extra_files_to_associate.append((file_path_str, filename)) log.info(f"DEBUG_ROO: EXTRA MATCH: File '{filename}' matched EXTRA pattern: {extra_pattern.pattern}")
log.debug(f"PASS 1: File '{filename}' matched EXTRA pattern: {extra_pattern.pattern}")
# For EXTRA, we assign it directly and don't check map rules for this file
classified_files_info[asset_name].append({
'file_path': file_path_str,
'item_type': "EXTRA",
'asset_name': asset_name
})
files_classified_as_extra.add(file_path_str)
is_extra = True is_extra = True
break break
if is_extra: if "BoucleChunky001_DISP_1K_METALNESS.png" in filename and not is_extra: # after the extra loop
continue log.info(f"DEBUG_ROO: EXTRA CHECK FAILED for {filename}. is_extra: {is_extra}")
# 2. Check for General Map Files in Pass 2 if "BoucleChunky001_DISP_1K_METALNESS.png" in filename and not is_extra:
log.info(f"DEBUG_ROO: EXTRA CHECK FAILED for {filename}. is_extra: {is_extra}")
if is_extra:
continue # Move to the next file
# If not EXTRA, check for MAP matches (collect all potential matches)
for target_type, patterns_list in compiled_map_regex.items(): for target_type, patterns_list in compiled_map_regex.items():
for compiled_regex, original_keyword, rule_index in patterns_list: for compiled_regex, original_keyword, rule_index, is_priority in patterns_list:
match = compiled_regex.search(filename) match = compiled_regex.search(filename)
if match: if match:
try: if "BoucleChunky001" in file_path_str:
# map_type_mapping_list = config.map_type_mapping # Old gloss logic source log.info(f"DEBUG_ROO: PASS 1 MAP MATCH: File '{filename}' matched keyword '{original_keyword}' (priority: {is_priority}) for target type '{target_type}' (Rule Index: {rule_index}).")
# matched_rule_details = map_type_mapping_list[rule_index] # Old gloss logic source log.debug(f" PASS 1: File '{filename}' matched keyword '{original_keyword}' (priority: {is_priority}) for target type '{target_type}' (Rule Index: {rule_index}).")
# is_gloss_flag = matched_rule_details.get('is_gloss_source', False) # Old gloss logic file_matches[file_path_str].append((target_type, rule_index, is_priority))
log.debug(f" PASS 2: Match found! Rule Index: {rule_index}, Keyword: '{original_keyword}', Target: '{target_type}'") # Removed Gloss from log
except Exception as e:
log.exception(f" PASS 2: Error accessing rule details for index {rule_index}: {e}")
# *** Crucial Check: Has a prioritized variant claimed this type? *** log.debug(f"--- Finished Pass 1. Collected matches for {len(file_matches)} files. ---")
if (asset_name, target_type) in primary_assignments:
log.debug(f"PASS 2: File '{filename}' matched '{original_keyword}' for type '{target_type}', but primary already assigned via Pass 1. Classifying as EXTRA.") # --- Pass 2: Determine Trumped Regular Matches ---
matched_item_type = "EXTRA" # Identify which regular matches are trumped by a priority match for the same rule_index within the asset.
# is_gloss_flag = False # Old gloss logic log.debug("--- Starting Classification Pass 2: Determine Trumped Regular Matches ---")
trumped_regular_matches: Set[Tuple[str, int]] = set() # Set of (file_path_str, rule_index) pairs that are trumped
# First, determine which rule_indices have *any* priority match across the entire asset
rule_index_has_priority_match_in_asset: Set[int] = set()
for file_path_str, matches in file_matches.items():
for match_target, match_rule_index, match_is_priority in matches:
if match_is_priority:
rule_index_has_priority_match_in_asset.add(match_rule_index)
log.debug(f" Rule indices with priority matches in asset: {sorted(list(rule_index_has_priority_match_in_asset))}")
# Then, for each file, check its matches against the rules that had priority matches
for file_path_str in file_list:
if file_path_str in files_classified_as_extra:
continue
matches_for_this_file = file_matches.get(file_path_str, [])
# Determine if this file has any priority match for a given rule_index
file_has_priority_match_for_rule: Dict[int, bool] = defaultdict(bool)
for match_target, match_rule_index, match_is_priority in matches_for_this_file:
if match_is_priority:
file_has_priority_match_for_rule[match_rule_index] = True
# Determine if this file has any regular match for a given rule_index
file_has_regular_match_for_rule: Dict[int, bool] = defaultdict(bool)
for match_target, match_rule_index, match_is_priority in matches_for_this_file:
if not match_is_priority:
file_has_regular_match_for_rule[match_rule_index] = True
# Identify trumped regular matches for this file
for match_target, match_rule_index, match_is_priority in matches_for_this_file:
if not match_is_priority: # Only consider regular matches
if match_rule_index in rule_index_has_priority_match_in_asset:
# This regular match is for a rule_index that had a priority match somewhere in the asset
if not file_has_priority_match_for_rule[match_rule_index]:
# And this specific file did NOT have a priority match for this rule_index
trumped_regular_matches.add((file_path_str, match_rule_index))
log.debug(f" File '{Path(file_path_str).name}': Regular match for Rule Index {match_rule_index} is trumped.")
if "BoucleChunky001" in file_path_str:
log.info(f"DEBUG_ROO: TRUMPED: File '{Path(file_path_str).name}': Regular match for Rule Index {match_rule_index} (target {match_target}) is trumped.")
if "BoucleChunky001" in file_path_str: # Check if it was actually added by checking the set, or just log if the condition was met
if (file_path_str, match_rule_index) in trumped_regular_matches:
log.info(f"DEBUG_ROO: TRUMPED: File '{Path(file_path_str).name}': Regular match for Rule Index {match_rule_index} (target {match_target}) is trumped.")
log.debug(f"--- Finished Pass 2. Identified {len(trumped_regular_matches)} trumped regular matches. ---")
# --- Pass 3: Final Assignment & Inter-Entry Resolution ---
# Iterate through files, apply ignore rules, and then apply earliest rule wins for remaining valid matches.
log.debug("--- Starting Classification Pass 3: Final Assignment ---")
final_file_assignments: Dict[str, str] = {} # {file_path: final_item_type}
for file_path_str in file_list:
# Check if the file was already classified as EXTRA in Pass 1 and added to classified_files_info
if file_path_str in files_classified_as_extra:
log.debug(f" Final Assignment: Skipping '{Path(file_path_str).name}' as it was already classified as EXTRA in Pass 1.")
continue # Skip this file in Pass 3 as it's already handled
asset_name = get_asset_name(Path(file_path_str), config) # Need asset name for the final output structure
# Get valid matches for this file after considering intra-entry priority trumps regular
valid_matches = []
for match_target, match_rule_index, match_is_priority in file_matches.get(file_path_str, []):
if (file_path_str, match_rule_index) not in trumped_regular_matches:
valid_matches.append((match_target, match_rule_index, match_is_priority))
log.debug(f" File '{Path(file_path_str).name}': Valid match - Target: '{match_target}', Rule Index: {match_rule_index}, Priority: {match_is_priority}")
else: else:
log.debug(f"PASS 2: File '{filename}' matched '{original_keyword}' for item_type '{target_type}'.") log.debug(f" File '{Path(file_path_str).name}': Invalid match (trumped by priority) - Target: '{match_target}', Rule Index: {match_rule_index}, Priority: {match_is_priority}")
matched_item_type = target_type
temp_grouped_files[asset_name].append({ if "BoucleChunky001" in file_path_str:
log.info(f"DEBUG_ROO: PASS 3 PRE-ASSIGN: File '{Path(file_path_str).name}'. Valid matches: {valid_matches}")
if "BoucleChunky001" in file_path_str:
log.info(f"DEBUG_ROO: PASS 3 PRE-ASSIGN: File '{Path(file_path_str).name}'. Valid matches: {valid_matches}")
final_item_type = "FILE_IGNORE" # Default to ignore if no valid matches
if valid_matches:
# Apply earliest rule wins among valid matches
best_match = min(valid_matches, key=lambda x: x[1]) # Find match with lowest rule_index
final_item_type = best_match[0] # Assign the target_type of the best match
log.debug(f" File '{Path(file_path_str).name}': Best valid match -> Target: '{best_match[0]}', Rule Index: {best_match[1]}. Final type: '{final_item_type}'.")
else:
log.debug(f" File '{Path(file_path_str).name}'': No valid matches after filtering. Final type: '{final_item_type}'.")
if "BoucleChunky001" in file_path_str:
log.info(f"DEBUG_ROO: PASS 3 FINAL ASSIGN: File '{Path(file_path_str).name}' -> Final Type: '{final_item_type}'")
final_file_assignments[file_path_str] = final_item_type
if "BoucleChunky001" in file_path_str:
log.info(f"DEBUG_ROO: PASS 3 FINAL ASSIGN: File '{Path(file_path_str).name}' -> Final Type: '{final_item_type}'")
# Add the file info to the classified_files_info structure
log.info(f"DEBUG_ROO: PASS 3 APPEND: Appending file '{Path(file_path_str).name}' with type '{final_item_type}' to classified_files_info['{asset_name}']")
classified_files_info[asset_name].append({
'file_path': file_path_str, 'file_path': file_path_str,
'item_type': matched_item_type, 'item_type': final_item_type,
'asset_name': asset_name 'asset_name': asset_name
}) })
is_map = True log.debug(f" Final Grouping: '{Path(file_path_str).name}' -> '{final_item_type}' (Asset: '{asset_name}')")
break
if is_map:
break
# 3. Handle Unmatched Files in Pass 2 (Not Extra, Not Map)
if not is_extra and not is_map:
log.debug(f"PASS 2: File '{filename}' did not match any map/extra pattern. Grouping under asset '{asset_name}' as FILE_IGNORE.")
temp_grouped_files[asset_name].append({
'file_path': file_path_str,
'item_type': "FILE_IGNORE",
'asset_name': asset_name
})
log.debug("--- Finished Pass 2 ---")
# --- Determine Primary Asset Name for Extra Association (using Pass 1 results) ---
final_primary_asset_name = None
if primary_asset_names:
primary_map_asset_names_pass1 = [
f_info['asset_name']
for asset_files in temp_grouped_files.values()
for f_info in asset_files
if f_info['asset_name'] in primary_asset_names and (f_info['asset_name'], f_info['item_type']) in primary_assignments
]
if primary_map_asset_names_pass1:
name_counts = Counter(primary_map_asset_names_pass1)
most_common_names = name_counts.most_common()
final_primary_asset_name = most_common_names[0][0]
if len(most_common_names) > 1 and most_common_names[0][1] == most_common_names[1][1]:
tied_names = sorted([name for name, count in most_common_names if count == most_common_names[0][1]])
final_primary_asset_name = tied_names[0]
log.warning(f"Multiple primary asset names tied for most common based on Pass 1: {tied_names}. Using '{final_primary_asset_name}' for associating extra files.")
log.debug(f"Determined primary asset name for extras based on Pass 1 primary maps: '{final_primary_asset_name}'")
else:
log.warning("Primary asset names set (from Pass 1) was populated, but no corresponding groups found. Falling back.")
if not final_primary_asset_name:
if temp_grouped_files and extra_files_to_associate:
fallback_name = sorted(temp_grouped_files.keys())[0]
final_primary_asset_name = fallback_name
log.warning(f"No primary map files found in Pass 1. Associating extras with first group found alphabetically: '{final_primary_asset_name}'.")
elif extra_files_to_associate:
log.warning(f"Could not determine any asset name to associate {len(extra_files_to_associate)} extra file(s) with. They will be ignored.")
else:
log.debug("No primary asset name determined (no maps or extras found).")
# --- Associate Extra Files (collected in Pass 2) --- log.debug(f"Classification complete. Found {len(classified_files_info)} potential assets.")
if final_primary_asset_name and extra_files_to_associate: # Enhanced logging for the content of classified_files_info
log.debug(f"Associating {len(extra_files_to_associate)} extra file(s) with primary asset '{final_primary_asset_name}'") boucle_chunky_data = {
for file_path_str, filename in extra_files_to_associate: key: val for key, val in classified_files_info.items()
if not any(f['file_path'] == file_path_str for f in temp_grouped_files[final_primary_asset_name]): if 'BoucleChunky001' in key or any('BoucleChunky001' in (f_info.get('file_path','')) for f_info in val)
temp_grouped_files[final_primary_asset_name].append({ }
'file_path': file_path_str, import json # Make sure json is imported if not already at top of file
'item_type': "EXTRA", log.info(f"DEBUG_ROO: Final classified_files_info for BoucleChunky001 (content): \n{json.dumps(boucle_chunky_data, indent=2)}")
'asset_name': final_primary_asset_name return dict(classified_files_info)
})
else:
log.debug(f"Skipping duplicate association of extra file: {filename}")
elif extra_files_to_associate:
pass
log.debug(f"Classification complete. Found {len(temp_grouped_files)} potential assets.")
return dict(temp_grouped_files)
class RuleBasedPredictionHandler(BasePredictionHandler): class RuleBasedPredictionHandler(BasePredictionHandler):
@@ -367,7 +392,8 @@ class RuleBasedPredictionHandler(BasePredictionHandler):
source_rule = SourceRule( source_rule = SourceRule(
input_path=input_source_identifier, input_path=input_source_identifier,
supplier_identifier=supplier_identifier, supplier_identifier=supplier_identifier,
preset_name=preset_name # Use the internal display name from the config object
preset_name=config.internal_display_preset_name
) )
asset_rules = [] asset_rules = []
file_type_definitions = config._core_settings.get('FILE_TYPE_DEFINITIONS', {}) file_type_definitions = config._core_settings.get('FILE_TYPE_DEFINITIONS', {})
@@ -463,23 +489,22 @@ class RuleBasedPredictionHandler(BasePredictionHandler):
base_item_type = file_info['item_type'] base_item_type = file_info['item_type']
target_asset_name_override = file_info['asset_name'] target_asset_name_override = file_info['asset_name']
final_item_type = base_item_type final_item_type = base_item_type
if not base_item_type.startswith("MAP_") and base_item_type not in ["FILE_IGNORE", "EXTRA", "MODEL"]: # The classification logic now returns the final item_type directly,
final_item_type = f"MAP_{base_item_type}" # including "FILE_IGNORE" and correctly prioritized MAP_ types.
# No need for the old MAP_ prefixing logic here.
if file_type_definitions and final_item_type not in file_type_definitions and base_item_type not in ["FILE_IGNORE", "EXTRA"]: # Validate the final_item_type against definitions, unless it's EXTRA or FILE_IGNORE
log.warning(f"Predicted ItemType '{base_item_type}' (checked as '{final_item_type}') for file '{file_info['file_path']}' is not in FILE_TYPE_DEFINITIONS. Setting to FILE_IGNORE.") if final_item_type not in ["EXTRA", "FILE_IGNORE"] and file_type_definitions and final_item_type not in file_type_definitions:
log.warning(f"Predicted ItemType '{final_item_type}' for file '{file_info['file_path']}' is not in FILE_TYPE_DEFINITIONS. Setting to FILE_IGNORE.")
final_item_type = "FILE_IGNORE" final_item_type = "FILE_IGNORE"
# is_gloss_source_value = file_info.get('is_gloss_source', False) # Removed
file_rule = FileRule( file_rule = FileRule(
file_path=file_info['file_path'], file_path=file_info['file_path'],
item_type=final_item_type, item_type=final_item_type,
item_type_override=final_item_type, item_type_override=final_item_type, # item_type_override defaults to item_type
target_asset_name_override=target_asset_name_override, target_asset_name_override=target_asset_name_override,
output_format_override=None, output_format_override=None,
# is_gloss_source=is_gloss_source_value if isinstance(is_gloss_source_value, bool) else False, # Removed
resolution_override=None, resolution_override=None,
channel_merge_instructions={}, channel_merge_instructions={},
) )
@@ -489,6 +514,18 @@ class RuleBasedPredictionHandler(BasePredictionHandler):
source_rule.assets = asset_rules source_rule.assets = asset_rules
source_rules_list.append(source_rule) source_rules_list.append(source_rule)
# DEBUG: Log the structure of the source_rule being emitted
if source_rule and source_rule.assets:
for asset_r_idx, asset_r in enumerate(source_rule.assets):
log.info(f"DEBUG_ROO_EMIT: Source '{input_source_identifier}', Asset {asset_r_idx} ('{asset_r.asset_name}') has {len(asset_r.files)} FileRules.")
for fr_idx, fr in enumerate(asset_r.files):
log.info(f"DEBUG_ROO_EMIT: FR {fr_idx}: Path='{fr.file_path}', Type='{fr.item_type}', TargetAsset='{fr.target_asset_name_override}'")
elif source_rule:
log.info(f"DEBUG_ROO_EMIT: Emitting SourceRule for {input_source_identifier} but it has no assets.")
else:
log.info(f"DEBUG_ROO_EMIT: Attempting to emit for {input_source_identifier}, but source_rule object is None.")
# END DEBUG
except Exception as e: except Exception as e:
log.exception(f"Error building rule hierarchy for source '{input_source_identifier}': {e}") log.exception(f"Error building rule hierarchy for source '{input_source_identifier}': {e}")
raise RuntimeError(f"Error building rule hierarchy: {e}") from e raise RuntimeError(f"Error building rule hierarchy: {e}") from e

View File

@@ -20,7 +20,8 @@ script_dir = Path(__file__).parent
project_root = script_dir.parent project_root = script_dir.parent
PRESETS_DIR = project_root / "Presets" PRESETS_DIR = project_root / "Presets"
TEMPLATE_PATH = PRESETS_DIR / "_template.json" TEMPLATE_PATH = PRESETS_DIR / "_template.json"
APP_SETTINGS_PATH_LOCAL = project_root / "config" / "app_settings.json" APP_SETTINGS_PATH_LOCAL = project_root / "config" / "app_settings.json" # Retain for other settings if used elsewhere
FILE_TYPE_DEFINITIONS_PATH = project_root / "config" / "file_type_definitions.json"
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -35,8 +36,8 @@ class PresetEditorWidget(QWidget):
# Signal emitted when presets list changes (saved, deleted, new) # Signal emitted when presets list changes (saved, deleted, new)
presets_changed_signal = Signal() presets_changed_signal = Signal()
# Signal emitted when the selected preset (or LLM/Placeholder) changes # Signal emitted when the selected preset (or LLM/Placeholder) changes
# Emits: mode ("preset", "llm", "placeholder"), preset_name (str or None) # Emits: mode ("preset", "llm", "placeholder"), display_name (str or None), file_path (Path or None)
preset_selection_changed_signal = Signal(str, str) preset_selection_changed_signal = Signal(str, str, Path)
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
@@ -63,18 +64,19 @@ class PresetEditorWidget(QWidget):
"""Loads FILE_TYPE_DEFINITIONS keys from app_settings.json.""" """Loads FILE_TYPE_DEFINITIONS keys from app_settings.json."""
keys = [] keys = []
try: try:
if APP_SETTINGS_PATH_LOCAL.is_file(): if FILE_TYPE_DEFINITIONS_PATH.is_file():
with open(APP_SETTINGS_PATH_LOCAL, 'r', encoding='utf-8') as f: with open(FILE_TYPE_DEFINITIONS_PATH, 'r', encoding='utf-8') as f:
settings = json.load(f) settings = json.load(f)
# The FILE_TYPE_DEFINITIONS key is at the root of file_type_definitions.json
ftd = settings.get("FILE_TYPE_DEFINITIONS", {}) ftd = settings.get("FILE_TYPE_DEFINITIONS", {})
keys = list(ftd.keys()) keys = list(ftd.keys())
log.debug(f"Successfully loaded {len(keys)} FILE_TYPE_DEFINITIONS keys.") log.debug(f"Successfully loaded {len(keys)} FILE_TYPE_DEFINITIONS keys from {FILE_TYPE_DEFINITIONS_PATH}.")
else: else:
log.error(f"app_settings.json not found at {APP_SETTINGS_PATH_LOCAL} for PresetEditorWidget.") log.error(f"file_type_definitions.json not found at {FILE_TYPE_DEFINITIONS_PATH} for PresetEditorWidget.")
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
log.error(f"Failed to parse app_settings.json in PresetEditorWidget: {e}") log.error(f"Failed to parse file_type_definitions.json in PresetEditorWidget: {e}")
except Exception as e: except Exception as e:
log.error(f"Error loading FILE_TYPE_DEFINITIONS keys in PresetEditorWidget: {e}") log.error(f"Error loading FILE_TYPE_DEFINITIONS keys from {FILE_TYPE_DEFINITIONS_PATH} in PresetEditorWidget: {e}")
return keys return keys
def _init_ui(self): def _init_ui(self):
@@ -294,8 +296,22 @@ class PresetEditorWidget(QWidget):
log.warning(msg) log.warning(msg)
else: else:
for preset_path in presets: for preset_path in presets:
item = QListWidgetItem(preset_path.stem) preset_display_name = preset_path.stem # Fallback
item.setData(Qt.ItemDataRole.UserRole, preset_path) try:
with open(preset_path, 'r', encoding='utf-8') as f:
preset_content = json.load(f)
internal_name = preset_content.get("preset_name")
if internal_name and isinstance(internal_name, str) and internal_name.strip():
preset_display_name = internal_name.strip()
else:
log.warning(f"Preset file {preset_path.name} is missing 'preset_name' or it's empty. Using filename stem '{preset_path.stem}' as display name.")
except json.JSONDecodeError:
log.error(f"Failed to parse JSON from {preset_path.name}. Using filename stem '{preset_path.stem}' as display name.")
except Exception as e:
log.error(f"Error reading {preset_path.name}: {e}. Using filename stem '{preset_path.stem}' as display name.")
item = QListWidgetItem(preset_display_name)
item.setData(Qt.ItemDataRole.UserRole, preset_path) # Store the path for loading
self.editor_preset_list.addItem(item) self.editor_preset_list.addItem(item)
log.info(f"Loaded {len(presets)} presets into editor list.") log.info(f"Loaded {len(presets)} presets into editor list.")
@@ -523,7 +539,8 @@ class PresetEditorWidget(QWidget):
log.debug(f"PresetEditor: currentItemChanged signal triggered. current: {current_item.text() if current_item else 'None'}") log.debug(f"PresetEditor: currentItemChanged signal triggered. current: {current_item.text() if current_item else 'None'}")
mode = "placeholder" mode = "placeholder"
preset_name = None display_name_to_emit = None # Changed from preset_name
file_path_to_emit = None # New variable for Path
# Check for unsaved changes before proceeding # Check for unsaved changes before proceeding
if self.check_unsaved_changes(): if self.check_unsaved_changes():
@@ -538,41 +555,53 @@ class PresetEditorWidget(QWidget):
# Determine mode and preset name based on selection # Determine mode and preset name based on selection
if current_item: if current_item:
item_data = current_item.data(Qt.ItemDataRole.UserRole) item_data = current_item.data(Qt.ItemDataRole.UserRole)
current_display_text = current_item.text() # This is the internal name from populate_presets
if item_data == "__PLACEHOLDER__": if item_data == "__PLACEHOLDER__":
log.debug("Placeholder item selected.") log.debug("Placeholder item selected.")
self._clear_editor() self._clear_editor()
self._set_editor_enabled(False) self._set_editor_enabled(False)
mode = "placeholder" mode = "placeholder"
display_name_to_emit = None
file_path_to_emit = None
self._last_valid_preset_name = None # Clear last valid name self._last_valid_preset_name = None # Clear last valid name
elif item_data == "__LLM__": elif item_data == "__LLM__":
log.debug("LLM Interpretation item selected.") log.debug("LLM Interpretation item selected.")
self._clear_editor() self._clear_editor()
self._set_editor_enabled(False) self._set_editor_enabled(False)
mode = "llm" mode = "llm"
# Keep _last_valid_preset_name as it was display_name_to_emit = None # LLM mode has no specific preset display name
elif isinstance(item_data, Path): file_path_to_emit = None
log.debug(f"Loading preset for editing: {current_item.text()}") # Keep _last_valid_preset_name as it was (it should be the display name)
preset_path = item_data elif isinstance(item_data, Path): # item_data is the Path object for a preset
self._load_preset_for_editing(preset_path) log.debug(f"Loading preset for editing: {current_display_text}")
self._last_valid_preset_name = preset_path.stem preset_file_path_obj = item_data
self._load_preset_for_editing(preset_file_path_obj)
# _last_valid_preset_name should store the display name for delegate use
self._last_valid_preset_name = current_display_text
mode = "preset" mode = "preset"
preset_name = self._last_valid_preset_name display_name_to_emit = current_display_text
else: file_path_to_emit = preset_file_path_obj
else: # Should not happen if list is populated correctly
log.error(f"Invalid data type for preset path: {type(item_data)}. Clearing editor.") log.error(f"Invalid data type for preset path: {type(item_data)}. Clearing editor.")
self._clear_editor() self._clear_editor()
self._set_editor_enabled(False) self._set_editor_enabled(False)
mode = "placeholder" # Treat as placeholder on error mode = "placeholder"
display_name_to_emit = None
file_path_to_emit = None
self._last_valid_preset_name = None self._last_valid_preset_name = None
else: else: # No current_item (e.g., list cleared)
log.debug("No preset selected. Clearing editor.") log.debug("No preset selected. Clearing editor.")
self._clear_editor() self._clear_editor()
self._set_editor_enabled(False) self._set_editor_enabled(False)
mode = "placeholder" mode = "placeholder"
display_name_to_emit = None
file_path_to_emit = None
self._last_valid_preset_name = None self._last_valid_preset_name = None
# Emit the signal regardless of what was selected # Emit the signal with all three arguments
log.debug(f"Emitting preset_selection_changed_signal: mode='{mode}', preset_name='{preset_name}'") log.debug(f"Emitting preset_selection_changed_signal: mode='{mode}', display_name='{display_name_to_emit}', file_path='{file_path_to_emit}'")
self.preset_selection_changed_signal.emit(mode, preset_name) self.preset_selection_changed_signal.emit(mode, display_name_to_emit, file_path_to_emit)
def _gather_editor_data(self) -> dict: def _gather_editor_data(self) -> dict:
"""Gathers data from all editor UI widgets and returns a dictionary.""" """Gathers data from all editor UI widgets and returns a dictionary."""
@@ -755,22 +784,25 @@ class PresetEditorWidget(QWidget):
# --- Public Access Methods for MainWindow --- # --- Public Access Methods for MainWindow ---
def get_selected_preset_mode(self) -> tuple[str, str | None]: def get_selected_preset_mode(self) -> tuple[str, str | None, Path | None]:
""" """
Returns the current selection mode and preset name (if applicable). Returns the current selection mode, display name, and file path for loading.
Returns: tuple(mode_string, preset_name_string_or_None) Returns: tuple(mode_string, display_name_string_or_None, file_path_or_None)
mode_string can be "preset", "llm", "placeholder" mode_string can be "preset", "llm", "placeholder"
""" """
current_item = self.editor_preset_list.currentItem() current_item = self.editor_preset_list.currentItem()
if current_item: if current_item:
item_data = current_item.data(Qt.ItemDataRole.UserRole) item_data = current_item.data(Qt.ItemDataRole.UserRole)
display_text = current_item.text() # This is now the internal name
if item_data == "__PLACEHOLDER__": if item_data == "__PLACEHOLDER__":
return "placeholder", None return "placeholder", None, None
elif item_data == "__LLM__": elif item_data == "__LLM__":
return "llm", None return "llm", None, None # LLM mode doesn't have a specific preset file path
elif isinstance(item_data, Path): elif isinstance(item_data, Path):
return "preset", item_data.stem # For a preset, display_text is the internal name, item_data is the Path
return "placeholder", None # Default or if no item selected return "preset", display_text, item_data # Return internal name and path
return "placeholder", None, None # Default or if no item selected
def get_last_valid_preset_name(self) -> str | None: def get_last_valid_preset_name(self) -> str | None:
""" """

View File

@@ -552,6 +552,13 @@ class UnifiedViewModel(QAbstractItemModel):
supplier_col_index = self.createIndex(existing_source_row, self.COL_SUPPLIER, existing_source_rule) supplier_col_index = self.createIndex(existing_source_row, self.COL_SUPPLIER, existing_source_rule)
self.dataChanged.emit(supplier_col_index, supplier_col_index, [Qt.DisplayRole, Qt.EditRole]) self.dataChanged.emit(supplier_col_index, supplier_col_index, [Qt.DisplayRole, Qt.EditRole])
# Always update the preset_name from the new_source_rule, as this reflects the latest prediction context
if existing_source_rule.preset_name != new_source_rule.preset_name:
log.debug(f" Updating preset_name for SourceRule '{source_path}' from '{existing_source_rule.preset_name}' to '{new_source_rule.preset_name}'")
existing_source_rule.preset_name = new_source_rule.preset_name
# Note: preset_name is not directly displayed in the view, so no dataChanged needed for a specific column,
# but if it influenced other display elements, dataChanged would be emitted for those.
# --- Merge AssetRules --- # --- Merge AssetRules ---
existing_assets_dict = {asset.asset_name: asset for asset in existing_source_rule.assets} existing_assets_dict = {asset.asset_name: asset for asset in existing_source_rule.assets}

View File

@@ -4,6 +4,7 @@ import time
import os import os
import logging import logging
from pathlib import Path from pathlib import Path
import re # Added for checking incrementing token
from concurrent.futures import ProcessPoolExecutor, as_completed from concurrent.futures import ProcessPoolExecutor, as_completed
import subprocess import subprocess
import shutil import shutil
@@ -238,9 +239,14 @@ class ProcessingTask(QRunnable):
# output_dir should already be a Path object # output_dir should already be a Path object
pattern = getattr(config, 'output_directory_pattern', None) pattern = getattr(config, 'output_directory_pattern', None)
if pattern: if pattern:
log.debug(f"Calculating next incrementing value for dir: {output_dir} using pattern: {pattern}") # Only call get_next_incrementing_value if the pattern contains an incrementing token
if re.search(r"\[IncrementingValue\]|#+", pattern):
log.debug(f"Incrementing token found in pattern '{pattern}'. Calculating next value for dir: {output_dir}")
next_increment_str = get_next_incrementing_value(output_dir, pattern) next_increment_str = get_next_incrementing_value(output_dir, pattern)
log.info(f"Calculated next incrementing value for {output_dir}: {next_increment_str}") log.info(f"Calculated next incrementing value for {output_dir}: {next_increment_str}")
else:
log.debug(f"No incrementing token found in pattern '{pattern}'. Skipping increment calculation.")
next_increment_str = None # Or a default like "00" if downstream expects a string, but None is cleaner if handled.
else: else:
log.warning(f"Cannot calculate incrementing value: 'output_directory_pattern' not found in configuration for preset {config.preset_name}") log.warning(f"Cannot calculate incrementing value: 'output_directory_pattern' not found in configuration for preset {config.preset_name}")
except Exception as e: except Exception as e:

View File

@@ -195,17 +195,25 @@ def _process_archive_task(archive_path: Path, output_dir: Path, processed_dir: P
# Assuming config object has 'output_directory_pattern' attribute/key # Assuming config object has 'output_directory_pattern' attribute/key
pattern = getattr(config, 'output_directory_pattern', None) # Use getattr for safety pattern = getattr(config, 'output_directory_pattern', None) # Use getattr for safety
if pattern: if pattern:
log.debug(f"[Task:{archive_path.name}] Calculating next incrementing value for dir: {output_dir} using pattern: {pattern}") if re.search(r"\[IncrementingValue\]|#+", pattern):
log.debug(f"[Task:{archive_path.name}] Incrementing token found in pattern '{pattern}'. Calculating next value for dir: {output_dir}")
next_increment_str = get_next_incrementing_value(output_dir, pattern) next_increment_str = get_next_incrementing_value(output_dir, pattern)
log.info(f"[Task:{archive_path.name}] Calculated next incrementing value: {next_increment_str}") log.info(f"[Task:{archive_path.name}] Calculated next incrementing value: {next_increment_str}")
else:
log.debug(f"[Task:{archive_path.name}] No incrementing token found in pattern '{pattern}'. Skipping increment calculation.")
next_increment_str = None
else: else:
# Check if config is a dict as fallback (depends on load_config implementation) # Check if config is a dict as fallback (depends on load_config implementation)
if isinstance(config, dict): if isinstance(config, dict):
pattern = config.get('output_directory_pattern') pattern = config.get('output_directory_pattern')
if pattern: if pattern:
log.debug(f"[Task:{archive_path.name}] Calculating next incrementing value for dir: {output_dir} using pattern (from dict): {pattern}") if re.search(r"\[IncrementingValue\]|#+", pattern):
log.debug(f"[Task:{archive_path.name}] Incrementing token found in pattern '{pattern}' (from dict). Calculating next value for dir: {output_dir}")
next_increment_str = get_next_incrementing_value(output_dir, pattern) next_increment_str = get_next_incrementing_value(output_dir, pattern)
log.info(f"[Task:{archive_path.name}] Calculated next incrementing value (from dict): {next_increment_str}") log.info(f"[Task:{archive_path.name}] Calculated next incrementing value (from dict): {next_increment_str}")
else:
log.debug(f"[Task:{archive_path.name}] No incrementing token found in pattern '{pattern}' (from dict). Skipping increment calculation.")
next_increment_str = None
else: else:
log.warning(f"[Task:{archive_path.name}] Cannot calculate incrementing value: 'output_directory_pattern' not found in configuration dictionary.") log.warning(f"[Task:{archive_path.name}] Cannot calculate incrementing value: 'output_directory_pattern' not found in configuration dictionary.")
else: else:

View File

@@ -1,3 +1,4 @@
import dataclasses # Added import
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional from typing import Dict, List, Optional
@@ -27,6 +28,7 @@ class ProcessedRegularMapData:
original_bit_depth: Optional[int] original_bit_depth: Optional[int]
original_dimensions: Optional[Tuple[int, int]] # (width, height) original_dimensions: Optional[Tuple[int, int]] # (width, height)
transformations_applied: List[str] transformations_applied: List[str]
resolution_key: Optional[str] = None # Added field
status: str = "Processed" status: str = "Processed"
error_message: Optional[str] = None error_message: Optional[str] = None
@@ -45,9 +47,10 @@ class ProcessedMergedMapData:
@dataclass @dataclass
class InitialScalingInput: class InitialScalingInput:
image_data: np.ndarray image_data: np.ndarray
initial_scaling_mode: str # Moved before fields with defaults
original_dimensions: Optional[Tuple[int, int]] # (width, height) original_dimensions: Optional[Tuple[int, int]] # (width, height)
resolution_key: Optional[str] = None # Added field
# Configuration needed # Configuration needed
initial_scaling_mode: str
# Output for InitialScalingStage # Output for InitialScalingStage
@dataclass @dataclass
@@ -55,6 +58,7 @@ class InitialScalingOutput:
scaled_image_data: np.ndarray scaled_image_data: np.ndarray
scaling_applied: bool scaling_applied: bool
final_dimensions: Tuple[int, int] # (width, height) final_dimensions: Tuple[int, int] # (width, height)
resolution_key: Optional[str] = None # Added field
# Input for SaveVariantsStage # Input for SaveVariantsStage
@dataclass @dataclass

View File

@@ -8,7 +8,7 @@ from typing import List, Dict, Optional, Any, Union # Added Any, Union
import numpy as np # Added numpy import numpy as np # Added numpy
from configuration import Configuration from configuration import Configuration
from rule_structure import SourceRule, AssetRule, FileRule # Added FileRule from rule_structure import SourceRule, AssetRule, FileRule, ProcessingItem # Added ProcessingItem
# Import new context classes and stages # Import new context classes and stages
from .asset_context import ( from .asset_context import (
@@ -200,103 +200,94 @@ class PipelineOrchestrator:
current_image_data: Optional[np.ndarray] = None # Track current image data ref current_image_data: Optional[np.ndarray] = None # Track current image data ref
try: try:
# 1. Process (Load/Merge + Transform) # The 'item' is now expected to be a ProcessingItem or MergeTaskDefinition
if isinstance(item, FileRule):
if item.item_type == 'EXTRA':
log.debug(f"{item_log_prefix}: Skipping image processing for EXTRA FileRule '{item.file_path}'.")
# Add a basic entry to processed_maps_details to acknowledge it was seen
context.processed_maps_details[item.file_path] = {
"status": "Skipped (EXTRA file)",
"internal_map_type": "EXTRA",
"source_file": str(item.file_path)
}
continue # Skip to the next item
item_key = item.file_path # Use file_path string as key
log.debug(f"{item_log_prefix}: Processing FileRule '{item.file_path}'...")
processed_data = self._regular_processor_stage.execute(context, item)
elif isinstance(item, MergeTaskDefinition):
item_key = item.task_key # Use task_key string as key
log.info(f"{item_log_prefix}: Executing MergedTaskProcessorStage for MergeTask '{item_key}'...") # Log call
processed_data = self._merged_processor_stage.execute(context, item)
# Log status/error from merge processor
if processed_data:
log.info(f"{item_log_prefix}: MergedTaskProcessorStage result - Status: {processed_data.status}, Error: {processed_data.error_message}")
else:
log.warning(f"{item_log_prefix}: MergedTaskProcessorStage returned None for MergeTask '{item_key}'.")
else:
log.warning(f"{item_log_prefix}: Unknown item type '{type(item)}'. Skipping.")
item_key = f"unknown_item_{item_index}"
context.processed_maps_details[item_key] = {"status": "Skipped", "notes": f"Unknown item type {type(item)}"}
asset_had_item_errors = True
continue # Next item
# Check for processing failure if isinstance(item, ProcessingItem):
if not processed_data or processed_data.status != "Processed": item_key = f"{item.source_file_info_ref}_{item.map_type_identifier}_{item.resolution_key}"
error_msg = processed_data.error_message if processed_data else "Processor returned None" item_log_prefix = f"Asset '{asset_name}', ProcItem '{item_key}'"
log.error(f"{item_log_prefix}: Failed during processing stage. Error: {error_msg}") log.info(f"{item_log_prefix}: Starting processing.")
context.processed_maps_details[item_key] = {"status": "Failed", "notes": f"Processing Error: {error_msg}", "stage": processed_data.__class__.__name__ if processed_data else "UnknownProcessor"}
asset_had_item_errors = True
continue # Next item
# Store intermediate result & get current image data # Data for ProcessingItem is already loaded by PrepareProcessingItemsStage
context.intermediate_results[item_key] = processed_data current_image_data = item.image_data
current_image_data = processed_data.processed_image_data if isinstance(processed_data, ProcessedRegularMapData) else processed_data.merged_image_data current_dimensions = item.current_dimensions
current_dimensions = processed_data.original_dimensions if isinstance(processed_data, ProcessedRegularMapData) else processed_data.final_dimensions item_resolution_key = item.resolution_key
# Transformations (like gloss to rough, normal invert) are assumed to be applied
# by RegularMapProcessorStage if it's still used, or directly in PrepareProcessingItemsStage
# before creating the ProcessingItem, or a new dedicated transformation stage.
# For now, assume item.image_data is ready for scaling/saving.
# Store initial ProcessingItem data as "processed_data" for consistency if RegularMapProcessor is bypassed
# This is a simplification; a dedicated transformation stage would be cleaner.
# For now, we assume transformations happened before or within PrepareProcessingItemsStage.
# The 'processed_data' variable here is more of a placeholder for what would feed into scaling.
# Create a simple ProcessedRegularMapData-like structure for logging/details if needed,
# or adapt the final_details population later.
# For now, we'll directly use 'item' fields.
# 2. Scale (Optional) # 2. Scale (Optional)
scaling_mode = getattr(context.config_obj, "INITIAL_SCALING_MODE", "NONE") scaling_mode = getattr(context.config_obj, "INITIAL_SCALING_MODE", "NONE")
if scaling_mode != "NONE" and current_image_data is not None and current_image_data.size > 0: # Pass the item's resolution_key to InitialScalingInput
if isinstance(item, MergeTaskDefinition): # Log scaling call for merge tasks
log.info(f"{item_log_prefix}: Calling InitialScalingStage for MergeTask '{item_key}' (Mode: {scaling_mode})...")
log.debug(f"{item_log_prefix}: Applying initial scaling (Mode: {scaling_mode})...")
scale_input = InitialScalingInput( scale_input = InitialScalingInput(
image_data=current_image_data, image_data=current_image_data,
original_dimensions=current_dimensions, # Pass original/merged dims original_dimensions=current_dimensions,
initial_scaling_mode=scaling_mode initial_scaling_mode=scaling_mode,
resolution_key=item_resolution_key # Pass the key
) )
# Add _source_file_path for logging within InitialScalingStage if available
setattr(scale_input, '_source_file_path', item.source_file_info_ref)
log.debug(f"{item_log_prefix}: Calling InitialScalingStage. Input res_key: {scale_input.resolution_key}")
scaled_data_output = self._scaling_stage.execute(scale_input) scaled_data_output = self._scaling_stage.execute(scale_input)
# Update intermediate result and current image data reference current_image_data = scaled_data_output.scaled_image_data
context.intermediate_results[item_key] = scaled_data_output # Overwrite previous intermediate current_dimensions = scaled_data_output.final_dimensions # Dimensions after scaling
current_image_data = scaled_data_output.scaled_image_data # Use scaled data for saving # The resolution_key from item is passed through by InitialScalingOutput
log.debug(f"{item_log_prefix}: Scaling applied: {scaled_data_output.scaling_applied}. New Dims: {scaled_data_output.final_dimensions}") output_resolution_key = scaled_data_output.resolution_key
else: log.debug(f"{item_log_prefix}: InitialScalingStage output. Scaled: {scaled_data_output.scaling_applied}, New Dims: {current_dimensions}, Output ResKey: {output_resolution_key}")
log.debug(f"{item_log_prefix}: Initial scaling skipped (Mode: NONE or empty image).") context.intermediate_results[item_key] = scaled_data_output
# Create dummy output if scaling skipped, using current dims
final_dims = current_dimensions if current_dimensions else (current_image_data.shape[1], current_image_data.shape[0]) if current_image_data is not None else (0,0)
scaled_data_output = InitialScalingOutput(scaled_image_data=current_image_data, scaling_applied=False, final_dimensions=final_dims)
# 3. Save Variants # 3. Save Variants
if current_image_data is None or current_image_data.size == 0: if current_image_data is None or current_image_data.size == 0:
log.warning(f"{item_log_prefix}: Skipping save stage because image data is empty.") log.warning(f"{item_log_prefix}: Skipping save stage because image data is empty.")
context.processed_maps_details[item_key] = {"status": "Skipped", "notes": "No image data to save", "stage": "SaveVariantsStage"} context.processed_maps_details[item_key] = {"status": "Skipped", "notes": "No image data to save", "stage": "SaveVariantsStage"}
# Don't mark as asset error, just skip this item's saving continue
continue # Next item
if isinstance(item, MergeTaskDefinition): # Log save call for merge tasks log.debug(f"{item_log_prefix}: Preparing to save variant with resolution key '{output_resolution_key}'...")
log.info(f"{item_log_prefix}: Calling SaveVariantsStage for MergeTask '{item_key}'...")
log.debug(f"{item_log_prefix}: Saving variants...")
# Prepare input for save stage
internal_map_type = processed_data.final_internal_map_type if isinstance(processed_data, ProcessedRegularMapData) else processed_data.output_map_type
source_bit_depth = [processed_data.original_bit_depth] if isinstance(processed_data, ProcessedRegularMapData) and processed_data.original_bit_depth is not None else processed_data.source_bit_depths if isinstance(processed_data, ProcessedMergedMapData) else [8] # Default bit depth if unknown
# Construct filename tokens (ensure temp dir is used)
output_filename_tokens = { output_filename_tokens = {
'asset_name': asset_name, 'asset_name': asset_name,
'output_base_directory': context.engine_temp_dir, # Save variants to temp dir 'output_base_directory': context.engine_temp_dir,
# Add other tokens from context/config as needed by the pattern
'supplier': context.effective_supplier or 'UnknownSupplier', 'supplier': context.effective_supplier or 'UnknownSupplier',
'resolution': output_resolution_key # Use the key from the item/scaling stage
} }
# Log the value being read for the threshold before creating the input object # Determine image_resolutions argument for save_image_variants
log.info(f"ORCHESTRATOR_DEBUG: Reading RESOLUTION_THRESHOLD_FOR_JPG from config for SaveVariantsInput: {getattr(context.config_obj, 'RESOLUTION_THRESHOLD_FOR_JPG', None)}") save_specific_resolutions = {}
if output_resolution_key == "LOWRES":
# For LOWRES, the "resolution value" is its actual dimension.
# image_saving_utils needs a dict like {"LOWRES": 64} if current_dim is 64x64
# Assuming current_dimensions[0] is width.
save_specific_resolutions = {"LOWRES": current_dimensions[0] if current_dimensions else 0}
log.debug(f"{item_log_prefix}: Preparing to save LOWRES variant. Dimensions: {current_dimensions}. Save resolutions arg: {save_specific_resolutions}")
elif output_resolution_key in context.config_obj.image_resolutions:
save_specific_resolutions = {output_resolution_key: context.config_obj.image_resolutions[output_resolution_key]}
else:
log.warning(f"{item_log_prefix}: Resolution key '{output_resolution_key}' not found in config.image_resolutions and not LOWRES. Saving might fail or use full res.")
# Fallback: pass all configured resolutions, image_saving_utils will try to match by size.
# This might not be ideal if the key is truly unknown.
# Or, more strictly, fail here if key is unknown and not LOWRES.
# For now, let image_saving_utils handle it by passing all.
save_specific_resolutions = context.config_obj.image_resolutions
save_input = SaveVariantsInput( save_input = SaveVariantsInput(
image_data=current_image_data, # Use potentially scaled data image_data=current_image_data,
internal_map_type=internal_map_type, internal_map_type=item.map_type_identifier,
source_bit_depth_info=source_bit_depth, source_bit_depth_info=[item.bit_depth] if item.bit_depth is not None else [8], # Default to 8 if not set
output_filename_pattern_tokens=output_filename_tokens, output_filename_pattern_tokens=output_filename_tokens,
# Pass config values needed by save stage image_resolutions=save_specific_resolutions, # Pass the specific resolution(s)
image_resolutions=context.config_obj.image_resolutions,
file_type_defs=getattr(context.config_obj, "FILE_TYPE_DEFINITIONS", {}), file_type_defs=getattr(context.config_obj, "FILE_TYPE_DEFINITIONS", {}),
output_format_8bit=context.config_obj.get_8bit_output_format(), output_format_8bit=context.config_obj.get_8bit_output_format(),
output_format_16bit_primary=context.config_obj.get_16bit_output_formats()[0], output_format_16bit_primary=context.config_obj.get_16bit_output_formats()[0],
@@ -304,41 +295,129 @@ class PipelineOrchestrator:
png_compression_level=context.config_obj.png_compression_level, png_compression_level=context.config_obj.png_compression_level,
jpg_quality=context.config_obj.jpg_quality, jpg_quality=context.config_obj.jpg_quality,
output_filename_pattern=context.config_obj.output_filename_pattern, output_filename_pattern=context.config_obj.output_filename_pattern,
resolution_threshold_for_jpg=getattr(context.config_obj, "resolution_threshold_for_jpg", None) # Corrected case resolution_threshold_for_jpg=getattr(context.config_obj, "resolution_threshold_for_jpg", None)
) )
saved_data = self._save_stage.execute(save_input) saved_data = self._save_stage.execute(save_input)
# Log saved_data for merge tasks
if isinstance(item, MergeTaskDefinition):
log.info(f"{item_log_prefix}: SaveVariantsStage result for MergeTask '{item_key}' - Status: {saved_data.status if saved_data else 'N/A'}, Saved Files: {len(saved_data.saved_files_details) if saved_data else 0}")
# Check save status and finalize item result
if saved_data and saved_data.status.startswith("Processed"): if saved_data and saved_data.status.startswith("Processed"):
item_status = saved_data.status # e.g., "Processed" or "Processed (No Output)" item_status = saved_data.status
log.info(f"{item_log_prefix}: Item successfully processed and saved. Status: {item_status}") log.info(f"{item_log_prefix}: Item successfully processed and saved. Status: {item_status}")
# Populate final details for this item context.processed_maps_details[item_key] = {
final_details = {
"status": item_status, "status": item_status,
"saved_files_info": saved_data.saved_files_details, # List of dicts from save util "saved_files_info": saved_data.saved_files_details,
"internal_map_type": internal_map_type, "internal_map_type": item.map_type_identifier,
"original_dimensions": processed_data.original_dimensions if isinstance(processed_data, ProcessedRegularMapData) else None, "resolution_key": output_resolution_key,
"final_dimensions": scaled_data_output.final_dimensions if scaled_data_output else current_dimensions, "original_dimensions": item.original_dimensions,
"transformations": processed_data.transformations_applied if isinstance(processed_data, ProcessedRegularMapData) else processed_data.transformations_applied_to_inputs, "final_dimensions": current_dimensions, # Dimensions after scaling
# Add source file if regular map "source_file": item.source_file_info_ref,
"source_file": str(processed_data.source_file_path) if isinstance(processed_data, ProcessedRegularMapData) else None,
} }
# Log final details addition for merge tasks
if isinstance(item, MergeTaskDefinition):
log.info(f"{item_log_prefix}: Adding final details to context.processed_maps_details for MergeTask '{item_key}'. Details: {final_details}")
context.processed_maps_details[item_key] = final_details
else: else:
error_msg = saved_data.error_message if saved_data else "Save stage returned None" error_msg = saved_data.error_message if saved_data else "Save stage returned None"
log.error(f"{item_log_prefix}: Failed during save stage. Error: {error_msg}") log.error(f"{item_log_prefix}: Failed during save stage. Error: {error_msg}")
context.processed_maps_details[item_key] = {"status": "Failed", "notes": f"Save Error: {error_msg}", "stage": "SaveVariantsStage"} context.processed_maps_details[item_key] = {"status": "Failed", "notes": f"Save Error: {error_msg}", "stage": "SaveVariantsStage"}
asset_had_item_errors = True asset_had_item_errors = True
item_status = "Failed" # Ensure item status reflects failure item_status = "Failed"
elif isinstance(item, MergeTaskDefinition):
# --- This part needs similar refactoring for resolution_key if merged outputs can be LOWRES ---
# --- For now, assume merged tasks always produce standard resolutions ---
item_key = item.task_key
item_log_prefix = f"Asset '{asset_name}', MergeTask '{item_key}'"
log.info(f"{item_log_prefix}: Processing MergeTask.")
# 1. Process Merge Task
processed_data = self._merged_processor_stage.execute(context, item)
if not processed_data or processed_data.status != "Processed":
error_msg = processed_data.error_message if processed_data else "Merge processor returned None"
log.error(f"{item_log_prefix}: Failed during merge processing. Error: {error_msg}")
context.processed_maps_details[item_key] = {"status": "Failed", "notes": f"Merge Error: {error_msg}", "stage": "MergedTaskProcessorStage"}
asset_had_item_errors = True
continue
context.intermediate_results[item_key] = processed_data
current_image_data = processed_data.merged_image_data
current_dimensions = processed_data.final_dimensions
# 2. Scale Merged Output (Optional)
# Merged tasks typically don't have a single "resolution_key" like LOWRES from source.
# They produce an image that then gets downscaled to 1K, PREVIEW etc.
# So, resolution_key for InitialScalingInput here would be None or a default.
scaling_mode = getattr(context.config_obj, "INITIAL_SCALING_MODE", "NONE")
scale_input = InitialScalingInput(
image_data=current_image_data,
original_dimensions=current_dimensions,
initial_scaling_mode=scaling_mode,
resolution_key=None # Merged outputs are not "LOWRES" themselves before this scaling
)
setattr(scale_input, '_source_file_path', f"MergeTask_{item_key}") # For logging
log.debug(f"{item_log_prefix}: Calling InitialScalingStage for merged data.")
scaled_data_output = self._scaling_stage.execute(scale_input)
current_image_data = scaled_data_output.scaled_image_data
current_dimensions = scaled_data_output.final_dimensions
# Merged items don't have a specific output_resolution_key from source,
# they will be saved to all applicable resolutions from config.
# So scaled_data_output.resolution_key will be None here.
context.intermediate_results[item_key] = scaled_data_output
# 3. Save Merged Variants
if current_image_data is None or current_image_data.size == 0:
log.warning(f"{item_log_prefix}: Skipping save for merged task, image data is empty.")
context.processed_maps_details[item_key] = {"status": "Skipped", "notes": "No merged image data to save", "stage": "SaveVariantsStage"}
continue
output_filename_tokens = {
'asset_name': asset_name,
'output_base_directory': context.engine_temp_dir,
'supplier': context.effective_supplier or 'UnknownSupplier',
# 'resolution' token will be filled by image_saving_utils for each variant
}
# For merged tasks, we usually want to generate all standard resolutions.
# The `resolution_key` from the item itself is not applicable here for the `resolution` token.
# The `image_saving_utils.save_image_variants` will iterate through `context.config_obj.image_resolutions`.
save_input = SaveVariantsInput(
image_data=current_image_data,
internal_map_type=processed_data.output_map_type,
source_bit_depth_info=processed_data.source_bit_depths,
output_filename_pattern_tokens=output_filename_tokens,
image_resolutions=context.config_obj.image_resolutions, # Pass all configured resolutions
file_type_defs=getattr(context.config_obj, "FILE_TYPE_DEFINITIONS", {}),
output_format_8bit=context.config_obj.get_8bit_output_format(),
output_format_16bit_primary=context.config_obj.get_16bit_output_formats()[0],
output_format_16bit_fallback=context.config_obj.get_16bit_output_formats()[1],
png_compression_level=context.config_obj.png_compression_level,
jpg_quality=context.config_obj.jpg_quality,
output_filename_pattern=context.config_obj.output_filename_pattern,
resolution_threshold_for_jpg=getattr(context.config_obj, "resolution_threshold_for_jpg", None)
)
saved_data = self._save_stage.execute(save_input)
if saved_data and saved_data.status.startswith("Processed"):
item_status = saved_data.status
log.info(f"{item_log_prefix}: Merged task successfully processed and saved. Status: {item_status}")
context.processed_maps_details[item_key] = {
"status": item_status,
"saved_files_info": saved_data.saved_files_details,
"internal_map_type": processed_data.output_map_type,
"final_dimensions": current_dimensions,
}
else:
error_msg = saved_data.error_message if saved_data else "Save stage for merged task returned None"
log.error(f"{item_log_prefix}: Failed during save stage for merged task. Error: {error_msg}")
context.processed_maps_details[item_key] = {"status": "Failed", "notes": f"Save Error (Merged): {error_msg}", "stage": "SaveVariantsStage"}
asset_had_item_errors = True
item_status = "Failed"
else:
log.warning(f"{item_log_prefix}: Unknown item type in loop: {type(item)}. Skipping.")
# Ensure some key exists to prevent KeyError if item_key was not set
unknown_item_key = f"unknown_item_at_index_{item_index}"
context.processed_maps_details[unknown_item_key] = {"status": "Skipped", "notes": f"Unknown item type {type(item)}"}
asset_had_item_errors = True
continue
except Exception as e: except Exception as e:
log.exception(f"{item_log_prefix}: Unhandled exception during item processing loop: {e}") log.exception(f"Asset '{asset_name}', Item Loop Index {item_index}: Unhandled exception: {e}")
# Ensure details are recorded even on unhandled exception # Ensure details are recorded even on unhandled exception
if item_key is not None: if item_key is not None:
context.processed_maps_details[item_key] = {"status": "Failed", "notes": f"Unhandled Loop Error: {e}", "stage": "OrchestratorLoop"} context.processed_maps_details[item_key] = {"status": "Failed", "notes": f"Unhandled Loop Error: {e}", "stage": "OrchestratorLoop"}

View File

@@ -1,5 +1,5 @@
import logging import logging
from typing import Tuple from typing import Tuple, Optional # Added Optional
import cv2 # Assuming cv2 is available for interpolation flags import cv2 # Assuming cv2 is available for interpolation flags
import numpy as np import numpy as np
@@ -7,77 +7,93 @@ import numpy as np
from .base_stage import ProcessingStage from .base_stage import ProcessingStage
# Import necessary context classes and utils # Import necessary context classes and utils
from ..asset_context import InitialScalingInput, InitialScalingOutput from ..asset_context import InitialScalingInput, InitialScalingOutput
# ProcessingItem is no longer created here, so its import can be removed if not used otherwise.
# For now, keep rule_structure import if other elements from it might be needed,
# but ProcessingItem itself is not directly instantiated by this stage anymore.
# from rule_structure import ProcessingItem
from ...utils import image_processing_utils as ipu from ...utils import image_processing_utils as ipu
import numpy as np
import cv2 # Added cv2 for interpolation flags (already used implicitly by ipu.resize_image)
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
class InitialScalingStage(ProcessingStage): class InitialScalingStage(ProcessingStage):
""" """
Applies initial scaling (e.g., Power-of-Two downscaling) to image data Applies initial Power-of-Two (POT) downscaling to image data if configured
if configured via the InitialScalingInput. and if the item is not already a 'LOWRES' variant.
""" """
def execute(self, input_data: InitialScalingInput) -> InitialScalingOutput: def execute(self, input_data: InitialScalingInput) -> InitialScalingOutput:
""" """
Applies scaling based on input_data.initial_scaling_mode. Applies POT scaling based on input_data.initial_scaling_mode,
unless input_data.resolution_key is 'LOWRES'.
Passes through the resolution_key.
""" """
log.debug(f"Initial Scaling Stage: Mode '{input_data.initial_scaling_mode}'.") # Safely access source_file_path for logging, if provided by orchestrator via underscore attribute
source_file_path = getattr(input_data, '_source_file_path', "UnknownSourcePath")
log_prefix = f"InitialScalingStage (Source: {source_file_path}, ResKey: {input_data.resolution_key})"
log.debug(f"{log_prefix}: Mode '{input_data.initial_scaling_mode}'. Received resolution_key: '{input_data.resolution_key}'")
image_to_scale = input_data.image_data image_to_scale = input_data.image_data
original_dims_wh = input_data.original_dimensions current_dimensions_wh = input_data.original_dimensions # Dimensions of the image_to_scale
scaling_mode = input_data.initial_scaling_mode scaling_mode = input_data.initial_scaling_mode
scaling_applied = False
final_image_data = image_to_scale # Default to original if no scaling happens output_resolution_key = input_data.resolution_key # Pass through the resolution key
if image_to_scale is None or image_to_scale.size == 0: if image_to_scale is None or image_to_scale.size == 0:
log.warning("Initial Scaling Stage: Input image data is None or empty. Skipping.") log.warning(f"{log_prefix}: Input image data is None or empty. Skipping POT scaling.")
# Return original (empty) data and indicate no scaling
return InitialScalingOutput( return InitialScalingOutput(
scaled_image_data=np.array([]), scaled_image_data=np.array([]),
scaling_applied=False, scaling_applied=False,
final_dimensions=(0, 0) final_dimensions=(0, 0),
resolution_key=output_resolution_key
) )
if original_dims_wh is None: if not current_dimensions_wh:
log.warning("Initial Scaling Stage: Original dimensions not provided. Using current image shape.") log.warning(f"{log_prefix}: Original dimensions not provided for POT scaling. Using current image shape.")
h_pre_scale, w_pre_scale = image_to_scale.shape[:2] h_pre_pot_scale, w_pre_pot_scale = image_to_scale.shape[:2]
original_dims_wh = (w_pre_scale, h_pre_scale)
else: else:
w_pre_scale, h_pre_scale = original_dims_wh w_pre_pot_scale, h_pre_pot_scale = current_dimensions_wh
final_image_data = image_to_scale # Default to original if no scaling happens
scaling_applied = False
if scaling_mode == "POT_DOWNSCALE": # Skip POT scaling if the item is already a LOWRES variant or scaling mode is NONE
pot_w = ipu.get_nearest_power_of_two_downscale(w_pre_scale) if output_resolution_key == "LOWRES":
pot_h = ipu.get_nearest_power_of_two_downscale(h_pre_scale) log.info(f"{log_prefix}: Item is a 'LOWRES' variant. Skipping POT downscaling.")
elif scaling_mode == "NONE":
log.info(f"{log_prefix}: Mode is NONE. No POT scaling applied.")
elif scaling_mode == "POT_DOWNSCALE":
pot_w = ipu.get_nearest_power_of_two_downscale(w_pre_pot_scale)
pot_h = ipu.get_nearest_power_of_two_downscale(h_pre_pot_scale)
if (pot_w, pot_h) != (w_pre_scale, h_pre_scale): if (pot_w, pot_h) != (w_pre_pot_scale, h_pre_pot_scale):
log.info(f"Initial Scaling: Applying POT Downscale from ({w_pre_scale},{h_pre_scale}) to ({pot_w},{pot_h}).") log.info(f"{log_prefix}: Applying POT Downscale from ({w_pre_pot_scale},{h_pre_pot_scale}) to ({pot_w},{pot_h}).")
# Use INTER_AREA for downscaling generally
resized_img = ipu.resize_image(image_to_scale, pot_w, pot_h, interpolation=cv2.INTER_AREA) resized_img = ipu.resize_image(image_to_scale, pot_w, pot_h, interpolation=cv2.INTER_AREA)
if resized_img is not None: if resized_img is not None:
final_image_data = resized_img final_image_data = resized_img
scaling_applied = True scaling_applied = True
log.debug("Initial Scaling: POT Downscale applied successfully.") log.debug(f"{log_prefix}: POT Downscale applied successfully.")
else: else:
log.warning("Initial Scaling: POT Downscale resize failed. Using original data.") log.warning(f"{log_prefix}: POT Downscale resize failed. Using pre-POT-scaled data.")
# final_image_data remains image_to_scale
else: else:
log.info("Initial Scaling: POT Downscale - Image already POT or smaller. No scaling needed.") log.info(f"{log_prefix}: Image already POT or smaller. No POT scaling needed.")
# final_image_data remains image_to_scale
elif scaling_mode == "NONE":
log.info("Initial Scaling: Mode is NONE. No scaling applied.")
# final_image_data remains image_to_scale
else: else:
log.warning(f"Initial Scaling: Unknown INITIAL_SCALING_MODE '{scaling_mode}'. Defaulting to NONE.") log.warning(f"{log_prefix}: Unknown INITIAL_SCALING_MODE '{scaling_mode}'. Defaulting to NONE (no scaling).")
# final_image_data remains image_to_scale
# Determine final dimensions # Determine final dimensions
if final_image_data is not None and final_image_data.size > 0:
final_h, final_w = final_image_data.shape[:2] final_h, final_w = final_image_data.shape[:2]
final_dims_wh = (final_w, final_h) final_dims_wh = (final_w, final_h)
else:
final_dims_wh = (0,0)
if final_image_data is None: # Ensure it's an empty array for consistency if None
final_image_data = np.array([])
return InitialScalingOutput( return InitialScalingOutput(
scaled_image_data=final_image_data, scaled_image_data=final_image_data,
scaling_applied=scaling_applied, scaling_applied=scaling_applied,
final_dimensions=final_dims_wh final_dimensions=final_dims_wh,
resolution_key=output_resolution_key # Pass through the resolution key
) )

View File

@@ -148,12 +148,15 @@ class MetadataInitializationStage(ProcessingStage):
context.asset_metadata['processing_start_time'] = datetime.datetime.now().isoformat() context.asset_metadata['processing_start_time'] = datetime.datetime.now().isoformat()
context.asset_metadata['status'] = "Pending" context.asset_metadata['status'] = "Pending"
if context.config_obj and hasattr(context.config_obj, 'general_settings') and \ app_version_value = None
hasattr(context.config_obj.general_settings, 'app_version'): if context.config_obj and hasattr(context.config_obj, 'app_version'):
context.asset_metadata['version'] = context.config_obj.general_settings.app_version app_version_value = context.config_obj.app_version
if app_version_value:
context.asset_metadata['version'] = app_version_value
else: else:
logger.warning("App version not found in config_obj.general_settings. Setting version to 'N/A'.") logger.warning("App version not found using config_obj.app_version. Setting version to 'N/A'.")
context.asset_metadata['version'] = "N/A" # Default or placeholder context.asset_metadata['version'] = "N/A"
if context.incrementing_value is not None: if context.incrementing_value is not None:
context.asset_metadata['incrementing_value'] = context.incrementing_value context.asset_metadata['incrementing_value'] = context.incrementing_value

View File

@@ -1,21 +1,69 @@
import logging import logging
from typing import List, Union, Optional from typing import List, Union, Optional, Tuple, Dict # Added Dict
from pathlib import Path # Added Path
from .base_stage import ProcessingStage from .base_stage import ProcessingStage
from ..asset_context import AssetProcessingContext, MergeTaskDefinition from ..asset_context import AssetProcessingContext, MergeTaskDefinition
from rule_structure import FileRule # Assuming FileRule is imported correctly from rule_structure import FileRule, ProcessingItem # Added ProcessingItem
from processing.utils import image_processing_utils as ipu # Added ipu
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
class PrepareProcessingItemsStage(ProcessingStage): class PrepareProcessingItemsStage(ProcessingStage):
""" """
Identifies and prepares a unified list of items (FileRule, MergeTaskDefinition) Identifies and prepares a unified list of ProcessingItem and MergeTaskDefinition objects
to be processed in subsequent stages. Performs initial validation. to be processed in subsequent stages. Performs initial validation and explodes
FileRules into specific ProcessingItems for each required output variant.
""" """
def _get_target_resolutions(self, source_w: int, source_h: int, config_resolutions: dict, file_rule: FileRule) -> Dict[str, int]:
"""
Determines the target output resolutions for a given source image.
Placeholder logic: Uses all config resolutions smaller than or equal to source, plus PREVIEW if smaller.
Needs to be refined to consider FileRule.resolution_override and actual project requirements.
"""
# For now, very basic logic:
# If FileRule has a resolution_override (e.g., (1024,1024)), that might be the *only* target.
# This needs to be clarified. Assuming override means *only* that size.
if file_rule.resolution_override and isinstance(file_rule.resolution_override, tuple) and len(file_rule.resolution_override) == 2:
# How to get a "key" for an arbitrary override? For now, skip if overridden.
# This part of the design (how overrides interact with standard resolutions) is unclear.
# Let's assume for now that if resolution_override is set, we don't generate standard named resolutions.
# This is likely incorrect for a full implementation.
log.warning(f"FileRule '{file_rule.file_path}' has resolution_override. Standard resolution key generation skipped (needs design refinement).")
return {}
target_res = {}
max_source_dim = max(source_w, source_h)
for key, res_val in config_resolutions.items():
if key == "PREVIEW": # Always consider PREVIEW if its value is smaller
if res_val < max_source_dim : # Or just always include PREVIEW? For now, if smaller.
target_res[key] = res_val
elif res_val <= max_source_dim:
target_res[key] = res_val
# Ensure PREVIEW is included if it's defined and smaller than the smallest other target, or if no other targets.
# This logic is still a bit naive.
if "PREVIEW" in config_resolutions and config_resolutions["PREVIEW"] < max_source_dim:
if not target_res or config_resolutions["PREVIEW"] < min(v for k,v in target_res.items() if k != "PREVIEW" and isinstance(v,int)):
target_res["PREVIEW"] = config_resolutions["PREVIEW"]
elif "PREVIEW" in config_resolutions and not target_res : # if only preview is applicable
if config_resolutions["PREVIEW"] <= max_source_dim:
target_res["PREVIEW"] = config_resolutions["PREVIEW"]
if not target_res and max_source_dim > 0 : # If no standard res is smaller, but image exists
log.debug(f"No standard resolutions from config are <= source dimension {max_source_dim}. Only LOWRES (if applicable) or PREVIEW (if smaller) might be generated.")
log.debug(f"Determined target resolutions for source {source_w}x{source_h}: {target_res}")
return target_res
def execute(self, context: AssetProcessingContext) -> AssetProcessingContext: def execute(self, context: AssetProcessingContext) -> AssetProcessingContext:
""" """
Populates context.processing_items with FileRule and MergeTaskDefinition objects. Populates context.processing_items with ProcessingItem and MergeTaskDefinition objects.
""" """
asset_name_for_log = context.asset_rule.asset_name if context.asset_rule else "Unknown Asset" asset_name_for_log = context.asset_rule.asset_name if context.asset_rule else "Unknown Asset"
log.info(f"Asset '{asset_name_for_log}': Preparing processing items...") log.info(f"Asset '{asset_name_for_log}': Preparing processing items...")
@@ -25,72 +73,135 @@ class PrepareProcessingItemsStage(ProcessingStage):
context.processing_items = [] context.processing_items = []
return context return context
items_to_process: List[Union[FileRule, MergeTaskDefinition]] = [] # Output list will now be List[Union[ProcessingItem, MergeTaskDefinition]]
items_to_process: List[Union[ProcessingItem, MergeTaskDefinition]] = []
preparation_failed = False preparation_failed = False
config = context.config_obj
# --- Add regular files --- # --- Process FileRules into ProcessingItems ---
if context.files_to_process: if context.files_to_process:
# Validate source path early for regular files
source_path_valid = True source_path_valid = True
if not context.source_rule or not context.source_rule.input_path: if not context.source_rule or not context.source_rule.input_path:
log.error(f"Asset '{asset_name_for_log}': SourceRule or SourceRule.input_path is not set. Cannot process regular files.") log.error(f"Asset '{asset_name_for_log}': SourceRule or SourceRule.input_path is not set.")
source_path_valid = False source_path_valid = False
preparation_failed = True # Mark as failed if source path is missing preparation_failed = True
context.status_flags['prepare_items_failed_reason'] = "SourceRule.input_path missing" context.status_flags['prepare_items_failed_reason'] = "SourceRule.input_path missing"
elif not context.workspace_path or not context.workspace_path.is_dir(): elif not context.workspace_path or not context.workspace_path.is_dir():
log.error(f"Asset '{asset_name_for_log}': Workspace path '{context.workspace_path}' is not a valid directory. Cannot process regular files.") log.error(f"Asset '{asset_name_for_log}': Workspace path '{context.workspace_path}' is invalid.")
source_path_valid = False source_path_valid = False
preparation_failed = True # Mark as failed if workspace path is bad preparation_failed = True
context.status_flags['prepare_items_failed_reason'] = "Workspace path invalid" context.status_flags['prepare_items_failed_reason'] = "Workspace path invalid"
if source_path_valid: if source_path_valid:
for file_rule in context.files_to_process: for file_rule in context.files_to_process:
# Basic validation for FileRule itself log_prefix_fr = f"Asset '{asset_name_for_log}', FileRule '{file_rule.file_path}'"
if not file_rule.file_path: if not file_rule.file_path:
log.warning(f"Asset '{asset_name_for_log}': Skipping FileRule with empty file_path.") log.warning(f"{log_prefix_fr}: Skipping FileRule with empty file_path.")
continue # Skip this specific rule, but don't fail the whole stage continue
items_to_process.append(file_rule)
log.debug(f"Asset '{asset_name_for_log}': Added {len(context.files_to_process)} potential FileRule items.") item_type = file_rule.item_type_override or file_rule.item_type
if not item_type or item_type == "EXTRA" or not item_type.startswith("MAP_"):
log.debug(f"{log_prefix_fr}: Item type is '{item_type}'. Not creating map ProcessingItems.")
# Optionally, create a different kind of ProcessingItem for EXTRAs if they need pipeline processing
continue
source_image_path = context.workspace_path / file_rule.file_path
if not source_image_path.is_file():
log.error(f"{log_prefix_fr}: Source image file not found at '{source_image_path}'. Skipping this FileRule.")
preparation_failed = True # Individual file error can contribute to overall stage failure
context.status_flags.setdefault('prepare_items_file_errors', []).append(str(source_image_path))
continue
# Load image data to get dimensions and for LOWRES variant
# This data will be passed to subsequent stages via ProcessingItem.
# Consider caching this load if RegularMapProcessorStage also loads.
# For now, load here as dimensions are needed for LOWRES decision.
log.debug(f"{log_prefix_fr}: Loading image from '{source_image_path}' to determine dimensions and prepare items.")
source_image_data = ipu.load_image(str(source_image_path))
if source_image_data is None:
log.error(f"{log_prefix_fr}: Failed to load image from '{source_image_path}'. Skipping this FileRule.")
preparation_failed = True
context.status_flags.setdefault('prepare_items_file_errors', []).append(f"Failed to load {source_image_path}")
continue
orig_h, orig_w = source_image_data.shape[:2]
original_dimensions_wh = (orig_w, orig_h)
source_bit_depth = ipu.get_image_bit_depth(str(source_image_path)) # Get bit depth from file
source_channels = ipu.get_image_channels(source_image_data)
# Determine standard resolutions to generate
# This logic needs to be robust and consider file_rule.resolution_override, etc.
# Using a placeholder _get_target_resolutions for now.
target_resolutions = self._get_target_resolutions(orig_w, orig_h, config.image_resolutions, file_rule)
for res_key, _res_val in target_resolutions.items():
pi = ProcessingItem(
source_file_info_ref=str(source_image_path), # Using full path as ref
map_type_identifier=item_type,
resolution_key=res_key,
image_data=source_image_data.copy(), # Give each PI its own copy
original_dimensions=original_dimensions_wh,
current_dimensions=original_dimensions_wh,
bit_depth=source_bit_depth,
channels=source_channels,
status="Pending"
)
items_to_process.append(pi)
log.debug(f"{log_prefix_fr}: Created standard ProcessingItem: {pi.map_type_identifier}_{pi.resolution_key}")
# Create LOWRES variant if applicable
if config.enable_low_resolution_fallback and max(orig_w, orig_h) < config.low_resolution_threshold:
# Check if a LOWRES item for this source_file_info_ref already exists (e.g. if target_resolutions was empty)
# This check is important if _get_target_resolutions might return empty for small images.
# A more robust way is to ensure LOWRES is distinct from standard resolutions.
# Avoid duplicate LOWRES if _get_target_resolutions somehow already made one (unlikely with current placeholder)
is_lowres_already_added = any(p.resolution_key == "LOWRES" and p.source_file_info_ref == str(source_image_path) for p in items_to_process if isinstance(p, ProcessingItem))
if not is_lowres_already_added:
pi_lowres = ProcessingItem(
source_file_info_ref=str(source_image_path),
map_type_identifier=item_type,
resolution_key="LOWRES",
image_data=source_image_data.copy(), # Fresh copy for LOWRES
original_dimensions=original_dimensions_wh,
current_dimensions=original_dimensions_wh,
bit_depth=source_bit_depth,
channels=source_channels,
status="Pending"
)
items_to_process.append(pi_lowres)
log.info(f"{log_prefix_fr}: Created LOWRES ProcessingItem because {orig_w}x{orig_h} < {config.low_resolution_threshold}px threshold.")
else: else:
log.warning(f"Asset '{asset_name_for_log}': Skipping addition of all FileRule items due to invalid source/workspace path.") log.debug(f"{log_prefix_fr}: LOWRES item for this source already added by target resolution logic. Skipping duplicate LOWRES creation.")
elif config.enable_low_resolution_fallback:
log.debug(f"{log_prefix_fr}: Image {orig_w}x{orig_h} not below LOWRES threshold {config.low_resolution_threshold}px.")
# --- Add merged tasks --- else: # Source path not valid
# --- Add merged tasks from global configuration --- log.warning(f"Asset '{asset_name_for_log}': Skipping creation of ProcessingItems from FileRules due to invalid source/workspace path.")
# merged_image_tasks are expected to be loaded into context.config_obj
# by the Configuration class from app_settings.json.
merged_tasks_list = getattr(context.config_obj, 'map_merge_rules', None)
# --- Add MergeTaskDefinitions --- (This part remains largely the same)
merged_tasks_list = getattr(config, 'map_merge_rules', None)
if merged_tasks_list and isinstance(merged_tasks_list, list): if merged_tasks_list and isinstance(merged_tasks_list, list):
log.debug(f"Asset '{asset_name_for_log}': Found {len(merged_tasks_list)} merge tasks in global config.") log.debug(f"Asset '{asset_name_for_log}': Found {len(merged_tasks_list)} merge tasks in global config.")
for task_idx, task_data in enumerate(merged_tasks_list): for task_idx, task_data in enumerate(merged_tasks_list):
if isinstance(task_data, dict): if isinstance(task_data, dict):
task_key = f"merged_task_{task_idx}" task_key = f"merged_task_{task_idx}"
# Basic validation for merge task data: requires output_map_type and an inputs dictionary
if not task_data.get('output_map_type') or not isinstance(task_data.get('inputs'), dict): if not task_data.get('output_map_type') or not isinstance(task_data.get('inputs'), dict):
log.warning(f"Asset '{asset_name_for_log}', Task Index {task_idx}: Skipping merge task due to missing 'output_map_type' or valid 'inputs' dictionary. Task data: {task_data}") log.warning(f"Asset '{asset_name_for_log}', Task Index {task_idx}: Skipping merge task due to missing 'output_map_type' or valid 'inputs'. Task data: {task_data}")
continue # Skip this specific task continue
log.debug(f"Asset '{asset_name_for_log}', Preparing Merge Task Index {task_idx}: Raw task_data: {task_data}")
merge_def = MergeTaskDefinition(task_data=task_data, task_key=task_key) merge_def = MergeTaskDefinition(task_data=task_data, task_key=task_key)
log.debug(f"Asset '{asset_name_for_log}': Created MergeTaskDefinition object: {merge_def}")
log.info(f"Asset '{asset_name_for_log}': Successfully CREATED MergeTaskDefinition: Key='{merge_def.task_key}', OutputType='{merge_def.task_data.get('output_map_type', 'N/A')}'")
items_to_process.append(merge_def) items_to_process.append(merge_def)
log.info(f"Asset '{asset_name_for_log}': Added MergeTaskDefinition: Key='{merge_def.task_key}', OutputType='{merge_def.task_data.get('output_map_type', 'N/A')}'")
else: else:
log.warning(f"Asset '{asset_name_for_log}': Item at index {task_idx} in config_obj.merged_image_tasks is not a dictionary. Skipping. Item: {task_data}") log.warning(f"Asset '{asset_name_for_log}': Item at index {task_idx} in config.map_merge_rules is not a dict. Skipping. Item: {task_data}")
# The log for "Added X potential MergeTaskDefinition items" will be covered by the final log. # ... (rest of merge task handling) ...
elif merged_tasks_list is None:
log.debug(f"Asset '{asset_name_for_log}': 'merged_image_tasks' not found in config_obj. No global merge tasks to add.")
elif not isinstance(merged_tasks_list, list):
log.warning(f"Asset '{asset_name_for_log}': 'merged_image_tasks' in config_obj is not a list. Skipping global merge tasks. Type: {type(merged_tasks_list)}")
else: # Empty list
log.debug(f"Asset '{asset_name_for_log}': 'merged_image_tasks' in config_obj is empty. No global merge tasks to add.")
if not items_to_process and not preparation_failed: # Check preparation_failed too
log.info(f"Asset '{asset_name_for_log}': No valid items (ProcessingItem or MergeTaskDefinition) found to process.")
if not items_to_process:
log.info(f"Asset '{asset_name_for_log}': No valid items found to process after preparation.")
log.debug(f"Asset '{asset_name_for_log}': Final items_to_process before assigning to context: {items_to_process}")
context.processing_items = items_to_process context.processing_items = items_to_process
context.intermediate_results = {} # Initialize intermediate results storage context.intermediate_results = {} # Initialize intermediate results storage

View File

@@ -37,7 +37,7 @@ class RegularMapProcessorStage(ProcessingStage):
""" """
final_internal_map_type = initial_internal_map_type # Default final_internal_map_type = initial_internal_map_type # Default
base_map_type_match = re.match(r"(MAP_[A-Z]{3})", initial_internal_map_type) base_map_type_match = re.match(r"(MAP_[A-Z]+)", initial_internal_map_type)
if not base_map_type_match or not asset_rule or not asset_rule.files: if not base_map_type_match or not asset_rule or not asset_rule.files:
return final_internal_map_type # Cannot determine suffix without base type or asset rule files return final_internal_map_type # Cannot determine suffix without base type or asset rule files
@@ -47,7 +47,7 @@ class RegularMapProcessorStage(ProcessingStage):
peers_of_same_base_type = [] peers_of_same_base_type = []
for fr_asset in asset_rule.files: for fr_asset in asset_rule.files:
fr_asset_item_type = fr_asset.item_type_override or fr_asset.item_type or "UnknownMapType" fr_asset_item_type = fr_asset.item_type_override or fr_asset.item_type or "UnknownMapType"
fr_asset_base_match = re.match(r"(MAP_[A-Z]{3})", fr_asset_item_type) fr_asset_base_match = re.match(r"(MAP_[A-Z]+)", fr_asset_item_type)
if fr_asset_base_match and fr_asset_base_match.group(1) == true_base_map_type: if fr_asset_base_match and fr_asset_base_match.group(1) == true_base_map_type:
peers_of_same_base_type.append(fr_asset) peers_of_same_base_type.append(fr_asset)
@@ -197,10 +197,17 @@ class RegularMapProcessorStage(ProcessingStage):
result.final_internal_map_type = final_map_type # Update if Gloss->Rough changed it result.final_internal_map_type = final_map_type # Update if Gloss->Rough changed it
result.transformations_applied = transform_notes result.transformations_applied = transform_notes
# --- Determine Resolution Key for LOWRES ---
if config.enable_low_resolution_fallback and result.original_dimensions:
w, h = result.original_dimensions
if max(w, h) < config.low_resolution_threshold:
result.resolution_key = "LOWRES"
log.info(f"{log_prefix}: Image dimensions ({w}x{h}) are below threshold ({config.low_resolution_threshold}px). Flagging as LOWRES.")
# --- Success --- # --- Success ---
result.status = "Processed" result.status = "Processed"
result.error_message = None result.error_message = None
log.info(f"{log_prefix}: Successfully processed regular map. Final type: '{result.final_internal_map_type}'.") log.info(f"{log_prefix}: Successfully processed regular map. Final type: '{result.final_internal_map_type}', ResolutionKey: {result.resolution_key}.")
except Exception as e: except Exception as e:
log.exception(f"{log_prefix}: Unhandled exception during processing: {e}") log.exception(f"{log_prefix}: Unhandled exception during processing: {e}")

View File

@@ -23,8 +23,17 @@ class SaveVariantsStage(ProcessingStage):
Calls isu.save_image_variants with data from input_data. Calls isu.save_image_variants with data from input_data.
""" """
internal_map_type = input_data.internal_map_type internal_map_type = input_data.internal_map_type
log_prefix = f"Save Variants Stage (Type: {internal_map_type})" # The input_data for SaveVariantsStage doesn't directly contain the ProcessingItem.
# It receives data *derived* from a ProcessingItem by previous stages.
# For debugging, we'd need to pass more context or rely on what's in output_filename_pattern_tokens.
resolution_key_from_tokens = input_data.output_filename_pattern_tokens.get('resolution', 'UnknownResKey')
log_prefix = f"Save Variants Stage (Type: {internal_map_type}, ResKey: {resolution_key_from_tokens})"
log.info(f"{log_prefix}: Starting.") log.info(f"{log_prefix}: Starting.")
log.debug(f"{log_prefix}: Input image_data shape: {input_data.image_data.shape if input_data.image_data is not None else 'None'}")
log.debug(f"{log_prefix}: Input source_bit_depth_info: {input_data.source_bit_depth_info}")
log.debug(f"{log_prefix}: Configured image_resolutions for saving: {input_data.image_resolutions}")
log.debug(f"{log_prefix}: Output filename pattern tokens: {input_data.output_filename_pattern_tokens}")
# Initialize output object with default failure state # Initialize output object with default failure state
result = SaveVariantsOutput( result = SaveVariantsOutput(
@@ -64,11 +73,11 @@ class SaveVariantsStage(ProcessingStage):
"resolution_threshold_for_jpg": input_data.resolution_threshold_for_jpg, # Added "resolution_threshold_for_jpg": input_data.resolution_threshold_for_jpg, # Added
} }
log.debug(f"{log_prefix}: Calling save_image_variants utility.") log.debug(f"{log_prefix}: Calling save_image_variants utility with args: {save_args}")
saved_files_details: List[Dict] = isu.save_image_variants(**save_args) saved_files_details: List[Dict] = isu.save_image_variants(**save_args)
if saved_files_details: if saved_files_details:
log.info(f"{log_prefix}: Save utility completed successfully. Saved {len(saved_files_details)} variants.") log.info(f"{log_prefix}: Save utility completed successfully. Saved {len(saved_files_details)} variants: {[details.get('filepath') for details in saved_files_details]}")
result.saved_files_details = saved_files_details result.saved_files_details = saved_files_details
result.status = "Processed" result.status = "Processed"
result.error_message = None result.error_message = None

View File

@@ -194,6 +194,16 @@ def get_image_bit_depth(image_path_str: str) -> Optional[int]:
print(f"Error getting bit depth for {image_path_str}: {e}") print(f"Error getting bit depth for {image_path_str}: {e}")
return None return None
def get_image_channels(image_data: np.ndarray) -> Optional[int]:
"""Determines the number of channels in an image."""
if image_data is None:
return None
if len(image_data.shape) == 2: # Grayscale
return 1
elif len(image_data.shape) == 3: # Color
return image_data.shape[2]
return None # Unknown shape
def calculate_image_stats(image_data: np.ndarray) -> Optional[Dict]: def calculate_image_stats(image_data: np.ndarray) -> Optional[Dict]:
""" """
Calculates min, max, mean for a given numpy image array. Calculates min, max, mean for a given numpy image array.

44
projectBrief.md Normal file
View File

@@ -0,0 +1,44 @@
# Project Brief: Asset Processor Tool
## 1. Main Goal & Purpose
The primary goal of the Asset Processor Tool is to provide **CG artists and 3D content teams with a friendly, fast, and flexible interface to process and organize 3D asset source files into a standardized library format.** It automates repetitive and complex tasks involved in preparing assets from various suppliers for use in production pipelines.
## 2. Key Features & Components
* **Automated Asset Processing:** Ingests 3D asset source files (texture sets, models, etc.) from `.zip`, `.rar`, `.7z` archives, or folders.
* **Preset-Driven Workflow:** Utilizes configurable JSON presets to interpret different asset sources (e.g., from various online vendors or internal standards), defining rules for file classification and processing.
* **Comprehensive File Operations:**
* **Classification:** Automatically identifies map types (Color, Normal, Roughness, etc.), models, and other file categories based on preset rules.
* **Image Processing:** Performs tasks like image resizing (to standard resolutions like 1K, 2K, 4K, avoiding upscaling), glossiness-to-roughness conversion, normal map green channel inversion (OpenGL/DirectX handling), alpha channel extraction, bit-depth adjustments, and low-resolution fallback generation for small source images.
* **Channel Merging:** Combines channels from different source maps into packed textures (e.g., Normal + Roughness + Metallic into a single NRMRGH map).
* **Metadata Generation:** Creates a detailed `metadata.json` file for each processed asset, containing information about maps, categories, processing settings, and more, for downstream tool integration.
* **Flexible Output Organization:** Generates a clean, structured output directory based on user-configurable naming patterns and tokens.
* **Multiple User Interfaces:**
* **Graphical User Interface (GUI):** The primary interface, designed to be user-friendly, offering drag-and-drop functionality, an integrated preset editor, a live preview table for rule validation and overrides, and clear processing controls.
* **Directory Monitor:** An automated script that watches a specified folder for new asset archives and processes them based on preset names embedded in the archive filename.
* **Command-Line Interface (CLI):** Intended for batch processing and scripting (currently with limited core functionality).
* **Optional Blender Integration:** Can automatically run Blender scripts post-processing to create PBR node groups and materials in specified `.blend` files, linking to the newly processed textures.
* **Hierarchical Rule System:** Allows for dynamic, granular overrides of preset configurations at the source, asset, or individual file level via the GUI.
* **Experimental LLM Prediction:** Includes an option to use a Large Language Model for file interpretation and rule prediction.
## 3. Target Audience
* **CG Artists:** Individual artists looking for an efficient way to manage and prepare their personal or downloaded asset libraries.
* **3D Content Creation Teams:** Studios or groups needing a standardized pipeline for processing and organizing assets from multiple sources.
* **Technical Artists/Pipeline Developers:** Who may extend or integrate the tool into broader production workflows.
## 4. Overall Architectural Style & Key Technologies
* **Core Language:** Python
* **GUI Framework:** PySide6
* **Configuration:** Primarily JSON-based (application settings, user overrides, type definitions, supplier settings, presets, LLM settings).
* **Processing Architecture:** A modular, staged processing pipeline orchestrated by a central engine. Each stage performs a discrete task on an `AssetProcessingContext` object.
* **Key Libraries:** OpenCV (image processing), NumPy (numerical operations), py7zr/rarfile (archive handling), watchdog (directory monitoring).
* **Design Principles:** Modularity, configurability, and user-friendliness (especially for the GUI).
## 5. Foundational Information
* The tool aims to significantly reduce manual effort and ensure consistency in asset preparation.
* It is designed to be adaptable to various asset sources and pipeline requirements through its extensive configuration options and preset system.
* The output `metadata.json` is key for enabling further automation and integration with other tools or digital content creation (DCC) applications.

View File

@@ -1,6 +1,7 @@
import dataclasses import dataclasses
import json import json
from typing import List, Dict, Any, Tuple, Optional from typing import List, Dict, Any, Tuple, Optional
import numpy as np # Added for ProcessingItem
@dataclasses.dataclass @dataclasses.dataclass
class FileRule: class FileRule:
file_path: str = None file_path: str = None
@@ -10,8 +11,12 @@ class FileRule:
resolution_override: Tuple[int, int] = None resolution_override: Tuple[int, int] = None
channel_merge_instructions: Dict[str, Any] = dataclasses.field(default_factory=dict) channel_merge_instructions: Dict[str, Any] = dataclasses.field(default_factory=dict)
output_format_override: str = None output_format_override: str = None
processing_items: List['ProcessingItem'] = dataclasses.field(default_factory=list) # Added field
def to_json(self) -> str: def to_json(self) -> str:
# Need to handle ProcessingItem serialization if it contains non-serializable types like np.ndarray
# For now, assume asdict handles it or it's handled before calling to_json for persistence.
# A custom asdict_factory might be needed for robust serialization.
return json.dumps(dataclasses.asdict(self), indent=4) return json.dumps(dataclasses.asdict(self), indent=4)
@classmethod @classmethod
@@ -54,4 +59,43 @@ class SourceRule:
data = json.loads(json_string) data = json.loads(json_string)
# Manually deserialize nested AssetRule objects # Manually deserialize nested AssetRule objects
data['assets'] = [AssetRule.from_json(json.dumps(asset_data)) for asset_data in data.get('assets', [])] data['assets'] = [AssetRule.from_json(json.dumps(asset_data)) for asset_data in data.get('assets', [])]
# Need to handle ProcessingItem deserialization if it was serialized
# For now, from_json for FileRule doesn't explicitly handle processing_items from JSON.
return cls(**data) return cls(**data)
@dataclasses.dataclass
class ProcessingItem:
"""
Represents a specific version of an image map to be processed and saved.
This could be a standard resolution (1K, 2K), a preview, or a special
variant like 'LOWRES'.
"""
source_file_info_ref: str # Reference to the original SourceFileInfo or unique ID of the source image
map_type_identifier: str # The internal map type (e.g., "MAP_COL", "MAP_ROUGH")
resolution_key: str # The resolution identifier (e.g., "1K", "PREVIEW", "LOWRES")
image_data: np.ndarray # The actual image data for this item
original_dimensions: Tuple[int, int] # (width, height) of the source image for this item
current_dimensions: Tuple[int, int] # (width, height) of the image_data in this item
target_filename: str = "" # Will be populated by SaveVariantsStage
is_extra: bool = False # If this item should be treated as an 'extra' file
bit_depth: Optional[int] = None
channels: Optional[int] = None
file_extension: Optional[str] = None # Determined during saving based on format
processing_applied_log: List[str] = dataclasses.field(default_factory=list)
status: str = "Pending" # e.g., Pending, Processed, Failed
error_message: Optional[str] = None
# __getstate__ and __setstate__ might be needed if we pickle these objects
# and np.ndarray causes issues. For JSON, image_data would typically not be serialized.
def __getstate__(self):
state = self.__dict__.copy()
# Don't pickle image_data if it's large or not needed for state
if 'image_data' in state: # Or a more sophisticated check
del state['image_data'] # Example: remove it
return state
def __setstate__(self, state):
self.__dict__.update(state)
# Potentially re-initialize or handle missing 'image_data'
if 'image_data' not in self.__dict__:
self.image_data = None # Or load it if a path was stored instead