13 Commits

68 changed files with 5505 additions and 1563 deletions

3
.gitattributes vendored Normal file
View File

@@ -0,0 +1,3 @@
*.bin filter=lfs diff=lfs merge=lfs -text
*.db filter=lfs diff=lfs merge=lfs -text
*.sqlite3 filter=lfs diff=lfs merge=lfs -text

47
.roo/mcp.json Normal file
View File

@@ -0,0 +1,47 @@
{
"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",
"update_decision"
]
}
}
}

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,6 +13,18 @@ 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/llm_settings.json`: For users who wish to utilize the experimental LLM Predictor feature, the following settings are available in `config/llm_settings.json`:

View File

@@ -58,6 +58,7 @@ The `<output_base_directory>` (the root folder where processing output starts) i
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

@@ -12,6 +12,9 @@ The tool's configuration is loaded from several JSON files, providing a layered
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. 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. * *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. 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.

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

@@ -0,0 +1,127 @@
# Code Review & Refactoring Plan: GUI File Table
**Objective:** To identify the root causes of file list discrepancies and persistent empty asset rows in the GUI file table, and to propose refactoring solutions for improved robustness and maintainability.
**Phase 1: In-Depth Code Review**
This phase will focus on understanding the current implementation and data flow within the relevant GUI modules.
1. **Identify Key Modules & Classes:**
* **`gui/unified_view_model.py` ([`UnifiedViewModel`](gui/unified_view_model.py:1)):** This is the primary focus. We need to analyze:
* How it loads and represents the hierarchical data (`SourceRule` -> `AssetRule` -> `FileRule`).
* Methods responsible for updating the model with new data (e.g., from predictions or user edits).
* Logic for adding, removing, and modifying rows, especially `AssetRule` and `FileRule` items.
* How it handles data consistency when underlying data changes (e.g., after LLM processing or renaming operations).
* Signal/slot connections related to data changes.
* **`gui/asset_restructure_handler.py` ([`AssetRestructureHandler`](gui/asset_restructure_handler.py:1)):**
* How it listens to changes in `AssetRule` names or `FileRule` target asset overrides.
* The logic for moving `FileRule` items between `AssetRule` items.
* The conditions under which it creates new `AssetRule` items or removes empty ones. This is critical for the "persistent empty asset rows" issue.
* **`gui/llm_prediction_handler.py` ([`LLMPredictionHandler`](gui/llm_prediction_handler.py:1)):**
* How it parses the LLM response.
* How it translates the LLM's (potentially hallucinated) file list into the `SourceRule` structure.
* How this new `SourceRule` data is passed to and integrated by the `UnifiedViewModel`. This is key for the "file list discrepancies" issue.
* **`gui/prediction_handler.py` ([`RuleBasedPredictionHandler`](gui/prediction_handler.py:1)):**
* Similar to the LLM handler, how it generates `SourceRule` data from presets.
* How its output is integrated into the `UnifiedViewModel`, especially when "reinterpreting with a systematic approach" restores correct files.
* **`gui/main_window.py` ([`MainWindow`](gui/main_window.py:1)) & `gui/main_panel_widget.py` ([`MainPanelWidget`](gui/main_panel_widget.py:1)):**
* How these components instantiate and connect the `UnifiedViewModel`, `AssetRestructureHandler`, and prediction handlers.
* Event handling related to loading data, triggering predictions, and user interactions that modify the table data.
* **`rule_structure.py`:**
* Review the definitions of `SourceRule`, `AssetRule`, and `FileRule` to ensure a clear understanding of the data being managed.
2. **Trace Data Flow & State Management:**
* **Initial Load:** How is the initial list of files/assets loaded and represented in the `UnifiedViewModel`?
* **LLM Processing:**
* Trace the data flow from the LLM response -> `LLMPredictionHandler` -> `UnifiedViewModel`.
* How does the `UnifiedViewModel` reconcile the LLM's version of the file list with any existing state? Is there a clear "source of truth" for the file list before and after LLM processing?
* **Preset-Based Processing:**
* Trace data flow from preset selection -> `RuleBasedPredictionHandler` -> `UnifiedViewModel`.
* How does this "systematic approach" correct discrepancies? Does it fully replace the model's data or merge it?
* **Renaming/Restructuring:**
* Trace the events and actions from a user renaming an asset -> `AssetRestructureHandler` -> `UnifiedViewModel`.
* How are `AssetRule` items checked for emptiness and subsequently removed (or not removed)?
3. **Analyze Event Handling and Signal/Slot Connections:**
* Map out the key signals and slots between the `UnifiedViewModel`, `AssetRestructureHandler`, prediction handlers, and the main UI components.
* Ensure that signals are emitted and slots are connected correctly to trigger necessary updates and prevent race conditions or missed updates.
**Phase 2: Identify Issues & Propose Refactoring Strategies**
Based on the review, we will pinpoint specific areas contributing to the reported problems and suggest improvements.
1. **For File List Discrepancies (especially post-LLM):**
* **Potential Issue:** The `UnifiedViewModel` might be directly replacing its internal data with the LLM's output without proper validation or merging against the original input file list.
* **Proposed Strategy:**
* Establish a clear "source of truth" for the actual input files that remains independent of the LLM's interpretation.
* When the LLM provides its categorized list, the `LLMPredictionHandler` or `UnifiedViewModel` should *map* the LLM's findings onto the *existing* source files rather than creating a new list from scratch based on LLM hallucinations.
* If the LLM identifies files not in the original input, these should be flagged or handled as discrepancies, not added as if they were real.
* If the LLM *misses* files from the original input, these should remain visible, perhaps marked as "uncategorized by LLM."
2. **For Persistent Empty Asset Rows:**
* **Potential Issue:** The `AssetRestructureHandler`'s logic for detecting and removing empty `AssetRule` items might be flawed or not consistently triggered. It might not correctly count child `FileRule` items after a move, or the signal to check for emptiness might not always fire.
* **Proposed Strategy:**
* Review and strengthen the logic within `AssetRestructureHandler` that checks if an `AssetRule` is empty after its `FileRule` children are moved or its name changes.
* Ensure that this check is reliably performed *after* all relevant model updates have completed.
* Consider adding explicit methods to `UnifiedViewModel` or `AssetRule` to query if an asset group is truly empty (has no associated `FileRule` items).
* Ensure that the `UnifiedViewModel` correctly emits signals that the `AssetRestructureHandler` can use to trigger cleanup of empty asset rows.
3. **General Robustness & Maintainability:**
* **State Management:** Clarify state management within `UnifiedViewModel`. Ensure data consistency and minimize side effects.
* **Modularity:** Ensure clear separation of concerns between the `UnifiedViewModel` (data representation), prediction handlers (data generation), and `AssetRestructureHandler` (data manipulation).
* **Logging & Error Handling:** Improve logging in these critical sections to make troubleshooting easier. Add robust error handling for unexpected data states.
* **Unit Tests:** Identify areas where unit tests could be added or improved to cover the scenarios causing these bugs, especially around model updates and restructuring.
**Phase 3: Documentation & Handoff**
1. Document the findings of the code review.
2. Detail the agreed-upon refactoring plan.
3. Prepare for handoff to a developer (e.g., by switching to "Code" mode).
**Visual Plan (Mermaid Diagram):**
```mermaid
graph TD
subgraph GUI Interaction
UserAction[User Action (Load Files, Rename Asset, Trigger LLM)]
end
subgraph Prediction Layer
LLM_Handler([`gui.llm_prediction_handler.LLMPredictionHandler`])
Preset_Handler([`gui.prediction_handler.RuleBasedPredictionHandler`])
end
subgraph Core GUI Logic
MainWindow([`gui.main_window.MainWindow`])
MainPanel([`gui.main_panel_widget.MainPanelWidget`])
ViewModel([`gui.unified_view_model.UnifiedViewModel`])
RestructureHandler([`gui.asset_restructure_handler.AssetRestructureHandler`])
end
subgraph Data Structures
RuleStruct([`rule_structure.py` <br> SourceRule, AssetRule, FileRule])
end
UserAction --> MainWindow
MainWindow --> MainPanel
MainPanel -- Triggers Predictions --> LLM_Handler
MainPanel -- Triggers Predictions --> Preset_Handler
MainPanel -- Displays Data From --> ViewModel
LLM_Handler -- Provides SourceRule Data --> ViewModel
Preset_Handler -- Provides SourceRule Data --> ViewModel
ViewModel -- Manages --> RuleStruct
ViewModel -- Signals Changes --> RestructureHandler
ViewModel -- Signals Changes --> MainPanel
RestructureHandler -- Modifies --> ViewModel
%% Issues
style LLM_Handler fill:#f9d,stroke:#333,stroke-width:2px %% Highlight LLM Handler for file list issue
style ViewModel fill:#f9d,stroke:#333,stroke-width:2px %% Highlight ViewModel for both issues
style RestructureHandler fill:#f9d,stroke:#333,stroke-width:2px %% Highlight Restructure Handler for empty row issue
note right of LLM_Handler: Potential source of file list discrepancies
note right of RestructureHandler: Potential source of persistent empty asset rows

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*",
@@ -28,7 +24,114 @@
"*_Fabric.*", "*_Fabric.*",
"*_Albedo*" "*_Albedo*"
], ],
"map_type_mapping": [], "map_type_mapping": [
{
"target_type": "MAP_COL",
"keywords": [
"COLOR*",
"COL",
"COL-*",
"DIFFUSE",
"DIF",
"ALBEDO"
]
},
{
"target_type": "MAP_NRM",
"keywords": [
"NORMAL*",
"NORM*",
"NRM*",
"N"
],
"priority_keywords": [
"*_NRM16*",
"*_NM16*",
"*Normal16*"
]
},
{
"target_type": "MAP_ROUGH",
"keywords": [
"ROUGHNESS",
"ROUGH"
]
},
{
"target_type": "MAP_GLOSS",
"keywords": [
"GLOSS"
]
},
{
"target_type": "MAP_AO",
"keywords": [
"AMBIENTOCCLUSION",
"AO"
]
},
{
"target_type": "MAP_DISP",
"keywords": [
"DISPLACEMENT",
"DISP",
"HEIGHT",
"BUMP"
],
"priority_keywords": [
"*_DISP16*",
"*_DSP16*",
"*DSP16*",
"*DISP16*",
"*Displacement16*",
"*Height16*"
]
},
{
"target_type": "MAP_REFL",
"keywords": [
"REFLECTION",
"REFL",
"SPECULAR",
"SPEC"
]
},
{
"target_type": "MAP_SSS",
"keywords": [
"SSS",
"SUBSURFACE*"
]
},
{
"target_type": "MAP_FUZZ",
"keywords": [
"FUZZ"
]
},
{
"target_type": "MAP_IDMAP",
"keywords": [
"IDMAP"
]
},
{
"target_type": "MAP_MASK",
"keywords": [
"OPAC*",
"TRANSP*",
"MASK*",
"ALPHA*"
]
},
{
"target_type": "MAP_METAL",
"keywords": [
"METAL*",
"METALLIC"
]
}
],
"asset_category_rules": { "asset_category_rules": {
"model_patterns": [ "model_patterns": [
"*.fbx", "*.fbx",

View File

@@ -0,0 +1,105 @@
# Bit Depth Terminology Refactoring Plan
## 1. Background
Currently, there's an inconsistency in how bit depth rules and settings are defined and used across the project:
* **`config/file_type_definitions.json`**: Uses `"bit_depth_rule"` with values like `"force_8bit"` and `"respect"`.
* **`config/app_settings.json`**: (Within `MAP_MERGE_RULES`) uses `"output_bit_depth"` with values like `"respect_inputs"`.
* **`processing/utils/image_saving_utils.py`**: Contains logic that attempts to handle `"respect_inputs"` but is currently unreachable, and the `"respect"` rule effectively defaults to 8-bit.
This plan aims to unify the terminology and correct the processing logic.
## 2. Proposed Unified Terminology
A new configuration key and a clear set of values will be adopted:
* **New Key**: `bit_depth_policy`
* This key will replace `"bit_depth_rule"` in `file_type_definitions.json`.
* This key will replace `"output_bit_depth"` in `app_settings.json` (for `MAP_MERGE_RULES`).
* **Values for `bit_depth_policy`**:
* `"force_8bit"`: Always output 8-bit.
* `"force_16bit"`: Always output 16-bit.
* `"preserve"`: If any source image (or any input to a merge operation) has a bit depth greater than 8-bit, the output will be 16-bit. Otherwise, the output will be 8-bit.
* `""` (empty string or `null`): No specific bit depth policy applies (e.g., for non-image files like models or text files).
## 3. Refactoring Plan Details
### Phase 1: Configuration File Updates
1. **`config/file_type_definitions.json`**:
* Rename all instances of the key `"bit_depth_rule"` to `"bit_depth_policy"`.
* Update values:
* `"force_8bit"` remains `"force_8bit"`.
* `"respect"` changes to `"preserve"`.
* `""` (empty string) remains `""`.
2. **`config/app_settings.json`**:
* Within each rule in the `MAP_MERGE_RULES` array, rename the key `"output_bit_depth"` to `"bit_depth_policy"`.
* Update the value: `"respect_inputs"` changes to `"preserve"`.
### Phase 2: Code Update - `configuration.py`
1. Modify the `Configuration` class:
* Rename the method `get_bit_depth_rule()` to `get_bit_depth_policy()`.
* Update this method to read the new `"bit_depth_policy"` key from the loaded file type definitions.
* Ensure it correctly handles and returns the new policy values (`"force_8bit"`, `"force_16bit"`, `"preserve"`, `""`).
* The method should continue to provide a sensible default if a map type is not found or has an invalid policy.
### Phase 3: Code Update - `processing/utils/image_saving_utils.py`
1. Refactor the `save_image_variants` function:
* It will receive the `bit_depth_policy` (e.g., `"preserve"`, `"force_8bit"`) via its `file_type_defs` argument (which originates from the `Configuration` object).
* Correct the internal logic for determining `target_bit_depth` based on the `bit_depth_policy` argument:
* If `bit_depth_policy == "force_8bit"`, then `target_bit_depth = 8`.
* If `bit_depth_policy == "force_16bit"`, then `target_bit_depth = 16`.
* If `bit_depth_policy == "preserve"`:
* Examine the `source_bit_depth_info` argument (list of bit depths of input images).
* If any source bit depth in `source_bit_depth_info` is greater than 8, then `target_bit_depth = 16`.
* Otherwise (all source bit depths are 8 or less, or list is empty/all None), `target_bit_depth = 8`.
* If `bit_depth_policy == ""` or is `null` (or any other unhandled value), a clear default behavior should be established (e.g., log a warning and default to `"preserve"` or skip bit depth adjustments if appropriate for the file type).
### Phase 4: Code Update - `processing/pipeline/stages/merged_task_processor.py`
1. This stage is largely unaffected in its core logic for collecting `input_source_bit_depths`.
2. The `ProcessedMergedMapData` object it produces will continue to carry these `source_bit_depths`.
3. When this data is later passed to the `SaveVariantsStage` (and subsequently to `save_image_variants`), the `internal_map_type` of the merged map (e.g., "MAP_NRMRGH") will be used. The `Configuration` object will provide its `bit_depth_policy` (which, after refactoring `file_type_definitions.json`, should be `"preserve"` for relevant merged maps).
4. The refactored `save_image_variants` will then use this `"preserve"` policy along with the `source_bit_depth_info` (derived from the merge inputs) to correctly determine the output bit depth for the merged map.
### Phase 5: Review Other Code & Potential Impacts
1. Conduct a codebase search for any remaining direct usages of the old keys (`"bit_depth_rule"`, `"output_bit_depth"`) or their values.
2. Update these locations to use the new `Configuration.get_bit_depth_policy()` method and the new `"bit_depth_policy"` key and values.
3. Pay special attention to any prediction logic (e.g., in `gui/prediction_handler.py` or `gui/llm_prediction_handler.py`) if it currently considers or tries to infer bit depth rules.
## 4. Backward Compatibility & Migration
* This is a breaking change for existing user-customized configuration files (`file_type_definitions.json`, `app_settings.json`, and any custom presets).
* **Recommended Approach**: Implement migration logic within the `Configuration` class's loading methods.
* When loading `file_type_definitions.json`: If `"bit_depth_rule"` is found, convert its value (e.g., `"respect"` to `"preserve"`) and store it under the new `"bit_depth_policy"` key. Log a warning.
* When loading `app_settings.json` (specifically `MAP_MERGE_RULES`): If `"output_bit_depth"` is found, convert its value (e.g., `"respect_inputs"` to `"preserve"`) and store it under `"bit_depth_policy"`. Log a warning.
* This ensures the application can still function with older user configs while guiding users to update.
## 5. Visualized Logic for `save_image_variants` (Post-Refactor)
```mermaid
graph TD
A[Start save_image_variants] --> B{Get bit_depth_policy for base_map_type};
B --> C{Policy is "force_8bit"?};
C -- Yes --> D[target_bit_depth = 8];
C -- No --> E{Policy is "force_16bit"?};
E -- Yes --> F[target_bit_depth = 16];
E -- No --> G{Policy is "preserve"?};
G -- Yes --> H{Any source_bit_depth_info > 8?};
H -- Yes --> I[target_bit_depth = 16];
H -- No --> J[target_bit_depth = 8];
G -- No --> K[Log warning: Unknown policy or "" policy, default to 8-bit or handle as per type];
K --> D;
D --> L[Proceed to save with 8-bit];
F --> M[Proceed to save with 16-bit];
I --> M;
J --> L;
L --> Z[End];
M --> Z;
```
This plan aims to create a more consistent, understandable, and correctly functioning system for handling bit depth across the application.

View File

@@ -1,6 +1,6 @@
# Asset Processing Utility # Asset Processing Utility
This tool streamlines the conversion of raw 3D asset source files from supplies (archives or folders) into a configurable library format. This tool streamlines the organisation of raw 3D asset source files from supplies (archives or folders) into a configurable library format.
Goals include automatically updating Assets in various DCC's on import as well - minimising end user workload. Goals include automatically updating Assets in various DCC's on import as well - minimising end user workload.
## Features ## Features

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

@@ -0,0 +1,280 @@
{
"preset_name": "Dinesen",
"supplier_name": "Dinesen",
"notes": "Preset for standard Poliigon downloads. Prioritizes _xxx16 files. Moves previews etc. to Extra/. Assumes Metal/Rough workflow.",
"source_naming": {
"separator": "_",
"part_indices": {
"base_name": 0,
"map_type": 1
},
"glossiness_keywords": [
"GLOSS"
]
},
"move_to_extra_patterns": [
"*_Preview*",
"*_Sphere*",
"*_Cube*",
"*_Flat*",
"*.txt",
"*.pdf",
"*.url",
"*.htm*",
"*_Fabric.*",
"*_DISP_*METALNESS*"
],
"map_type_mapping": [
{
"target_type": "MAP_COL",
"keywords": [
"COLOR*",
"COL",
"COL-*",
"DIFFUSE",
"DIF",
"ALBEDO"
]
},
{
"target_type": "MAP_NRM",
"keywords": [
"NORMAL*",
"NORM*",
"NRM*",
"N"
],
"priority_keywords": [
"*_NRM16*",
"*_NM16*",
"*Normal16*"
]
},
{
"target_type": "MAP_ROUGH",
"keywords": [
"ROUGHNESS",
"ROUGH"
]
},
{
"target_type": "MAP_GLOSS",
"keywords": [
"GLOSS"
]
},
{
"target_type": "MAP_AO",
"keywords": [
"AMBIENTOCCLUSION",
"AO"
]
},
{
"target_type": "MAP_DISP",
"keywords": [
"DISPLACEMENT",
"DISP",
"HEIGHT",
"BUMP"
],
"priority_keywords": [
"*_DISP16*",
"*_DSP16*",
"*DSP16*",
"*DISP16*",
"*Displacement16*",
"*Height16*"
]
},
{
"target_type": "MAP_REFL",
"keywords": [
"REFLECTION",
"REFL",
"SPECULAR",
"SPEC"
]
},
{
"target_type": "MAP_SSS",
"keywords": [
"SSS",
"SUBSURFACE*"
]
},
{
"target_type": "MAP_FUZZ",
"keywords": [
"FUZZ"
]
},
{
"target_type": "MAP_IDMAP",
"keywords": [
"IDMAP"
]
},
{
"target_type": "MAP_MASK",
"keywords": [
"OPAC*",
"TRANSP*",
"MASK*",
"ALPHA*"
]
},
{
"target_type": "MAP_METAL",
"keywords": [
"METAL*",
"METALLIC"
]
}
],
"asset_category_rules": {
"model_patterns": [
"*.fbx",
"*.obj",
"*.blend",
"*.mtl"
],
"decal_keywords": [
"Decal"
]
},
"archetype_rules": [
[
"Foliage",
{
"match_any": [
"Plant",
"Leaf",
"Leaves",
"Grass"
],
"match_all": []
}
],
[
"Fabric",
{
"match_any": [
"Fabric",
"Carpet",
"Cloth",
"Textile",
"Leather"
],
"match_all": []
}
],
[
"Wood",
{
"match_any": [
"Wood",
"Timber",
"Plank",
"Board"
],
"match_all": []
}
],
[
"Metal",
{
"match_any": [
"_Metal",
"Steel",
"Iron",
"Gold",
"Copper",
"Chrome",
"Aluminum",
"Brass",
"Bronze"
],
"match_all": []
}
],
[
"Concrete",
{
"match_any": [
"Concrete",
"Cement"
],
"match_all": []
}
],
[
"Ground",
{
"match_any": [
"Ground",
"Dirt",
"Soil",
"Mud",
"Sand",
"Gravel",
"Asphalt",
"Road",
"Moss"
],
"match_all": []
}
],
[
"Stone",
{
"match_any": [
"Stone",
"Rock*",
"Marble",
"Granite",
"Brick",
"Tile",
"Paving",
"Pebble*",
"Terrazzo",
"Slate"
],
"match_all": []
}
],
[
"Plaster",
{
"match_any": [
"Plaster",
"Stucco",
"Wall",
"Paint"
],
"match_all": []
}
],
[
"Plastic",
{
"match_any": [
"Plastic",
"PVC",
"Resin",
"Rubber"
],
"match_all": []
}
],
[
"Glass",
{
"match_any": [
"Glass"
],
"match_all": []
}
]
]
}

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,219 @@
{
"FILE_TYPE_DEFINITIONS": {
"MAP_COL": {
"bit_depth_policy": "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_policy": "preserve",
"color": "#cca2f1",
"description": "Normal Map",
"examples": [
"_nrm.",
"_normal."
],
"is_grayscale": false,
"keybind": "N",
"standard_type": "NRM"
},
"MAP_NRMRGH": {
"bit_depth_policy": "preserve",
"color": "#abcdef",
"description": "Normal + Roughness Merged Map",
"examples": [],
"is_grayscale": false,
"keybind": "",
"standard_type": "NRMRGH"
},
"MAP_METAL": {
"bit_depth_policy": "force_8bit",
"color": "#dcf4f2",
"description": "Metalness Map",
"examples": [
"_metal.",
"_met."
],
"is_grayscale": true,
"keybind": "M",
"standard_type": "METAL"
},
"MAP_ROUGH": {
"bit_depth_policy": "force_8bit",
"color": "#bfd6bf",
"description": "Roughness Map",
"examples": [
"_rough.",
"_rgh.",
"_gloss"
],
"is_grayscale": true,
"keybind": "R",
"standard_type": "ROUGH"
},
"MAP_GLOSS": {
"bit_depth_policy": "force_8bit",
"color": "#d6bfd6",
"description": "Glossiness Map",
"examples": [
"_gloss.",
"_gls."
],
"is_grayscale": true,
"keybind": "R",
"standard_type": "GLOSS"
},
"MAP_AO": {
"bit_depth_policy": "force_8bit",
"color": "#e3c7c7",
"description": "Ambient Occlusion Map",
"examples": [
"_ao.",
"_ambientocclusion."
],
"is_grayscale": true,
"keybind": "",
"standard_type": "AO"
},
"MAP_DISP": {
"bit_depth_policy": "preserve",
"color": "#c6ddd5",
"description": "Displacement/Height Map",
"examples": [
"_disp.",
"_height."
],
"is_grayscale": true,
"keybind": "D",
"standard_type": "DISP"
},
"MAP_REFL": {
"bit_depth_policy": "force_8bit",
"color": "#c2c2b9",
"description": "Reflection/Specular Map",
"examples": [
"_refl.",
"_specular."
],
"is_grayscale": true,
"keybind": "M",
"standard_type": "REFL"
},
"MAP_SSS": {
"bit_depth_policy": "preserve",
"color": "#a0d394",
"description": "Subsurface Scattering Map",
"examples": [
"_sss.",
"_subsurface."
],
"is_grayscale": true,
"keybind": "",
"standard_type": "SSS"
},
"MAP_FUZZ": {
"bit_depth_policy": "force_8bit",
"color": "#a2d1da",
"description": "Fuzz/Sheen Map",
"examples": [
"_fuzz.",
"_sheen."
],
"is_grayscale": true,
"keybind": "",
"standard_type": "FUZZ"
},
"MAP_IDMAP": {
"bit_depth_policy": "force_8bit",
"color": "#ca8fb4",
"description": "ID Map (for masking)",
"examples": [
"_id.",
"_matid."
],
"is_grayscale": false,
"keybind": "",
"standard_type": "IDMAP"
},
"MAP_MASK": {
"bit_depth_policy": "force_8bit",
"color": "#c6e2bf",
"description": "Generic Mask Map",
"examples": [
"_mask."
],
"is_grayscale": true,
"keybind": "",
"standard_type": "MASK"
},
"MAP_IMPERFECTION": {
"bit_depth_policy": "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_policy": "",
"color": "#3db2bd",
"description": "3D Model File",
"examples": [
".fbx",
".obj"
],
"is_grayscale": false,
"keybind": "",
"standard_type": ""
},
"EXTRA": {
"bit_depth_policy": "",
"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_policy": "",
"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

@@ -0,0 +1,267 @@
{
"llm_predictor_examples": [
{
"input": "MessyTextures/Concrete_Damage_Set/concrete_col.png\nMessyTextures/Concrete_Damage_Set/concrete_N.png\nMessyTextures/Concrete_Damage_Set/concrete_rough.jpg\nMessyTextures/Concrete_Damage_Set/height_map_concrete.tif\nMessyTextures/Concrete_Damage_Set/Thumbs.db\nMessyTextures/Fabric_Pattern/pattern_01_diffuse.tga\nMessyTextures/Fabric_Pattern/pattern_01_ao.png\nMessyTextures/Fabric_Pattern/pattern_01_normal.png\nMessyTextures/Fabric_Pattern/notes.txt\nMessyTextures/Fabric_Pattern/variant_blue_diffuse.tga\nMessyTextures/Fabric_Pattern/fabric_flat.jpg",
"output": {
"individual_file_analysis": [
{
"relative_file_path": "MessyTextures/Concrete_Damage_Set/concrete_col.png",
"classified_file_type": "MAP_COL",
"proposed_asset_group_name": "Concrete_Damage_Set"
},
{
"relative_file_path": "MessyTextures/Concrete_Damage_Set/concrete_N.png",
"classified_file_type": "MAP_NRM",
"proposed_asset_group_name": "Concrete_Damage_Set"
},
{
"relative_file_path": "MessyTextures/Concrete_Damage_Set/concrete_rough.jpg",
"classified_file_type": "MAP_ROUGH",
"proposed_asset_group_name": "Concrete_Damage_Set"
},
{
"relative_file_path": "MessyTextures/Concrete_Damage_Set/height_map_concrete.tif",
"classified_file_type": "MAP_DISP",
"proposed_asset_group_name": "Concrete_Damage_Set"
},
{
"relative_file_path": "MessyTextures/Concrete_Damage_Set/Thumbs.db",
"classified_file_type": "FILE_IGNORE",
"proposed_asset_group_name": null
},
{
"relative_file_path": "MessyTextures/Fabric_Pattern/pattern_01_diffuse.tga",
"classified_file_type": "MAP_COL",
"proposed_asset_group_name": "Fabric_Pattern_01"
},
{
"relative_file_path": "MessyTextures/Fabric_Pattern/pattern_01_ao.png",
"classified_file_type": "MAP_AO",
"proposed_asset_group_name": "Fabric_Pattern_01"
},
{
"relative_file_path": "MessyTextures/Fabric_Pattern/pattern_01_normal.png",
"classified_file_type": "MAP_NRM",
"proposed_asset_group_name": "Fabric_Pattern_01"
},
{
"relative_file_path": "MessyTextures/Fabric_Pattern/notes.txt",
"classified_file_type": "EXTRA",
"proposed_asset_group_name": "Fabric_Pattern_01"
},
{
"relative_file_path": "MessyTextures/Fabric_Pattern/variant_blue_diffuse.tga",
"classified_file_type": "MAP_COL",
"proposed_asset_group_name": "Fabric_Pattern_01"
},
{
"relative_file_path": "MessyTextures/Fabric_Pattern/fabric_flat.jpg",
"classified_file_type": "EXTRA",
"proposed_asset_group_name": "Fabric_Pattern_01"
}
],
"asset_group_classifications": {
"Concrete_Damage_Set": "Surface",
"Fabric_Pattern_01": "Surface"
}
}
},
{
"input": "SciFi_Drone/Drone_Model.fbx\nSciFi_Drone/Textures/Drone_BaseColor.png\nSciFi_Drone/Textures/Drone_Metallic.png\nSciFi_Drone/Textures/Drone_Roughness.png\nSciFi_Drone/Textures/Drone_Normal.png\nSciFi_Drone/Textures/Drone_Emissive.jpg\nSciFi_Drone/ReferenceImages/concept.jpg",
"output": {
"individual_file_analysis": [
{
"relative_file_path": "SciFi_Drone/Drone_Model.fbx",
"classified_file_type": "MODEL",
"proposed_asset_group_name": "SciFi_Drone"
},
{
"relative_file_path": "SciFi_Drone/Textures/Drone_BaseColor.png",
"classified_file_type": "MAP_COL",
"proposed_asset_group_name": "SciFi_Drone"
},
{
"relative_file_path": "SciFi_Drone/Textures/Drone_Metallic.png",
"classified_file_type": "MAP_METAL",
"proposed_asset_group_name": "SciFi_Drone"
},
{
"relative_file_path": "SciFi_Drone/Textures/Drone_Roughness.png",
"classified_file_type": "MAP_ROUGH",
"proposed_asset_group_name": "SciFi_Drone"
},
{
"relative_file_path": "SciFi_Drone/Textures/Drone_Normal.png",
"classified_file_type": "MAP_NRM",
"proposed_asset_group_name": "SciFi_Drone"
},
{
"relative_file_path": "SciFi_Drone/Textures/Drone_Emissive.jpg",
"classified_file_type": "EXTRA",
"proposed_asset_group_name": "SciFi_Drone"
},
{
"relative_file_path": "SciFi_Drone/ReferenceImages/concept.jpg",
"classified_file_type": "EXTRA",
"proposed_asset_group_name": "SciFi_Drone"
}
],
"asset_group_classifications": {
"SciFi_Drone": "Model"
}
}
},
{
"input": "21_hairs_deposits.tif\n22_hairs_fabric.tif\n23_hairs_fibres.tif\n24_hairs_fibres.tif\n25_bonus_isolatedFingerprints.tif\n26_bonus_isolatedPalmprint.tif\n27_metal_aluminum.tif\n28_metal_castIron.tif\n29_scratcehes_deposits_shapes.tif\n30_scratches_deposits.tif",
"output": {
"individual_file_analysis": [
{
"relative_file_path": "21_hairs_deposits.tif",
"classified_file_type": "MAP_IMPERFECTION",
"proposed_asset_group_name": "Hairs_Deposits_21"
},
{
"relative_file_path": "22_hairs_fabric.tif",
"classified_file_type": "MAP_IMPERFECTION",
"proposed_asset_group_name": "Hairs_Fabric_22"
},
{
"relative_file_path": "23_hairs_fibres.tif",
"classified_file_type": "MAP_IMPERFECTION",
"proposed_asset_group_name": "Hairs_Fibres_23"
},
{
"relative_file_path": "24_hairs_fibres.tif",
"classified_file_type": "MAP_IMPERFECTION",
"proposed_asset_group_name": "Hairs_Fibres_24"
},
{
"relative_file_path": "25_bonus_isolatedFingerprints.tif",
"classified_file_type": "MAP_IMPERFECTION",
"proposed_asset_group_name": "Bonus_IsolatedFingerprints_25"
},
{
"relative_file_path": "26_bonus_isolatedPalmprint.tif",
"classified_file_type": "MAP_IMPERFECTION",
"proposed_asset_group_name": "Bonus_IsolatedPalmprint_26"
},
{
"relative_file_path": "27_metal_aluminum.tif",
"classified_file_type": "MAP_IMPERFECTION",
"proposed_asset_group_name": "Metal_Aluminum_27"
},
{
"relative_file_path": "28_metal_castIron.tif",
"classified_file_type": "MAP_IMPERFECTION",
"proposed_asset_group_name": "Metal_CastIron_28"
},
{
"relative_file_path": "29_scratcehes_deposits_shapes.tif",
"classified_file_type": "MAP_IMPERFECTION",
"proposed_asset_group_name": "Scratches_Deposits_Shapes_29"
},
{
"relative_file_path": "30_scratches_deposits.tif",
"classified_file_type": "MAP_IMPERFECTION",
"proposed_asset_group_name": "Scratches_Deposits_30"
}
],
"asset_group_classifications": {
"Hairs_Deposits_21": "UtilityMap",
"Hairs_Fabric_22": "UtilityMap",
"Hairs_Fibres_23": "UtilityMap",
"Hairs_Fibres_24": "UtilityMap",
"Bonus_IsolatedFingerprints_25": "UtilityMap",
"Bonus_IsolatedPalmprint_26": "UtilityMap",
"Metal_Aluminum_27": "UtilityMap",
"Metal_CastIron_28": "UtilityMap",
"Scratches_Deposits_Shapes_29": "UtilityMap",
"Scratches_Deposits_30": "UtilityMap"
}
}
},
{
"input": "Part1/TextureSupply_Boards001_A_28x300cm-Albedo.jpg\nPart1/TextureSupply_Boards001_A_28x300cm-Normal.jpg\nPart1/TextureSupply_Boards001_B_28x300cm-Albedo.jpg\nPart1/TextureSupply_Boards001_B_28x300cm-Normal.jpg\nPart1/TextureSupply_Boards001_C_28x300cm-Albedo.jpg\nPart1/TextureSupply_Boards001_C_28x300cm-Normal.jpg\nPart1/TextureSupply_Boards001_D_28x300cm-Albedo.jpg\nPart1/TextureSupply_Boards001_D_28x300cm-Normal.jpg\nPart1/TextureSupply_Boards001_E_28x300cm-Albedo.jpg\nPart1/TextureSupply_Boards001_E_28x300cm-Normal.jpg\nPart1/TextureSupply_Boards001_F_28x300cm-Albedo.jpg\nPart1/TextureSupply_Boards001_F_28x300cm-Normal.jpg",
"output": {
"individual_file_analysis": [
{
"relative_file_path": "Part1/TextureSupply_Boards001_A_28x300cm-Albedo.jpg",
"classified_file_type": "MAP_COL",
"proposed_asset_group_name": "Boards001_A"
},
{
"relative_file_path": "Part1/TextureSupply_Boards001_A_28x300cm-Normal.jpg",
"classified_file_type": "MAP_NRM",
"proposed_asset_group_name": "Boards001_A"
},
{
"relative_file_path": "Part1/TextureSupply_Boards001_B_28x300cm-Albedo.jpg",
"classified_file_type": "MAP_COL",
"proposed_asset_group_name": "Boards001_B"
},
{
"relative_file_path": "Part1/TextureSupply_Boards001_B_28x300cm-Normal.jpg",
"classified_file_type": "MAP_NRM",
"proposed_asset_group_name": "Boards001_B"
},
{
"relative_file_path": "Part1/TextureSupply_Boards001_C_28x300cm-Albedo.jpg",
"classified_file_type": "MAP_COL",
"proposed_asset_group_name": "Boards001_C"
},
{
"relative_file_path": "Part1/TextureSupply_Boards001_C_28x300cm-Normal.jpg",
"classified_file_type": "MAP_NRM",
"proposed_asset_group_name": "Boards001_C"
},
{
"relative_file_path": "Part1/TextureSupply_Boards001_D_28x300cm-Albedo.jpg",
"classified_file_type": "MAP_COL",
"proposed_asset_group_name": "Boards001_D"
},
{
"relative_file_path": "Part1/TextureSupply_Boards001_D_28x300cm-Normal.jpg",
"classified_file_type": "MAP_NRM",
"proposed_asset_group_name": "Boards001_D"
},
{
"relative_file_path": "Part1/TextureSupply_Boards001_E_28x300cm-Albedo.jpg",
"classified_file_type": "MAP_COL",
"proposed_asset_group_name": "Boards001_E"
},
{
"relative_file_path": "Part1/TextureSupply_Boards001_E_28x300cm-Normal.jpg",
"classified_file_type": "MAP_NRM",
"proposed_asset_group_name": "Boards001_E"
},
{
"relative_file_path": "Part1/TextureSupply_Boards001_F_28x300cm-Albedo.jpg",
"classified_file_type": "MAP_COL",
"proposed_asset_group_name": "Boards001_F"
},
{
"relative_file_path": "Part1/TextureSupply_Boards001_F_28x300cm-Normal.jpg",
"classified_file_type": "MAP_NRM",
"proposed_asset_group_name": "Boards001_F"
}
],
"asset_group_classifications": {
"Boards001_A": "Surface",
"Boards001_B": "Surface",
"Boards001_C": "Surface",
"Boards001_D": "Surface",
"Boards001_E": "Surface",
"Boards001_F": "Surface"
}
}
}
],
"asset_type_definition_format": "{KEY} = {DESCRIPTION}, examples of content of {KEY} could be: {EXAMPLES}",
"file_type_definition_format": "{KEY} = {DESCRIPTION}, examples of keywords for {KEY} could be: {EXAMPLES}",
"llm_endpoint_url": "http://100.65.14.122:1234/v1/chat/completions",
"llm_api_key": "",
"llm_model_name": "qwen2.5-coder:3b",
"llm_temperature": 0.5,
"llm_request_timeout": 120,
"llm_predictor_prompt": "You are an expert asset classification system. Your task is to analyze a list of file paths, understand their relationships based on naming and directory structure, and output a structured JSON object that classifies each file individually and then classifies the logical asset groups they belong to.\\n\\nDefinitions:\\n\\nAsset Types: These define the overall category of a logical asset group. Use one of the following keys when classifying asset groups. Each definition is provided as a formatted string (e.g., 'Surface = A single PBR material set..., examples: WoodFloor01, MetalPlate05'):\\n{ASSET_TYPE_DEFINITIONS}\\n\\n\\nFile Types: These define the specific purpose of each individual file. Use one of the following keys when classifying individual files. Each definition is provided as a formatted string (e.g., 'MAP_COL = Color/Albedo Map, examples: _col., _basecolor.'):\\n{FILE_TYPE_DEFINITIONS}\\n\\n\\nCore Task & Logic:\\n\\n1. **Individual File Analysis:**\\n * Examine each `relative_file_path` in the input `FILE_LIST`.\\n * For EACH file, determine its most likely `classified_file_type` using the `FILE_TYPE_DEFINITIONS`. Pay attention to filename suffixes, keywords, and extensions. Use `FILE_IGNORE` for files like `Thumbs.db` or `.DS_Store`. Use `EXTRA` for previews, metadata, or unidentifiable maps.\\n * For EACH file, propose a logical `proposed_asset_group_name` (string). This name should represent the asset the file likely belongs to, based on common base names (e.g., `WoodFloor01` from `WoodFloor01_col.png`, `WoodFloor01_nrm.png`) or directory structure (e.g., `SciFi_Drone` for files within that folder).\\n * Files that seem to be standalone utility maps (like `scratches.png`, `FlowMap.tif`) should get a unique group name derived from their filename (e.g., `Scratches`, `FlowMap`).\\n * If a file doesn't seem to belong to any logical group (e.g., a stray readme file in the root), you can propose `null` or a generic name like `Miscellaneous`.\\n * Be consistent with the proposed names for files belonging to the same logical asset.\\n * Populate the `individual_file_analysis` array with one object for *every* file in the input list, containing `relative_file_path`, `classified_file_type`, and `proposed_asset_group_name`.\\n\\n2. **Asset Group Classification:**\\n * Collect all unique, non-null `proposed_asset_group_name` values generated in the previous step.\\n * For EACH unique group name, determine the overall `asset_type` (using `ASSET_TYPE_DEFINITIONS`) based on the types of files assigned to that group name in the `individual_file_analysis`.\\n * Example: If files proposed as `AssetGroup1` include `MAP_COL`, `MAP_NRM`, `MAP_ROUGH`, classify `AssetGroup1` as `Surface`.\\n * Example: If files proposed as `AssetGroup2` include `MODEL` and texture maps, classify `AssetGroup2` as `Model`.\\n * Example: If `AssetGroup3` only has one file classified as `MAP_IMPERFECTION`, classify `AssetGroup3` as `UtilityMap`.\\n * Populate the `asset_group_classifications` dictionary, mapping each unique `proposed_asset_group_name` to its determined `asset_type`.\\n\\nInput File List:\\n\\ntext\\n{FILE_LIST}\\n\\n\\nOutput Format:\\n\\nYour response MUST be ONLY a single JSON object. You MAY include comments (using // or /* */) within the JSON structure for clarification if needed, but the core structure must be valid JSON. Do NOT include any text, explanations, or introductory phrases before or after the JSON object itself. Ensure all strings are correctly quoted and escaped.\\n\\nCRITICAL: The output JSON structure must strictly adhere to the following format:\\n\\n```json\\n{{\\n \"individual_file_analysis\": [\\n {{\\n // Optional comment about this file\\n \"relative_file_path\": \"string\", // Exact relative path from the input list\\n \"classified_file_type\": \"string\", // Key from FILE_TYPE_DEFINITIONS\\n \"proposed_asset_group_name\": \"string_or_null\" // Your suggested group name for this file\\n }}\\n // ... one object for EVERY file in the input list\\n ],\\n \"asset_group_classifications\": {{\\n // Dictionary mapping unique proposed group names to asset types\\n \"ProposedGroupName1\": \"string\", // Key: proposed_asset_group_name, Value: Key from ASSET_TYPE_DEFINITIONS\\n \"ProposedGroupName2\": \"string\"\\n // ... one entry for each unique, non-null proposed_asset_group_name\\n }}\\n}}\\n```\\n\\nExamples:\\n\\nHere are examples of input file lists and the desired JSON output, illustrating the two-part structure:\\n\\njson\\n[\\n {EXAMPLE_INPUT_OUTPUT_PAIRS}\\n]\\n\\n\\nNow, process the provided FILE_LIST and generate ONLY the JSON output according to these instructions. Remember to include an entry in `individual_file_analysis` for every single input file path."
}

View File

@@ -0,0 +1,11 @@
{
"Dimensiva": {
"normal_map_type": "OpenGL"
},
"Dinesen": {
"normal_map_type": "OpenGL"
},
"Poliigon": {
"normal_map_type": "OpenGL"
}
}

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.")
@@ -588,63 +604,64 @@ class AutoTester(QObject):
logger.error(f"Value mismatch for field '{key}' in {item_type_name} ({current_context}): Actual='{actual_value}', Expected='{expected_value}'.") logger.error(f"Value mismatch for field '{key}' in {item_type_name} ({current_context}): Actual='{actual_value}', Expected='{expected_value}'.")
item_match = False item_match = False
return item_match return item_match
def _compare_list_of_rules(self, actual_list: List[Dict[str, Any]], expected_list: List[Dict[str, Any]], item_type_name: str, parent_context: str, item_key_field: str) -> bool: def _compare_list_of_rules(self, actual_list: List[Dict[str, Any]], expected_list: List[Dict[str, Any]], item_type_name: str, parent_context: str, item_key_field: str) -> bool:
""" """
Compares a list of actual rule items against a list of expected rule items. Compares a list of actual rule items against a list of expected rule items.
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
if len(actual_list) != len(expected_list): if len(actual_list) != len(expected_list):
logger.error(f"Mismatch in number of {item_type_name}s for {parent_context}. Actual: {len(actual_list)}, Expected: {len(expected_list)}.") logger.error(f"Mismatch in number of {item_type_name}s for {parent_context}. Actual: {len(actual_list)}, Expected: {len(expected_list)}.")
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}
# Keep track of expected items that found a match to identify missing ones more easily actual_items_map = {item.get(item_key_field): item for item in actual_list if item.get(item_key_field) is not None}
matched_expected_keys = set()
for expected_item in expected_list: # Keep track of expected items that found a match to identify missing ones more easily
expected_key_value = expected_item.get(item_key_field) matched_expected_keys = set()
if expected_key_value is None:
logger.error(f"Expected {item_type_name} in {parent_context} is missing key field '{item_key_field}'. Cannot compare this item: {expected_item}")
list_match = False # This specific expected item cannot be processed
continue
actual_item = actual_items_map.get(expected_key_value) for expected_item in expected_list:
if actual_item: expected_key_value = expected_item.get(item_key_field)
matched_expected_keys.add(expected_key_value) if expected_key_value is None:
if not self._compare_rule_item(actual_item, expected_item, item_type_name, parent_context): logger.error(f"Expected {item_type_name} in {parent_context} is missing key field '{item_key_field}'. Cannot compare this item: {expected_item}")
list_match = False # Individual item comparison failed list_match = False # This specific expected item cannot be processed
else: continue
logger.error(f"Expected {item_type_name} with {item_key_field} '{expected_key_value}' not found in actual items for {parent_context}.")
actual_item = actual_items_map.get(expected_key_value)
if actual_item:
matched_expected_keys.add(expected_key_value)
if not self._compare_rule_item(actual_item, expected_item, item_type_name, parent_context):
list_match = False # Individual item comparison failed
else:
logger.error(f"Expected {item_type_name} with {item_key_field} '{expected_key_value}' not found in actual items for {parent_context}.")
list_match = False
# Identify actual items that were not matched by any expected item
# This is useful if len(actual_list) >= len(expected_list) but some actual items are "extra"
for actual_key_value, actual_item_data in actual_items_map.items():
if actual_key_value not in matched_expected_keys:
logger.debug(f"Extra actual {item_type_name} with {item_key_field} '{actual_key_value}' found in {parent_context} (not in expected list or already matched).")
if len(actual_list) != len(expected_list): # If counts already flagged a mismatch, this is just detail
pass
else: # Counts matched, but content didn't align perfectly by key
list_match = False list_match = False
# Identify actual items that were not matched by any expected item
# This is useful if len(actual_list) >= len(expected_list) but some actual items are "extra"
for actual_key_value, actual_item_data in actual_items_map.items():
if actual_key_value not in matched_expected_keys:
logger.debug(f"Extra actual {item_type_name} with {item_key_field} '{actual_key_value}' found in {parent_context} (not in expected list or already matched).")
if len(actual_list) != len(expected_list): # If counts already flagged a mismatch, this is just detail
pass
else: # Counts matched, but content didn't align perfectly by key
list_match = False
return list_match
return list_match # Corrected indentation
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 +790,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.")
@@ -781,7 +802,7 @@ class AutoTester(QObject):
autotest_memory_handler.close() # MemoryHandler close is a no-op but good practice autotest_memory_handler.close() # MemoryHandler close is a no-op but good practice
autotest_memory_handler = None autotest_memory_handler = None
logger.info(f"Test {'succeeded' if success else 'failed'}. Cleaning up and exiting...") # KEEP INFO - Passes filter logger.info(f"Test {'succeeded' if success else 'failed'}. Cleaning up and exiting...you can ignore the non-zero exitcode") # KEEP INFO - Passes filter
q_app = QCoreApplication.instance() q_app = QCoreApplication.instance()
if q_app: if q_app:
q_app.quit() q_app.quit()
@@ -792,7 +813,7 @@ def main():
"""Main function to run the autotest script.""" """Main function to run the autotest script."""
cli_args = parse_arguments() cli_args = parse_arguments()
# Logger is configured above, this will now use the new filtered setup # Logger is configured above, this will now use the new filtered setup
logger.info(f"Parsed CLI arguments: {cli_args}") # KEEP INFO - Passes filter #logger.info(f"Parsed CLI arguments: {cli_args}") # KEEP INFO - Passes filter
# Clean and ensure output directory exists # Clean and ensure output directory exists
output_dir_path = Path(cli_args.outputdir) output_dir_path = Path(cli_args.outputdir)
@@ -833,9 +854,15 @@ def main():
try: try:
# Instantiate main.App() - this should create MainWindow but not show it by default # Instantiate main.App() - this should create MainWindow but not show it by default
# if App is designed to not show GUI unless app.main_window.show() is called. # if App is designed to not show GUI unless app.main_window.show() is called.
app_instance = App() # Define a user config path for the test environment
test_user_config_path = project_root / "TestFiles" / "TestConfig"
test_user_config_path.mkdir(parents=True, exist_ok=True) # Ensure the directory exists
app_instance = App(user_config_path=str(test_user_config_path)) # Pass the path as a string
# Load the preset after App initialization
app_instance.load_preset(cli_args.preset)
except Exception as e: except Exception as e:
logger.error(f"Failed to initialize main.App: {e}", exc_info=True) logger.error(f"Failed to initialize main.App or load preset: {e}", exc_info=True)
sys.exit(1) sys.exit(1)
if not app_instance.main_window: if not app_instance.main_window:

View File

@@ -0,0 +1,167 @@
# Plan: Assessing Compilation of Asset Processor with PyInstaller and Cython
## Objective
To assess the feasibility and create a plan for compiling the Asset Processor project into standalone executables using PyInstaller, incorporating Cython for general speedup and source code obfuscation. A key requirement is to maintain user access to, and the ability to modify, configuration files (like `user_settings.json`, `asset_type_definitions.json`, etc.) and `Preset` files post-compilation.
---
## Phase 1: Initial Analysis & Information Gathering
* **Project Dependencies (from [`requirements.txt`](requirements.txt:1)):**
* `opencv-python`
* `numpy`
* `openexr`
* `PySide6`
* `py7zr`
* `rarfile`
* `requests`
* *Note: `PySide6`, `opencv-python`, and `openexr` may require special handling with PyInstaller (e.g., hidden imports, hooks).*
* **Configuration Loading (based on [`configuration.py`](configuration.py:1)):**
* Configuration files (`app_settings.json`, `llm_settings.json`, `asset_type_definitions.json`, `file_type_definitions.json`, `user_settings.json`, `suppliers.json`) are loaded from a `config/` subdirectory relative to [`configuration.py`](configuration.py:1).
* Preset files are loaded from a `Presets/` subdirectory relative to [`configuration.py`](configuration.py:1).
* `BASE_DIR` is `Path(__file__).parent`, which will refer to the bundled location in a PyInstaller build.
* [`user_settings.json`](configuration.py:16) is designed for overrides and is a candidate for external management.
* Saving functions write back to these relative paths, which needs adaptation.
* **Potential Cython Candidates:**
* Modules within the `processing/` directory.
* Specifically: `processing/utils/image_processing_utils.py` and individual stage files in `processing/pipeline/stages/` (e.g., `alpha_extraction_to_mask.py`, `gloss_to_rough_conversion.py`, etc.).
* Other modules (e.g., `processing/pipeline/orchestrator.py`) could be Cythonized primarily for obfuscation.
* **User-Accessible Files (Defaults):**
* The `config/` directory (containing `app_settings.json`, `asset_type_definitions.json`, `file_type_definitions.json`, `llm_settings.json`, `suppliers.json`).
* The `Presets/` directory and its contents.
---
## Phase 2: Strategy Development
1. **Cython Strategy:**
* **Build Integration:** Utilize a `setup.py` script with `setuptools` and `Cython.Build.cythonize` to compile `.py` files into C extensions (`.pyd` on Windows, `.so` on Linux/macOS).
* **Candidate Prioritization:** Focus on `processing/` modules for performance gains and obfuscation.
* **Compatibility & Challenges:**
* GUI modules (PySide6) are generally left as Python.
* Ensure compatibility with OpenCV, NumPy, and OpenEXR.
* Address potential issues with highly dynamic Python code.
* Consider iterative conversion to `.pyx` files with C-style type annotations for maximum performance in identified hot spots.
* **Obfuscation:** The primary goal for many modules might be obfuscation rather than pure speedup.
2. **PyInstaller Strategy:**
* **Bundle Type:** One-directory bundle (`--onedir`) is recommended for easier debugging and data file management.
* **Data Files (`.spec` file `datas` section):**
* Bundle default `config/` directory (containing `app_settings.json`, `asset_type_definitions.json`, `file_type_definitions.json`, `llm_settings.json`, `suppliers.json`).
* Bundle default `Presets/` directory.
* Include any other necessary GUI assets (icons, etc.).
* Consider bundling the `blender_addon/` if it's to be deployed with the app.
* **Hidden Imports & Hooks (`.spec` file):**
* Add explicit `hiddenimports` for `PySide6`, `opencv-python`, `openexr`, and any other problematic libraries.
* Utilize or create PyInstaller hooks if necessary.
* **Console Window:** Disable for GUI application (`console=False`).
3. **User-Accessible Files & First-Time Setup Strategy:**
* **First-Run Detection:** Application checks for a marker file or stored configuration path.
* **First-Time Setup UI (PySide6 Dialog):**
* **Configuration Location Choice:**
* Option A (Recommended): Store in user profile (e.g., `Documents/AssetProcessor` or `AppData/Roaming/AssetProcessor`).
* Option B (Advanced): User chooses a custom folder.
* The application copies default `config/` (excluding `app_settings.json` but including other definition files) and `Presets/` to the chosen location.
* The chosen path is saved.
* **Key Application Settings Configuration (saved to `user_settings.json` in user's chosen location):**
* Default Library Output Path (`OUTPUT_BASE_DIR`).
* Asset Structure (`OUTPUT_DIRECTORY_PATTERN`).
* Image Output Formats (`OUTPUT_FORMAT_16BIT_PRIMARY`, `OUTPUT_FORMAT_16BIT_FALLBACK`, `OUTPUT_FORMAT_8BIT`).
* JPG Threshold (`RESOLUTION_THRESHOLD_FOR_JPG`).
* Blender Paths (`DEFAULT_NODEGROUP_BLEND_PATH`, `DEFAULT_MATERIALS_BLEND_PATH`, `BLENDER_EXECUTABLE_PATH`).
* **Configuration Loading Logic Modification ([`configuration.py`](configuration.py:1)):**
* `BASE_DIR` for user-modifiable files will point to the user-chosen location.
* `app_settings.json` (master defaults) always loaded from the bundle.
* `user_settings.json` loaded from the user-chosen location, containing overrides.
* Other definition files and `Presets` loaded from the user-chosen location, with a fallback/re-copy mechanism from bundled defaults if missing.
* **Saving Logic Modification ([`configuration.py`](configuration.py:1)):**
* All configuration saving functions will write to the user-chosen configuration location. Bundled defaults remain read-only post-installation.
---
## Phase 3: Outline of Combined Build Process
1. **Environment Setup (Developer):** Install Python, Cython, PyInstaller, and project dependencies.
2. **Cythonization (`setup.py`):**
* Create `setup.py` using `setuptools` and `Cython.Build.cythonize`.
* List `.py` files/modules for compilation (e.g., `processing.utils.image_processing_utils`, `processing.pipeline.stages.*`).
* Include `numpy.get_include()` if Cython files use NumPy C-API.
* Run `python setup.py build_ext --inplace` to generate `.pyd`/`.so` files.
3. **PyInstaller Packaging (`.spec` file):**
* Generate initial `AssetProcessor.spec` with `pyinstaller --name AssetProcessor main.py`.
* Modify `.spec` file:
* `datas`: Add default `config/` and `Presets/` directories, and other assets.
* `hiddenimports`: List modules for `PySide6`, `opencv-python`, etc.
* `excludes`: Optionally exclude original `.py` files for Cythonized modules.
* Set `onedir = True`, `onefile = False`, `console = False`.
* Run `pyinstaller AssetProcessor.spec` to create `dist/AssetProcessor`.
4. **Post-Build Steps (Optional):**
* Clean up original `.py` files from `dist/` if obfuscation is paramount.
* Archive `dist/AssetProcessor` for distribution (ZIP, installer).
---
## Phase 4: Distribution Structure
**Inside `dist/AssetProcessor/` (Distribution Package):**
* `AssetProcessor.exe` (or platform equivalent)
* Core Python and library dependencies (DLLs/SOs)
* Cythonized modules (`.pyd`/`.so` files, e.g., `processing/utils/image_processing_utils.pyd`)
* Non-Cythonized Python modules (`.pyc` files)
* Bundled default `config/` directory (with `app_settings.json`, `asset_type_definitions.json`, etc.)
* Bundled default `Presets/` directory (with `_template.json`, `Dinesen.json`, etc.)
* Other GUI assets (icons, etc.)
* Potentially `blender_addon/` files if bundled.
**User's Configuration Directory (e.g., `Documents/AssetProcessor/`, created on first run):**
* `user_settings.json` (user's choices for paths, formats, etc.)
* Copied `config/` directory (for user modification of `asset_type_definitions.json`, etc.)
* Copied `Presets/` directory (for user modification/additions)
* Marker file for first-time setup choice.
---
## Phase 5: Plan for Testing & Validation
1. **Core Functionality:** Test GUI operations, Directory Monitor, CLI (if applicable).
2. **Configuration System:**
* Verify first-time setup UI, config location choice, copying of defaults.
* Confirm loading from and saving to the user's chosen config location.
* Test modification of user configs and application's reflection of changes.
3. **Dependency Checks:** Ensure bundled libraries (PySide6, OpenCV) function correctly.
4. **Performance (Cython):** Basic comparison of critical operations (Python vs. Cythonized).
5. **Obfuscation (Cython):** Verify absence of original `.py` files for Cythonized modules in distribution (if desired) and that `.pyd`/`.so` files are used.
6. **Cross-Platform Testing:** Repeat build and test process on all target OS.
---
## Phase 6: Documentation Outline
1. **Developer/Build Documentation:**
* Build environment setup.
* `setup.py` (Cython) and `pyinstaller` command usage.
* Structure of `setup.py` and `.spec` file, key configurations.
* Troubleshooting common build issues.
2. **User Documentation:**
* First-time setup guide (config location, initial settings).
* Managing user-specific configurations and presets (location, backup).
* How to reset to default configurations.
---
## Phase 7: Risk Assessment & Mitigation (Brief)
* **Risk:** Cython compilation issues.
* **Mitigation:** Incremental compilation, selective Cythonization.
* **Risk:** PyInstaller packaging complexities.
* **Mitigation:** Thorough testing, community hooks, iterative `.spec` refinement.
* **Risk:** Logic errors in new configuration loading/saving.
* **Mitigation:** Careful coding, detailed testing of config pathways.
* **Risk:** Cython performance not meeting expectations.
* **Mitigation:** Profile Python code first; focus Cython on CPU-bound loops.
* **Risk:** Increased build complexity.
* **Mitigation:** Automate build steps with scripts.

View File

@@ -1,5 +1,4 @@
{ {
"TARGET_FILENAME_PATTERN": "{base_name}_{map_type}_{resolution}.{ext}",
"RESPECT_VARIANT_MAP_TYPES": [ "RESPECT_VARIANT_MAP_TYPES": [
"COL" "COL"
], ],
@@ -38,7 +37,7 @@
"G": 0.5, "G": 0.5,
"B": 0.5 "B": 0.5
}, },
"output_bit_depth": "respect_inputs" "bit_depth_policy": "preserve"
} }
], ],
"CALCULATE_STATS_RESOLUTION": "1K", "CALCULATE_STATS_RESOLUTION": "1K",
@@ -46,7 +45,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

@@ -1,7 +1,7 @@
{ {
"FILE_TYPE_DEFINITIONS": { "FILE_TYPE_DEFINITIONS": {
"MAP_COL": { "MAP_COL": {
"bit_depth_rule": "force_8bit", "bit_depth_policy": "force_8bit",
"color": "#ffaa00", "color": "#ffaa00",
"description": "Color/Albedo Map", "description": "Color/Albedo Map",
"examples": [ "examples": [
@@ -15,7 +15,7 @@
"standard_type": "COL" "standard_type": "COL"
}, },
"MAP_NRM": { "MAP_NRM": {
"bit_depth_rule": "respect", "bit_depth_policy": "preserve",
"color": "#cca2f1", "color": "#cca2f1",
"description": "Normal Map", "description": "Normal Map",
"examples": [ "examples": [
@@ -27,7 +27,7 @@
"standard_type": "NRM" "standard_type": "NRM"
}, },
"MAP_METAL": { "MAP_METAL": {
"bit_depth_rule": "force_8bit", "bit_depth_policy": "force_8bit",
"color": "#dcf4f2", "color": "#dcf4f2",
"description": "Metalness Map", "description": "Metalness Map",
"examples": [ "examples": [
@@ -39,7 +39,7 @@
"standard_type": "METAL" "standard_type": "METAL"
}, },
"MAP_ROUGH": { "MAP_ROUGH": {
"bit_depth_rule": "force_8bit", "bit_depth_policy": "force_8bit",
"color": "#bfd6bf", "color": "#bfd6bf",
"description": "Roughness Map", "description": "Roughness Map",
"examples": [ "examples": [
@@ -52,7 +52,7 @@
"standard_type": "ROUGH" "standard_type": "ROUGH"
}, },
"MAP_GLOSS": { "MAP_GLOSS": {
"bit_depth_rule": "force_8bit", "bit_depth_policy": "force_8bit",
"color": "#d6bfd6", "color": "#d6bfd6",
"description": "Glossiness Map", "description": "Glossiness Map",
"examples": [ "examples": [
@@ -64,7 +64,7 @@
"standard_type": "GLOSS" "standard_type": "GLOSS"
}, },
"MAP_AO": { "MAP_AO": {
"bit_depth_rule": "force_8bit", "bit_depth_policy": "force_8bit",
"color": "#e3c7c7", "color": "#e3c7c7",
"description": "Ambient Occlusion Map", "description": "Ambient Occlusion Map",
"examples": [ "examples": [
@@ -76,7 +76,7 @@
"standard_type": "AO" "standard_type": "AO"
}, },
"MAP_DISP": { "MAP_DISP": {
"bit_depth_rule": "respect", "bit_depth_policy": "preserve",
"color": "#c6ddd5", "color": "#c6ddd5",
"description": "Displacement/Height Map", "description": "Displacement/Height Map",
"examples": [ "examples": [
@@ -88,7 +88,7 @@
"standard_type": "DISP" "standard_type": "DISP"
}, },
"MAP_REFL": { "MAP_REFL": {
"bit_depth_rule": "force_8bit", "bit_depth_policy": "force_8bit",
"color": "#c2c2b9", "color": "#c2c2b9",
"description": "Reflection/Specular Map", "description": "Reflection/Specular Map",
"examples": [ "examples": [
@@ -100,7 +100,7 @@
"standard_type": "REFL" "standard_type": "REFL"
}, },
"MAP_SSS": { "MAP_SSS": {
"bit_depth_rule": "respect", "bit_depth_policy": "preserve",
"color": "#a0d394", "color": "#a0d394",
"description": "Subsurface Scattering Map", "description": "Subsurface Scattering Map",
"examples": [ "examples": [
@@ -112,7 +112,7 @@
"standard_type": "SSS" "standard_type": "SSS"
}, },
"MAP_FUZZ": { "MAP_FUZZ": {
"bit_depth_rule": "force_8bit", "bit_depth_policy": "force_8bit",
"color": "#a2d1da", "color": "#a2d1da",
"description": "Fuzz/Sheen Map", "description": "Fuzz/Sheen Map",
"examples": [ "examples": [
@@ -124,7 +124,7 @@
"standard_type": "FUZZ" "standard_type": "FUZZ"
}, },
"MAP_IDMAP": { "MAP_IDMAP": {
"bit_depth_rule": "force_8bit", "bit_depth_policy": "force_8bit",
"color": "#ca8fb4", "color": "#ca8fb4",
"description": "ID Map (for masking)", "description": "ID Map (for masking)",
"examples": [ "examples": [
@@ -136,7 +136,7 @@
"standard_type": "IDMAP" "standard_type": "IDMAP"
}, },
"MAP_MASK": { "MAP_MASK": {
"bit_depth_rule": "force_8bit", "bit_depth_policy": "force_8bit",
"color": "#c6e2bf", "color": "#c6e2bf",
"description": "Generic Mask Map", "description": "Generic Mask Map",
"examples": [ "examples": [
@@ -146,8 +146,19 @@
"keybind": "", "keybind": "",
"standard_type": "MASK" "standard_type": "MASK"
}, },
"MAP_NRMRGH": {
"bit_depth_policy": "preserve",
"color": "#abcdef",
"description": "Packed Normal + Roughness + Metallic Map",
"examples": [
"_nrmrgh."
],
"is_grayscale": false,
"keybind": "",
"standard_type": "NRMRGH"
},
"MAP_IMPERFECTION": { "MAP_IMPERFECTION": {
"bit_depth_rule": "force_8bit", "bit_depth_policy": "force_8bit",
"color": "#e6d1a6", "color": "#e6d1a6",
"description": "Imperfection Map (scratches, dust)", "description": "Imperfection Map (scratches, dust)",
"examples": [ "examples": [
@@ -164,7 +175,7 @@
"standard_type": "IMPERFECTION" "standard_type": "IMPERFECTION"
}, },
"MODEL": { "MODEL": {
"bit_depth_rule": "", "bit_depth_policy": "",
"color": "#3db2bd", "color": "#3db2bd",
"description": "3D Model File", "description": "3D Model File",
"examples": [ "examples": [
@@ -176,7 +187,7 @@
"standard_type": "" "standard_type": ""
}, },
"EXTRA": { "EXTRA": {
"bit_depth_rule": "", "bit_depth_policy": "",
"color": "#8c8c8c", "color": "#8c8c8c",
"description": "asset previews or metadata", "description": "asset previews or metadata",
"examples": [ "examples": [
@@ -190,19 +201,21 @@
], ],
"is_grayscale": false, "is_grayscale": false,
"keybind": "E", "keybind": "E",
"standard_type": "" "standard_type": "EXTRA"
}, },
"FILE_IGNORE": { "FILE_IGNORE": {
"bit_depth_rule": "", "bit_depth_policy": "",
"color": "#673d35", "color": "#673d35",
"description": "File to be ignored", "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": [ "examples": [
"Thumbs.db", "Thumbs.db",
".DS_Store" ".DS_Store"
], ],
"is_grayscale": false, "is_grayscale": false,
"keybind": "X", "keybind": "X",
"standard_type": "" "standard_type": "",
"details": {}
} }
} }
} }

File diff suppressed because it is too large Load Diff

Binary file not shown.

BIN
context_portal/context.db LFS Normal file

Binary file not shown.

View File

@@ -973,27 +973,27 @@ class ConfigEditorDialog(QDialog):
self.merge_rule_details_layout.addRow(group) self.merge_rule_details_layout.addRow(group)
self.merge_rule_widgets["defaults_table"] = defaults_table self.merge_rule_widgets["defaults_table"] = defaults_table
# bit_depth_policy: QComboBox (Options: "preserve", "force_8bit", "force_16bit"). Label: "Bit Depth Policy".
# output_bit_depth: QComboBox (Options: "respect_inputs", "force_8bit", "force_16bit"). Label: "Output Bit Depth". if "bit_depth_policy" in rule_data:
if "output_bit_depth" in rule_data: label = QLabel("Bit Depth Policy:")
label = QLabel("Output Bit Depth:") widget = QComboBox()
widget = QComboBox() options = ["preserve", "force_8bit", "force_16bit"]
options = ["respect_inputs", "force_8bit", "force_16bit"] widget.addItems(options)
widget.addItems(options) if rule_data["bit_depth_policy"] in options:
if rule_data["output_bit_depth"] in options: widget.setCurrentText(rule_data["bit_depth_policy"])
widget.setCurrentText(rule_data["output_bit_depth"]) self.merge_rule_details_layout.addRow(label, widget)
self.merge_rule_details_layout.addRow(label, widget) self.merge_rule_widgets["bit_depth_policy"] = widget
self.merge_rule_widgets["output_bit_depth"] = widget
# Add stretch to push widgets to the top
self.merge_rule_details_layout.addStretch()
# Connect output_bit_depth QComboBox to update rule data # Add stretch to push widgets to the top
if "output_bit_depth" in self.merge_rule_widgets and isinstance(self.merge_rule_widgets["output_bit_depth"], QComboBox): self.merge_rule_details_layout.addStretch()
self.merge_rule_widgets["output_bit_depth"].currentTextChanged.connect(
lambda text, key="output_bit_depth": self.update_rule_data_simple_field(text, key)
) # Connect bit_depth_policy QComboBox to update rule data
if "bit_depth_policy" in self.merge_rule_widgets and isinstance(self.merge_rule_widgets["bit_depth_policy"], QComboBox):
self.merge_rule_widgets["bit_depth_policy"].currentTextChanged.connect(
lambda text, key="bit_depth_policy": self.update_rule_data_simple_field(text, key)
)
def update_rule_output_map_type(self, new_text): def update_rule_output_map_type(self, new_text):
@@ -1107,7 +1107,7 @@ class ConfigEditorDialog(QDialog):
"output_map_type": "NEW_RULE", "output_map_type": "NEW_RULE",
"inputs": {"R": "", "G": "", "B": "", "A": ""}, "inputs": {"R": "", "G": "", "B": "", "A": ""},
"defaults": {"R": 0.0, "G": 0.0, "B": 0.0, "A": 1.0}, "defaults": {"R": 0.0, "G": 0.0, "B": 0.0, "A": 1.0},
"output_bit_depth": "respect_inputs" "bit_depth_policy": "preserve"
} }
# Add to the internal list that backs the UI # Add to the internal list that backs the UI
@@ -1417,8 +1417,9 @@ class ConfigEditorDialog(QDialog):
self.widgets["RESOLUTION_THRESHOLD_FOR_JPG"].setCurrentText(current_text_selection) self.widgets["RESOLUTION_THRESHOLD_FOR_JPG"].setCurrentText(current_text_selection)
elif key == "MAP_BIT_DEPTH_RULES" and "MAP_BIT_DEPTH_RULES_TABLE" in self.widgets: # The MAP_BIT_DEPTH_RULES table is removed as per refactoring plan.
self.populate_map_bit_depth_rules_table(self.widgets["MAP_BIT_DEPTH_RULES_TABLE"], value) # elif key == "MAP_BIT_DEPTH_RULES" and "MAP_BIT_DEPTH_RULES_TABLE" in self.widgets:
# self.populate_map_bit_depth_rules_table(self.widgets["MAP_BIT_DEPTH_RULES_TABLE"], value)
elif key == "MAP_MERGE_RULES" and hasattr(self, 'merge_rules_list'): # Check if the list widget exists elif key == "MAP_MERGE_RULES" and hasattr(self, 'merge_rules_list'): # Check if the list widget exists
@@ -1492,10 +1493,10 @@ class ConfigEditorDialog(QDialog):
item_standard_type = QTableWidgetItem(standard_type_str) item_standard_type = QTableWidgetItem(standard_type_str)
table.setItem(row, 4, item_standard_type) table.setItem(row, 4, item_standard_type)
# Bit Depth Rule column (simple QTableWidgetItem for now) # Bit Depth Policy column (simple QTableWidgetItem for now)
bit_depth_rule_str = details.get("bit_depth_rule", "") bit_depth_policy_str = details.get("bit_depth_policy", "")
item_bit_depth_rule = QTableWidgetItem(bit_depth_rule_str) item_bit_depth_policy = QTableWidgetItem(bit_depth_policy_str)
table.setItem(row, 5, item_bit_depth_rule) table.setItem(row, 5, item_bit_depth_policy)
# Background color is now handled by the delegate's paint method based on data # Background color is now handled by the delegate's paint method based on data
@@ -1525,14 +1526,15 @@ class ConfigEditorDialog(QDialog):
row += 1 row += 1
def populate_map_bit_depth_rules_table(self, table: QTableWidget, rules_data: dict): # The populate_map_bit_depth_rules_table method is removed as per refactoring plan.
"""Populates the map bit depth rules table.""" # def populate_map_bit_depth_rules_table(self, table: QTableWidget, rules_data: dict):
table.setRowCount(len(rules_data)) # """Populates the map bit depth rules table."""
row = 0 # table.setRowCount(len(rules_data))
for map_type, rule in rules_data.items(): # row = 0
table.setItem(row, 0, QTableWidgetItem(map_type)) # for map_type, rule in rules_data.items():
table.setItem(row, 1, QTableWidgetItem(str(rule))) # Rule (respect/force_8bit) # table.setItem(row, 0, QTableWidgetItem(map_type))
row += 1 # table.setItem(row, 1, QTableWidgetItem(str(rule))) # Rule (respect/force_8bit)
# row += 1

View File

@@ -8,38 +8,16 @@ from PySide6.QtWidgets import (
from PySide6.QtGui import QColor, QPalette, QMouseEvent # Added QMouseEvent from PySide6.QtGui import QColor, QPalette, QMouseEvent # Added QMouseEvent
from PySide6.QtCore import Qt, QEvent from PySide6.QtCore import Qt, QEvent
# Assuming load_asset_definitions, load_file_type_definitions, load_supplier_settings from PySide6.QtGui import QColor, QPalette, QMouseEvent
# are in configuration.py at the root level. from PySide6.QtCore import Qt, QEvent
# Adjust the import path if configuration.py is located elsewhere relative to this file.
# For example, if configuration.py is in the parent directory: # Import the Configuration class
# from ..configuration import load_asset_definitions, load_file_type_definitions, load_supplier_settings from configuration import Configuration, ConfigurationError
# Or if it's in the same directory (less likely for a root config file):
# from .configuration import ...
# Given the project structure, configuration.py is at the root.
import sys
import os
# Add project root to sys.path to allow direct import of configuration
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
try:
from configuration import (
load_asset_definitions, save_asset_definitions,
load_file_type_definitions, save_file_type_definitions,
load_supplier_settings, save_supplier_settings
)
except ImportError as e:
logging.error(f"Failed to import configuration functions: {e}. Ensure configuration.py is in the project root and accessible.")
# Provide dummy functions if import fails, so the UI can still be tested somewhat
def load_asset_definitions(): return {}
def save_asset_definitions(data): pass
def load_file_type_definitions(): return {}
def save_file_type_definitions(data): pass
def load_supplier_settings(): return {}
# def save_supplier_settings(data): pass
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class DebugListWidget(QListWidget): class DebugListWidget(QListWidget):
def mousePressEvent(self, event: QMouseEvent): # QMouseEvent needs to be imported from PySide6.QtGui def mousePressEvent(self, event: QMouseEvent):
logger.info(f"DebugListWidget.mousePressEvent: pos={event.pos()}") logger.info(f"DebugListWidget.mousePressEvent: pos={event.pos()}")
item = self.itemAt(event.pos()) item = self.itemAt(event.pos())
if item: if item:
@@ -50,8 +28,9 @@ class DebugListWidget(QListWidget):
logger.info("DebugListWidget.mousePressEvent: super call finished.") logger.info("DebugListWidget.mousePressEvent: super call finished.")
class DefinitionsEditorDialog(QDialog): class DefinitionsEditorDialog(QDialog):
def __init__(self, parent=None): def __init__(self, config: Configuration, parent=None):
super().__init__(parent) super().__init__(parent)
self.config = config # Store the Configuration object
self.setWindowTitle("Definitions Editor") self.setWindowTitle("Definitions Editor")
self.setGeometry(200, 200, 800, 600) # x, y, width, height self.setGeometry(200, 200, 800, 600) # x, y, width, height
@@ -588,8 +567,8 @@ class DefinitionsEditorDialog(QDialog):
# Bit Depth Rule # Bit Depth Rule
self.ft_bit_depth_combo = QComboBox() self.ft_bit_depth_combo = QComboBox()
self.ft_bit_depth_combo.addItems(["respect", "force_8bit", "force_16bit"]) self.ft_bit_depth_combo.addItems(["preserve", "force_8bit", "force_16bit"])
details_layout.addRow("Bit Depth Rule:", self.ft_bit_depth_combo) details_layout.addRow("Bit Depth Policy:", self.ft_bit_depth_combo)
# Is Grayscale # Is Grayscale
self.ft_is_grayscale_check = QCheckBox("Is Grayscale") self.ft_is_grayscale_check = QCheckBox("Is Grayscale")
@@ -627,7 +606,7 @@ class DefinitionsEditorDialog(QDialog):
logger.warning(f"File type data for '{key}' is not a dict: {ft_data_item}. Using default.") logger.warning(f"File type data for '{key}' is not a dict: {ft_data_item}. Using default.")
ft_data_item = { ft_data_item = {
"description": str(ft_data_item), "color": "#ffffff", "examples": [], "description": str(ft_data_item), "color": "#ffffff", "examples": [],
"standard_type": "", "bit_depth_rule": "respect", "standard_type": "", "bit_depth_policy": "preserve",
"is_grayscale": False, "keybind": "" "is_grayscale": False, "keybind": ""
} }
@@ -636,7 +615,7 @@ class DefinitionsEditorDialog(QDialog):
ft_data_item.setdefault('color', '#ffffff') ft_data_item.setdefault('color', '#ffffff')
ft_data_item.setdefault('examples', []) ft_data_item.setdefault('examples', [])
ft_data_item.setdefault('standard_type', '') ft_data_item.setdefault('standard_type', '')
ft_data_item.setdefault('bit_depth_rule', 'respect') ft_data_item.setdefault('bit_depth_policy', 'preserve')
ft_data_item.setdefault('is_grayscale', False) ft_data_item.setdefault('is_grayscale', False)
ft_data_item.setdefault('keybind', '') ft_data_item.setdefault('keybind', '')
@@ -672,7 +651,7 @@ class DefinitionsEditorDialog(QDialog):
logger.error(f"Invalid data for file type item {current_item.text()}. Expected dict, got {type(ft_data)}") logger.error(f"Invalid data for file type item {current_item.text()}. Expected dict, got {type(ft_data)}")
ft_data = { ft_data = {
"description": "Error: Invalid data", "color": "#ff0000", "examples": [], "description": "Error: Invalid data", "color": "#ff0000", "examples": [],
"standard_type": "error", "bit_depth_rule": "respect", "standard_type": "error", "bit_depth_policy": "preserve",
"is_grayscale": False, "keybind": "X" "is_grayscale": False, "keybind": "X"
} }
@@ -685,11 +664,11 @@ class DefinitionsEditorDialog(QDialog):
self.ft_standard_type_edit.setText(ft_data.get('standard_type', '')) self.ft_standard_type_edit.setText(ft_data.get('standard_type', ''))
bdr_index = self.ft_bit_depth_combo.findText(ft_data.get('bit_depth_rule', 'respect')) bdr_index = self.ft_bit_depth_combo.findText(ft_data.get('bit_depth_policy', 'preserve'))
if bdr_index != -1: if bdr_index != -1:
self.ft_bit_depth_combo.setCurrentIndex(bdr_index) self.ft_bit_depth_combo.setCurrentIndex(bdr_index)
else: else:
self.ft_bit_depth_combo.setCurrentIndex(0) # Default to 'respect' self.ft_bit_depth_combo.setCurrentIndex(0) # Default to 'preserve'
self.ft_is_grayscale_check.setChecked(ft_data.get('is_grayscale', False)) self.ft_is_grayscale_check.setChecked(ft_data.get('is_grayscale', False))
self.ft_keybind_edit.setText(ft_data.get('keybind', '')) self.ft_keybind_edit.setText(ft_data.get('keybind', ''))
@@ -746,7 +725,7 @@ class DefinitionsEditorDialog(QDialog):
"color": "#ffffff", "color": "#ffffff",
"examples": [], "examples": [],
"standard_type": "", "standard_type": "",
"bit_depth_rule": "respect", "bit_depth_policy": "preserve",
"is_grayscale": False, "is_grayscale": False,
"keybind": "" "keybind": ""
} }
@@ -890,7 +869,7 @@ class DefinitionsEditorDialog(QDialog):
# Update based on which widget triggered (or update all) # Update based on which widget triggered (or update all)
ft_data['description'] = self.ft_description_edit.toPlainText() ft_data['description'] = self.ft_description_edit.toPlainText()
ft_data['standard_type'] = self.ft_standard_type_edit.text() ft_data['standard_type'] = self.ft_standard_type_edit.text()
ft_data['bit_depth_rule'] = self.ft_bit_depth_combo.currentText() ft_data['bit_depth_policy'] = self.ft_bit_depth_combo.currentText()
ft_data['is_grayscale'] = self.ft_is_grayscale_check.isChecked() ft_data['is_grayscale'] = self.ft_is_grayscale_check.isChecked()
# Keybind validation (force uppercase) # Keybind validation (force uppercase)

View File

@@ -1,7 +1,7 @@
from pathlib import Path from pathlib import Path
from PySide6.QtWidgets import QStyledItemDelegate, QLineEdit, QComboBox from PySide6.QtWidgets import QStyledItemDelegate, QLineEdit, QComboBox
from PySide6.QtCore import Qt, QModelIndex from PySide6.QtCore import Qt, QModelIndex
from configuration import Configuration, ConfigurationError, load_base_config # Keep load_base_config for SupplierSearchDelegate from configuration import Configuration, ConfigurationError # Keep load_base_config for SupplierSearchDelegate
from PySide6.QtWidgets import QListWidgetItem from PySide6.QtWidgets import QListWidgetItem
import json import json
@@ -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

@@ -0,0 +1,388 @@
import sys
import os
import shutil
import json
from pathlib import Path
from typing import Optional, Tuple
from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton,
QFileDialog, QMessageBox, QGroupBox, QFormLayout, QSpinBox, QDialogButtonBox
)
from PySide6.QtCore import Qt, Slot
# Constants for bundled resource locations relative to app base
BUNDLED_CONFIG_SUBDIR_NAME = "config"
BUNDLED_PRESETS_SUBDIR_NAME = "Presets"
DEFAULT_USER_DATA_SUBDIR_NAME = "user_data" # For portable path attempt
# Files to copy from bundled config to user config
DEFAULT_CONFIG_FILES = [
"asset_type_definitions.json",
"file_type_definitions.json",
"llm_settings.json",
"suppliers.json"
]
# app_settings.json is NOT copied. user_settings.json is handled separately.
USER_SETTINGS_FILENAME = "user_settings.json"
PERSISTENT_PATH_MARKER_FILENAME = ".first_run_complete"
PERSISTENT_CONFIG_ROOT_STORAGE_FILENAME = "asset_processor_user_root.txt" # Stores USER_CHOSEN_PATH
APP_NAME = "AssetProcessor" # Used for AppData paths
def get_app_base_dir() -> Path:
"""Determines the base directory for the application (executable or script)."""
if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'):
# Running in a PyInstaller bundle
return Path(sys._MEIPASS)
else:
# Running as a script
return Path(__file__).resolve().parent.parent # Assuming this file is in gui/ subdir
def get_os_specific_app_data_dir() -> Path:
"""Gets the OS-specific application data directory."""
if sys.platform == "win32":
path_str = os.getenv('APPDATA')
if path_str:
return Path(path_str) / APP_NAME
# Fallback if APPDATA is not set, though unlikely
return Path.home() / "AppData" / "Roaming" / APP_NAME
elif sys.platform == "darwin": # macOS
return Path.home() / "Library" / "Application Support" / APP_NAME
else: # Linux and other Unix-like
return Path.home() / ".config" / APP_NAME
class FirstTimeSetupDialog(QDialog):
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Asset Processor - First-Time Setup")
self.setModal(True)
self.setMinimumWidth(600)
self.app_base_dir = get_app_base_dir()
self.user_chosen_path: Optional[Path] = None
self._init_ui()
self._propose_default_config_path()
def _init_ui(self):
main_layout = QVBoxLayout(self)
# Configuration Path Group
config_path_group = QGroupBox("Configuration Location")
config_path_layout = QVBoxLayout()
self.proposed_path_label = QLabel("Proposed default configuration path:")
config_path_layout.addWidget(self.proposed_path_label)
path_selection_layout = QHBoxLayout()
self.config_path_edit = QLineEdit()
self.config_path_edit.setReadOnly(False) # Allow editing, then validate
path_selection_layout.addWidget(self.config_path_edit)
browse_button = QPushButton("Browse...")
browse_button.clicked.connect(self._browse_config_path)
path_selection_layout.addWidget(browse_button)
config_path_layout.addLayout(path_selection_layout)
config_path_group.setLayout(config_path_layout)
main_layout.addWidget(config_path_group)
# User Settings Group
user_settings_group = QGroupBox("Initial User Settings")
user_settings_form_layout = QFormLayout()
self.output_base_dir_edit = QLineEdit()
output_base_dir_browse_button = QPushButton("Browse...")
output_base_dir_browse_button.clicked.connect(self._browse_output_base_dir)
output_base_dir_layout = QHBoxLayout()
output_base_dir_layout.addWidget(self.output_base_dir_edit)
output_base_dir_layout.addWidget(output_base_dir_browse_button)
user_settings_form_layout.addRow("Default Library Output Path:", output_base_dir_layout)
self.output_dir_pattern_edit = QLineEdit("[supplier]/[asset_category]/[asset_name]")
user_settings_form_layout.addRow("Asset Structure Pattern:", self.output_dir_pattern_edit)
self.output_format_16bit_primary_edit = QLineEdit("png")
user_settings_form_layout.addRow("Default 16-bit Output Format (Primary):", self.output_format_16bit_primary_edit)
self.output_format_8bit_edit = QLineEdit("png")
user_settings_form_layout.addRow("Default 8-bit Output Format:", self.output_format_8bit_edit)
self.resolution_threshold_jpg_spinbox = QSpinBox()
self.resolution_threshold_jpg_spinbox.setRange(256, 16384)
self.resolution_threshold_jpg_spinbox.setValue(4096)
self.resolution_threshold_jpg_spinbox.setSuffix(" px")
user_settings_form_layout.addRow("JPG Resolution Threshold (for 8-bit):", self.resolution_threshold_jpg_spinbox)
user_settings_group.setLayout(user_settings_form_layout)
main_layout.addWidget(user_settings_group)
# Dialog Buttons
self.button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
self.button_box.button(QDialogButtonBox.StandardButton.Ok).setText("Finish Setup")
self.button_box.accepted.connect(self._on_finish_setup)
self.button_box.rejected.connect(self.reject)
main_layout.addWidget(self.button_box)
def _propose_default_config_path(self):
proposed_path = None
# 1. Try portable path: user_data/ next to the application base dir
# If running from script, app_base_dir is .../Asset_processor_tool/gui, so parent is .../Asset_processor_tool
# If bundled, app_base_dir is the directory of the executable.
# Let's refine app_base_dir for portable path logic
# If script: Path(__file__).parent.parent = Asset_processor_tool
# If frozen: sys._MEIPASS (which is the temp extraction dir, not ideal for persistent user_data)
# A better approach for portable if frozen: Path(sys.executable).parent
current_app_dir = Path(sys.executable).parent if getattr(sys, 'frozen', False) else self.app_base_dir
portable_path_candidate = current_app_dir / DEFAULT_USER_DATA_SUBDIR_NAME
try:
portable_path_candidate.mkdir(parents=True, exist_ok=True)
if os.access(str(portable_path_candidate), os.W_OK):
proposed_path = portable_path_candidate
self.proposed_path_label.setText(f"Proposed portable path (writable):")
else:
self.proposed_path_label.setText(f"Portable path '{portable_path_candidate}' not writable.")
except Exception as e:
self.proposed_path_label.setText(f"Could not use portable path '{portable_path_candidate}': {e}")
print(f"Error checking/creating portable path: {e}") # For debugging
# 2. Fallback to OS-specific app data directory
if not proposed_path:
os_specific_path = get_os_specific_app_data_dir()
try:
os_specific_path.mkdir(parents=True, exist_ok=True)
if os.access(str(os_specific_path), os.W_OK):
proposed_path = os_specific_path
self.proposed_path_label.setText(f"Proposed standard path (writable):")
else:
self.proposed_path_label.setText(f"Standard path '{os_specific_path}' not writable. Please choose a location.")
except Exception as e:
self.proposed_path_label.setText(f"Could not use standard path '{os_specific_path}': {e}. Please choose a location.")
print(f"Error checking/creating standard path: {e}") # For debugging
if proposed_path:
self.config_path_edit.setText(str(proposed_path.resolve()))
else:
# Should not happen if OS specific path creation works, but as a last resort:
self.config_path_edit.setText(str(Path.home())) # Default to home if all else fails
QMessageBox.warning(self, "Path Issue", "Could not determine a default writable configuration path. Please select one manually.")
@Slot()
def _browse_config_path(self):
directory = QFileDialog.getExistingDirectory(
self,
"Select Configuration Directory",
self.config_path_edit.text() or str(Path.home())
)
if directory:
self.config_path_edit.setText(directory)
@Slot()
def _browse_output_base_dir(self):
directory = QFileDialog.getExistingDirectory(
self,
"Select Default Library Output Directory",
self.output_base_dir_edit.text() or str(Path.home())
)
if directory:
self.output_base_dir_edit.setText(directory)
def _validate_inputs(self) -> bool:
# Validate chosen config path
path_str = self.config_path_edit.text().strip()
if not path_str:
QMessageBox.warning(self, "Input Error", "Configuration path cannot be empty.")
return False
self.user_chosen_path = Path(path_str)
try:
self.user_chosen_path.mkdir(parents=True, exist_ok=True)
if not os.access(str(self.user_chosen_path), os.W_OK):
QMessageBox.warning(self, "Path Error", f"The chosen configuration path '{self.user_chosen_path}' is not writable.")
return False
except Exception as e:
QMessageBox.warning(self, "Path Error", f"Error with chosen configuration path '{self.user_chosen_path}': {e}")
return False
# Validate output base dir
output_base_dir_str = self.output_base_dir_edit.text().strip()
if not output_base_dir_str:
QMessageBox.warning(self, "Input Error", "Default Library Output Path cannot be empty.")
return False
try:
Path(output_base_dir_str).mkdir(parents=True, exist_ok=True) # Check if creatable
if not os.access(output_base_dir_str, os.W_OK):
QMessageBox.warning(self, "Path Error", f"The chosen output base path '{output_base_dir_str}' is not writable.")
return False
except Exception as e:
QMessageBox.warning(self, "Path Error", f"Error with output base path '{output_base_dir_str}': {e}")
return False
if not self.output_dir_pattern_edit.text().strip():
QMessageBox.warning(self, "Input Error", "Asset Structure Pattern cannot be empty.")
return False
if not self.output_format_16bit_primary_edit.text().strip():
QMessageBox.warning(self, "Input Error", "Default 16-bit Output Format cannot be empty.")
return False
if not self.output_format_8bit_edit.text().strip():
QMessageBox.warning(self, "Input Error", "Default 8-bit Output Format cannot be empty.")
return False
return True
def _copy_default_files(self):
if not self.user_chosen_path:
return
bundled_config_dir = self.app_base_dir / BUNDLED_CONFIG_SUBDIR_NAME
user_target_config_dir = self.user_chosen_path / BUNDLED_CONFIG_SUBDIR_NAME # User files also go into a 'config' subdir
try:
user_target_config_dir.mkdir(parents=True, exist_ok=True)
except Exception as e:
QMessageBox.critical(self, "Error", f"Could not create user config subdirectory '{user_target_config_dir}': {e}")
return
for filename in DEFAULT_CONFIG_FILES:
source_file = bundled_config_dir / filename
target_file = user_target_config_dir / filename
if not target_file.exists():
if source_file.is_file():
try:
shutil.copy2(str(source_file), str(target_file))
print(f"Copied '{source_file}' to '{target_file}'")
except Exception as e:
QMessageBox.warning(self, "File Copy Error", f"Could not copy '{filename}' to '{target_file}': {e}")
else:
print(f"Default config file '{source_file}' not found in bundle.")
else:
print(f"User config file '{target_file}' already exists. Skipping copy.")
# Copy Presets
bundled_presets_dir = self.app_base_dir / BUNDLED_PRESETS_SUBDIR_NAME
user_target_presets_dir = self.user_chosen_path / BUNDLED_PRESETS_SUBDIR_NAME
if bundled_presets_dir.is_dir():
try:
user_target_presets_dir.mkdir(parents=True, exist_ok=True)
for item in bundled_presets_dir.iterdir():
target_item = user_target_presets_dir / item.name
if not target_item.exists():
if item.is_file():
shutil.copy2(str(item), str(target_item))
print(f"Copied preset '{item.name}' to '{target_item}'")
# Add elif item.is_dir() for recursive copy if presets can have subdirs
except Exception as e:
QMessageBox.warning(self, "Preset Copy Error", f"Could not copy presets to '{user_target_presets_dir}': {e}")
else:
print(f"Bundled presets directory '{bundled_presets_dir}' not found.")
def _save_initial_user_settings(self):
if not self.user_chosen_path:
return
user_settings_path = self.user_chosen_path / USER_SETTINGS_FILENAME
settings_data = {}
# Load existing if it exists (though unlikely for first-time setup, but good practice)
if user_settings_path.exists():
try:
with open(user_settings_path, 'r', encoding='utf-8') as f:
settings_data = json.load(f)
except Exception as e:
QMessageBox.warning(self, "Error Loading Settings", f"Could not load existing user settings from '{user_settings_path}': {e}. Will create a new one.")
settings_data = {}
# Update with new values from dialog
settings_data['OUTPUT_BASE_DIR'] = self.output_base_dir_edit.text().strip()
settings_data['OUTPUT_DIRECTORY_PATTERN'] = self.output_dir_pattern_edit.text().strip()
settings_data['OUTPUT_FORMAT_16BIT_PRIMARY'] = self.output_format_16bit_primary_edit.text().strip().lower()
settings_data['OUTPUT_FORMAT_8BIT'] = self.output_format_8bit_edit.text().strip().lower()
settings_data['RESOLUTION_THRESHOLD_FOR_JPG'] = self.resolution_threshold_jpg_spinbox.value()
# Ensure general_settings exists for app_version if needed, or other core settings
if 'general_settings' not in settings_data:
settings_data['general_settings'] = {}
# Example: settings_data['general_settings']['some_new_user_setting'] = True
try:
with open(user_settings_path, 'w', encoding='utf-8') as f:
json.dump(settings_data, f, indent=4)
print(f"Saved user settings to '{user_settings_path}'")
except Exception as e:
QMessageBox.critical(self, "Error Saving Settings", f"Could not save user settings to '{user_settings_path}': {e}")
def _save_persistent_info(self):
if not self.user_chosen_path:
return
# 1. Save USER_CHOSEN_PATH to a persistent location (e.g., AppData)
persistent_storage_dir = get_os_specific_app_data_dir()
try:
persistent_storage_dir.mkdir(parents=True, exist_ok=True)
persistent_path_file = persistent_storage_dir / PERSISTENT_CONFIG_ROOT_STORAGE_FILENAME
with open(persistent_path_file, 'w', encoding='utf-8') as f:
f.write(str(self.user_chosen_path.resolve()))
print(f"Saved chosen config path to '{persistent_path_file}'")
except Exception as e:
QMessageBox.warning(self, "Error Saving Path", f"Could not persistently save the chosen configuration path: {e}")
# This is not critical enough to stop the setup, but user might need to re-select on next launch.
# 2. Create marker file in USER_CHOSEN_PATH
marker_file = self.user_chosen_path / PERSISTENT_PATH_MARKER_FILENAME
try:
with open(marker_file, 'w', encoding='utf-8') as f:
f.write("Asset Processor first-time setup complete.")
print(f"Created marker file at '{marker_file}'")
except Exception as e:
QMessageBox.warning(self, "Error Creating Marker", f"Could not create first-run marker file at '{marker_file}': {e}")
@Slot()
def _on_finish_setup(self):
if not self._validate_inputs():
return
# Confirmation before proceeding
reply = QMessageBox.question(self, "Confirm Setup",
f"The following path will be used for configuration and user data:\n"
f"{self.user_chosen_path}\n\n"
f"Default configuration files and presets will be copied if they don't exist.\n"
f"Initial user settings will be saved.\n\nProceed with setup?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No)
if reply == QMessageBox.StandardButton.No:
return
try:
self._copy_default_files()
self._save_initial_user_settings()
self._save_persistent_info()
QMessageBox.information(self, "Setup Complete", "First-time setup completed successfully!")
self.accept()
except Exception as e:
QMessageBox.critical(self, "Setup Error", f"An unexpected error occurred during setup: {e}")
# Optionally, attempt cleanup or guide user
def get_chosen_config_path(self) -> Optional[Path]:
"""Returns the path chosen by the user after successful completion."""
if self.result() == QDialog.DialogCode.Accepted:
return self.user_chosen_path
return None
if __name__ == '__main__':
from PySide6.QtWidgets import QApplication
app = QApplication(sys.argv)
dialog = FirstTimeSetupDialog()
if dialog.exec():
chosen_path = dialog.get_chosen_config_path()
print(f"Dialog accepted. Chosen config path: {chosen_path}")
else:
print("Dialog cancelled.")
sys.exit()

View File

@@ -10,7 +10,7 @@ from PySide6.QtWidgets import (
from PySide6.QtCore import Slot as pyqtSlot, Signal as pyqtSignal # Use PySide6 equivalents from PySide6.QtCore import Slot as pyqtSlot, Signal as pyqtSignal # Use PySide6 equivalents
# Assuming configuration module exists and has relevant functions later # Assuming configuration module exists and has relevant functions later
from configuration import save_llm_config, ConfigurationError from configuration import ConfigurationError
# For now, define path directly for initial structure # For now, define path directly for initial structure
LLM_CONFIG_PATH = "config/llm_settings.json" LLM_CONFIG_PATH = "config/llm_settings.json"
@@ -280,7 +280,13 @@ class LLMEditorWidget(QWidget):
# 1.d. Save Updated Content # 1.d. Save Updated Content
try: try:
save_llm_config(target_file_content) # Save the potentially modified target_file_content # Ensure the directory exists before saving
import os
os.makedirs(os.path.dirname(LLM_CONFIG_PATH), exist_ok=True)
with open(LLM_CONFIG_PATH, 'w', encoding='utf-8') as f:
json.dump(target_file_content, f, indent=4)
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 # Update original_llm_settings to reflect the newly saved state
@@ -291,9 +297,9 @@ class LLMEditorWidget(QWidget):
self.settings_saved.emit() self.settings_saved.emit()
logger.info("LLM settings saved successfully.") logger.info("LLM settings saved successfully.")
except ConfigurationError as e: except (IOError, OSError) as e:
logger.error(f"Failed to save LLM settings: {e}") logger.error(f"Failed to write LLM settings file {LLM_CONFIG_PATH}: {e}")
QMessageBox.critical(self, "Save Error", f"Could not save LLM settings.\n\nError: {e}") QMessageBox.critical(self, "Save Error", f"Could not write LLM settings file.\n\nError: {e}")
self.save_button.setEnabled(True) # Keep save enabled self.save_button.setEnabled(True) # Keep save enabled
self._unsaved_changes = True self._unsaved_changes = True
except Exception as e: except Exception as e:

View File

@@ -24,6 +24,9 @@ class LLMPredictionHandler(BasePredictionHandler):
Handles the interaction with an LLM for predicting asset structures Handles the interaction with an LLM for predicting asset structures
based on a directory's file list. Inherits from BasePredictionHandler. based on a directory's file list. Inherits from BasePredictionHandler.
""" """
# Define a constant for files not classified by the LLM
FILE_UNCLASSIFIED_BY_LLM = "FILE_UNCLASSIFIED_BY_LLM"
# Signals (prediction_ready, prediction_error, status_update) are inherited # Signals (prediction_ready, prediction_error, status_update) are inherited
# Changed 'config: Configuration' to 'settings: dict' # Changed 'config: Configuration' to 'settings: dict'
@@ -307,54 +310,67 @@ class LLMPredictionHandler(BasePredictionHandler):
valid_file_types = list(self.settings.get('file_type_definitions', {}).keys()) valid_file_types = list(self.settings.get('file_type_definitions', {}).keys())
asset_rules_map: Dict[str, AssetRule] = {} # Maps group_name to AssetRule asset_rules_map: Dict[str, AssetRule] = {} # Maps group_name to AssetRule
# --- Process Individual Files and Build Rules --- # --- Map LLM File Analysis for Quick Lookup ---
for file_data in response_data["individual_file_analysis"]: llm_file_map: Dict[str, Dict[str, Any]] = {}
for file_data in response_data.get("individual_file_analysis", []):
if isinstance(file_data, dict):
file_path_rel = file_data.get("relative_file_path")
if file_path_rel and isinstance(file_path_rel, str):
llm_file_map[file_path_rel] = file_data
else:
log.warning(f"Skipping LLM file data entry with missing or invalid 'relative_file_path': {file_data}")
else:
log.warning(f"Skipping invalid LLM file data entry (not a dict): {file_data}")
# --- Process Actual Input Files and Reconcile with LLM Data ---
for file_path_rel in self.file_list:
# Check for cancellation within the loop # Check for cancellation within the loop
if self._is_cancelled: if self._is_cancelled:
log.info("LLM prediction cancelled during response parsing (files).") log.info("LLM prediction cancelled during response parsing (files).")
return [] return []
if not isinstance(file_data, dict): file_data = llm_file_map.pop(file_path_rel, None) # Get data if exists, remove from map
log.warning(f"Skipping invalid file data entry (not a dict): {file_data}")
continue
file_path_rel = file_data.get("relative_file_path") if file_data:
file_type = file_data.get("classified_file_type") # --- File found in LLM output - Use LLM Classification ---
group_name = file_data.get("proposed_asset_group_name") # Can be string or null file_type = file_data.get("classified_file_type")
group_name = file_data.get("proposed_asset_group_name") # Can be string or null
# --- Validate File Data --- # Validate file_type against definitions, unless it's FILE_IGNORE
if not file_path_rel or not isinstance(file_path_rel, str): if not file_type or not isinstance(file_type, str):
log.warning(f"Missing or invalid 'relative_file_path' in file data: {file_data}. Skipping file.") log.warning(f"Missing or invalid 'classified_file_type' for file '{file_path_rel}' from LLM. Defaulting to {self.FILE_UNCLASSIFIED_BY_LLM}.")
continue file_type = self.FILE_UNCLASSIFIED_BY_LLM
elif file_type != "FILE_IGNORE" and file_type not in valid_file_types:
log.warning(f"Invalid predicted_file_type '{file_type}' for file '{file_path_rel}' from LLM. Defaulting to EXTRA.")
file_type = "EXTRA"
if not file_type or not isinstance(file_type, str): # Handle FILE_IGNORE explicitly - do not create a rule for it
log.warning(f"Missing or invalid 'classified_file_type' for file '{file_path_rel}'. Skipping file.") if file_type == "FILE_IGNORE":
continue log.debug(f"Ignoring file as per LLM prediction: {file_path_rel}")
continue
# Handle FILE_IGNORE explicitly # Determine group name and asset type
if file_type == "FILE_IGNORE": if not group_name or not isinstance(group_name, str):
log.debug(f"Ignoring file as per LLM prediction: {file_path_rel}") log.warning(f"File '{file_path_rel}' has missing, null, or invalid 'proposed_asset_group_name' ({group_name}) from LLM. Assigning to default asset.")
continue # Skip creating a rule for this file group_name = "Unclassified Files" # Default group name
asset_type = "UtilityMap" # Default asset type for unclassified files (or another sensible default)
else:
asset_type = response_data["asset_group_classifications"].get(group_name)
if not asset_type:
log.warning(f"No classification found in 'asset_group_classifications' for group '{group_name}' (proposed for file '{file_path_rel}'). Assigning to default asset.")
group_name = "Unclassified Files" # Default group name
asset_type = "UtilityMap" # Default asset type
elif asset_type not in valid_asset_types:
log.warning(f"Invalid asset_type '{asset_type}' found in 'asset_group_classifications' for group '{group_name}'. Assigning to default asset.")
group_name = "Unclassified Files" # Default group name
asset_type = "UtilityMap" # Default asset type
# Validate file_type against definitions else:
if file_type not in valid_file_types: # --- File NOT found in LLM output - Assign Default Classification ---
log.warning(f"Invalid predicted_file_type '{file_type}' for file '{file_path_rel}'. Defaulting to EXTRA.") log.warning(f"File '{file_path_rel}' from input list was NOT classified by LLM. Assigning type {self.FILE_UNCLASSIFIED_BY_LLM} and default asset.")
file_type = "EXTRA" file_type = self.FILE_UNCLASSIFIED_BY_LLM
group_name = "Unclassified Files" # Default group name
# --- Handle Grouping and Asset Type --- asset_type = "UtilityMap" # Default asset type
if not group_name or not isinstance(group_name, str):
log.warning(f"File '{file_path_rel}' has missing, null, or invalid 'proposed_asset_group_name' ({group_name}). Cannot assign to an asset. Skipping file.")
continue
asset_type = response_data["asset_group_classifications"].get(group_name)
if not asset_type:
log.warning(f"No classification found in 'asset_group_classifications' for group '{group_name}' (proposed for file '{file_path_rel}'). Skipping file.")
continue
if asset_type not in valid_asset_types:
log.warning(f"Invalid asset_type '{asset_type}' found in 'asset_group_classifications' for group '{group_name}'. Skipping file '{file_path_rel}'.")
continue
# --- Construct Absolute Path --- # --- Construct Absolute Path ---
try: try:
@@ -373,25 +389,34 @@ class LLMPredictionHandler(BasePredictionHandler):
# Create new AssetRule if this is the first file for this group # Create new AssetRule if this is the first file for this group
log.debug(f"Creating new AssetRule for group '{group_name}' with type '{asset_type}'.") log.debug(f"Creating new AssetRule for group '{group_name}' with type '{asset_type}'.")
asset_rule = AssetRule(asset_name=group_name, asset_type=asset_type) asset_rule = AssetRule(asset_name=group_name, asset_type=asset_type)
asset_rule.parent_source = source_rule # Set parent back-reference
source_rule.assets.append(asset_rule) source_rule.assets.append(asset_rule)
asset_rules_map[group_name] = asset_rule asset_rules_map[group_name] = asset_rule
# If asset_rule already exists, ensure its type is consistent or handle conflicts if necessary.
# For now, we'll assume the first file dictates the asset type for the default group.
# For LLM-classified groups, the type comes from asset_group_classifications.
# --- Create and Add File Rule --- # --- Create and Add File Rule ---
file_rule = FileRule( file_rule = FileRule(
file_path=file_path_abs, file_path=file_path_abs,
item_type=file_type, item_type=file_type,
item_type_override=file_type, # Initial override based on LLM item_type_override=file_type, # Initial override based on classification (LLM or default)
target_asset_name_override=group_name, target_asset_name_override=group_name,
output_format_override=None, output_format_override=None,
resolution_override=None, resolution_override=None,
channel_merge_instructions={} channel_merge_instructions={}
) )
file_rule.parent_asset = asset_rule # Set parent back-reference
asset_rule.files.append(file_rule) asset_rule.files.append(file_rule)
log.debug(f"Added file '{file_path_rel}' (type: {file_type}) to asset '{group_name}'.") log.debug(f"Added file '{file_path_rel}' (type: {file_type}) to asset '{group_name}'.")
# --- Handle LLM Hallucinations (Remaining entries in llm_file_map) ---
for file_path_rel, file_data in llm_file_map.items():
log.warning(f"LLM predicted file '{file_path_rel}' which was NOT in the actual input file list. Ignoring this hallucinated entry.")
# No FileRule is created for this hallucinated file.
# Log if no assets were created # Log if no assets were created
if not source_rule.assets: if not source_rule.assets:
log.warning(f"LLM prediction for '{self.input_source_identifier}' resulted in zero valid assets after parsing.") log.warning(f"LLM prediction for '{self.input_source_identifier}' resulted in zero valid assets after processing actual file list.")
return [source_rule] # Return list containing the single SourceRule return [source_rule] # Return list containing the single SourceRule

View File

@@ -23,15 +23,8 @@ from .unified_view_model import UnifiedViewModel
from rule_structure import SourceRule, AssetRule, FileRule from rule_structure import SourceRule, AssetRule, FileRule
import configuration import configuration
try:
from configuration import ConfigurationError, load_base_config
except ImportError:
ConfigurationError = Exception
load_base_config = None
class configuration:
PRESETS_DIR = "Presets"
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
from configuration import Configuration, ConfigurationError # Import Configuration class and Error
class MainPanelWidget(QWidget): class MainPanelWidget(QWidget):
""" """
@@ -57,7 +50,7 @@ class MainPanelWidget(QWidget):
blender_settings_changed = Signal(bool, str, str) blender_settings_changed = Signal(bool, str, str)
def __init__(self, unified_model: UnifiedViewModel, parent=None, file_type_keys: list[str] | None = None): def __init__(self, config: Configuration, unified_model: UnifiedViewModel, parent=None, file_type_keys: list[str] | None = None):
""" """
Initializes the MainPanelWidget. Initializes the MainPanelWidget.
@@ -67,6 +60,7 @@ class MainPanelWidget(QWidget):
file_type_keys: A list of available file type names (keys from FILE_TYPE_DEFINITIONS). file_type_keys: A list of available file type names (keys from FILE_TYPE_DEFINITIONS).
""" """
super().__init__(parent) super().__init__(parent)
self._config = config # Store the Configuration object
self.unified_model = unified_model self.unified_model = unified_model
self.file_type_keys = file_type_keys if file_type_keys else [] self.file_type_keys = file_type_keys if file_type_keys else []
self.llm_processing_active = False self.llm_processing_active = False
@@ -91,21 +85,19 @@ class MainPanelWidget(QWidget):
output_layout.addWidget(self.browse_output_button) output_layout.addWidget(self.browse_output_button)
main_layout.addLayout(output_layout) main_layout.addLayout(output_layout)
if load_base_config: try:
try: # Access configuration directly from the stored object
base_config = load_base_config() # Use the output_directory_pattern from the Configuration object
output_base_dir_config = base_config.get('OUTPUT_BASE_DIR', '../Asset_Processor_Output') output_pattern = self._config.output_directory_pattern
default_output_dir = (self.project_root / output_base_dir_config).resolve() # Assuming the pattern is relative to the project root for the default
self.output_path_edit.setText(str(default_output_dir)) default_output_dir = (self.project_root / output_pattern).resolve()
log.info(f"MainPanelWidget: Default output directory set to: {default_output_dir}") self.output_path_edit.setText(str(default_output_dir))
except ConfigurationError as e: log.info(f"MainPanelWidget: Default output directory set to: {default_output_dir} based on pattern '{output_pattern}'")
log.error(f"MainPanelWidget: Error reading base configuration for default output directory: {e}") except ConfigurationError as e:
self.output_path_edit.setText("") log.error(f"MainPanelWidget: Configuration Error setting default output directory: {e}")
except Exception as e: self.output_path_edit.setText("")
log.exception(f"MainPanelWidget: Error setting default output directory: {e}") except Exception as e:
self.output_path_edit.setText("") log.exception(f"MainPanelWidget: Unexpected Error setting default output directory: {e}")
else:
log.warning("MainPanelWidget: load_base_config not available to set default output path.")
self.output_path_edit.setText("") self.output_path_edit.setText("")
@@ -180,19 +172,14 @@ class MainPanelWidget(QWidget):
materials_layout.addWidget(self.browse_materials_blend_button) materials_layout.addWidget(self.browse_materials_blend_button)
blender_layout.addLayout(materials_layout) blender_layout.addLayout(materials_layout)
if load_base_config: try:
try: # Use hardcoded defaults as Configuration object does not expose these via public interface
base_config = load_base_config() default_ng_path = ''
default_ng_path = base_config.get('DEFAULT_NODEGROUP_BLEND_PATH', '') default_mat_path = ''
default_mat_path = base_config.get('DEFAULT_MATERIALS_BLEND_PATH', '') self.nodegroup_blend_path_input.setText(default_ng_path if default_ng_path else "")
self.nodegroup_blend_path_input.setText(default_ng_path if default_ng_path else "") self.materials_blend_path_input.setText(default_mat_path if default_mat_path else "")
self.materials_blend_path_input.setText(default_mat_path if default_mat_path else "") except Exception as e:
except ConfigurationError as e: log.error(f"MainPanelWidget: Error setting default Blender paths: {e}")
log.error(f"MainPanelWidget: Error reading base configuration for default Blender paths: {e}")
except Exception as e:
log.error(f"MainPanelWidget: Error reading default Blender paths from config: {e}")
else:
log.warning("MainPanelWidget: load_base_config not available to set default Blender paths.")
self.nodegroup_blend_path_input.setEnabled(False) self.nodegroup_blend_path_input.setEnabled(False)

View File

@@ -46,14 +46,13 @@ if str(project_root) not in sys.path:
sys.path.insert(0, str(project_root)) sys.path.insert(0, str(project_root))
try: try:
from configuration import Configuration, ConfigurationError, load_base_config from configuration import Configuration, ConfigurationError
except ImportError as e: except ImportError as e:
print(f"ERROR: Failed to import backend modules: {e}") print(f"ERROR: Failed to import backend modules: {e}")
print(f"Ensure GUI is run from project root or backend modules are in PYTHONPATH.") print(f"Ensure GUI is run from project root or backend modules are in PYTHONPATH.")
Configuration = None Configuration = None
load_base_config = None
ConfigurationError = Exception ConfigurationError = Exception
AssetProcessor = None AssetProcessor = None
RuleBasedPredictionHandler = None RuleBasedPredictionHandler = None
@@ -97,8 +96,9 @@ class MainWindow(QMainWindow):
start_prediction_signal = Signal(str, list, str) start_prediction_signal = Signal(str, list, str)
start_backend_processing = Signal(list, dict) start_backend_processing = Signal(list, dict)
def __init__(self): def __init__(self, config: Configuration):
super().__init__() super().__init__()
self.config = config # Store the Configuration object
self.setWindowTitle("Asset Processor Tool") self.setWindowTitle("Asset Processor Tool")
self.resize(1200, 700) self.resize(1200, 700)
@@ -132,7 +132,7 @@ class MainWindow(QMainWindow):
self.setCentralWidget(self.splitter) self.setCentralWidget(self.splitter)
# --- Create Models --- # --- Create Models ---
self.unified_model = UnifiedViewModel() self.unified_model = UnifiedViewModel(config=self.config)
# --- Instantiate Handlers that depend on the model --- # --- Instantiate Handlers that depend on the model ---
self.restructure_handler = AssetRestructureHandler(self.unified_model, self) self.restructure_handler = AssetRestructureHandler(self.unified_model, self)
@@ -143,17 +143,16 @@ class MainWindow(QMainWindow):
# --- Load File Type Definitions for Rule Editor --- # --- Load File Type Definitions for Rule Editor ---
file_type_keys = [] file_type_keys = []
try: try:
base_cfg_data = load_base_config() # Access configuration directly from the stored object using public methods
if base_cfg_data and "FILE_TYPE_DEFINITIONS" in base_cfg_data: file_type_defs = self.config.get_file_type_definitions_with_examples()
file_type_keys = list(base_cfg_data["FILE_TYPE_DEFINITIONS"].keys()) file_type_keys = list(file_type_defs.keys())
log.info(f"Loaded {len(file_type_keys)} FILE_TYPE_DEFINITIONS keys for RuleEditor.") log.info(f"Loaded {len(file_type_keys)} FILE_TYPE_DEFINITIONS keys for RuleEditor.")
else:
log.warning("FILE_TYPE_DEFINITIONS not found in base_config. RuleEditor item_type dropdown might be empty.")
except Exception as e: except Exception as e:
log.exception(f"Error loading FILE_TYPE_DEFINITIONS for RuleEditor: {e}") log.exception(f"Error loading FILE_TYPE_DEFINITIONS for RuleEditor: {e}")
file_type_keys = [] # Ensure it's a list even on error
# Instantiate MainPanelWidget, passing the model, self (MainWindow) for context, and file_type_keys # Instantiate MainPanelWidget, passing the config, model, self (MainWindow) for context, and file_type_keys
self.main_panel_widget = MainPanelWidget(self.unified_model, self, file_type_keys=file_type_keys) self.main_panel_widget = MainPanelWidget(config=self.config, unified_model=self.unified_model, parent=self, file_type_keys=file_type_keys)
self.log_console = LogConsoleWidget(self) self.log_console = LogConsoleWidget(self)
# --- Create Left Pane with Static Selector and Stacked Editor --- # --- Create Left Pane with Static Selector and Stacked Editor ---
@@ -215,8 +214,8 @@ class MainWindow(QMainWindow):
} }
self.qt_key_to_ftd_map = {} self.qt_key_to_ftd_map = {}
try: try:
base_settings = load_base_config() # Access configuration directly from the stored object using public methods
file_type_defs = base_settings.get('FILE_TYPE_DEFINITIONS', {}) file_type_defs = self.config.get_file_type_definitions_with_examples()
for ftd_key, ftd_value in file_type_defs.items(): for ftd_key, ftd_value in file_type_defs.items():
if isinstance(ftd_value, dict) and 'keybind' in ftd_value: if isinstance(ftd_value, dict) and 'keybind' in ftd_value:
char_key = ftd_value['keybind'] char_key = ftd_value['keybind']
@@ -311,7 +310,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.")
@@ -330,8 +329,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()
@@ -343,7 +343,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":
@@ -446,7 +447,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")
@@ -694,7 +700,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).")
@@ -749,9 +755,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()
@@ -764,8 +771,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:
@@ -779,7 +786,8 @@ class MainWindow(QMainWindow):
if RuleBasedPredictionHandler and self.prediction_thread is None: if RuleBasedPredictionHandler and self.prediction_thread is None:
self.prediction_thread = QThread(self) self.prediction_thread = QThread(self)
self.prediction_handler = RuleBasedPredictionHandler(input_source_identifier="", original_input_paths=[], preset_name="") # Pass the Configuration object to the prediction handler
self.prediction_handler = RuleBasedPredictionHandler(config_obj=self.config, input_source_identifier="", original_input_paths=[], preset_name="")
self.prediction_handler.moveToThread(self.prediction_thread) self.prediction_handler.moveToThread(self.prediction_thread)
self.start_prediction_signal.connect(self.prediction_handler.run_prediction, Qt.ConnectionType.QueuedConnection) self.start_prediction_signal.connect(self.prediction_handler.run_prediction, Qt.ConnectionType.QueuedConnection)
@@ -1066,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.")
@@ -1094,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:
@@ -1332,6 +1340,7 @@ def run_gui():
"""Initializes and runs the Qt application.""" """Initializes and runs the Qt application."""
print("--- Reached run_gui() ---") print("--- Reached run_gui() ---")
from PySide6.QtGui import QKeySequence from PySide6.QtGui import QKeySequence
from configuration import Configuration # Import Configuration here for instantiation
app = QApplication(sys.argv) app = QApplication(sys.argv)
@@ -1343,7 +1352,16 @@ def run_gui():
app.setPalette(palette) app.setPalette(palette)
window = MainWindow() # Create a Configuration instance and pass it to MainWindow
try:
config = Configuration()
log.info("Configuration loaded successfully for GUI.")
except Exception as e:
log.critical(f"Failed to load configuration for GUI: {e}")
QMessageBox.critical(None, "Configuration Error", f"Failed to load application configuration:\n{e}\n\nApplication will exit.")
sys.exit(1) # Exit if configuration fails
window = MainWindow(config)
window.show() window.show()
sys.exit(app.exec()) sys.exit(app.exec())

View File

@@ -6,7 +6,7 @@ import re
import tempfile import tempfile
import zipfile import zipfile
from collections import defaultdict, Counter from collections import defaultdict, Counter
from typing import List, Dict, Any from typing import List, Dict, Any, Set, Tuple # Added Set, Tuple
# --- PySide6 Imports --- # --- PySide6 Imports ---
from PySide6.QtCore import QObject, Slot # Keep QObject for parent type hint, Slot for classify_files if kept as method from PySide6.QtCore import QObject, Slot # Keep QObject for parent type hint, Slot for classify_files if kept as method
@@ -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.")
matched_item_type = "EXTRA"
# is_gloss_flag = False # Old gloss logic
else:
log.debug(f"PASS 2: File '{filename}' matched '{original_keyword}' for item_type '{target_type}'.")
matched_item_type = target_type
temp_grouped_files[asset_name].append({ # --- Pass 2: Determine Trumped Regular Matches ---
'file_path': file_path_str, # Identify which regular matches are trumped by a priority match for the same rule_index within the asset.
'item_type': matched_item_type, log.debug("--- Starting Classification Pass 2: Determine Trumped Regular Matches ---")
'asset_name': asset_name
})
is_map = True
break
if is_map:
break
# 3. Handle Unmatched Files in Pass 2 (Not Extra, Not Map) trumped_regular_matches: Set[Tuple[str, int]] = set() # Set of (file_path_str, rule_index) pairs that are trumped
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 ---") # 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)
# --- Determine Primary Asset Name for Extra Association (using Pass 1 results) --- log.debug(f" Rule indices with priority matches in asset: {sorted(list(rule_index_has_priority_match_in_asset))}")
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: # Then, for each file, check its matches against the rules that had priority matches
if temp_grouped_files and extra_files_to_associate: for file_path_str in file_list:
fallback_name = sorted(temp_grouped_files.keys())[0] if file_path_str in files_classified_as_extra:
final_primary_asset_name = fallback_name continue
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: matches_for_this_file = file_matches.get(file_path_str, [])
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: # Determine if this file has any priority match for a given rule_index
log.debug("No primary asset name determined (no maps or extras found).") 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.")
# --- Associate Extra Files (collected in Pass 2) --- log.debug(f"--- Finished Pass 2. Identified {len(trumped_regular_matches)} trumped regular matches. ---")
if final_primary_asset_name and extra_files_to_associate:
log.debug(f"Associating {len(extra_files_to_associate)} extra file(s) with primary asset '{final_primary_asset_name}'") # --- Pass 3: Final Assignment & Inter-Entry Resolution ---
for file_path_str, filename in extra_files_to_associate: # Iterate through files, apply ignore rules, and then apply earliest rule wins for remaining valid matches.
if not any(f['file_path'] == file_path_str for f in temp_grouped_files[final_primary_asset_name]): log.debug("--- Starting Classification Pass 3: Final Assignment ---")
temp_grouped_files[final_primary_asset_name].append({
'file_path': file_path_str, final_file_assignments: Dict[str, str] = {} # {file_path: final_item_type}
'item_type': "EXTRA",
'asset_name': final_primary_asset_name
}) 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"Skipping duplicate association of extra file: {filename}") 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}")
elif extra_files_to_associate:
pass 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,
'item_type': final_item_type,
'asset_name': asset_name
})
log.debug(f" Final Grouping: '{Path(file_path_str).name}' -> '{final_item_type}' (Asset: '{asset_name}')")
log.debug(f"Classification complete. Found {len(temp_grouped_files)} potential assets.") log.debug(f"Classification complete. Found {len(classified_files_info)} potential assets.")
return dict(temp_grouped_files) # Enhanced logging for the content of classified_files_info
boucle_chunky_data = {
key: val for key, val in classified_files_info.items()
if 'BoucleChunky001' in key or any('BoucleChunky001' in (f_info.get('file_path','')) for f_info in val)
}
import json # Make sure json is imported if not already at top of file
log.info(f"DEBUG_ROO: Final classified_files_info for BoucleChunky001 (content): \n{json.dumps(boucle_chunky_data, indent=2)}")
return dict(classified_files_info)
class RuleBasedPredictionHandler(BasePredictionHandler): class RuleBasedPredictionHandler(BasePredictionHandler):
@@ -278,17 +303,19 @@ class RuleBasedPredictionHandler(BasePredictionHandler):
Inherits from BasePredictionHandler for common threading and signaling. Inherits from BasePredictionHandler for common threading and signaling.
""" """
def __init__(self, input_source_identifier: str, original_input_paths: list[str], preset_name: str, parent: QObject = None): def __init__(self, config_obj: Configuration, input_source_identifier: str, original_input_paths: list[str], preset_name: str, parent: QObject = None):
""" """
Initializes the rule-based handler. Initializes the rule-based handler with a Configuration object.
Args: Args:
config_obj: The main configuration object.
input_source_identifier: The unique identifier for the input source (e.g., file path). input_source_identifier: The unique identifier for the input source (e.g., file path).
original_input_paths: List of absolute file paths extracted from the source. original_input_paths: List of absolute file paths extracted from the source.
preset_name: The name of the preset configuration to use. preset_name: The name of the preset configuration to use.
parent: The parent QObject. parent: The parent QObject.
""" """
super().__init__(input_source_identifier, parent) super().__init__(input_source_identifier, parent)
self.config = config_obj # Store the Configuration object
self.original_input_paths = original_input_paths self.original_input_paths = original_input_paths
self.preset_name = preset_name self.preset_name = preset_name
self._current_input_path = None self._current_input_path = None
@@ -337,16 +364,24 @@ class RuleBasedPredictionHandler(BasePredictionHandler):
log.warning(f"Input source path does not exist: '{input_source_identifier}'. Skipping prediction.") log.warning(f"Input source path does not exist: '{input_source_identifier}'. Skipping prediction.")
raise FileNotFoundError(f"Input source path not found: {input_source_identifier}") raise FileNotFoundError(f"Input source path not found: {input_source_identifier}")
# --- Load Configuration --- # --- Use Provided Configuration ---
config = Configuration(preset_name) # The Configuration object is now passed during initialization.
log.info(f"Successfully loaded configuration for preset '{preset_name}'.") # Ensure the correct preset is loaded in the passed config object if necessary,
# or rely on the caller (MainWindow) to ensure the config object is in the correct state.
# MainWindow's load_preset method re-initializes the config, so it should be correct.
# We just need to use the stored self.config.
log.info(f"Using provided configuration object for preset '{preset_name}'.")
# No need to create a new Configuration instance here.
# config = Configuration(preset_name) # REMOVED
# log.info(f"Successfully loaded configuration for preset '{preset_name}'.") # REMOVED
if self._is_cancelled: raise RuntimeError("Prediction cancelled before classification.") if self._is_cancelled: raise RuntimeError("Prediction cancelled before classification.")
# --- Perform Classification --- # --- Perform Classification ---
self.status_update.emit(f"Classifying files for '{source_path.name}'...") self.status_update.emit(f"Classifying files for '{source_path.name}'...")
try: try:
classified_assets = classify_files(original_input_paths, config) # Use the stored config object
classified_assets = classify_files(original_input_paths, self.config)
except Exception as e: except Exception as e:
log.exception(f"Error during file classification for source '{input_source_identifier}': {e}") log.exception(f"Error during file classification for source '{input_source_identifier}': {e}")
raise RuntimeError(f"Error classifying files: {e}") from e raise RuntimeError(f"Error classifying files: {e}") from e
@@ -363,25 +398,29 @@ class RuleBasedPredictionHandler(BasePredictionHandler):
# --- Build the Hierarchy --- # --- Build the Hierarchy ---
self.status_update.emit(f"Building rule hierarchy for '{source_path.name}'...") self.status_update.emit(f"Building rule hierarchy for '{source_path.name}'...")
try: try:
supplier_identifier = config.supplier_name # Use the stored config object
supplier_identifier = self.config.supplier_name
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 stored config object
preset_name=self.config.internal_display_preset_name
) )
asset_rules = [] asset_rules = []
file_type_definitions = config._core_settings.get('FILE_TYPE_DEFINITIONS', {}) # Access file type definitions via the public getter method from the stored config object
file_type_definitions = self.config.get_file_type_definitions_with_examples()
for asset_name, files_info in classified_assets.items(): for asset_name, files_info in classified_assets.items():
if self._is_cancelled: raise RuntimeError("Prediction cancelled during hierarchy building (assets).") if self._is_cancelled: raise RuntimeError("Prediction cancelled during hierarchy building (assets).")
if not files_info: continue if not files_info: continue
asset_category_rules = config.asset_category_rules # Use the stored config object
asset_type_definitions = config.get_asset_type_definitions() asset_category_rules = self.config.asset_category_rules
asset_type_definitions = self.config.get_asset_type_definitions()
asset_type_keys = list(asset_type_definitions.keys()) asset_type_keys = list(asset_type_definitions.keys())
# Initialize predicted_asset_type using the validated default # Initialize predicted_asset_type using the validated default from stored config
predicted_asset_type = config.default_asset_category predicted_asset_type = self.config.default_asset_category
log.debug(f"Asset '{asset_name}': Initial predicted_asset_type set to default: '{predicted_asset_type}'.") log.debug(f"Asset '{asset_name}': Initial predicted_asset_type set to default: '{predicted_asset_type}'.")
# 1. Check asset_category_rules from preset # 1. Check asset_category_rules from preset
@@ -389,7 +428,8 @@ class RuleBasedPredictionHandler(BasePredictionHandler):
# Check for Model type based on file patterns # Check for Model type based on file patterns
if "Model" in asset_type_keys: if "Model" in asset_type_keys:
model_patterns_regex = config.compiled_model_regex # Use the stored config object
model_patterns_regex = self.config.compiled_model_regex
for f_info in files_info: for f_info in files_info:
if f_info['item_type'] in ["EXTRA", "FILE_IGNORE"]: if f_info['item_type'] in ["EXTRA", "FILE_IGNORE"]:
continue continue
@@ -421,12 +461,13 @@ class RuleBasedPredictionHandler(BasePredictionHandler):
pass pass
# 2. If not determined by specific rules, check for Surface (if not Model/Decal by rule) # 2. If not determined by specific rules, check for Surface (if not Model/Decal by rule)
if not determined_by_rule and predicted_asset_type == config.default_asset_category and "Surface" in asset_type_keys: if not determined_by_rule and predicted_asset_type == self.config.default_asset_category and "Surface" in asset_type_keys:
item_types_in_asset = {f_info['item_type'] for f_info in files_info} item_types_in_asset = {f_info['item_type'] for f_info in files_info}
# Ensure we are checking against standard map types from FILE_TYPE_DEFINITIONS # Ensure we are checking against standard map types from FILE_TYPE_DEFINITIONS
# This check is primarily for PBR texture sets. # This check is primarily for PBR texture sets.
# Use the stored config object
material_indicators = { material_indicators = {
ft_key for ft_key, ft_def in config.get_file_type_definitions_with_examples().items() ft_key for ft_key, ft_def in self.config.get_file_type_definitions_with_examples().items()
if ft_def.get('standard_type') and ft_def.get('standard_type') not in ["", "EXTRA", "FILE_IGNORE", "MODEL"] if ft_def.get('standard_type') and ft_def.get('standard_type') not in ["", "EXTRA", "FILE_IGNORE", "MODEL"]
} }
# Add common direct standard types as well for robustness # Add common direct standard types as well for robustness
@@ -440,7 +481,7 @@ class RuleBasedPredictionHandler(BasePredictionHandler):
has_material_map = True has_material_map = True
break break
# Check standard type if item_type is a key in FILE_TYPE_DEFINITIONS # Check standard type if item_type is a key in FILE_TYPE_DEFINITIONS
item_def = config.get_file_type_definitions_with_examples().get(item_type) item_def = self.config.get_file_type_definitions_with_examples().get(item_type)
if item_def and item_def.get('standard_type') in material_indicators: if item_def and item_def.get('standard_type') in material_indicators:
has_material_map = True has_material_map = True
break break
@@ -452,8 +493,8 @@ class RuleBasedPredictionHandler(BasePredictionHandler):
# 3. Final validation: Ensure predicted_asset_type is a valid key. # 3. Final validation: Ensure predicted_asset_type is a valid key.
if predicted_asset_type not in asset_type_keys: if predicted_asset_type not in asset_type_keys:
log.warning(f"Derived AssetType '{predicted_asset_type}' for asset '{asset_name}' is not in ASSET_TYPE_DEFINITIONS. " log.warning(f"Derived AssetType '{predicted_asset_type}' for asset '{asset_name}' is not in ASSET_TYPE_DEFINITIONS. "
f"Falling back to default: '{config.default_asset_category}'.") f"Falling back to default: '{self.config.default_asset_category}'.")
predicted_asset_type = config.default_asset_category predicted_asset_type = self.config.default_asset_category
asset_rule = AssetRule(asset_name=asset_name, asset_type=predicted_asset_type) asset_rule = AssetRule(asset_name=asset_name, asset_type=predicted_asset_type)
file_rules = [] file_rules = []
@@ -463,23 +504,23 @@ 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.") # Use the stored config object
if final_item_type not in ["EXTRA", "FILE_IGNORE"] and self.config.get_file_type_definitions_with_examples() and final_item_type not in self.config.get_file_type_definitions_with_examples():
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 +530,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

@@ -1,12 +1,12 @@
# gui/unified_view_model.py # gui/unified_view_model.py
import logging import logging
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
from PySide6.QtCore import QAbstractItemModel, QModelIndex, Qt, Signal, Slot, QMimeData, QByteArray, QDataStream, QIODevice from PySide6.QtCore import QAbstractItemModel, QModelIndex, Qt, Signal, Slot, QMimeData, QByteArray, QDataStream, QIODevice, QPersistentModelIndex
from PySide6.QtGui import QColor from PySide6.QtGui import QColor
from pathlib import Path from pathlib import Path
from rule_structure import SourceRule, AssetRule, FileRule from rule_structure import SourceRule, AssetRule, FileRule
from configuration import load_base_config
from typing import List from typing import List
from configuration import Configuration # Import Configuration class
class CustomRoles: class CustomRoles:
MapTypeRole = Qt.UserRole + 1 MapTypeRole = Qt.UserRole + 1
@@ -46,8 +46,9 @@ class UnifiedViewModel(QAbstractItemModel):
# --- Drag and Drop MIME Type --- # --- Drag and Drop MIME Type ---
MIME_TYPE = "application/x-filerule-index-list" MIME_TYPE = "application/x-filerule-index-list"
def __init__(self, parent=None): def __init__(self, config: Configuration, parent=None):
super().__init__(parent) super().__init__(parent)
self._config = config # Store the Configuration object
self._source_rules = [] self._source_rules = []
# self._display_mode removed # self._display_mode removed
self._asset_type_colors = {} self._asset_type_colors = {}
@@ -59,9 +60,9 @@ class UnifiedViewModel(QAbstractItemModel):
def _load_definitions(self): def _load_definitions(self):
"""Loads configuration and caches colors and type keys.""" """Loads configuration and caches colors and type keys."""
try: try:
base_config = load_base_config() # Access configuration directly from the stored object using public methods
asset_type_defs = base_config.get('ASSET_TYPE_DEFINITIONS', {}) asset_type_defs = self._config.get_asset_type_definitions()
file_type_defs = base_config.get('FILE_TYPE_DEFINITIONS', {}) file_type_defs = self._config.get_file_type_definitions_with_examples()
# Cache Asset Type Definitions (Keys and Colors) # Cache Asset Type Definitions (Keys and Colors)
self._asset_type_keys = sorted(list(asset_type_defs.keys())) self._asset_type_keys = sorted(list(asset_type_defs.keys()))
@@ -552,6 +553,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}
@@ -898,37 +906,23 @@ class UnifiedViewModel(QAbstractItemModel):
encoded_data = QByteArray() encoded_data = QByteArray()
stream = QDataStream(encoded_data, QIODevice.OpenModeFlag.WriteOnly) stream = QDataStream(encoded_data, QIODevice.OpenModeFlag.WriteOnly)
dragged_file_info = [] # Store QPersistentModelIndex for robustness
# Collect file paths of dragged FileRule items
file_paths = []
for index in indexes: for index in indexes:
if not index.isValid() or index.column() != 0: if index.isValid() and index.column() == 0:
continue item = index.internalPointer()
item = index.internalPointer() if isinstance(item, FileRule):
if isinstance(item, FileRule): file_paths.append(item.file_path)
parent_index = self.parent(index) log.debug(f"mimeData: Added file path for file: {Path(item.file_path).name}")
if parent_index.isValid():
# Store: source_row, source_parent_row, source_grandparent_row
# This allows reconstructing the index later
grandparent_index = self.parent(parent_index)
# Ensure grandparent_index is valid before accessing its row
if grandparent_index.isValid():
dragged_file_info.append((index.row(), parent_index.row(), grandparent_index.row()))
else:
# Handle case where grandparent is the root (shouldn't happen for FileRule, but safety)
# Or if parent() failed unexpectedly
log.warning(f"mimeData: Could not get valid grandparent index for FileRule at row {index.row()}, parent row {parent_index.row()}")
else: # Write the number of items first, then each file path string
log.warning(f"mimeData: Could not get parent index for FileRule at row {index.row()}") stream.writeInt32(len(file_paths)) # Use writeInt32 for potentially more items
for file_path in file_paths:
# Write the number of items first, then each tuple stream.writeQString(file_path) # Use writeQString for strings
stream.writeInt8(len(dragged_file_info))
for info in dragged_file_info:
stream.writeInt8(info[0])
stream.writeInt8(info[1])
stream.writeInt8(info[2])
mime_data.setData(self.MIME_TYPE, encoded_data) mime_data.setData(self.MIME_TYPE, encoded_data)
log.debug(f"mimeData: Encoded {len(dragged_file_info)} FileRule indices.") log.debug(f"mimeData: Encoded {len(file_paths)} FileRule file paths.")
return mime_data return mime_data
def canDropMimeData(self, data: QMimeData, action: Qt.DropAction, row: int, column: int, parent: QModelIndex) -> bool: def canDropMimeData(self, data: QMimeData, action: Qt.DropAction, row: int, column: int, parent: QModelIndex) -> bool:
@@ -963,75 +957,68 @@ class UnifiedViewModel(QAbstractItemModel):
encoded_data = data.data(self.MIME_TYPE) encoded_data = data.data(self.MIME_TYPE)
stream = QDataStream(encoded_data, QIODevice.OpenModeFlag.ReadOnly) stream = QDataStream(encoded_data, QIODevice.OpenModeFlag.ReadOnly)
num_items = stream.readInt8() # Read file paths from the stream
source_indices_info = [] dragged_file_paths = []
num_items = stream.readInt32()
log.debug(f"dropMimeData: Decoding {num_items} file paths.")
for _ in range(num_items): for _ in range(num_items):
source_row = stream.readInt8() dragged_file_paths.append(stream.readQString()) # Use readQString for strings
source_parent_row = stream.readInt8()
source_grandparent_row = stream.readInt8()
source_indices_info.append((source_row, source_parent_row, source_grandparent_row))
log.debug(f"dropMimeData: Decoded {len(source_indices_info)} source indices. Target Asset: '{target_asset_item.asset_name}'") log.debug(f"dropMimeData: Decoded {len(dragged_file_paths)} file paths. Target Asset: '{target_asset_item.asset_name}'")
if not source_indices_info: if not dragged_file_paths:
log.warning("dropMimeData: No valid source index information decoded.") log.warning("dropMimeData: No file path information decoded.")
return False
# Find the current FileRule objects and their indices based on file paths
dragged_items_with_indices = []
for file_path in dragged_file_paths:
found_item = None
found_index = QModelIndex()
# Iterate through the model to find the FileRule object by file_path
for sr_row, source_rule in enumerate(self._source_rules):
for ar_row, asset_rule in enumerate(source_rule.assets):
for fr_row, file_rule in enumerate(asset_rule.files):
if file_rule.file_path == file_path:
found_item = file_rule
# Get the current index for this item
parent_asset_index = self.index(ar_row, 0, self.createIndex(sr_row, 0, source_rule))
if parent_asset_index.isValid():
found_index = self.index(fr_row, 0, parent_asset_index)
if found_index.isValid():
dragged_items_with_indices.append((found_item, found_index))
log.debug(f"dropMimeData: Found item and index for file: {Path(file_path).name}")
else:
log.warning(f"dropMimeData: Could not get valid index for found file item: {Path(file_path).name}")
else:
log.warning(f"dropMimeData: Could not get valid parent asset index for found file item: {Path(file_path).name}")
break # Found the file rule, move to the next dragged file path
if found_item: break # Found the file rule, move to the next dragged file path
if found_item: break # Found the file rule, move to the next dragged file path
if not found_item:
log.warning(f"dropMimeData: Could not find FileRule item for path: {file_path}. Skipping.")
if not dragged_items_with_indices:
log.warning("dropMimeData: No valid FileRule items found in the model for the dragged paths.")
return False return False
# Keep track of original parents that might become empty # Keep track of original parents that might become empty
original_parents = set() original_parents_to_check = set()
moved_files_new_indices = {} moved_files_new_indices = {}
# --- BEGIN FIX: Reconstruct all source indices BEFORE the move loop --- # Process moves using the retrieved items and their current indices
source_indices_to_process = [] for file_item, source_file_index in dragged_items_with_indices:
log.debug("Reconstructing initial source indices...") # Track original parent for cleanup using the parent back-reference
for src_row, src_parent_row, src_grandparent_row in source_indices_info: old_parent_asset = getattr(file_item, 'parent_asset', None)
grandparent_index = self.index(src_grandparent_row, 0, QModelIndex()) if old_parent_asset and isinstance(old_parent_asset, AssetRule):
if not grandparent_index.isValid(): source_rule = getattr(old_parent_asset, 'parent_source', None)
log.error(f"dropMimeData: Failed initial reconstruction of grandparent index (row {src_grandparent_row}). Skipping item.") if source_rule:
continue # Store a hashable representation (tuple of identifiers)
old_parent_index = self.index(src_parent_row, 0, grandparent_index) original_parents_to_check.add((source_rule.input_path, old_parent_asset.asset_name))
if not old_parent_index.isValid(): else:
log.error(f"dropMimeData: Failed initial reconstruction of old parent index (row {src_parent_row}). Skipping item.") log.warning(f"dropMimeData: Original parent asset '{old_parent_asset.asset_name}' has no parent source reference for cleanup tracking.")
continue
source_file_index = self.index(src_row, 0, old_parent_index)
if not source_file_index.isValid():
# Log the specific parent it failed under for better debugging
parent_name = getattr(old_parent_index.internalPointer(), 'asset_name', 'Unknown Parent')
log.error(f"dropMimeData: Failed initial reconstruction of source file index (original row {src_row}) under parent '{parent_name}'. Skipping item.")
continue
# Check if the reconstructed index actually points to a FileRule
item_check = source_file_index.internalPointer()
if isinstance(item_check, FileRule):
source_indices_to_process.append(source_file_index)
log.debug(f" Successfully reconstructed index for file: {Path(item_check.file_path).name}")
else:
log.warning(f"dropMimeData: Initial reconstructed index (row {src_row}) does not point to a FileRule. Skipping.")
log.debug(f"Successfully reconstructed {len(source_indices_to_process)} valid source indices.")
# --- END FIX ---
# Process moves using the pre-calculated valid indices
for source_file_index in source_indices_to_process:
# Get the file item (already validated during reconstruction)
file_item = source_file_index.internalPointer()
# Track original parent for cleanup (using the valid index)
old_parent_index = self.parent(source_file_index)
if old_parent_index.isValid():
old_parent_asset = old_parent_index.internalPointer()
if isinstance(old_parent_asset, AssetRule):
# Need grandparent row for the tuple key
grandparent_index = self.parent(old_parent_index)
if grandparent_index.isValid():
original_parents.add((grandparent_index.row(), old_parent_asset.asset_name))
else:
log.warning(f"Could not get grandparent index for original parent '{old_parent_asset.asset_name}' during cleanup tracking.")
else:
log.warning(f"Parent of file '{Path(file_item.file_path).name}' is not an AssetRule.")
else:
log.warning(f"Could not get valid parent index for file '{Path(file_item.file_path).name}' during cleanup tracking.")
# Perform the move using the model's method and the valid source_file_index # Perform the move using the model's method and the valid source_file_index
@@ -1043,15 +1030,25 @@ class UnifiedViewModel(QAbstractItemModel):
if file_item.target_asset_name_override != target_asset_item.asset_name: if file_item.target_asset_name_override != target_asset_item.asset_name:
log.debug(f" Updating target override for '{Path(file_item.file_path).name}' to '{target_asset_item.asset_name}'") log.debug(f" Updating target override for '{Path(file_item.file_path).name}' to '{target_asset_item.asset_name}'")
file_item.target_asset_name_override = target_asset_item.asset_name file_item.target_asset_name_override = target_asset_item.asset_name
# Need the *new* index of the moved file to emit dataChanged # Need the *new* index of the moved file to emit dataChanged AND the override changed signal
try: try:
# Find the new row of the file item within the target parent's list
new_row = target_asset_item.files.index(file_item) new_row = target_asset_item.files.index(file_item)
new_file_index_col0 = self.index(new_row, 0, parent) # Create the index for the target asset column (for dataChanged)
new_file_index_target_col = self.index(new_row, self.COL_TARGET_ASSET, parent) new_file_index_target_col = self.index(new_row, self.COL_TARGET_ASSET, parent)
if new_file_index_target_col.isValid(): if new_file_index_target_col.isValid():
moved_files_new_indices[file_item.file_path] = new_file_index_target_col moved_files_new_indices[file_item.file_path] = new_file_index_target_col
else: else:
log.warning(f" Could not get valid *new* index for target column of moved file: {Path(file_item.file_path).name}") log.warning(f" Could not get valid *new* index for target column of moved file: {Path(file_item.file_path).name}")
# Emit the targetAssetOverrideChanged signal for the handler
new_file_index_col_0 = self.index(new_row, 0, parent) # Index for column 0
if new_file_index_col_0.isValid():
self.targetAssetOverrideChanged.emit(file_item, target_asset_item.asset_name, new_file_index_col_0)
log.debug(f" Emitted targetAssetOverrideChanged for '{Path(file_item.file_path).name}'")
else:
log.warning(f" Could not get valid *new* index for column 0 of moved file to emit signal: {Path(file_item.file_path).name}")
except ValueError: except ValueError:
log.error(f" Could not find moved file '{Path(file_item.file_path).name}' in target parent's list after move.") log.error(f" Could not find moved file '{Path(file_item.file_path).name}' in target parent's list after move.")
@@ -1067,24 +1064,43 @@ class UnifiedViewModel(QAbstractItemModel):
self.dataChanged.emit(new_index, new_index, [Qt.DisplayRole, Qt.EditRole]) self.dataChanged.emit(new_index, new_index, [Qt.DisplayRole, Qt.EditRole])
# --- Cleanup: Remove any original parent AssetRules that are now empty --- # --- Cleanup: Remove any original parent AssetRules that are now empty ---
log.debug(f"dropMimeData: Checking original parents for cleanup: {list(original_parents)}") log.debug(f"dropMimeData: Checking original parents for cleanup: {[f'{path}/{name}' for path, name in original_parents_to_check]}")
for gp_row, asset_name in list(original_parents): # Convert set to list to iterate
try: for source_path, asset_name_to_check in list(original_parents_to_check):
if 0 <= gp_row < len(self._source_rules): found_asset_rule_to_check = None
source_rule = self._source_rules[gp_row] # Find the AssetRule object based on source_path and asset_name
# Find the asset rule within the correct source rule for source_rule in self._source_rules:
asset_rule_to_check = next((asset for asset in source_rule.assets if asset.asset_name == asset_name), None) if source_rule.input_path == source_path:
for asset_rule in source_rule.assets:
if asset_rule.asset_name == asset_name_to_check:
found_asset_rule_to_check = asset_rule
break
if found_asset_rule_to_check: break
if asset_rule_to_check and not asset_rule_to_check.files and asset_rule_to_check != target_asset_item: if found_asset_rule_to_check:
log.info(f"dropMimeData: Attempting cleanup of now empty original parent: '{asset_rule_to_check.asset_name}'") try:
if not self.removeAssetRule(asset_rule_to_check): # Re-check if the asset is still in the model and is now empty
log.warning(f"dropMimeData: Failed to remove empty original parent '{asset_rule_to_check.asset_name}'.") # Use parent back-reference to find the source rule (should be the same as source_rule found above)
elif not asset_rule_to_check: source_rule = getattr(found_asset_rule_to_check, 'parent_source', None)
log.warning(f"dropMimeData: Cleanup check failed. Could not find original parent asset '{asset_name}' in source rule at row {gp_row}.") if source_rule:
else: # Check if the asset rule is still in its parent's list
log.warning(f"dropMimeData: Cleanup check failed. Invalid grandparent row index {gp_row} found in original_parents set.") if found_asset_rule_to_check in source_rule.assets:
except Exception as e: if not found_asset_rule_to_check.files and found_asset_rule_to_check is not target_asset_item:
log.exception(f"dropMimeData: Error during cleanup check for parent '{asset_name}' (gp_row {gp_row}): {e}") log.info(f"dropMimeData: Attempting cleanup of now empty original parent: '{found_asset_rule_to_check.asset_name}'")
if not self.removeAssetRule(found_asset_rule_to_check):
log.warning(f"dropMimeData: Failed to remove empty original parent '{found_asset_rule_to_check.asset_name}'.")
elif found_asset_rule_to_check.files:
log.debug(f"dropMimeData: Original parent '{found_asset_rule_to_check.asset_name}' is not empty after moves. Skipping cleanup.")
# If it's the target asset, we don't remove it
else:
log.warning(f"dropMimeData: Cleanup check failed. Original parent asset '{found_asset_rule_to_check.asset_name}' not found in its source rule's list.")
else:
log.warning(f"dropMimeData: Cleanup check failed. Original parent asset '{found_asset_rule_to_check.asset_name}' has no parent source reference.")
except Exception as e:
log.exception(f"dropMimeData: Error during cleanup check for parent '{found_asset_rule_to_check.asset_name}': {e}")
else:
log.warning(f"dropMimeData: Could not find original parent asset '{asset_name_to_check}' for cleanup.")
return True return True

386
main.py
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
@@ -14,11 +15,12 @@ from typing import List, Dict, Tuple, Optional
# --- Utility Imports --- # --- Utility Imports ---
from utils.hash_utils import calculate_sha256 from utils.hash_utils import calculate_sha256
from utils.path_utils import get_next_incrementing_value from utils.path_utils import get_next_incrementing_value
from utils import app_setup_utils # Import the new utility module
# --- Qt Imports for Application Structure --- # --- Qt Imports for Application Structure ---
from PySide6.QtCore import QObject, Slot, QThreadPool, QRunnable, Signal from PySide6.QtCore import QObject, Slot, QThreadPool, QRunnable, Signal
from PySide6.QtCore import Qt from PySide6.QtCore import Qt
from PySide6.QtWidgets import QApplication from PySide6.QtWidgets import QApplication, QDialog # Import QDialog for the setup dialog
# --- Backend Imports --- # --- Backend Imports ---
# Add current directory to sys.path for direct execution # Add current directory to sys.path for direct execution
@@ -44,6 +46,10 @@ try:
from gui.main_window import MainWindow from gui.main_window import MainWindow
print("DEBUG: Successfully imported MainWindow.") print("DEBUG: Successfully imported MainWindow.")
print("DEBUG: Attempting to import FirstTimeSetupDialog...")
from gui.first_time_setup_dialog import FirstTimeSetupDialog # Import the setup dialog
print("DEBUG: Successfully imported FirstTimeSetupDialog.")
print("DEBUG: Attempting to import prepare_processing_workspace...") print("DEBUG: Attempting to import prepare_processing_workspace...")
from utils.workspace_utils import prepare_processing_workspace from utils.workspace_utils import prepare_processing_workspace
print("DEBUG: Successfully imported prepare_processing_workspace.") print("DEBUG: Successfully imported prepare_processing_workspace.")
@@ -238,9 +244,15 @@ 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
next_increment_str = get_next_incrementing_value(output_dir, pattern) if re.search(r"\[IncrementingValue\]|#+", pattern):
log.info(f"Calculated next incrementing value for {output_dir}: {next_increment_str}") 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)
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.
log.debug(f"Calculated next incrementing value for {output_dir}: {next_increment_str}")
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:
@@ -294,68 +306,61 @@ class App(QObject):
# Signal emitted when all queued processing tasks are complete # Signal emitted when all queued processing tasks are complete
all_tasks_finished = Signal(int, int, int) # processed_count, skipped_count, failed_count (Placeholder counts for now) all_tasks_finished = Signal(int, int, int) # processed_count, skipped_count, failed_count (Placeholder counts for now)
def __init__(self): def __init__(self, user_config_path: str):
super().__init__() super().__init__()
self.config_obj = None self.user_config_path = user_config_path # Store the determined user config path
self.processing_engine = None self.config_obj = None # Initialize config_obj to None
self.processing_engine = None # Initialize processing_engine to None
self.main_window = None self.main_window = None
self.thread_pool = QThreadPool() self.thread_pool = QThreadPool()
self._active_tasks_count = 0 self._active_tasks_count = 0
self._task_results = {"processed": 0, "skipped": 0, "failed": 0} self._task_results = {"processed": 0, "skipped": 0, "failed": 0}
log.info(f"Maximum threads for pool: {self.thread_pool.maxThreadCount()}") log.info(f"Maximum threads for pool: {self.thread_pool.maxThreadCount()}")
self._load_config() # Configuration, engine, and GUI are now initialized via load_preset
self._init_engine() log.debug("App initialized. Configuration, engine, and GUI will be loaded via load_preset.")
self._init_gui()
def _load_config(self): def _load_config(self, user_config_path: str, preset_name: str):
"""Loads the base configuration using a default preset.""" """
# The actual preset name comes from the GUI request later, but the engine Loads the configuration using the determined user config path and specified preset.
# needs an initial valid configuration object. Sets self.config_obj. Does NOT exit on failure; raises ConfigurationError.
"""
log.debug(f"App: Attempting to load configuration with user_config_path='{user_config_path}' and preset_name='{preset_name}'")
try: try:
# Find the first available preset to use as a default # Convert user_config_path string to a Path object before passing to Configuration
preset_dir = Path(__file__).parent / "Presets" user_config_path_obj = Path(user_config_path)
default_preset_name = None # Instantiate Configuration with the determined user config path and the specified preset name
if preset_dir.is_dir(): self.config_obj = Configuration(preset_name=preset_name, base_dir_user_config=user_config_path_obj)
presets = sorted([f.stem for f in preset_dir.glob("*.json") if f.is_file() and not f.name.startswith('_')]) log.info(f"App: Configuration loaded successfully with preset '{preset_name}'.")
if presets:
default_preset_name = presets[0]
log.info(f"Using first available preset as default for initial config: '{default_preset_name}'")
if not default_preset_name:
# Fallback or raise error if no presets found
log.error("No presets found in the 'Presets' directory. Cannot initialize default configuration.")
# Option 1: Raise an error
raise ConfigurationError("No presets found to load default configuration.")
self.config_obj = Configuration(preset_name=default_preset_name)
log.info(f"Base configuration loaded using default preset '{default_preset_name}'.")
except ConfigurationError as e: except ConfigurationError as e:
log.error(f"Fatal: Failed to load base configuration using default preset: {e}") log.error(f"App: Failed to load configuration with preset '{preset_name}': {e}")
# In a real app, show this error to the user before exiting self.config_obj = None # Ensure config_obj is None on failure
sys.exit(1) raise # Re-raise the exception
except Exception as e: except Exception as e:
log.exception(f"Fatal: Unexpected error loading configuration: {e}") log.exception(f"App: Unexpected error loading configuration with preset '{preset_name}': {e}")
sys.exit(1) self.config_obj = None # Ensure config_obj is None on failure
raise # Re-raise unexpected errors
def _init_engine(self): def _init_engine(self):
"""Initializes the ProcessingEngine.""" """Initializes the ProcessingEngine if config_obj is available."""
if self.config_obj: if self.config_obj:
try: try:
self.processing_engine = ProcessingEngine(self.config_obj) self.processing_engine = ProcessingEngine(self.config_obj)
log.info("ProcessingEngine initialized.") log.info("App: ProcessingEngine initialized.")
except Exception as e: except Exception as e:
log.exception(f"Fatal: Failed to initialize ProcessingEngine: {e}") log.exception(f"App: Failed to initialize ProcessingEngine: {e}")
# Show error and exit self.processing_engine = None # Ensure engine is None on failure
sys.exit(1) # Depending on context, this might need to be a fatal error.
# For now, log and set to None.
else: else:
log.error("Fatal: Cannot initialize ProcessingEngine without configuration.") log.warning("App: Cannot initialize ProcessingEngine: config_obj is None.")
sys.exit(1) self.processing_engine = None
def _init_gui(self): def _init_gui(self):
"""Initializes the MainWindow and connects signals.""" """Initializes the MainWindow and connects signals if processing_engine is available."""
if self.processing_engine: if self.processing_engine and self.config_obj:
self.main_window = MainWindow() # MainWindow now part of the App # Pass the config object to MainWindow during initialization
self.main_window = MainWindow(config=self.config_obj)
# Connect the signal from the GUI to the App's slot using QueuedConnection # Connect the signal from the GUI to the App's slot using QueuedConnection
# Connect the signal from the MainWindow (which is triggered by the panel) to the App's slot # Connect the signal from the MainWindow (which is triggered by the panel) to the App's slot
connection_success = self.main_window.start_backend_processing.connect(self.on_processing_requested, Qt.ConnectionType.QueuedConnection) connection_success = self.main_window.start_backend_processing.connect(self.on_processing_requested, Qt.ConnectionType.QueuedConnection)
@@ -366,10 +371,53 @@ class App(QObject):
log.error("*********************************************************") log.error("*********************************************************")
# Connect the App's completion signal to the MainWindow's slot # Connect the App's completion signal to the MainWindow's slot
self.all_tasks_finished.connect(self.main_window.on_processing_finished) self.all_tasks_finished.connect(self.main_window.on_processing_finished)
log.info("MainWindow initialized and signals connected.") log.info("App: MainWindow initialized and signals connected.")
else: else:
log.error("Fatal: Cannot initialize MainWindow without ProcessingEngine.") log.warning("App: Cannot initialize MainWindow: ProcessingEngine or config_obj is None.")
sys.exit(1) self.main_window = None # Ensure main_window is None if initialization fails
def load_preset(self, preset_name: str):
"""
Loads the specified preset and re-initializes the configuration and processing engine.
This is intended to be called after App initialization, e.g., by the GUI or autotest.
"""
log.info(f"App: Loading preset '{preset_name}'...")
try:
# Load the configuration with the specified preset
self._load_config(self.user_config_path, preset_name)
log.info(f"App: Configuration reloaded with preset '{preset_name}'.")
# Re-initialize the ProcessingEngine with the new configuration
self._init_engine()
log.info("App: ProcessingEngine re-initialized with new configuration.")
# Initialize GUI if it hasn't been already (e.g., in Autotest where it's needed after config)
if not self.main_window:
self._init_gui()
if self.main_window:
log.debug("App: MainWindow initialized after preset load.")
else:
log.error("App: Failed to initialize MainWindow after preset load.")
else:
# If GUI was already initialized (e.g., in GUI mode),
# inform it about the config change if needed
# (e.g., to update delegates or other config-dependent UI elements)
# The MainWindow and its components (like UnifiedViewModel, MainPanelWidget)
# already hold a reference to the config_obj.
# If they need to react to a *change* in config_obj, they would need
# a signal or a method call here.
# For now, assume they access the updated self.config_obj directly when needed.
log.debug("App: MainWindow already exists, assuming it will use the updated config_obj.")
except ConfigurationError as e:
log.error(f"App: Failed to load preset '{preset_name}': {e}")
# Depending on context (GUI vs CLI/Autotest), this might need to be handled differently.
# For Autotest, this is likely a fatal error. For GUI, show a message box.
raise # Re-raise the exception to be caught by the caller (e.g., Autotest)
except Exception as e:
log.exception(f"App: Unexpected error loading preset '{preset_name}': {e}")
raise # Re-raise unexpected errors
@Slot(list, dict) # Slot to receive List[SourceRule] and processing_settings dict @Slot(list, dict) # Slot to receive List[SourceRule] and processing_settings dict
def on_processing_requested(self, source_rules: list, processing_settings: dict): def on_processing_requested(self, source_rules: list, processing_settings: dict):
@@ -380,142 +428,98 @@ class App(QObject):
log.info(f"VERIFY: App.on_processing_requested received {len(source_rules)} rules.") log.info(f"VERIFY: App.on_processing_requested received {len(source_rules)} rules.")
for i, rule in enumerate(source_rules): for i, rule in enumerate(source_rules):
log.debug(f" VERIFY Rule {i}: Input='{rule.input_path}', Assets={len(rule.assets)}") log.debug(f" VERIFY Rule {i}: Input='{rule.input_path}', Assets={len(rule.assets)}")
if not self.processing_engine: if not self.processing_engine:
log.error("Processing engine not available. Cannot process request.") log.error("Processing engine not available. Cannot process request.")
self.main_window.statusBar().showMessage("Error: Processing Engine not ready.", 5000) if self.main_window:
self.main_window.statusBar().showMessage("Error: Processing Engine not ready.", 5000)
# Emit finished signal with failure counts if engine is not ready
self.all_tasks_finished.emit(0, 0, len(source_rules))
return return
if not source_rules: if not source_rules:
log.warning("Processing requested with an empty rule list.") log.warning("Processing requested with an empty rule list.")
self.main_window.statusBar().showMessage("No rules to process.", 3000) if self.main_window:
self.main_window.statusBar().showMessage("No rules to process.", 3000)
# Emit finished signal immediately if no rules
self.all_tasks_finished.emit(0, 0, 0)
return return
# Reset task counter and results for this batch # Reset task counter and results for this batch
self._active_tasks_count = len(source_rules) self._active_tasks_count = len(source_rules)
self._task_results = {"processed": 0, "skipped": 0, "failed": 0} self._task_results = {"processed": 0, "skipped": 0, "failed": 0}
log.debug(f"Initialized active task count to: {self._active_tasks_count}") log.info(f"Initialized active task count to: {self._active_tasks_count}")
# Update GUI progress bar/status via MainPanelWidget # Update GUI progress bar/status via MainPanelWidget
self.main_window.main_panel_widget.progress_bar.setMaximum(len(source_rules)) if self.main_window and hasattr(self.main_window, 'main_panel_widget') and self.main_window.main_panel_widget:
self.main_window.main_panel_widget.progress_bar.setValue(0) # Set maximum value of progress bar to total number of tasks
self.main_window.main_panel_widget.progress_bar.setFormat(f"0/{len(source_rules)} tasks") self.main_window.main_panel_widget.progress_bar.setMaximum(self._active_tasks_count)
self.main_window.main_panel_widget.update_progress_bar(0, self._active_tasks_count) # Start at 0
else:
log.warning("App: Cannot update progress bar, main_window or main_panel_widget not available.")
# --- Get paths needed for ProcessingTask --- # Extract processing settings
try: output_dir = Path(processing_settings.get("output_dir"))
# Get output_dir from processing_settings passed from autotest.py overwrite = processing_settings.get("overwrite", False)
output_base_path_str = processing_settings.get("output_dir") # Workers setting is used by QThreadPool itself, not passed to individual tasks
log.info(f"APP_DEBUG: Received output_dir in processing_settings: {output_base_path_str}") # blender_enabled, nodegroup_blend_path, materials_blend_path are not used by the engine directly,
# they would be handled by a post-processing stage if implemented.
if not output_base_path_str: # Submit tasks to the thread pool
log.error("Cannot queue tasks: Output directory path is empty in processing_settings.") log.info(f"Submitting {len(source_rules)} processing tasks to the thread pool.")
# self.main_window.statusBar().showMessage("Error: Output directory cannot be empty.", 5000) # GUI specific for rule in source_rules:
return # Create a ProcessingTask for each SourceRule
output_base_path = Path(output_base_path_str) # workspace_path, incrementing_value, and sha5_value are calculated within ProcessingTask.run
# Basic validation - check if it's likely a valid path structure (doesn't guarantee existence/writability here) task = ProcessingTask(
if not output_base_path.is_absolute(): engine=self.processing_engine,
# Or attempt to resolve relative to workspace? For now, require absolute from GUI. rule=rule,
log.warning(f"Output path '{output_base_path}' is not absolute. Processing might fail if relative path is not handled correctly by engine.") workspace_path=Path(rule.input_path), # Pass the original input path for workspace preparation
# Consider resolving: output_base_path = Path.cwd() / output_base_path # If relative paths are allowed output_base_path=output_dir
)
# Connect the task's finished signal to the App's slot
task.signals.finished.connect(self._on_task_finished)
# Start the task in the thread pool
self.thread_pool.start(task)
log.debug(f"Submitted task for rule: {rule.input_path}")
# Define workspace path (assuming main.py is in the project root) log.info("All processing tasks submitted to thread pool.")
workspace_path = Path(__file__).parent.resolve()
log.debug(f"Using Workspace Path: {workspace_path}")
log.debug(f"Using Output Base Path: {output_base_path}")
except Exception as e: @Slot(str, str, object) # rule_input_path, status, result/error
log.exception(f"Error getting/validating paths for processing task: {e}") def _on_task_finished(self, rule_input_path: str, status: str, result_or_error: object):
self.main_window.statusBar().showMessage(f"Error preparing paths: {e}", 5000) """Slot to handle the completion of an individual processing task."""
return log.debug(f"DEBUG: App._on_task_finished slot entered for rule: {rule_input_path} with status: {status}")
# --- End Get paths ---
# Decrement the active task count
# Set max threads based on GUI setting
worker_count = processing_settings.get('workers', 1)
self.thread_pool.setMaxThreadCount(worker_count)
log.info(f"Set thread pool max workers to: {worker_count}")
# Queue tasks in the thread pool
log.debug("DEBUG: Entering task queuing loop.")
for i, rule in enumerate(source_rules):
if isinstance(rule, SourceRule):
log.info(f"DEBUG Task {i+1}: Rule Input='{rule.input_path}', Supplier ID='{getattr(rule, 'supplier_identifier', 'Not Set')}', Preset='{getattr(rule, 'preset_name', 'Not Set')}'")
log.debug(f"DEBUG: Preparing to queue task {i+1}/{len(source_rules)} for rule: {rule.input_path}")
# --- Create a new Configuration and Engine instance for this specific task ---
task_engine = None
try:
# Get preset name from the rule, fallback to app's default if missing
preset_name_for_task = getattr(rule, 'preset_name', None)
if not preset_name_for_task:
log.warning(f"Task {i+1} (Rule: {rule.input_path}): SourceRule missing preset_name. Falling back to default preset '{self.config_obj.preset_name}'.")
preset_name_for_task = self.config_obj.preset_name
task_config = Configuration(preset_name=preset_name_for_task)
task_engine = ProcessingEngine(task_config)
log.debug(f"Task {i+1}: Created new ProcessingEngine instance with preset '{preset_name_for_task}'.")
except ConfigurationError as config_err:
log.error(f"Task {i+1} (Rule: {rule.input_path}): Failed to load configuration for preset '{preset_name_for_task}': {config_err}. Skipping task.")
self._active_tasks_count -= 1 # Decrement count as this task won't run
self._task_results["failed"] += 1
# Optionally update GUI status for this specific rule
self.main_window.update_file_status(str(rule.input_path), "failed", f"Config Error: {config_err}")
continue # Skip to the next rule
except Exception as engine_err:
log.exception(f"Task {i+1} (Rule: {rule.input_path}): Failed to initialize ProcessingEngine for preset '{preset_name_for_task}': {engine_err}. Skipping task.")
self._active_tasks_count -= 1 # Decrement count
self._task_results["failed"] += 1
self.main_window.update_file_status(str(rule.input_path), "failed", f"Engine Init Error: {engine_err}")
continue # Skip to the next rule
if task_engine is None: # Should not happen if exceptions are caught, but safety check
log.error(f"Task {i+1} (Rule: {rule.input_path}): Engine is None after initialization attempt. Skipping task.")
self._active_tasks_count -= 1 # Decrement count
self._task_results["failed"] += 1
self.main_window.update_file_status(str(rule.input_path), "failed", "Engine initialization failed (unknown reason).")
continue # Skip to the next rule
# --- End Engine Instantiation ---
task = ProcessingTask(
engine=task_engine,
rule=rule,
workspace_path=workspace_path,
output_base_path=output_base_path # This is Path(output_base_path_str)
)
log.info(f"APP_DEBUG: Passing to ProcessingTask: output_base_path = {output_base_path}")
task.signals.finished.connect(self._on_task_finished)
log.debug(f"DEBUG: Calling thread_pool.start() for task {i+1}")
self.thread_pool.start(task)
log.debug(f"DEBUG: Returned from thread_pool.start() for task {i+1}")
else:
log.warning(f"Skipping invalid item (index {i}) in rule list: {type(rule)}")
log.info(f"Queued {len(source_rules)} processing tasks (finished loop).")
# GUI status already updated in MainWindow when signal was emitted
# --- Slot to handle completion of individual tasks ---
@Slot(str, str, object)
def _on_task_finished(self, rule_input_path, status, result_or_error):
"""Handles the 'finished' signal from a ProcessingTask."""
log.info(f"Task finished signal received for {rule_input_path}. Status: {status}")
self._active_tasks_count -= 1 self._active_tasks_count -= 1
log.debug(f"Active tasks remaining: {self._active_tasks_count}")
# Update overall results (basic counts for now) # Update task results based on status
if status == "processed": if status == "processed":
self._task_results["processed"] += 1 self._task_results["processed"] += 1
elif status == "skipped": # Assuming engine might return 'skipped' status eventually elif status == "skipped":
self._task_results["skipped"] += 1 self._task_results["skipped"] += 1
else: # Count all other statuses (failed_preparation, failed_processing) as failed elif status.startswith("failed"): # Catches "failed_preparation" and "failed_processing"
self._task_results["failed"] += 1 self._task_results["failed"] += 1
log.error(f"Task failed for {rule_input_path}: {result_or_error}")
else:
log.warning(f"Task finished with unknown status '{status}' for {rule_input_path}. Treating as failed.")
self._task_results["failed"] += 1
log.error(f"Task with unknown status failed for {rule_input_path}: {result_or_error}")
# Update progress bar via MainPanelWidget log.info(f"Task finished for {rule_input_path}. Status: {status}. Remaining tasks: {self._active_tasks_count}")
total_tasks = self.main_window.main_panel_widget.progress_bar.maximum() log.debug(f"Current task results: Processed={self._task_results['processed']}, Skipped={self._task_results['skipped']}, Failed={self._task_results['failed']}")
completed_tasks = total_tasks - self._active_tasks_count
self.main_window.main_panel_widget.update_progress_bar(completed_tasks, total_tasks) # Use MainPanelWidget's method
# Update status for the specific file in the GUI (if needed) # Update GUI progress bar
if self.main_window and hasattr(self.main_window, 'main_panel_widget') and self.main_window.main_panel_widget:
completed_tasks = self._task_results["processed"] + self._task_results["skipped"] + self._task_results["failed"]
self.main_window.main_panel_widget.update_progress_bar(completed_tasks, self._task_results["processed"] + self._task_results["skipped"] + self._task_results["failed"] + self._active_tasks_count) # Update with current counts
# Update status text if needed (e.g., "Processing X of Y...")
self.main_window.main_panel_widget.set_progress_bar_text(f"Processing: {completed_tasks}/{self._task_results['processed'] + self._task_results['skipped'] + self._task_results['failed'] + self._active_tasks_count}")
else:
log.warning("App: Cannot update progress bar in _on_task_finished, main_window or main_panel_widget not available.")
if self._active_tasks_count == 0:
# Check if all tasks are finished
if self._active_tasks_count <= 0: # Use <= 0 to handle potential errors leading to negative count
log.info("All processing tasks finished.") log.info("All processing tasks finished.")
# Emit the signal with the final counts # Emit the signal with the final counts
self.all_tasks_finished.emit( self.all_tasks_finished.emit(
@@ -523,6 +527,9 @@ class App(QObject):
self._task_results["skipped"], self._task_results["skipped"],
self._task_results["failed"] self._task_results["failed"]
) )
# Reset task count to 0 explicitly
self._active_tasks_count = 0
log.debug("Emitted all_tasks_finished signal.")
elif self._active_tasks_count < 0: elif self._active_tasks_count < 0:
log.error("Error: Active task count went below zero!") # Should not happen log.error("Error: Active task count went below zero!") # Should not happen
@@ -534,6 +541,14 @@ class App(QObject):
else: else:
log.error("Cannot run application, MainWindow not initialized.") log.error("Cannot run application, MainWindow not initialized.")
def run(self):
"""Shows the main window."""
if self.main_window:
self.main_window.show()
log.info("Application started. Showing main window.")
else:
log.error("Cannot run application, MainWindow not initialized.")
if __name__ == "__main__": if __name__ == "__main__":
parser = setup_arg_parser() parser = setup_arg_parser()
@@ -552,9 +567,58 @@ if __name__ == "__main__":
log.info("No required CLI arguments detected, starting GUI mode.") log.info("No required CLI arguments detected, starting GUI mode.")
# --- Run the GUI Application --- # --- Run the GUI Application ---
try: try:
qt_app = QApplication(sys.argv) user_config_path = app_setup_utils.read_saved_user_config_path()
log.debug(f"Read saved user config path: {user_config_path}")
app_instance = App() first_run_needed = False
if user_config_path is None or not user_config_path.strip():
log.info("No saved user config path found. First run setup needed.")
first_run_needed = True
else:
user_config_dir = Path(user_config_path)
marker_file = app_setup_utils.get_first_run_marker_file(user_config_path)
if not user_config_dir.is_dir():
log.warning(f"Saved user config directory does not exist: {user_config_path}. First run setup needed.")
first_run_needed = True
elif not Path(marker_file).is_file():
log.warning(f"First run marker file not found in {user_config_path}. First run setup needed.")
first_run_needed = True
else:
log.info(f"Saved user config path found and valid: {user_config_path}. Marker file exists.")
qt_app = None
if first_run_needed:
log.info("Initiating first-time setup dialog.")
# Need a QApplication instance to show the dialog
qt_app = QApplication.instance()
if qt_app is None:
qt_app = QApplication(sys.argv)
dialog = FirstTimeSetupDialog()
if dialog.exec() == QDialog.Accepted:
user_config_path = dialog.get_chosen_path()
log.info(f"First-time setup completed. Chosen path: {user_config_path}")
# The dialog should have already saved the path and created the marker file
else:
log.info("First-time setup cancelled by user. Exiting application.")
sys.exit(0) # Exit gracefully
# If qt_app was created for the dialog, reuse it. Otherwise, create it now.
if qt_app is None:
qt_app = QApplication.instance()
if qt_app is None:
qt_app = QApplication(sys.argv)
# Ensure user_config_path is set before initializing App
if not user_config_path or not Path(user_config_path).is_dir():
log.error(f"Fatal: User config path is invalid or not set after setup: {user_config_path}. Cannot proceed.")
sys.exit(1)
app_instance = App(user_config_path) # Pass the determined path
# Load an initial preset after App initialization to set up config, engine, and GUI
app_instance.load_preset("_template")
app_instance.run() app_instance.run()
sys.exit(qt_app.exec()) sys.exit(qt_app.exec())

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):
next_increment_str = get_next_incrementing_value(output_dir, pattern) log.debug(f"[Task:{archive_path.name}] Incrementing token found in pattern '{pattern}'. Calculating next value for dir: {output_dir}")
log.info(f"[Task:{archive_path.name}] Calculated next incrementing value: {next_increment_str}") next_increment_str = get_next_incrementing_value(output_dir, pattern)
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):
next_increment_str = get_next_incrementing_value(output_dir, pattern) log.debug(f"[Task:{archive_path.name}] Incrementing token found in pattern '{pattern}' (from dict). Calculating next value for dir: {output_dir}")
log.info(f"[Task:{archive_path.name}] Calculated next incrementing value (from dict): {next_increment_str}") 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}")
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,12 +58,13 @@ 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
class SaveVariantsInput: class SaveVariantsInput:
image_data: np.ndarray # Final data (potentially scaled) image_data: np.ndarray # Final data (potentially scaled)
internal_map_type: str # Final internal type (e.g., MAP_ROUGH, MAP_COL-1) final_internal_map_type: str # Final internal type (e.g., MAP_ROUGH, MAP_COL-1)
source_bit_depth_info: List[int] source_bit_depth_info: List[int]
# Configuration needed # Configuration needed
output_filename_pattern_tokens: Dict[str, Any] output_filename_pattern_tokens: Dict[str, Any]

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,145 +200,224 @@ 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
# 2. Scale (Optional) # Transformations (like gloss to rough, normal invert) are assumed to be applied
scaling_mode = getattr(context.config_obj, "INITIAL_SCALING_MODE", "NONE") # by RegularMapProcessorStage if it's still used, or directly in PrepareProcessingItemsStage
if scaling_mode != "NONE" and current_image_data is not None and current_image_data.size > 0: # before creating the ProcessingItem, or a new dedicated transformation stage.
if isinstance(item, MergeTaskDefinition): # Log scaling call for merge tasks # For now, assume item.image_data is ready for scaling/saving.
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})...") # 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)
scaling_mode = getattr(context.config_obj, "INITIAL_SCALING_MODE", "NONE")
# Pass the item's resolution_key to InitialScalingInput
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,
'output_base_directory': context.engine_temp_dir, # Save variants to temp dir 'supplier': context.effective_supplier or 'UnknownSupplier',
# Add other tokens from context/config as needed by the pattern 'resolution': output_resolution_key # Use the key from the item/scaling stage
'supplier': context.effective_supplier or 'UnknownSupplier',
}
# Log the value being read for the threshold before creating the input object
log.info(f"ORCHESTRATOR_DEBUG: Reading RESOLUTION_THRESHOLD_FOR_JPG from config for SaveVariantsInput: {getattr(context.config_obj, 'RESOLUTION_THRESHOLD_FOR_JPG', None)}")
save_input = SaveVariantsInput(
image_data=current_image_data, # Use potentially scaled data
internal_map_type=internal_map_type,
source_bit_depth_info=source_bit_depth,
output_filename_pattern_tokens=output_filename_tokens,
# Pass config values needed by save stage
image_resolutions=context.config_obj.image_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) # Corrected case
)
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"):
item_status = saved_data.status # e.g., "Processed" or "Processed (No Output)"
log.info(f"{item_log_prefix}: Item successfully processed and saved. Status: {item_status}")
# Populate final details for this item
final_details = {
"status": item_status,
"saved_files_info": saved_data.saved_files_details, # List of dicts from save util
"internal_map_type": internal_map_type,
"original_dimensions": processed_data.original_dimensions if isinstance(processed_data, ProcessedRegularMapData) else None,
"final_dimensions": scaled_data_output.final_dimensions if scaled_data_output else current_dimensions,
"transformations": processed_data.transformations_applied if isinstance(processed_data, ProcessedRegularMapData) else processed_data.transformations_applied_to_inputs,
# Add source file if regular map
"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): # Determine image_resolutions argument for save_image_variants
log.info(f"{item_log_prefix}: Adding final details to context.processed_maps_details for MergeTask '{item_key}'. Details: {final_details}") save_specific_resolutions = {}
context.processed_maps_details[item_key] = final_details 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(
image_data=current_image_data,
final_internal_map_type=item.map_type_identifier,
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,
image_resolutions=save_specific_resolutions, # Pass the specific resolution(s)
file_type_defs=context.config_obj.get_file_type_definitions_with_examples(),
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}: Item 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": item.map_type_identifier,
"resolution_key": output_resolution_key,
"original_dimensions": item.original_dimensions,
"final_dimensions": current_dimensions, # Dimensions after scaling
"source_file": item.source_file_info_ref,
}
else:
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}")
context.processed_maps_details[item_key] = {"status": "Failed", "notes": f"Save Error: {error_msg}", "stage": "SaveVariantsStage"}
asset_had_item_errors = True
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,
final_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: else:
error_msg = saved_data.error_message if saved_data else "Save stage returned None" log.warning(f"{item_log_prefix}: Unknown item type in loop: {type(item)}. Skipping.")
log.error(f"{item_log_prefix}: Failed during save stage. Error: {error_msg}") # Ensure some key exists to prevent KeyError if item_key was not set
context.processed_maps_details[item_key] = {"status": "Failed", "notes": f"Save Error: {error_msg}", "stage": "SaveVariantsStage"} 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 asset_had_item_errors = True
item_status = "Failed" # Ensure item status reflects failure 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
final_h, final_w = final_image_data.shape[:2] if final_image_data is not None and final_image_data.size > 0:
final_dims_wh = (final_w, final_h) final_h, final_w = final_image_data.shape[:2]
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

@@ -97,6 +97,7 @@ class OutputOrganizationStage(ProcessingStage):
token_data_variant = { token_data_variant = {
"assetname": asset_name_for_log, "assetname": asset_name_for_log,
"supplier": context.effective_supplier or "DefaultSupplier", "supplier": context.effective_supplier or "DefaultSupplier",
"asset_category": context.asset_rule.asset_type, # Used asset_type for asset_category token
"maptype": base_map_type, "maptype": base_map_type,
"resolution": variant_resolution_key, "resolution": variant_resolution_key,
"ext": variant_ext, "ext": variant_ext,
@@ -162,22 +163,23 @@ class OutputOrganizationStage(ProcessingStage):
resolution_str = details.get('processed_resolution_name', details.get('original_resolution_name', 'resX')) resolution_str = details.get('processed_resolution_name', details.get('original_resolution_name', 'resX'))
token_data = { token_data = {
"assetname": asset_name_for_log, "assetname": asset_name_for_log,
"supplier": context.effective_supplier or "DefaultSupplier", "supplier": context.effective_supplier or "DefaultSupplier",
"maptype": base_map_type, "asset_category": context.asset_rule.asset_type, # Used asset_type for asset_category token
"resolution": resolution_str, "maptype": base_map_type,
"ext": temp_file_path.suffix.lstrip('.'), "resolution": resolution_str,
"incrementingvalue": getattr(context, 'incrementing_value', None), "ext": temp_file_path.suffix.lstrip('.'),
"sha5": getattr(context, 'sha5_value', None) "incrementingvalue": getattr(context, 'incrementing_value', None),
} "sha5": getattr(context, 'sha5_value', None)
}
token_data_cleaned = {k: v for k, v in token_data.items() if v is not None} token_data_cleaned = {k: v for k, v in token_data.items() if v is not None}
output_filename = generate_path_from_pattern(output_filename_pattern_config, token_data_cleaned) output_filename = generate_path_from_pattern(output_filename_pattern_config, token_data_cleaned)
try: try:
relative_dir_path_str = generate_path_from_pattern( relative_dir_path_str = generate_path_from_pattern(
pattern_string=output_dir_pattern, pattern_string=output_dir_pattern,
token_data=token_data_cleaned token_data=token_data_cleaned
) )
logger.debug(f"OUTPUT_ORG_DEBUG: SingleFile - Using context.output_base_path = {context.output_base_path} for final_path construction.") # Added logger.debug(f"OUTPUT_ORG_DEBUG: SingleFile - Using context.output_base_path = {context.output_base_path} for final_path construction.") # Added
final_path = Path(context.output_base_path) / Path(relative_dir_path_str) / Path(output_filename) final_path = Path(context.output_base_path) / Path(relative_dir_path_str) / Path(output_filename)
@@ -214,9 +216,8 @@ class OutputOrganizationStage(ProcessingStage):
details['status'] = 'Organization Failed' details['status'] = 'Organization Failed'
# --- Handle other statuses (Skipped, Failed, etc.) --- # --- Handle other statuses (Skipped, Failed, etc.) ---
else: # Catches statuses not explicitly handled above else: # Catches statuses not explicitly handled above
logger.debug(f"Asset '{asset_name_for_log}': Skipping map key '{processed_map_key}' (status: '{map_status}') for organization as it's not a recognized final processed state or variant state.") logger.debug(f"Asset '{asset_name_for_log}': Skipping map key '{processed_map_key}' (status: '{map_status}') for organization as it's not a recognized final processed state or variant state.")
continue
else: else:
logger.debug(f"Asset '{asset_name_for_log}': No processed individual maps to organize.") logger.debug(f"Asset '{asset_name_for_log}': No processed individual maps to organize.")
@@ -243,18 +244,19 @@ class OutputOrganizationStage(ProcessingStage):
# However, generate_path_from_pattern might expect them or handle their absence. # However, generate_path_from_pattern might expect them or handle their absence.
# For the base asset directory, only assetname and supplier are typically primary. # For the base asset directory, only assetname and supplier are typically primary.
base_token_data = { base_token_data = {
"assetname": asset_name_for_log, "assetname": asset_name_for_log,
"supplier": context.effective_supplier or "DefaultSupplier", "supplier": context.effective_supplier or "DefaultSupplier",
# Add other tokens if your output_directory_pattern uses them at the asset level "asset_category": context.asset_rule.asset_type, # Used asset_type for asset_category token
"incrementingvalue": getattr(context, 'incrementing_value', None), # Add other tokens if your output_directory_pattern uses them at the asset level
"sha5": getattr(context, 'sha5_value', None) "incrementingvalue": getattr(context, 'incrementing_value', None),
"sha5": getattr(context, 'sha5_value', None)
} }
base_token_data_cleaned = {k: v for k, v in base_token_data.items() if v is not None} base_token_data_cleaned = {k: v for k, v in base_token_data.items() if v is not None}
try: try:
asset_base_output_dir_str = generate_path_from_pattern( asset_base_output_dir_str = generate_path_from_pattern(
pattern_string=output_dir_pattern, # Uses the same pattern as other maps for base dir pattern_string=output_dir_pattern, # Uses the same pattern as other maps for base dir
token_data=base_token_data_cleaned token_data=base_token_data_cleaned
) )
# Destination: <output_base_path>/<asset_base_output_dir_str>/<extra_subdir_name>/<original_filename> # Destination: <output_base_path>/<asset_base_output_dir_str>/<extra_subdir_name>/<original_filename>
logger.debug(f"OUTPUT_ORG_DEBUG: ExtraFiles - Using context.output_base_path = {context.output_base_path} for final_dest_path construction.") # Added logger.debug(f"OUTPUT_ORG_DEBUG: ExtraFiles - Using context.output_base_path = {context.output_base_path} for final_dest_path construction.") # Added

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
else: if not item_type or item_type == "EXTRA" or not item_type.startswith("MAP_"):
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}: 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)
# --- Add merged tasks --- # Determine standard resolutions to generate
# --- Add merged tasks from global configuration --- # This logic needs to be robust and consider file_rule.resolution_override, etc.
# merged_image_tasks are expected to be loaded into context.config_obj # Using a placeholder _get_target_resolutions for now.
# by the Configuration class from app_settings.json. target_resolutions = self._get_target_resolutions(orig_w, orig_h, config.image_resolutions, file_rule)
merged_tasks_list = getattr(context.config_obj, 'map_merge_rules', None) 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:
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.")
else: # Source path not valid
log.warning(f"Asset '{asset_name_for_log}': Skipping creation of ProcessingItems from FileRules due to invalid source/workspace path.")
# --- 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)
@@ -178,12 +178,20 @@ class RegularMapProcessorStage(ProcessingStage):
log.debug(f"{log_prefix}: Loaded image {result.original_dimensions[0]}x{result.original_dimensions[1]}.") log.debug(f"{log_prefix}: Loaded image {result.original_dimensions[0]}x{result.original_dimensions[1]}.")
# Get original bit depth # Get original bit depth
try: # Determine original bit depth from the loaded image data's dtype
result.original_bit_depth = ipu.get_image_bit_depth(str(source_file_path_found)) dtype_to_bit_depth = {
log.info(f"{log_prefix}: Determined source bit depth: {result.original_bit_depth}") np.dtype('uint8'): 8,
except Exception as e: np.dtype('uint16'): 16,
log.warning(f"{log_prefix}: Could not determine source bit depth for {source_file_path_found}: {e}. Setting to None.") np.dtype('float32'): 32,
result.original_bit_depth = None # Indicate failure to determine np.dtype('int8'): 8,
np.dtype('int16'): 16,
}
result.original_bit_depth = dtype_to_bit_depth.get(source_image_data.dtype)
if result.original_bit_depth is None:
log.warning(f"{log_prefix}: Unknown dtype {source_image_data.dtype} for loaded image data, cannot determine bit depth. Setting to None.")
else:
log.info(f"{log_prefix}: Determined source bit depth from loaded data dtype: {result.original_bit_depth}")
# --- Apply Transformations --- # --- Apply Transformations ---
transformed_image_data, final_map_type, transform_notes = ipu.apply_common_map_transformations( transformed_image_data, final_map_type, transform_notes = ipu.apply_common_map_transformations(
@@ -197,10 +205,23 @@ 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
# Log dtype and shape after transformations
log.info(f"{log_prefix}: Image data dtype after transformations: {transformed_image_data.dtype}, shape: {transformed_image_data.shape}")
bit_depth_after_transform = dtype_to_bit_depth.get(transformed_image_data.dtype)
log.info(f"{log_prefix}: Determined bit depth after transformations: {bit_depth_after_transform}")
# --- 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}.")
log.debug(f"{log_prefix}: Processed image data dtype before returning: {result.processed_image_data.dtype}, shape: {result.processed_image_data.shape}")
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

@@ -22,9 +22,18 @@ 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.final_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(
@@ -50,7 +59,7 @@ class SaveVariantsStage(ProcessingStage):
save_args = { save_args = {
"source_image_data": input_data.image_data, "source_image_data": input_data.image_data,
"base_map_type": base_map_type_friendly, # Use the friendly type "final_internal_map_type": input_data.final_internal_map_type, # Pass the internal type identifier
"source_bit_depth_info": input_data.source_bit_depth_info, "source_bit_depth_info": input_data.source_bit_depth_info,
"image_resolutions": input_data.image_resolutions, "image_resolutions": input_data.image_resolutions,
"file_type_defs": input_data.file_type_defs, "file_type_defs": input_data.file_type_defs,
@@ -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.
@@ -294,9 +304,11 @@ def load_image(image_path: Union[str, Path], read_flag: int = cv2.IMREAD_UNCHANG
try: try:
img = cv2.imread(str(image_path), read_flag) img = cv2.imread(str(image_path), read_flag)
if img is None: if img is None:
# print(f"Warning: Failed to load image: {image_path}") # Optional: for debugging utils ipu_log.warning(f"Failed to load image: {image_path}")
return None return None
ipu_log.debug(f"Loaded image '{image_path}'. Initial dtype: {img.dtype}, shape: {img.shape}")
# Ensure RGB/RGBA for color images # Ensure RGB/RGBA for color images
if len(img.shape) == 3: if len(img.shape) == 3:
if img.shape[2] == 4: # BGRA from OpenCV if img.shape[2] == 4: # BGRA from OpenCV
@@ -382,8 +394,11 @@ def save_image(
path_obj = Path(image_path) path_obj = Path(image_path)
path_obj.parent.mkdir(parents=True, exist_ok=True) path_obj.parent.mkdir(parents=True, exist_ok=True)
ipu_log.debug(f"Saving image '{path_obj}'. Initial data dtype: {img_to_save.dtype}, shape: {img_to_save.shape}")
# 1. Data Type Conversion # 1. Data Type Conversion
if output_dtype_target is not None: if output_dtype_target is not None:
ipu_log.debug(f"Attempting to convert image data to target dtype: {output_dtype_target}")
if output_dtype_target == np.uint8 and img_to_save.dtype != np.uint8: if output_dtype_target == np.uint8 and img_to_save.dtype != np.uint8:
if img_to_save.dtype == np.uint16: img_to_save = (img_to_save.astype(np.float32) / 65535.0 * 255.0).astype(np.uint8) if img_to_save.dtype == np.uint16: img_to_save = (img_to_save.astype(np.float32) / 65535.0 * 255.0).astype(np.uint8)
elif img_to_save.dtype in [np.float16, np.float32, np.float64]: img_to_save = (np.clip(img_to_save, 0.0, 1.0) * 255.0).astype(np.uint8) elif img_to_save.dtype in [np.float16, np.float32, np.float64]: img_to_save = (np.clip(img_to_save, 0.0, 1.0) * 255.0).astype(np.uint8)
@@ -403,6 +418,8 @@ def save_image(
elif img_to_save.dtype == np.float16: img_to_save = img_to_save.astype(np.float32) elif img_to_save.dtype == np.float16: img_to_save = img_to_save.astype(np.float32)
ipu_log.debug(f"Saving image '{path_obj}'. Data dtype after conversion attempt: {img_to_save.dtype}, shape: {img_to_save.shape}")
# 2. Color Space Conversion (Internal RGB/RGBA -> BGR/BGRA for OpenCV) # 2. Color Space Conversion (Internal RGB/RGBA -> BGR/BGRA for OpenCV)
# Input `image_data` is assumed to be in RGB/RGBA format (due to `load_image` changes). # Input `image_data` is assumed to be in RGB/RGBA format (due to `load_image` changes).
# OpenCV's `imwrite` typically expects BGR/BGRA for formats like PNG, JPG. # OpenCV's `imwrite` typically expects BGR/BGRA for formats like PNG, JPG.
@@ -450,6 +467,8 @@ def apply_common_map_transformations(
current_image_data = image_data # Start with original data current_image_data = image_data # Start with original data
updated_processing_map_type = processing_map_type # Start with original type updated_processing_map_type = processing_map_type # Start with original type
ipu_log.debug(f"{log_prefix}: apply_common_map_transformations - Initial image data dtype: {current_image_data.dtype}, shape: {current_image_data.shape}")
# Gloss-to-Rough # Gloss-to-Rough
# Check if the base type is Gloss (before suffix) # Check if the base type is Gloss (before suffix)
base_map_type_match = re.match(r"(MAP_GLOSS)", processing_map_type) base_map_type_match = re.match(r"(MAP_GLOSS)", processing_map_type)
@@ -484,6 +503,8 @@ def apply_common_map_transformations(
current_image_data = invert_normal_map_green_channel(current_image_data) current_image_data = invert_normal_map_green_channel(current_image_data)
transformation_notes.append("Normal Green Inverted (Global)") transformation_notes.append("Normal Green Inverted (Global)")
ipu_log.debug(f"{log_prefix}: apply_common_map_transformations - Final image data dtype: {current_image_data.dtype}, shape: {current_image_data.shape}")
return current_image_data, updated_processing_map_type, transformation_notes return current_image_data, updated_processing_map_type, transformation_notes
# --- Normal Map Utilities --- # --- Normal Map Utilities ---

View File

@@ -4,6 +4,9 @@ import numpy as np
from pathlib import Path from pathlib import Path
from typing import List, Dict, Any, Tuple, Optional from typing import List, Dict, Any, Tuple, Optional
# Import necessary utility functions
from utils.path_utils import get_filename_friendly_map_type # Import the function
# Potentially import ipu from ...utils import image_processing_utils as ipu # Potentially import ipu from ...utils import image_processing_utils as ipu
# Assuming ipu is available in the same utils directory or parent # Assuming ipu is available in the same utils directory or parent
try: try:
@@ -22,7 +25,7 @@ logger = logging.getLogger(__name__)
def save_image_variants( def save_image_variants(
source_image_data: np.ndarray, source_image_data: np.ndarray,
base_map_type: str, # Filename-friendly map type final_internal_map_type: str, # Use the internal map type identifier
source_bit_depth_info: List[Optional[int]], source_bit_depth_info: List[Optional[int]],
image_resolutions: Dict[str, int], image_resolutions: Dict[str, int],
file_type_defs: Dict[str, Dict[str, Any]], file_type_defs: Dict[str, Dict[str, Any]],
@@ -42,14 +45,13 @@ def save_image_variants(
Args: Args:
source_image_data (np.ndarray): High-res image data (in memory, potentially transformed). source_image_data (np.ndarray): High-res image data (in memory, potentially transformed).
base_map_type (str): Final map type (e.g., "COL", "ROUGH", "NORMAL", "MAP_NRMRGH"). final_internal_map_type (str): Final internal map type (e.g., "MAP_COL", "MAP_NRM", "MAP_NRMRGH").
This is the filename-friendly map type.
source_bit_depth_info (List[Optional[int]]): List of original source bit depth(s) source_bit_depth_info (List[Optional[int]]): List of original source bit depth(s)
(e.g., [8], [16], [8, 16]). Can contain None. (e.g., [8], [16], [8, 16]). Can contain None.
image_resolutions (Dict[str, int]): Dictionary mapping resolution keys (e.g., "4K") image_resolutions (Dict[str, int]): Dictionary mapping resolution keys (e.g., "4K")
to max dimensions (e.g., 4096). to max dimensions (e.g., 4096).
file_type_defs (Dict[str, Dict[str, Any]]): Dictionary defining properties for map types, file_type_defs (Dict[str, Dict[str, Any]]): Dictionary defining properties for map types,
including 'bit_depth_rule'. including 'bit_depth_policy'.
output_format_8bit (str): File extension for 8-bit output (e.g., "jpg", "png"). output_format_8bit (str): File extension for 8-bit output (e.g., "jpg", "png").
output_format_16bit_primary (str): Primary file extension for 16-bit output (e.g., "png", "tif"). output_format_16bit_primary (str): Primary file extension for 16-bit output (e.g., "png", "tif").
output_format_16bit_fallback (str): Fallback file extension for 16-bit output. output_format_16bit_fallback (str): Fallback file extension for 16-bit output.
@@ -64,8 +66,8 @@ def save_image_variants(
Returns: Returns:
List[Dict[str, Any]]: A list of dictionaries, each containing details about a saved file. List[Dict[str, Any]]: A list of dictionaries, each containing details about a saved file.
Example: [{'path': str, 'resolution_key': str, 'format': str, Example: [{'path': str, 'resolution_key': str, 'format': str,
'bit_depth': int, 'dimensions': (w,h)}, ...] 'bit_depth': int, 'dimensions': (w,h)}, ...]
""" """
if ipu is None: if ipu is None:
logger.error("image_processing_utils is not available. Cannot save images.") logger.error("image_processing_utils is not available. Cannot save images.")
@@ -76,30 +78,46 @@ def save_image_variants(
source_max_dim = max(source_h, source_w) source_max_dim = max(source_h, source_w)
# 1. Use provided configuration inputs (already available as function arguments) # 1. Use provided configuration inputs (already available as function arguments)
logger.info(f"SaveImageVariants: Starting for map type: {base_map_type}. Source shape: {source_image_data.shape}, Source bit depths: {source_bit_depth_info}") logger.info(f"SaveImageVariants: Starting for map type: {final_internal_map_type}. Source shape: {source_image_data.shape}, Source bit depths: {source_bit_depth_info}")
logger.debug(f"SaveImageVariants: Resolutions: {image_resolutions}, File Type Defs: {file_type_defs.keys()}, Output Formats: 8bit={output_format_8bit}, 16bit_pri={output_format_16bit_primary}, 16bit_fall={output_format_16bit_fallback}") logger.debug(f"SaveImageVariants: Resolutions: {image_resolutions}, File Type Defs: {file_type_defs.keys()}, Output Formats: 8bit={output_format_8bit}, 16bit_pri={output_format_16bit_primary}, 16bit_fall={output_format_16bit_fallback}")
logger.debug(f"SaveImageVariants: PNG Comp: {png_compression_level}, JPG Qual: {jpg_quality}") logger.debug(f"SaveImageVariants: PNG Comp: {png_compression_level}, JPG Qual: {jpg_quality}")
logger.debug(f"SaveImageVariants: Output Tokens: {output_filename_pattern_tokens}, Output Pattern: {output_filename_pattern}") logger.debug(f"SaveImageVariants: Output Tokens: {output_filename_pattern_tokens}, Output Pattern: {output_filename_pattern}")
logger.debug(f"SaveImageVariants: Received resolution_threshold_for_jpg: {resolution_threshold_for_jpg}") # Log received threshold logger.debug(f"SaveImageVariants: Received resolution_threshold_for_jpg: {resolution_threshold_for_jpg}") # Log received threshold
# 2. Determine Target Bit Depth # 2. Determine Target Bit Depth based on bit_depth_policy
target_bit_depth = 8 # Default # Use the final_internal_map_type for lookup in file_type_defs
bit_depth_rule = file_type_defs.get(base_map_type, {}).get('bit_depth_rule', 'force_8bit') bit_depth_policy = file_type_defs.get(final_internal_map_type, {}).get('bit_depth_policy', '')
if bit_depth_rule not in ['force_8bit', 'respect_inputs']:
logger.warning(f"Unknown bit_depth_rule '{bit_depth_rule}' for map type '{base_map_type}'. Defaulting to 'force_8bit'.")
bit_depth_rule = 'force_8bit'
if bit_depth_rule == 'respect_inputs': logger.info(f"SaveImageVariants: Determining target bit depth for map type: {final_internal_map_type} with policy: '{bit_depth_policy}'. Source bit depths: {source_bit_depth_info}")
if bit_depth_policy == "force_8bit":
target_bit_depth = 8
logger.debug(f"SaveImageVariants: Policy 'force_8bit' applied. Target bit depth: {target_bit_depth}")
elif bit_depth_policy == "force_16bit":
target_bit_depth = 16
logger.debug(f"SaveImageVariants: Policy 'force_16bit' applied. Target bit depth: {target_bit_depth}")
elif bit_depth_policy == "preserve":
# Check if any source bit depth is > 8, ignoring None # Check if any source bit depth is > 8, ignoring None
if any(depth is not None and depth > 8 for depth in source_bit_depth_info): if any(depth is not None and depth > 8 for depth in source_bit_depth_info):
target_bit_depth = 16 target_bit_depth = 16
logger.debug(f"SaveImageVariants: Policy 'preserve' applied, source > 8 found. Setting target_bit_depth = {target_bit_depth}")
else: else:
target_bit_depth = 8 target_bit_depth = 8
logger.info(f"Bit depth rule 'respect_inputs' applied. Source bit depths: {source_bit_depth_info}. Target bit depth: {target_bit_depth}") logger.debug(f"SaveImageVariants: Policy 'preserve' applied, no source > 8 found. Setting target_bit_depth = {target_bit_depth}")
else: # force_8bit elif bit_depth_policy == "" or bit_depth_policy not in ["force_8bit", "force_16bit", "preserve"]:
target_bit_depth = 8 # Handle "" policy or any other unexpected/unknown value
logger.info(f"Bit depth rule 'force_8bit' applied. Target bit depth: {target_bit_depth}") # For unknown/empty policies, apply the 'preserve' logic based on source bit depths.
if bit_depth_policy == "":
logger.warning(f"Empty bit_depth_policy for map type '{final_internal_map_type}'. Applying 'preserve' logic.")
else:
logger.warning(f"Unknown bit_depth_policy '{bit_depth_policy}' for map type '{final_internal_map_type}'. Applying 'preserve' logic.")
if any(depth is not None and depth > 8 for depth in source_bit_depth_info):
target_bit_depth = 16
logger.debug(f"SaveImageVariants: Applying 'preserve' logic, source > 8 found. Setting target_bit_depth = {target_bit_depth}")
else:
target_bit_depth = 8
logger.debug(f"SaveImageVariants: Applying 'preserve' logic, no source > 8 found. Setting target_bit_depth = {target_bit_depth}")
# 3. Determine Output File Format(s) # 3. Determine Output File Format(s)
if target_bit_depth == 8: if target_bit_depth == 8:
@@ -118,26 +136,27 @@ def save_image_variants(
current_output_ext = output_ext # Store the initial extension based on bit depth current_output_ext = output_ext # Store the initial extension based on bit depth
logger.info(f"SaveImageVariants: Determined target bit depth: {target_bit_depth}, Initial output format: {current_output_ext} for map type {base_map_type}") # Move this logging statement AFTER current_output_ext is assigned
logger.info(f"SaveImageVariants: Final determined target bit depth: {target_bit_depth}, Initial output format: {current_output_ext} for map type {final_internal_map_type}")
# 4. Generate and Save Resolution Variants # 4. Generate and Save Resolution Variants
# Sort resolutions by max dimension descending # Sort resolutions by max dimension descending
sorted_resolutions = sorted(image_resolutions.items(), key=lambda item: item[1], reverse=True) sorted_resolutions = sorted(image_resolutions.items(), key=lambda item: item[1], reverse=True)
for res_key, res_max_dim in sorted_resolutions: for res_key, res_max_dim in sorted_resolutions:
logger.info(f"SaveImageVariants: Processing variant {res_key} ({res_max_dim}px) for {base_map_type}") logger.info(f"SaveImageVariants: Processing variant {res_key} ({res_max_dim}px) for {final_internal_map_type}")
# --- Prevent Upscaling --- # --- Prevent Upscaling ---
# Skip this resolution variant if its target dimension is larger than the source image's largest dimension. # Skip this resolution variant if its target dimension is larger than the source image's largest dimension.
if res_max_dim > source_max_dim: if res_max_dim > source_max_dim:
logger.info(f"SaveImageVariants: Skipping variant {res_key} ({res_max_dim}px) for {base_map_type} because target resolution is larger than source ({source_max_dim}px).") logger.info(f"SaveImageVariants: Skipping variant {res_key} ({res_max_dim}px) for {final_internal_map_type} because target resolution is larger than source ({source_max_dim}px).")
continue # Skip to the next resolution continue # Skip to the next resolution
# Calculate target dimensions for valid variants (equal or smaller than source) # Calculate target dimensions for valid variants (equal or smaller than source)
if source_max_dim == res_max_dim: if source_max_dim == res_max_dim:
# Use source dimensions if target is equal # Use source dimensions if target is equal
target_w_res, target_h_res = source_w, source_h target_w_res, target_h_res = source_w, source_h
logger.info(f"SaveImageVariants: Using source resolution ({source_w}x{source_h}) for {res_key} variant of {base_map_type} as target matches source.") logger.info(f"SaveImageVariants: Using source resolution ({source_w}x{source_h}) for {res_key} variant of {final_internal_map_type} as target matches source.")
else: # Downscale (source_max_dim > res_max_dim) else: # Downscale (source_max_dim > res_max_dim)
# Downscale, maintaining aspect ratio # Downscale, maintaining aspect ratio
aspect_ratio = source_w / source_h aspect_ratio = source_w / source_h
@@ -147,14 +166,14 @@ def save_image_variants(
else: else:
target_h_res = res_max_dim target_h_res = res_max_dim
target_w_res = max(1, int(res_max_dim * aspect_ratio)) # Ensure width is at least 1 target_w_res = max(1, int(res_max_dim * aspect_ratio)) # Ensure width is at least 1
logger.info(f"SaveImageVariants: Calculated downscale for {base_map_type} {res_key}: from ({source_w}x{source_h}) to ({target_w_res}x{target_h_res})") logger.info(f"SaveImageVariants: Calculated downscale for {final_internal_map_type} {res_key}: from ({source_w}x{source_h}) to ({target_w_res}x{target_h_res})")
# Resize source_image_data (only if necessary) # Resize source_image_data (only if necessary)
if (target_w_res, target_h_res) == (source_w, source_h): if (target_w_res, target_h_res) == (source_w, source_h):
# No resize needed if dimensions match # No resize needed if dimensions match
variant_data = source_image_data.copy() # Copy to avoid modifying original if needed later variant_data = source_image_data.copy() # Copy to avoid modifying original if needed later
logger.debug(f"SaveImageVariants: No resize needed for {base_map_type} {res_key}, using copy of source data.") logger.debug(f"SaveImageVariants: No resize needed for {final_internal_map_type} {res_key}, using copy of source data.")
else: else:
# Perform resize only if dimensions differ (i.e., downscaling) # Perform resize only if dimensions differ (i.e., downscaling)
interpolation_method = cv2.INTER_AREA # Good for downscaling interpolation_method = cv2.INTER_AREA # Good for downscaling
@@ -162,21 +181,22 @@ def save_image_variants(
variant_data = ipu.resize_image(source_image_data, target_w_res, target_h_res, interpolation=interpolation_method) variant_data = ipu.resize_image(source_image_data, target_w_res, target_h_res, interpolation=interpolation_method)
if variant_data is None: # Check if resize failed if variant_data is None: # Check if resize failed
raise ValueError("ipu.resize_image returned None") raise ValueError("ipu.resize_image returned None")
logger.debug(f"SaveImageVariants: Resized variant data shape for {base_map_type} {res_key}: {variant_data.shape}") logger.debug(f"SaveImageVariants: Resized variant data shape for {final_internal_map_type} {res_key}: {variant_data.shape}")
except Exception as e: except Exception as e:
logger.error(f"SaveImageVariants: Error resizing image for {base_map_type} {res_key} variant: {e}") logger.error(f"SaveImageVariants: Error resizing image for {final_internal_map_type} {res_key} variant: {e}")
continue # Skip this variant if resizing fails continue # Skip this variant if resizing fails
# Filename Construction # Filename Construction
current_tokens = output_filename_pattern_tokens.copy() current_tokens = output_filename_pattern_tokens.copy()
current_tokens['maptype'] = base_map_type # Use the filename-friendly version for the filename token
current_tokens['maptype'] = get_filename_friendly_map_type(final_internal_map_type, file_type_defs)
current_tokens['resolution'] = res_key current_tokens['resolution'] = res_key
# Determine final extension for this variant, considering JPG threshold # Determine final extension for this variant, considering JPG threshold
final_variant_ext = current_output_ext final_variant_ext = current_output_ext
# --- Start JPG Threshold Logging --- # --- Start JPG Threshold Logging ---
logger.debug(f"SaveImageVariants: JPG Threshold Check for {base_map_type} {res_key}:") logger.debug(f"SaveImageVariants: JPG Threshold Check for {final_internal_map_type} {res_key}:")
logger.debug(f" - target_bit_depth: {target_bit_depth}") logger.debug(f" - target_bit_depth: {target_bit_depth}")
logger.debug(f" - resolution_threshold_for_jpg: {resolution_threshold_for_jpg}") logger.debug(f" - resolution_threshold_for_jpg: {resolution_threshold_for_jpg}")
logger.debug(f" - target_w_res: {target_w_res}, target_h_res: {target_h_res}") logger.debug(f" - target_w_res: {target_w_res}, target_h_res: {target_h_res}")
@@ -198,7 +218,7 @@ def save_image_variants(
if cond_bit_depth and cond_threshold_not_none and cond_res_exceeded and cond_is_png: if cond_bit_depth and cond_threshold_not_none and cond_res_exceeded and cond_is_png:
final_variant_ext = 'jpg' final_variant_ext = 'jpg'
logger.info(f"SaveImageVariants: Overriding 8-bit PNG to JPG for {base_map_type} {res_key} due to resolution {max(target_w_res, target_h_res)}px > threshold {resolution_threshold_for_jpg}px.") logger.info(f"SaveImageVariants: Overriding 8-bit PNG to JPG for {final_internal_map_type} {res_key} due to resolution {max(target_w_res, target_h_res)}px > threshold {resolution_threshold_for_jpg}px.")
current_tokens['ext'] = final_variant_ext current_tokens['ext'] = final_variant_ext
@@ -216,14 +236,14 @@ def save_image_variants(
continue # Skip this variant continue # Skip this variant
output_path = output_base_directory / filename output_path = output_base_directory / filename
logger.info(f"SaveImageVariants: Constructed output path for {base_map_type} {res_key}: {output_path}") logger.info(f"SaveImageVariants: Constructed output path for {final_internal_map_type} {res_key}: {output_path}")
# Ensure parent directory exists # Ensure parent directory exists
output_path.parent.mkdir(parents=True, exist_ok=True) output_path.parent.mkdir(parents=True, exist_ok=True)
logger.debug(f"SaveImageVariants: Ensured directory exists for {base_map_type} {res_key}: {output_path.parent}") logger.debug(f"SaveImageVariants: Ensured directory exists for {final_internal_map_type} {res_key}: {output_path.parent}")
except Exception as e: except Exception as e:
logger.error(f"SaveImageVariants: Error constructing filepath for {base_map_type} {res_key} variant: {e}") logger.error(f"SaveImageVariants: Error constructing filepath for {final_internal_map_type} {res_key} variant: {e}")
continue # Skip this variant if path construction fails continue # Skip this variant if path construction fails
@@ -232,11 +252,11 @@ def save_image_variants(
if final_variant_ext == 'jpg': # Check against final_variant_ext if final_variant_ext == 'jpg': # Check against final_variant_ext
save_params_cv2.append(cv2.IMWRITE_JPEG_QUALITY) save_params_cv2.append(cv2.IMWRITE_JPEG_QUALITY)
save_params_cv2.append(jpg_quality) save_params_cv2.append(jpg_quality)
logger.debug(f"SaveImageVariants: Using JPG quality: {jpg_quality} for {base_map_type} {res_key}") logger.debug(f"SaveImageVariants: Using JPG quality: {jpg_quality} for {final_internal_map_type} {res_key}")
elif final_variant_ext == 'png': # Check against final_variant_ext elif final_variant_ext == 'png': # Check against final_variant_ext
save_params_cv2.append(cv2.IMWRITE_PNG_COMPRESSION) save_params_cv2.append(cv2.IMWRITE_PNG_COMPRESSION)
save_params_cv2.append(png_compression_level) save_params_cv2.append(png_compression_level)
logger.debug(f"SaveImageVariants: Using PNG compression level: {png_compression_level} for {base_map_type} {res_key}") logger.debug(f"SaveImageVariants: Using PNG compression level: {png_compression_level} for {final_internal_map_type} {res_key}")
# Add other format specific parameters if needed (e.g., TIFF compression) # Add other format specific parameters if needed (e.g., TIFF compression)
@@ -257,7 +277,8 @@ def save_image_variants(
# Saving # Saving
try: try:
# ipu.save_image is expected to handle the actual cv2.imwrite call # ipu.save_image is expected to handle the actual cv2.imwrite call
logger.debug(f"SaveImageVariants: Attempting to save {base_map_type} {res_key} to {output_path} with params {save_params_cv2}, target_dtype: {output_dtype_for_save}") logger.debug(f"SaveImageVariants: Preparing to save {final_internal_map_type} {res_key}. Data dtype: {image_data_for_save.dtype}, shape: {image_data_for_save.shape}. Target dtype for ipu.save_image: {output_dtype_for_save}")
logger.debug(f"SaveImageVariants: Attempting to save {final_internal_map_type} {res_key} to {output_path} with params {save_params_cv2}, target_dtype: {output_dtype_for_save}")
success = ipu.save_image( success = ipu.save_image(
str(output_path), str(output_path),
image_data_for_save, image_data_for_save,
@@ -265,7 +286,7 @@ def save_image_variants(
params=save_params_cv2 params=save_params_cv2
) )
if success: if success:
logger.info(f"SaveImageVariants: Successfully saved {base_map_type} {res_key} variant to {output_path}") logger.info(f"SaveImageVariants: Successfully saved {final_internal_map_type} {res_key} variant to {output_path}")
# Collect details for the returned list # Collect details for the returned list
saved_file_details.append({ saved_file_details.append({
'path': str(output_path), 'path': str(output_path),
@@ -275,10 +296,10 @@ def save_image_variants(
'dimensions': (target_w_res, target_h_res) 'dimensions': (target_w_res, target_h_res)
}) })
else: else:
logger.error(f"SaveImageVariants: Failed to save {base_map_type} {res_key} variant to {output_path} (ipu.save_image returned False)") logger.error(f"SaveImageVariants: Failed to save {final_internal_map_type} {res_key} variant to {output_path} (ipu.save_image returned False)")
except Exception as e: except Exception as e:
logger.error(f"SaveImageVariants: Error during ipu.save_image for {base_map_type} {res_key} variant to {output_path}: {e}", exc_info=True) logger.error(f"SaveImageVariants: Error during ipu.save_image for {final_internal_map_type} {res_key} variant to {output_path}: {e}", exc_info=True)
# Continue to next variant even if one fails # Continue to next variant even if one fails
@@ -288,7 +309,7 @@ def save_image_variants(
# 5. Return List of Saved File Details # 5. Return List of Saved File Details
logger.info(f"Finished saving variants for map type: {base_map_type}. Saved {len(saved_file_details)} variants.") logger.info(f"Finished saving variants for map type: {final_internal_map_type}. Saved {len(saved_file_details)} variants.")
return saved_file_details return saved_file_details
# Optional Helper Functions (can be added here if needed) # Optional Helper Functions (can be added here if needed)

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,9 +11,15 @@ 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
parent_asset: 'AssetRule' = None # Added parent back-reference
def to_json(self) -> str: def to_json(self) -> str:
return json.dumps(dataclasses.asdict(self), indent=4) # Exclude parent_asset to avoid circular references
data = dataclasses.asdict(self)
if 'parent_asset' in data:
del data['parent_asset']
return json.dumps(data, indent=4)
@classmethod @classmethod
def from_json(cls, json_string: str) -> 'FileRule': def from_json(cls, json_string: str) -> 'FileRule':
@@ -26,9 +33,14 @@ class AssetRule:
asset_type_override: str = None asset_type_override: str = None
common_metadata: Dict[str, Any] = dataclasses.field(default_factory=dict) common_metadata: Dict[str, Any] = dataclasses.field(default_factory=dict)
files: List[FileRule] = dataclasses.field(default_factory=list) files: List[FileRule] = dataclasses.field(default_factory=list)
parent_source: 'SourceRule' = None # Added parent back-reference
def to_json(self) -> str: def to_json(self) -> str:
return json.dumps(dataclasses.asdict(self), indent=4) # Exclude parent_source to avoid circular references
data = dataclasses.asdict(self)
if 'parent_source' in data:
del data['parent_source']
return json.dumps(data, indent=4)
@classmethod @classmethod
def from_json(cls, json_string: str) -> 'AssetRule': def from_json(cls, json_string: str) -> 'AssetRule':
@@ -54,4 +66,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

View File

@@ -0,0 +1 @@
Asset Processor first-time setup complete.

View File

@@ -0,0 +1,280 @@
{
"preset_name": "Dinesen",
"supplier_name": "Dinesen",
"notes": "Preset for standard Poliigon downloads. Prioritizes _xxx16 files. Moves previews etc. to Extra/. Assumes Metal/Rough workflow.",
"source_naming": {
"separator": "_",
"part_indices": {
"base_name": 0,
"map_type": 1
},
"glossiness_keywords": [
"GLOSS"
]
},
"move_to_extra_patterns": [
"*_Preview*",
"*_Sphere*",
"*_Cube*",
"*_Flat*",
"*.txt",
"*.pdf",
"*.url",
"*.htm*",
"*_Fabric.*",
"*_DISP_*METALNESS*"
],
"map_type_mapping": [
{
"target_type": "MAP_COL",
"keywords": [
"COLOR*",
"COL",
"COL-*",
"DIFFUSE",
"DIF",
"ALBEDO"
]
},
{
"target_type": "MAP_NRM",
"keywords": [
"NORMAL*",
"NORM*",
"NRM*",
"N"
],
"priority_keywords": [
"*_NRM16*",
"*_NM16*",
"*Normal16*"
]
},
{
"target_type": "MAP_ROUGH",
"keywords": [
"ROUGHNESS",
"ROUGH"
]
},
{
"target_type": "MAP_GLOSS",
"keywords": [
"GLOSS"
]
},
{
"target_type": "MAP_AO",
"keywords": [
"AMBIENTOCCLUSION",
"AO"
]
},
{
"target_type": "MAP_DISP",
"keywords": [
"DISPLACEMENT",
"DISP",
"HEIGHT",
"BUMP"
],
"priority_keywords": [
"*_DISP16*",
"*_DSP16*",
"*DSP16*",
"*DISP16*",
"*Displacement16*",
"*Height16*"
]
},
{
"target_type": "MAP_REFL",
"keywords": [
"REFLECTION",
"REFL",
"SPECULAR",
"SPEC"
]
},
{
"target_type": "MAP_SSS",
"keywords": [
"SSS",
"SUBSURFACE*"
]
},
{
"target_type": "MAP_FUZZ",
"keywords": [
"FUZZ"
]
},
{
"target_type": "MAP_IDMAP",
"keywords": [
"IDMAP"
]
},
{
"target_type": "MAP_MASK",
"keywords": [
"OPAC*",
"TRANSP*",
"MASK*",
"ALPHA*"
]
},
{
"target_type": "MAP_METAL",
"keywords": [
"METAL*",
"METALLIC"
]
}
],
"asset_category_rules": {
"model_patterns": [
"*.fbx",
"*.obj",
"*.blend",
"*.mtl"
],
"decal_keywords": [
"Decal"
]
},
"archetype_rules": [
[
"Foliage",
{
"match_any": [
"Plant",
"Leaf",
"Leaves",
"Grass"
],
"match_all": []
}
],
[
"Fabric",
{
"match_any": [
"Fabric",
"Carpet",
"Cloth",
"Textile",
"Leather"
],
"match_all": []
}
],
[
"Wood",
{
"match_any": [
"Wood",
"Timber",
"Plank",
"Board"
],
"match_all": []
}
],
[
"Metal",
{
"match_any": [
"_Metal",
"Steel",
"Iron",
"Gold",
"Copper",
"Chrome",
"Aluminum",
"Brass",
"Bronze"
],
"match_all": []
}
],
[
"Concrete",
{
"match_any": [
"Concrete",
"Cement"
],
"match_all": []
}
],
[
"Ground",
{
"match_any": [
"Ground",
"Dirt",
"Soil",
"Mud",
"Sand",
"Gravel",
"Asphalt",
"Road",
"Moss"
],
"match_all": []
}
],
[
"Stone",
{
"match_any": [
"Stone",
"Rock*",
"Marble",
"Granite",
"Brick",
"Tile",
"Paving",
"Pebble*",
"Terrazzo",
"Slate"
],
"match_all": []
}
],
[
"Plaster",
{
"match_any": [
"Plaster",
"Stucco",
"Wall",
"Paint"
],
"match_all": []
}
],
[
"Plastic",
{
"match_any": [
"Plastic",
"PVC",
"Resin",
"Rubber"
],
"match_all": []
}
],
[
"Glass",
{
"match_any": [
"Glass"
],
"match_all": []
}
]
]
}

View File

@@ -0,0 +1,280 @@
{
"preset_name": "Poliigon Standard v2",
"supplier_name": "Poliigon",
"notes": "Preset for standard Poliigon downloads. Prioritizes _xxx16 files. Moves previews etc. to Extra/. Assumes Metal/Rough workflow.",
"source_naming": {
"separator": "_",
"part_indices": {
"base_name": 0,
"map_type": 1
},
"glossiness_keywords": [
"GLOSS"
]
},
"move_to_extra_patterns": [
"*_Preview*",
"*_Sphere*",
"*_Cube*",
"*_Flat*",
"*.txt",
"*.pdf",
"*.url",
"*.htm*",
"*_Fabric.*",
"*_Albedo*"
],
"map_type_mapping": [
{
"target_type": "MAP_COL",
"keywords": [
"COLOR*",
"COL",
"COL-*",
"DIFFUSE",
"DIF",
"ALBEDO"
]
},
{
"target_type": "MAP_NRM",
"keywords": [
"NORMAL*",
"NORM*",
"NRM*",
"N"
],
"priority_keywords": [
"*_NRM16*",
"*_NM16*",
"*Normal16*"
]
},
{
"target_type": "MAP_ROUGH",
"keywords": [
"ROUGHNESS",
"ROUGH"
]
},
{
"target_type": "MAP_GLOSS",
"keywords": [
"GLOSS"
]
},
{
"target_type": "MAP_AO",
"keywords": [
"AMBIENTOCCLUSION",
"AO"
]
},
{
"target_type": "MAP_DISP",
"keywords": [
"DISPLACEMENT",
"DISP",
"HEIGHT",
"BUMP"
],
"priority_keywords": [
"*_DISP16*",
"*_DSP16*",
"*DSP16*",
"*DISP16*",
"*Displacement16*",
"*Height16*"
]
},
{
"target_type": "MAP_REFL",
"keywords": [
"REFLECTION",
"REFL",
"SPECULAR",
"SPEC"
]
},
{
"target_type": "MAP_SSS",
"keywords": [
"SSS",
"SUBSURFACE*"
]
},
{
"target_type": "MAP_FUZZ",
"keywords": [
"FUZZ"
]
},
{
"target_type": "MAP_IDMAP",
"keywords": [
"IDMAP"
]
},
{
"target_type": "MAP_MASK",
"keywords": [
"OPAC*",
"TRANSP*",
"MASK*",
"ALPHA*"
]
},
{
"target_type": "MAP_METAL",
"keywords": [
"METAL*",
"METALLIC"
]
}
],
"asset_category_rules": {
"model_patterns": [
"*.fbx",
"*.obj",
"*.blend",
"*.mtl"
],
"decal_keywords": [
"Decal"
]
},
"archetype_rules": [
[
"Foliage",
{
"match_any": [
"Plant",
"Leaf",
"Leaves",
"Grass"
],
"match_all": []
}
],
[
"Fabric",
{
"match_any": [
"Fabric",
"Carpet",
"Cloth",
"Textile",
"Leather"
],
"match_all": []
}
],
[
"Wood",
{
"match_any": [
"Wood",
"Timber",
"Plank",
"Board"
],
"match_all": []
}
],
[
"Metal",
{
"match_any": [
"_Metal",
"Steel",
"Iron",
"Gold",
"Copper",
"Chrome",
"Aluminum",
"Brass",
"Bronze"
],
"match_all": []
}
],
[
"Concrete",
{
"match_any": [
"Concrete",
"Cement"
],
"match_all": []
}
],
[
"Ground",
{
"match_any": [
"Ground",
"Dirt",
"Soil",
"Mud",
"Sand",
"Gravel",
"Asphalt",
"Road",
"Moss"
],
"match_all": []
}
],
[
"Stone",
{
"match_any": [
"Stone",
"Rock*",
"Marble",
"Granite",
"Brick",
"Tile",
"Paving",
"Pebble*",
"Terrazzo",
"Slate"
],
"match_all": []
}
],
[
"Plaster",
{
"match_any": [
"Plaster",
"Stucco",
"Wall",
"Paint"
],
"match_all": []
}
],
[
"Plastic",
{
"match_any": [
"Plastic",
"PVC",
"Resin",
"Rubber"
],
"match_all": []
}
],
[
"Glass",
{
"match_any": [
"Glass"
],
"match_all": []
}
]
]
}

View File

@@ -0,0 +1,270 @@
{
"preset_name": "Poliigon Standard v2",
"supplier_name": "Poliigon",
"notes": "Preset for standard Poliigon downloads. Prioritizes _xxx16 files. Moves previews etc. to Extra/. Assumes Metal/Rough workflow.",
"source_naming": {
"separator": "_",
"part_indices": {
"base_name": 0,
"map_type": 1
},
"glossiness_keywords": [
"GLOSS"
],
"bit_depth_variants": {
"NRM": "*_NRM16*",
"DISP": "*_DISP16*"
}
},
"move_to_extra_patterns": [
"*_Preview*",
"*_Sphere*",
"*_Cube*",
"*_Flat*",
"*.txt",
"*.pdf",
"*.url",
"*.htm*",
"*_Fabric.*"
],
"map_type_mapping": [
{
"target_type": "MAP_COL",
"keywords": [
"COLOR*",
"COL",
"DIFFUSE",
"DIF",
"ALBEDO"
]
},
{
"target_type": "MAP_NRM",
"keywords": [
"NORMAL*",
"NORM*",
"NRM*",
"N"
]
},
{
"target_type": "MAP_ROUGH",
"keywords": [
"ROUGHNESS",
"ROUGH"
]
},
{
"target_type": "MAP_ROUGH",
"keywords": [
"GLOSS"
]
},
{
"target_type": "MAP_AO",
"keywords": [
"AMBIENTOCCLUSION",
"AO"
]
},
{
"target_type": "MAP_DISP",
"keywords": [
"DISPLACEMENT",
"DISP",
"HEIGHT",
"BUMP"
]
},
{
"target_type": "MAP_REFL",
"keywords": [
"REFLECTION",
"REFL",
"SPECULAR",
"SPEC"
]
},
{
"target_type": "MAP_SSS",
"keywords": [
"SSS",
"SUBSURFACE*"
]
},
{
"target_type": "MAP_FUZZ",
"keywords": [
"FUZZ"
]
},
{
"target_type": "MAP_IDMAP",
"keywords": [
"ID*",
"IDMAP"
]
},
{
"target_type": "MAP_MASK",
"keywords": [
"OPAC*",
"TRANS*",
"MASK*",
"ALPHA*"
]
},
{
"target_type": "MAP_METAL",
"keywords": [
"METALNESS_",
"METALLIC"
]
}
],
"asset_category_rules": {
"model_patterns": [
"*.fbx",
"*.obj",
"*.blend",
"*.mtl"
],
"decal_keywords": [
"Decal"
]
},
"archetype_rules": [
[
"Foliage",
{
"match_any": [
"Plant",
"Leaf",
"Leaves",
"Grass"
],
"match_all": []
}
],
[
"Fabric",
{
"match_any": [
"Fabric",
"Carpet",
"Cloth",
"Textile",
"Leather"
],
"match_all": []
}
],
[
"Wood",
{
"match_any": [
"Wood",
"Timber",
"Plank",
"Board"
],
"match_all": []
}
],
[
"Metal",
{
"match_any": [
"_Metal",
"Steel",
"Iron",
"Gold",
"Copper",
"Chrome",
"Aluminum",
"Brass",
"Bronze"
],
"match_all": []
}
],
[
"Concrete",
{
"match_any": [
"Concrete",
"Cement"
],
"match_all": []
}
],
[
"Ground",
{
"match_any": [
"Ground",
"Dirt",
"Soil",
"Mud",
"Sand",
"Gravel",
"Asphalt",
"Road",
"Moss"
],
"match_all": []
}
],
[
"Stone",
{
"match_any": [
"Stone",
"Rock*",
"Marble",
"Granite",
"Brick",
"Tile",
"Paving",
"Pebble*",
"Terrazzo",
"Slate"
],
"match_all": []
}
],
[
"Plaster",
{
"match_any": [
"Plaster",
"Stucco",
"Wall",
"Paint"
],
"match_all": []
}
],
[
"Plastic",
{
"match_any": [
"Plastic",
"PVC",
"Resin",
"Rubber"
],
"match_all": []
}
],
[
"Glass",
{
"match_any": [
"Glass"
],
"match_all": []
}
]
]
}

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

@@ -0,0 +1,267 @@
{
"llm_predictor_examples": [
{
"input": "MessyTextures/Concrete_Damage_Set/concrete_col.png\nMessyTextures/Concrete_Damage_Set/concrete_N.png\nMessyTextures/Concrete_Damage_Set/concrete_rough.jpg\nMessyTextures/Concrete_Damage_Set/height_map_concrete.tif\nMessyTextures/Concrete_Damage_Set/Thumbs.db\nMessyTextures/Fabric_Pattern/pattern_01_diffuse.tga\nMessyTextures/Fabric_Pattern/pattern_01_ao.png\nMessyTextures/Fabric_Pattern/pattern_01_normal.png\nMessyTextures/Fabric_Pattern/notes.txt\nMessyTextures/Fabric_Pattern/variant_blue_diffuse.tga\nMessyTextures/Fabric_Pattern/fabric_flat.jpg",
"output": {
"individual_file_analysis": [
{
"relative_file_path": "MessyTextures/Concrete_Damage_Set/concrete_col.png",
"classified_file_type": "MAP_COL",
"proposed_asset_group_name": "Concrete_Damage_Set"
},
{
"relative_file_path": "MessyTextures/Concrete_Damage_Set/concrete_N.png",
"classified_file_type": "MAP_NRM",
"proposed_asset_group_name": "Concrete_Damage_Set"
},
{
"relative_file_path": "MessyTextures/Concrete_Damage_Set/concrete_rough.jpg",
"classified_file_type": "MAP_ROUGH",
"proposed_asset_group_name": "Concrete_Damage_Set"
},
{
"relative_file_path": "MessyTextures/Concrete_Damage_Set/height_map_concrete.tif",
"classified_file_type": "MAP_DISP",
"proposed_asset_group_name": "Concrete_Damage_Set"
},
{
"relative_file_path": "MessyTextures/Concrete_Damage_Set/Thumbs.db",
"classified_file_type": "FILE_IGNORE",
"proposed_asset_group_name": null
},
{
"relative_file_path": "MessyTextures/Fabric_Pattern/pattern_01_diffuse.tga",
"classified_file_type": "MAP_COL",
"proposed_asset_group_name": "Fabric_Pattern_01"
},
{
"relative_file_path": "MessyTextures/Fabric_Pattern/pattern_01_ao.png",
"classified_file_type": "MAP_AO",
"proposed_asset_group_name": "Fabric_Pattern_01"
},
{
"relative_file_path": "MessyTextures/Fabric_Pattern/pattern_01_normal.png",
"classified_file_type": "MAP_NRM",
"proposed_asset_group_name": "Fabric_Pattern_01"
},
{
"relative_file_path": "MessyTextures/Fabric_Pattern/notes.txt",
"classified_file_type": "EXTRA",
"proposed_asset_group_name": "Fabric_Pattern_01"
},
{
"relative_file_path": "MessyTextures/Fabric_Pattern/variant_blue_diffuse.tga",
"classified_file_type": "MAP_COL",
"proposed_asset_group_name": "Fabric_Pattern_01"
},
{
"relative_file_path": "MessyTextures/Fabric_Pattern/fabric_flat.jpg",
"classified_file_type": "EXTRA",
"proposed_asset_group_name": "Fabric_Pattern_01"
}
],
"asset_group_classifications": {
"Concrete_Damage_Set": "Surface",
"Fabric_Pattern_01": "Surface"
}
}
},
{
"input": "SciFi_Drone/Drone_Model.fbx\nSciFi_Drone/Textures/Drone_BaseColor.png\nSciFi_Drone/Textures/Drone_Metallic.png\nSciFi_Drone/Textures/Drone_Roughness.png\nSciFi_Drone/Textures/Drone_Normal.png\nSciFi_Drone/Textures/Drone_Emissive.jpg\nSciFi_Drone/ReferenceImages/concept.jpg",
"output": {
"individual_file_analysis": [
{
"relative_file_path": "SciFi_Drone/Drone_Model.fbx",
"classified_file_type": "MODEL",
"proposed_asset_group_name": "SciFi_Drone"
},
{
"relative_file_path": "SciFi_Drone/Textures/Drone_BaseColor.png",
"classified_file_type": "MAP_COL",
"proposed_asset_group_name": "SciFi_Drone"
},
{
"relative_file_path": "SciFi_Drone/Textures/Drone_Metallic.png",
"classified_file_type": "MAP_METAL",
"proposed_asset_group_name": "SciFi_Drone"
},
{
"relative_file_path": "SciFi_Drone/Textures/Drone_Roughness.png",
"classified_file_type": "MAP_ROUGH",
"proposed_asset_group_name": "SciFi_Drone"
},
{
"relative_file_path": "SciFi_Drone/Textures/Drone_Normal.png",
"classified_file_type": "MAP_NRM",
"proposed_asset_group_name": "SciFi_Drone"
},
{
"relative_file_path": "SciFi_Drone/Textures/Drone_Emissive.jpg",
"classified_file_type": "EXTRA",
"proposed_asset_group_name": "SciFi_Drone"
},
{
"relative_file_path": "SciFi_Drone/ReferenceImages/concept.jpg",
"classified_file_type": "EXTRA",
"proposed_asset_group_name": "SciFi_Drone"
}
],
"asset_group_classifications": {
"SciFi_Drone": "Model"
}
}
},
{
"input": "21_hairs_deposits.tif\n22_hairs_fabric.tif\n23_hairs_fibres.tif\n24_hairs_fibres.tif\n25_bonus_isolatedFingerprints.tif\n26_bonus_isolatedPalmprint.tif\n27_metal_aluminum.tif\n28_metal_castIron.tif\n29_scratcehes_deposits_shapes.tif\n30_scratches_deposits.tif",
"output": {
"individual_file_analysis": [
{
"relative_file_path": "21_hairs_deposits.tif",
"classified_file_type": "MAP_IMPERFECTION",
"proposed_asset_group_name": "Hairs_Deposits_21"
},
{
"relative_file_path": "22_hairs_fabric.tif",
"classified_file_type": "MAP_IMPERFECTION",
"proposed_asset_group_name": "Hairs_Fabric_22"
},
{
"relative_file_path": "23_hairs_fibres.tif",
"classified_file_type": "MAP_IMPERFECTION",
"proposed_asset_group_name": "Hairs_Fibres_23"
},
{
"relative_file_path": "24_hairs_fibres.tif",
"classified_file_type": "MAP_IMPERFECTION",
"proposed_asset_group_name": "Hairs_Fibres_24"
},
{
"relative_file_path": "25_bonus_isolatedFingerprints.tif",
"classified_file_type": "MAP_IMPERFECTION",
"proposed_asset_group_name": "Bonus_IsolatedFingerprints_25"
},
{
"relative_file_path": "26_bonus_isolatedPalmprint.tif",
"classified_file_type": "MAP_IMPERFECTION",
"proposed_asset_group_name": "Bonus_IsolatedPalmprint_26"
},
{
"relative_file_path": "27_metal_aluminum.tif",
"classified_file_type": "MAP_IMPERFECTION",
"proposed_asset_group_name": "Metal_Aluminum_27"
},
{
"relative_file_path": "28_metal_castIron.tif",
"classified_file_type": "MAP_IMPERFECTION",
"proposed_asset_group_name": "Metal_CastIron_28"
},
{
"relative_file_path": "29_scratcehes_deposits_shapes.tif",
"classified_file_type": "MAP_IMPERFECTION",
"proposed_asset_group_name": "Scratches_Deposits_Shapes_29"
},
{
"relative_file_path": "30_scratches_deposits.tif",
"classified_file_type": "MAP_IMPERFECTION",
"proposed_asset_group_name": "Scratches_Deposits_30"
}
],
"asset_group_classifications": {
"Hairs_Deposits_21": "UtilityMap",
"Hairs_Fabric_22": "UtilityMap",
"Hairs_Fibres_23": "UtilityMap",
"Hairs_Fibres_24": "UtilityMap",
"Bonus_IsolatedFingerprints_25": "UtilityMap",
"Bonus_IsolatedPalmprint_26": "UtilityMap",
"Metal_Aluminum_27": "UtilityMap",
"Metal_CastIron_28": "UtilityMap",
"Scratches_Deposits_Shapes_29": "UtilityMap",
"Scratches_Deposits_30": "UtilityMap"
}
}
},
{
"input": "Part1/TextureSupply_Boards001_A_28x300cm-Albedo.jpg\nPart1/TextureSupply_Boards001_A_28x300cm-Normal.jpg\nPart1/TextureSupply_Boards001_B_28x300cm-Albedo.jpg\nPart1/TextureSupply_Boards001_B_28x300cm-Normal.jpg\nPart1/TextureSupply_Boards001_C_28x300cm-Albedo.jpg\nPart1/TextureSupply_Boards001_C_28x300cm-Normal.jpg\nPart1/TextureSupply_Boards001_D_28x300cm-Albedo.jpg\nPart1/TextureSupply_Boards001_D_28x300cm-Normal.jpg\nPart1/TextureSupply_Boards001_E_28x300cm-Albedo.jpg\nPart1/TextureSupply_Boards001_E_28x300cm-Normal.jpg\nPart1/TextureSupply_Boards001_F_28x300cm-Albedo.jpg\nPart1/TextureSupply_Boards001_F_28x300cm-Normal.jpg",
"output": {
"individual_file_analysis": [
{
"relative_file_path": "Part1/TextureSupply_Boards001_A_28x300cm-Albedo.jpg",
"classified_file_type": "MAP_COL",
"proposed_asset_group_name": "Boards001_A"
},
{
"relative_file_path": "Part1/TextureSupply_Boards001_A_28x300cm-Normal.jpg",
"classified_file_type": "MAP_NRM",
"proposed_asset_group_name": "Boards001_A"
},
{
"relative_file_path": "Part1/TextureSupply_Boards001_B_28x300cm-Albedo.jpg",
"classified_file_type": "MAP_COL",
"proposed_asset_group_name": "Boards001_B"
},
{
"relative_file_path": "Part1/TextureSupply_Boards001_B_28x300cm-Normal.jpg",
"classified_file_type": "MAP_NRM",
"proposed_asset_group_name": "Boards001_B"
},
{
"relative_file_path": "Part1/TextureSupply_Boards001_C_28x300cm-Albedo.jpg",
"classified_file_type": "MAP_COL",
"proposed_asset_group_name": "Boards001_C"
},
{
"relative_file_path": "Part1/TextureSupply_Boards001_C_28x300cm-Normal.jpg",
"classified_file_type": "MAP_NRM",
"proposed_asset_group_name": "Boards001_C"
},
{
"relative_file_path": "Part1/TextureSupply_Boards001_D_28x300cm-Albedo.jpg",
"classified_file_type": "MAP_COL",
"proposed_asset_group_name": "Boards001_D"
},
{
"relative_file_path": "Part1/TextureSupply_Boards001_D_28x300cm-Normal.jpg",
"classified_file_type": "MAP_NRM",
"proposed_asset_group_name": "Boards001_D"
},
{
"relative_file_path": "Part1/TextureSupply_Boards001_E_28x300cm-Albedo.jpg",
"classified_file_type": "MAP_COL",
"proposed_asset_group_name": "Boards001_E"
},
{
"relative_file_path": "Part1/TextureSupply_Boards001_E_28x300cm-Normal.jpg",
"classified_file_type": "MAP_NRM",
"proposed_asset_group_name": "Boards001_E"
},
{
"relative_file_path": "Part1/TextureSupply_Boards001_F_28x300cm-Albedo.jpg",
"classified_file_type": "MAP_COL",
"proposed_asset_group_name": "Boards001_F"
},
{
"relative_file_path": "Part1/TextureSupply_Boards001_F_28x300cm-Normal.jpg",
"classified_file_type": "MAP_NRM",
"proposed_asset_group_name": "Boards001_F"
}
],
"asset_group_classifications": {
"Boards001_A": "Surface",
"Boards001_B": "Surface",
"Boards001_C": "Surface",
"Boards001_D": "Surface",
"Boards001_E": "Surface",
"Boards001_F": "Surface"
}
}
}
],
"asset_type_definition_format": "{KEY} = {DESCRIPTION}, examples of content of {KEY} could be: {EXAMPLES}",
"file_type_definition_format": "{KEY} = {DESCRIPTION}, examples of keywords for {KEY} could be: {EXAMPLES}",
"llm_endpoint_url": "http://100.65.14.122:1234/v1/chat/completions",
"llm_api_key": "",
"llm_model_name": "qwen2.5-coder:3b",
"llm_temperature": 0.5,
"llm_request_timeout": 120,
"llm_predictor_prompt": "You are an expert asset classification system. Your task is to analyze a list of file paths, understand their relationships based on naming and directory structure, and output a structured JSON object that classifies each file individually and then classifies the logical asset groups they belong to.\\n\\nDefinitions:\\n\\nAsset Types: These define the overall category of a logical asset group. Use one of the following keys when classifying asset groups. Each definition is provided as a formatted string (e.g., 'Surface = A single PBR material set..., examples: WoodFloor01, MetalPlate05'):\\n{ASSET_TYPE_DEFINITIONS}\\n\\n\\nFile Types: These define the specific purpose of each individual file. Use one of the following keys when classifying individual files. Each definition is provided as a formatted string (e.g., 'MAP_COL = Color/Albedo Map, examples: _col., _basecolor.'):\\n{FILE_TYPE_DEFINITIONS}\\n\\n\\nCore Task & Logic:\\n\\n1. **Individual File Analysis:**\\n * Examine each `relative_file_path` in the input `FILE_LIST`.\\n * For EACH file, determine its most likely `classified_file_type` using the `FILE_TYPE_DEFINITIONS`. Pay attention to filename suffixes, keywords, and extensions. Use `FILE_IGNORE` for files like `Thumbs.db` or `.DS_Store`. Use `EXTRA` for previews, metadata, or unidentifiable maps.\\n * For EACH file, propose a logical `proposed_asset_group_name` (string). This name should represent the asset the file likely belongs to, based on common base names (e.g., `WoodFloor01` from `WoodFloor01_col.png`, `WoodFloor01_nrm.png`) or directory structure (e.g., `SciFi_Drone` for files within that folder).\\n * Files that seem to be standalone utility maps (like `scratches.png`, `FlowMap.tif`) should get a unique group name derived from their filename (e.g., `Scratches`, `FlowMap`).\\n * If a file doesn't seem to belong to any logical group (e.g., a stray readme file in the root), you can propose `null` or a generic name like `Miscellaneous`.\\n * Be consistent with the proposed names for files belonging to the same logical asset.\\n * Populate the `individual_file_analysis` array with one object for *every* file in the input list, containing `relative_file_path`, `classified_file_type`, and `proposed_asset_group_name`.\\n\\n2. **Asset Group Classification:**\\n * Collect all unique, non-null `proposed_asset_group_name` values generated in the previous step.\\n * For EACH unique group name, determine the overall `asset_type` (using `ASSET_TYPE_DEFINITIONS`) based on the types of files assigned to that group name in the `individual_file_analysis`.\\n * Example: If files proposed as `AssetGroup1` include `MAP_COL`, `MAP_NRM`, `MAP_ROUGH`, classify `AssetGroup1` as `Surface`.\\n * Example: If files proposed as `AssetGroup2` include `MODEL` and texture maps, classify `AssetGroup2` as `Model`.\\n * Example: If `AssetGroup3` only has one file classified as `MAP_IMPERFECTION`, classify `AssetGroup3` as `UtilityMap`.\\n * Populate the `asset_group_classifications` dictionary, mapping each unique `proposed_asset_group_name` to its determined `asset_type`.\\n\\nInput File List:\\n\\ntext\\n{FILE_LIST}\\n\\n\\nOutput Format:\\n\\nYour response MUST be ONLY a single JSON object. You MAY include comments (using // or /* */) within the JSON structure for clarification if needed, but the core structure must be valid JSON. Do NOT include any text, explanations, or introductory phrases before or after the JSON object itself. Ensure all strings are correctly quoted and escaped.\\n\\nCRITICAL: The output JSON structure must strictly adhere to the following format:\\n\\n```json\\n{{\\n \"individual_file_analysis\": [\\n {{\\n // Optional comment about this file\\n \"relative_file_path\": \"string\", // Exact relative path from the input list\\n \"classified_file_type\": \"string\", // Key from FILE_TYPE_DEFINITIONS\\n \"proposed_asset_group_name\": \"string_or_null\" // Your suggested group name for this file\\n }}\\n // ... one object for EVERY file in the input list\\n ],\\n \"asset_group_classifications\": {{\\n // Dictionary mapping unique proposed group names to asset types\\n \"ProposedGroupName1\": \"string\", // Key: proposed_asset_group_name, Value: Key from ASSET_TYPE_DEFINITIONS\\n \"ProposedGroupName2\": \"string\"\\n // ... one entry for each unique, non-null proposed_asset_group_name\\n }}\\n}}\\n```\\n\\nExamples:\\n\\nHere are examples of input file lists and the desired JSON output, illustrating the two-part structure:\\n\\njson\\n[\\n {EXAMPLE_INPUT_OUTPUT_PAIRS}\\n]\\n\\n\\nNow, process the provided FILE_LIST and generate ONLY the JSON output according to these instructions. Remember to include an entry in `individual_file_analysis` for every single input file path."
}

View File

@@ -0,0 +1,11 @@
{
"Dimensiva": {
"normal_map_type": "OpenGL"
},
"Dinesen": {
"normal_map_type": "OpenGL"
},
"Poliigon": {
"normal_map_type": "OpenGL"
}
}

View File

@@ -0,0 +1,8 @@
{
"OUTPUT_BASE_DIR": "G:/02 Content/10-19 Content/13 Textures Power of Two/TestOutput",
"OUTPUT_DIRECTORY_PATTERN": "[supplier]/[asset_category]/[asset_name]",
"OUTPUT_FORMAT_16BIT_PRIMARY": "png",
"OUTPUT_FORMAT_8BIT": "png",
"RESOLUTION_THRESHOLD_FOR_JPG": 4096,
"general_settings": {}
}

66
utils/app_setup_utils.py Normal file
View File

@@ -0,0 +1,66 @@
import os
import sys
import platform
def get_app_data_dir():
"""
Gets the OS-specific application data directory for Asset Processor.
Uses standard library methods as appdirs is not available.
"""
app_name = "AssetProcessor"
if platform.system() == "Windows":
# On Windows, use APPDATA environment variable
app_data_dir = os.path.join(os.environ.get("APPDATA", "~"), app_name)
elif platform.system() == "Darwin":
# On macOS, use ~/Library/Application Support
app_data_dir = os.path.join("~", "Library", "Application Support", app_name)
else:
# On Linux and other Unix-like systems, use ~/.config
app_data_dir = os.path.join("~", ".config", app_name)
# Expand the user home directory symbol if present
return os.path.expanduser(app_data_dir)
def get_persistent_config_path_file():
"""
Gets the full path to the file storing the user's chosen config directory.
"""
app_data_dir = get_app_data_dir()
# Ensure the app data directory exists
os.makedirs(app_data_dir, exist_ok=True)
return os.path.join(app_data_dir, "asset_processor_user_root.txt")
def read_saved_user_config_path():
"""
Reads the saved user config path from the persistent file.
Returns the path string or None if the file doesn't exist or is empty.
"""
path_file = get_persistent_config_path_file()
if os.path.exists(path_file):
try:
with open(path_file, "r", encoding="utf-8") as f:
saved_path = f.read().strip()
if saved_path:
return saved_path
except IOError:
# Handle potential file reading errors
pass
return None
def save_user_config_path(user_config_path):
"""
Saves the user's chosen config path to the persistent file.
"""
path_file = get_persistent_config_path_file()
try:
with open(path_file, "w", encoding="utf-8") as f:
f.write(user_config_path)
except IOError:
# Handle potential file writing errors
print(f"Error saving user config path to {path_file}", file=sys.stderr)
def get_first_run_marker_file(user_config_path):
"""
Gets the full path to the first-run marker file within the user config directory.
"""
return os.path.join(user_config_path, ".first_run_complete")

View File

@@ -9,6 +9,7 @@ from typing import Optional, Dict
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def generate_path_from_pattern(pattern_string: str, token_data: dict) -> str: def generate_path_from_pattern(pattern_string: str, token_data: dict) -> str:
logger.debug(f"generate_path_from_pattern called with pattern: '{pattern_string}', token_data keys: {list(token_data.keys())}")
""" """
Generates a file path by replacing tokens in a pattern string with values Generates a file path by replacing tokens in a pattern string with values
from the provided token_data dictionary. from the provided token_data dictionary.
@@ -54,7 +55,8 @@ def generate_path_from_pattern(pattern_string: str, token_data: dict) -> str:
# Add variations like #### for IncrementingValue # Add variations like #### for IncrementingValue
known_tokens_lc = { known_tokens_lc = {
'assettype', 'supplier', 'assetname', 'resolution', 'ext', 'assettype', 'supplier', 'assetname', 'resolution', 'ext',
'incrementingvalue', '####', 'date', 'time', 'sha5', 'applicationpath' 'incrementingvalue', '####', 'date', 'time', 'sha5', 'applicationpath',
'asset_category'
} }
output_path = pattern_string output_path = pattern_string