Compare commits
No commits in common. "Dev" and "Pipeline" have entirely different histories.
3
.gitattributes
vendored
3
.gitattributes
vendored
@ -1,3 +0,0 @@
|
||||
*.bin filter=lfs diff=lfs merge=lfs -text
|
||||
*.db filter=lfs diff=lfs merge=lfs -text
|
||||
*.sqlite3 filter=lfs diff=lfs merge=lfs -text
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@ -30,6 +30,6 @@ Thumbs.db
|
||||
gui/__pycache__
|
||||
__pycache__
|
||||
|
||||
|
||||
Testfiles/TestOutputs
|
||||
Testfiles
|
||||
Testfiles/
|
||||
Testfiles_
|
||||
|
||||
@ -1,47 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
15
.roomodes
15
.roomodes
@ -1,15 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -1,112 +0,0 @@
|
||||
# Plan for Autotest GUI Mode Implementation
|
||||
|
||||
**I. Objective:**
|
||||
Create an `autotest.py` script that can launch the Asset Processor GUI headlessly, load a predefined asset (`.zip`), select a predefined preset, verify the predicted rule structure against an expected JSON, trigger processing to a predefined output directory, check the output, and analyze logs for errors or specific messages. This serves as a sanity check for core GUI-driven workflows.
|
||||
|
||||
**II. `TestFiles` Directory:**
|
||||
A new directory named `TestFiles` will be created in the project root (`c:/Users/Theis/Assetprocessor/Asset-Frameworker/TestFiles/`). This directory will house:
|
||||
* Sample asset `.zip` files for testing (e.g., `TestFiles/SampleAsset1.zip`).
|
||||
* Expected rule structure JSON files (e.g., `TestFiles/SampleAsset1_PresetX_expected_rules.json`).
|
||||
* A subdirectory for test outputs (e.g., `TestFiles/TestOutputs/`).
|
||||
|
||||
**III. `autotest.py` Script:**
|
||||
|
||||
1. **Location:** `c:/Users/Theis/Assetprocessor/Asset-Frameworker/autotest.py` (or `scripts/autotest.py`).
|
||||
2. **Command-Line Arguments (with defaults pointing to `TestFiles/`):**
|
||||
* `--zipfile`: Path to the test asset. Default: `TestFiles/default_test_asset.zip`.
|
||||
* `--preset`: Name of the preset. Default: `DefaultTestPreset`.
|
||||
* `--expectedrules`: Path to expected rules JSON. Default: `TestFiles/default_test_asset_rules.json`.
|
||||
* `--outputdir`: Path for processing output. Default: `TestFiles/TestOutputs/DefaultTestOutput`.
|
||||
* `--search` (optional): Log search term. Default: `None`.
|
||||
* `--additional-lines` (optional): Context lines for log search. Default: `0`.
|
||||
3. **Core Structure:**
|
||||
* Imports necessary modules from the main application and PySide6.
|
||||
* Adds project root to `sys.path` for imports.
|
||||
* `AutoTester` class:
|
||||
* **`__init__(self, app_instance: App)`:**
|
||||
* Stores `app_instance` and `main_window`.
|
||||
* Initializes `QEventLoop`.
|
||||
* Connects `app_instance.all_tasks_finished` to `self._on_all_tasks_finished`.
|
||||
* Loads expected rules from the `--expectedrules` file.
|
||||
* **`run_test(self)`:** Orchestrates the test steps sequentially:
|
||||
1. Load ZIP (`main_window.add_input_paths()`).
|
||||
2. Select Preset (`main_window.preset_editor_widget.editor_preset_list.setCurrentItem()`).
|
||||
3. Await Prediction (using `QTimer` to poll `main_window._pending_predictions`, manage with `QEventLoop`).
|
||||
4. Retrieve & Compare Rulelist:
|
||||
* Get actual rules: `main_window.unified_model.get_all_source_rules()`.
|
||||
* Convert actual rules to comparable dict (`_convert_rules_to_comparable()`).
|
||||
* Compare with loaded expected rules (`_compare_rules()`). If mismatch, log and fail.
|
||||
5. Start Processing (emit `main_window.start_backend_processing` with rules and output settings).
|
||||
6. Await Processing (use `QEventLoop` waiting for `_on_all_tasks_finished`).
|
||||
7. Check Output Path (verify existence of output dir, list contents, basic sanity checks like non-emptiness or presence of key asset folders).
|
||||
8. Retrieve & Analyze Logs (`main_window.log_console.log_console_output.toPlainText()`, filter by `--search`, check for tracebacks).
|
||||
9. Report result and call `cleanup_and_exit()`.
|
||||
* **`_check_prediction_status(self)`:** Slot for prediction polling timer.
|
||||
* **`_on_all_tasks_finished(self, processed_count, skipped_count, failed_count)`:** Slot for `App.all_tasks_finished` signal.
|
||||
* **`_convert_rules_to_comparable(self, source_rules_list: List[SourceRule]) -> dict`:** Converts `SourceRule` objects to the JSON structure defined below.
|
||||
* **`_compare_rules(self, actual_rules_data: dict, expected_rules_data: dict) -> bool`:** Implements Option 1 comparison logic:
|
||||
* Errors if an expected field is missing or its value mismatches.
|
||||
* Logs (but doesn't error on) fields present in actual but not in expected.
|
||||
* **`_process_and_display_logs(self, logs_text: str)`:** Handles log filtering/display.
|
||||
* **`cleanup_and_exit(self, success=True)`:** Quits `QCoreApplication` and `sys.exit()`.
|
||||
* `main()` function:
|
||||
* Parses CLI arguments.
|
||||
* Initializes `QApplication`.
|
||||
* Instantiates `main.App()` (does *not* show the GUI).
|
||||
* Instantiates `AutoTester(app_instance)`.
|
||||
* Uses `QTimer.singleShot(0, tester.run_test)` to start the test.
|
||||
* Runs `q_app.exec()`.
|
||||
|
||||
**IV. `expected_rules.json` Structure (Revised):**
|
||||
Located in `TestFiles/`. Example: `TestFiles/SampleAsset1_PresetX_expected_rules.json`.
|
||||
```json
|
||||
{
|
||||
"source_rules": [
|
||||
{
|
||||
"input_path": "SampleAsset1.zip",
|
||||
"supplier_identifier": "ExpectedSupplier",
|
||||
"preset_name": "PresetX",
|
||||
"assets": [
|
||||
{
|
||||
"asset_name": "AssetNameFromPrediction",
|
||||
"asset_type": "Prop",
|
||||
"files": [
|
||||
{
|
||||
"file_path": "relative/path/to/file1.png",
|
||||
"item_type": "MAP_COL",
|
||||
"target_asset_name_override": null
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**V. Mermaid Diagram of Autotest Flow:**
|
||||
```mermaid
|
||||
graph TD
|
||||
A[Start autotest.py with CLI Args (defaults to TestFiles/)] --> B{Setup Args & Logging};
|
||||
B --> C[Init QApplication & main.App (GUI Headless)];
|
||||
C --> D[Instantiate AutoTester(app_instance)];
|
||||
D --> E[QTimer.singleShot -> AutoTester.run_test()];
|
||||
|
||||
subgraph AutoTester.run_test()
|
||||
E --> F[Load Expected Rules from --expectedrules JSON];
|
||||
F --> G[Load ZIP (--zipfile) via main_window.add_input_paths()];
|
||||
G --> H[Select Preset (--preset) via main_window.preset_editor_widget];
|
||||
H --> I[Await Prediction (Poll main_window._pending_predictions via QTimer & QEventLoop)];
|
||||
I -- Prediction Done --> J[Get Actual Rules from main_window.unified_model];
|
||||
J --> K[Convert Actual Rules to Comparable JSON Structure];
|
||||
K --> L{Compare Actual vs Expected Rules (Option 1 Logic)};
|
||||
L -- Match --> M[Start Processing (Emit main_window.start_backend_processing with --outputdir)];
|
||||
L -- Mismatch --> ZFAIL[Log Mismatch & Call cleanup_and_exit(False)];
|
||||
M --> N[Await Processing (QEventLoop for App.all_tasks_finished signal)];
|
||||
N -- Processing Done --> O[Check Output Dir (--outputdir): Exists? Not Empty? Key Asset Folders?];
|
||||
O --> P[Retrieve & Analyze Logs (Search, Tracebacks)];
|
||||
P --> Q[Log Test Success & Call cleanup_and_exit(True)];
|
||||
end
|
||||
|
||||
ZFAIL --> ZEND[AutoTester.cleanup_and_exit() -> QCoreApplication.quit() & sys.exit()];
|
||||
Q --> ZEND;
|
||||
@ -16,7 +16,6 @@ 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.
|
||||
* 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`).
|
||||
* **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`).
|
||||
* **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>/`).
|
||||
|
||||
@ -13,21 +13,9 @@ 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.
|
||||
* `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
|
||||
|
||||
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/app_settings.json`:
|
||||
|
||||
* `llm_endpoint_url`: The URL of the LLM API endpoint. For local LLMs like LM Studio or Ollama, this will typically be `http://localhost:<port>/v1`. Consult your LLM server documentation for the exact endpoint.
|
||||
* `llm_api_key`: The API key required to access the LLM endpoint. Some local LLM servers may not require a key, in which case this can be left empty.
|
||||
@ -35,39 +23,15 @@ For users who wish to utilize the experimental LLM Predictor feature, the follow
|
||||
* `llm_temperature`: Controls the randomness of the LLM's output. Lower values (e.g., 0.1-0.5) make the output more deterministic and focused, while higher values (e.g., 0.6-1.0) make it more creative and varied. For prediction tasks, lower temperatures are generally recommended.
|
||||
* `llm_request_timeout`: The maximum time (in seconds) to wait for a response from the LLM API. Adjust this based on the performance of your LLM server and the complexity of the requests.
|
||||
|
||||
Note that the `llm_predictor_prompt` and `llm_predictor_examples` settings are also present in `config/llm_settings.json`. These define the instructions and examples provided to the LLM for prediction. While they can be viewed here, they are primarily intended for developer reference and tuning the LLM's behavior, and most users will not need to modify them directly via the file. These settings are editable via the LLM Editor panel in the main GUI when the LLM interpretation mode is selected.
|
||||
Note that the `llm_predictor_prompt` and `llm_predictor_examples` settings are also present in `app_settings.json`. These define the instructions and examples provided to the LLM for prediction. While they can be viewed here, they are primarily intended for developer reference and tuning the LLM's behavior, and most users will not need to modify them.
|
||||
|
||||
## Application Preferences (`config/app_settings.json` overrides)
|
||||
## GUI Configuration Editor
|
||||
|
||||
You can modify user-overridable application settings using the built-in GUI editor. These settings are loaded from `config/app_settings.json` and saved as overrides in `config/user_settings.json`. Access it via the **Edit** -> **Preferences...** menu.
|
||||
You can modify the `app_settings.json` file using the built-in GUI editor. Access it via the **Edit** -> **Preferences...** menu.
|
||||
|
||||
This editor provides a tabbed interface to view and change various application behaviors. The tabs include:
|
||||
* **General:** Basic settings like output base directory and temporary file prefix.
|
||||
* **Output & Naming:** Settings controlling output directory and filename patterns, and how variants are handled.
|
||||
* **Image Processing:** Settings related to image resolution definitions, compression levels, and format choices.
|
||||
* **Map Merging:** Configuration for how multiple input maps are combined into single output maps.
|
||||
* **Postprocess Scripts:** Paths to default Blender files for post-processing.
|
||||
This editor provides a tabbed interface (e.g., "General", "Output & Naming") to view and change the core application settings defined in `app_settings.json`. Settings in the editor directly correspond to the structure and values within the JSON file. Note that any changes made through the GUI editor require an application restart to take effect.
|
||||
|
||||
Note that this editor focuses on user-specific overrides of core application settings. **Asset Type Definitions, File Type Definitions, and Supplier Settings are managed in a separate Definitions Editor.**
|
||||
|
||||
Any changes made through the Preferences editor require an application restart to take effect.
|
||||
|
||||
*(Ideally, a screenshot of the Application Preferences editor would be included here.)*
|
||||
|
||||
## Definitions Editor (`config/asset_type_definitions.json`, `config/file_type_definitions.json`, `config/suppliers.json`)
|
||||
|
||||
Core application definitions that are separate from general user preferences are managed in the dedicated Definitions Editor. This includes defining known asset types, file types, and configuring settings specific to different suppliers. Access it via the **Edit** -> **Edit Definitions...** menu.
|
||||
|
||||
The editor is organized into three tabs:
|
||||
* **Asset Type Definitions:** Define the different categories of assets (e.g., Surface, Model, Decal). For each asset type, you can configure its description, a color for UI representation, and example usage strings.
|
||||
* **File Type Definitions:** Define the specific types of files the tool recognizes (e.g., MAP_COL, MAP_NRM, MODEL). For each file type, you can configure its description, a color, example keywords/patterns, a standard type alias, bit depth handling rules, whether it's grayscale, and an optional keybind for quick assignment in the GUI.
|
||||
* **Supplier Settings:** Configure settings that are specific to assets originating from different suppliers. Currently, this includes the "Normal Map Type" (OpenGL or DirectX) used for normal maps from that supplier.
|
||||
|
||||
Each tab presents a list of the defined items on the left (Asset Types, File Types, or Suppliers). Selecting an item in the list displays its configurable details on the right. Buttons are provided to add new definitions or remove existing ones.
|
||||
|
||||
Changes made in the Definitions Editor are saved directly to their respective configuration files (`config/asset_type_definitions.json`, `config/file_type_definitions.json`, and `config/suppliers.json`). Some changes may require an application restart to take full effect in processing logic.
|
||||
|
||||
*(Ideally, screenshots of the Definitions Editor tabs would be included here.)*
|
||||
*(Ideally, a screenshot of the GUI Configuration Editor would be included here.)*
|
||||
|
||||
## Preset Files (`presets/*.json`)
|
||||
|
||||
|
||||
@ -12,10 +12,7 @@ python -m gui.main_window
|
||||
|
||||
## Interface Overview
|
||||
|
||||
* **Menu Bar:** The "Edit" menu contains options to configure application settings and definitions:
|
||||
* **Preferences...:** Opens the Application Preferences editor for user-overridable settings (saved to `config/user_settings.json`).
|
||||
* **Edit Definitions...:** Opens the Definitions Editor for managing Asset Type Definitions, File Type Definitions, and Supplier Settings (saved to their respective files).
|
||||
The "View" menu allows you to toggle the visibility of the Log Console and the Detailed File Preview.
|
||||
* **Menu Bar:** The "Edit" menu contains the "Preferences..." option to open the GUI Configuration Editor. The "View" menu allows you to toggle the visibility of the Log Console and the Detailed File Preview.
|
||||
* **Preset Editor Panel (Left):**
|
||||
* **Optional Log Console:** Displays application logs (toggle via View menu).
|
||||
* **Preset List:** Create, delete, load, edit, and save presets. On startup, the "-- Select a Preset --" item is explicitly selected. You must select a specific preset from this list to load it into the editor below, enable the detailed file preview, and enable the "Start Processing" button.
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
This document describes the directory structure and contents of the processed assets generated by the Asset Processor Tool.
|
||||
|
||||
Processed assets are saved to a location determined by two global settings, `OUTPUT_DIRECTORY_PATTERN` and `OUTPUT_FILENAME_PATTERN`, defined in `config/app_settings.json`. These settings can be overridden by the user via `config/user_settings.json`.
|
||||
Processed assets are saved to a location determined by two global settings defined in `config/app_settings.json`:
|
||||
|
||||
* `OUTPUT_DIRECTORY_PATTERN`: Defines the directory structure *within* the Base Output Directory.
|
||||
* `OUTPUT_FILENAME_PATTERN`: Defines the naming convention for individual files *within* the directory created by `OUTPUT_DIRECTORY_PATTERN`.
|
||||
@ -23,7 +23,7 @@ The following tokens can be used in both `OUTPUT_DIRECTORY_PATTERN` and `OUTPUT_
|
||||
* `[Time]`: Current time (`HHMMSS`).
|
||||
* `[Sha5]`: The first 5 characters of the SHA-256 hash of the original input source file (e.g., the source zip archive).
|
||||
* `[ApplicationPath]`: Absolute path to the application directory.
|
||||
* `[maptype]`: The standardized map type identifier (e.g., `COL` for Color/Albedo, `NRM` for Normal, `RGH` for Roughness). This is derived from the `standard_type` defined in the application's `FILE_TYPE_DEFINITIONS` (managed in `config/file_type_definitions.json` via the Definitions Editor) and may include a variant suffix if applicable. (Primarily for filename pattern)
|
||||
* `[maptype]`: The standardized map type identifier (e.g., `COL` for Color/Albedo, `NRM` for Normal, `RGH` for Roughness). This is derived from the `standard_type` defined in the application's `FILE_TYPE_DEFINITIONS` (see `config/app_settings.json`) and may include a variant suffix if applicable. (Primarily for filename pattern)
|
||||
* `[dimensions]`: Pixel dimensions (e.g., `2048x2048`).
|
||||
* `[bitdepth]`: Output bit depth (e.g., `8bit`, `16bit`).
|
||||
* `[category]`: Asset category determined by preset rules.
|
||||
@ -51,14 +51,13 @@ The final output path is constructed by combining the Base Output Directory (set
|
||||
* `OUTPUT_FILENAME_PATTERN`: `[maptype].[ext]`
|
||||
* Resulting Path for a Normal map: `Output/Texture/Wood/WoodFloor001/Normal.exr`
|
||||
|
||||
The `<output_base_directory>` (the root folder where processing output starts) is configured separately via the GUI (**Edit** -> **Preferences...** -> **General** tab -> **Output Base Directory**) or the `--output` CLI argument. The `OUTPUT_DIRECTORY_PATTERN` defines the structure *within* this base directory, and `OUTPUT_FILENAME_PATTERN` defines the filenames within that structure.
|
||||
The `<output_base_directory>` (the root folder where processing output starts) is configured separately via the GUI (**Edit** -> **Preferences...** -> **Output & Naming** tab -> **Base Output Directory**) or the `--output` CLI argument. The `OUTPUT_DIRECTORY_PATTERN` defines the structure *within* this base directory, and `OUTPUT_FILENAME_PATTERN` defines the filenames within that structure.
|
||||
|
||||
## Contents of Each Asset Directory
|
||||
|
||||
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.
|
||||
* **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.
|
||||
* 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).
|
||||
|
||||
@ -1,85 +0,0 @@
|
||||
# User Guide: Usage - Automated GUI Testing (`autotest.py`)
|
||||
|
||||
This document explains how to use the `autotest.py` script for automated sanity checks of the Asset Processor Tool's GUI-driven workflow.
|
||||
|
||||
## Overview
|
||||
|
||||
The `autotest.py` script provides a way to run predefined test scenarios headlessly (without displaying the GUI). It simulates the core user actions: loading an asset, selecting a preset, allowing rules to be predicted, processing the asset, and then checks the results against expectations. This is primarily intended as a developer tool for regression testing and ensuring core functionality remains stable.
|
||||
|
||||
## Running the Autotest Script
|
||||
|
||||
From the project root directory, you can run the script using Python:
|
||||
|
||||
```bash
|
||||
python autotest.py [OPTIONS]
|
||||
```
|
||||
|
||||
### Command-Line Options
|
||||
|
||||
The script accepts several command-line arguments to configure the test run. If not provided, they use predefined default values.
|
||||
|
||||
* `--zipfile PATH_TO_ZIP`:
|
||||
* Specifies the path to the input asset `.zip` file to be used for the test.
|
||||
* Default: `TestFiles/BoucleChunky001.zip`
|
||||
* `--preset PRESET_NAME`:
|
||||
* Specifies the name of the preset to be selected and used for rule prediction and processing.
|
||||
* Default: `Dinesen`
|
||||
* `--expectedrules PATH_TO_JSON`:
|
||||
* Specifies the path to a JSON file containing the expected rule structure that should be generated after the preset is applied to the input asset.
|
||||
* Default: `TestFiles/test-BoucleChunky001.json`
|
||||
* `--outputdir PATH_TO_DIR`:
|
||||
* Specifies the directory where the processed assets will be written.
|
||||
* Default: `TestFiles/TestOutputs/DefaultTestOutput`
|
||||
* `--search "SEARCH_TERM"` (optional):
|
||||
* 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
|
||||
* `--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. A good non-zero value is 1-2.
|
||||
* Default: `0`
|
||||
|
||||
**Example Usage:**
|
||||
|
||||
```bash
|
||||
# Run with default test files and settings
|
||||
python autotest.py
|
||||
|
||||
# Run with specific test files and search for a log message
|
||||
python autotest.py --zipfile TestFiles/MySpecificAsset.zip --preset MyPreset --expectedrules TestFiles/MySpecificAsset_rules.json --outputdir TestFiles/TestOutputs/MySpecificOutput --search "Processing complete for asset"
|
||||
```
|
||||
|
||||
## `TestFiles` Directory
|
||||
|
||||
The autotest script relies on a directory named `TestFiles` located in the project root. This directory should contain:
|
||||
|
||||
* **Test Asset `.zip` files:** The actual asset archives used as input for tests (e.g., `default_test_asset.zip`, `MySpecificAsset.zip`).
|
||||
* **Expected Rules `.json` files:** JSON files defining the expected rule structure for a given asset and preset combination (e.g., `default_test_asset_rules.json`, `MySpecificAsset_rules.json`). The structure of this file is detailed in the main autotest plan (`AUTOTEST_GUI_PLAN.md`).
|
||||
* **`TestOutputs/` subdirectory:** This is the default parent directory where the autotest script will create specific output folders for each test run (e.g., `TestFiles/TestOutputs/DefaultTestOutput/`).
|
||||
|
||||
## Test Workflow
|
||||
|
||||
When executed, `autotest.py` performs the following steps:
|
||||
|
||||
1. **Initialization:** Parses command-line arguments and initializes the main application components headlessly.
|
||||
2. **Load Expected Rules:** Loads the `expected_rules.json` file.
|
||||
3. **Load Asset:** Loads the specified `.zip` file into the application.
|
||||
4. **Select Preset:** Selects the specified preset. This triggers the internal rule prediction process.
|
||||
5. **Await Prediction:** Waits for the rule prediction to complete.
|
||||
6. **Compare Rules:** Retrieves the predicted rules from the application and compares them against the loaded expected rules. If there's a mismatch, the test typically fails at this point.
|
||||
7. **Start Processing:** If the rules match, it initiates the asset processing pipeline, directing output to the specified output directory.
|
||||
8. **Await Processing:** Waits for all backend processing tasks to complete.
|
||||
9. **Check Output:** Verifies the existence of the output directory and lists its contents. Basic checks ensure some output was generated.
|
||||
10. **Analyze Logs:** Retrieves logs from the application. If a search term was provided, it filters and displays relevant log portions. It also checks for Python tracebacks, which usually indicate a failure.
|
||||
11. **Report Result:** Prints a summary of the test outcome (success or failure) and exits with an appropriate status code (0 for success, 1 for failure).
|
||||
|
||||
## Interpreting Results
|
||||
|
||||
* **Console Output:** The script will log its progress and the results of each step to the console.
|
||||
* **Log Analysis:** Pay attention to the log output, especially if a `--search` term was used or if any tracebacks are reported.
|
||||
* **Exit Code:**
|
||||
* `0`: Test completed successfully.
|
||||
* `1`: Test failed at some point (e.g., rule mismatch, processing error, traceback found).
|
||||
* **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.
|
||||
|
||||
Note: Under some conditions, the autotest will exit with errorcode "3221226505". This has no consequence and can therefor be ignore.
|
||||
@ -2,144 +2,43 @@
|
||||
|
||||
This document provides technical details about the configuration system and the structure of preset files for developers working on the Asset Processor Tool.
|
||||
|
||||
## Configuration System Overview
|
||||
## Configuration Flow
|
||||
|
||||
The tool's configuration is managed by the `configuration.py` module and loaded from several JSON files, providing a layered approach for defaults, user overrides, definitions, and source-specific presets.
|
||||
The tool utilizes a two-tiered configuration system managed by the `configuration.py` module:
|
||||
|
||||
### Configuration Files
|
||||
1. **Application Settings (`config/app_settings.json`):** This JSON file defines the core global default settings, constants, and rules that apply generally across different asset sources (e.g., the global `OUTPUT_DIRECTORY_PATTERN` and `OUTPUT_FILENAME_PATTERN`, standard image resolutions, map merge rules, output format rules, Blender paths, `FILE_TYPE_DEFINITIONS`, `ASSET_TYPE_DEFINITIONS`). See the [User Guide: Output Structure](../01_User_Guide/09_Output_Structure.md#available-tokens) for a list of available tokens for these patterns.
|
||||
* **`FILE_TYPE_DEFINITIONS` Enhancements:**
|
||||
* **`keybind` Property:** Each file type object within `FILE_TYPE_DEFINITIONS` can now optionally include a `keybind` property. This property accepts a single character string (e.g., `"C"`, `"R"`) representing the keyboard key. In the GUI, this key (typically combined with `Ctrl`, or standalone like `F2` for asset naming) is used as a shortcut to set or toggle the corresponding file type for selected items in the Preview Table.
|
||||
*Example:*
|
||||
```json
|
||||
"MAP_COL": {
|
||||
"description": "Color/Albedo Map",
|
||||
"color": [200, 200, 200],
|
||||
"examples": ["albedo", "col", "basecolor"],
|
||||
"standard_type": "COL",
|
||||
"bit_depth_rule": "respect",
|
||||
"is_grayscale": false,
|
||||
"keybind": "C"
|
||||
},
|
||||
```
|
||||
* **New File Type `MAP_GLOSS`:** A new standard file type, `MAP_GLOSS`, has been added. It is typically configured as follows:
|
||||
*Example:*
|
||||
```json
|
||||
"MAP_GLOSS": {
|
||||
"description": "Glossiness Map",
|
||||
"color": [180, 180, 220],
|
||||
"examples": ["gloss", "gls"],
|
||||
"standard_type": "GLOSS",
|
||||
"bit_depth_rule": "respect",
|
||||
"is_grayscale": true,
|
||||
"keybind": "R"
|
||||
}
|
||||
```
|
||||
Note: The `keybind` "R" for `MAP_GLOSS` is often shared with `MAP_ROUGH` to allow toggling between them.
|
||||
2. **LLM Settings (`config/llm_settings.json`):** This JSON file contains settings specifically related to the LLM predictor, such as the API endpoint, model name, prompt template, and examples. These settings can be edited through the GUI using the `LLMEditorWidget`.
|
||||
3. **Preset Files (`Presets/*.json`):** These JSON files define supplier-specific rules and overrides. They contain patterns to interpret filenames, classify map types, handle variants, define naming conventions, and specify other source-specific behaviors.
|
||||
|
||||
The tool's configuration is loaded from several JSON files, providing a layered approach for defaults, user overrides, definitions, and source-specific presets.
|
||||
|
||||
1. **Application Settings (`config/app_settings.json`):** This JSON file defines the core global default settings, constants, and rules that apply generally across different asset sources (e.g., the global `OUTPUT_DIRECTORY_PATTERN` and `OUTPUT_FILENAME_PATTERN`, standard image resolutions, map merge rules, output format rules, Blender paths, temporary directory prefix, initial scaling mode, merge dimension mismatch strategy). See the [User Guide: Output Structure](../01_User_Guide/09_Output_Structure.md#available-tokens) for a list of available tokens for these patterns.
|
||||
* *Note:* `ASSET_TYPE_DEFINITIONS` and `FILE_TYPE_DEFINITIONS` are no longer stored here; they have been moved to dedicated files.
|
||||
* It also includes settings for new features like the "Low-Resolution Fallback":
|
||||
* `ENABLE_LOW_RESOLUTION_FALLBACK` (boolean): Enables or disables the generation of "LOWRES" variants for small source images. Defaults to `true`.
|
||||
* `LOW_RESOLUTION_THRESHOLD` (integer): The pixel dimension threshold (largest side) below which a "LOWRES" variant is created if the feature is enabled. Defaults to `512`.
|
||||
|
||||
2. **User Settings (`config/user_settings.json`):** This optional JSON file allows users to override specific settings defined in `config/app_settings.json`. If this file exists, its values for corresponding keys will take precedence over the base application settings. This file is primarily managed through the GUI's Application Preferences Editor.
|
||||
|
||||
3. **Asset Type Definitions (`config/asset_type_definitions.json`):** This dedicated JSON file contains the definitions for different asset types (e.g., Surface, Model, Decal), including their descriptions, colors for UI representation, and example usage strings.
|
||||
|
||||
4. **File Type Definitions (`config/file_type_definitions.json`):** This dedicated JSON file contains the definitions for different file types (specifically texture maps and models), including descriptions, colors for UI representation, examples of keywords/patterns, a standard alias (`standard_type`), bit depth handling rules (`bit_depth_rule`), a grayscale flag (`is_grayscale`), and an optional GUI keybind (`keybind`).
|
||||
* **`keybind` Property:** Each file type object within `FILE_TYPE_DEFINITIONS` can optionally include a `keybind` property. This property accepts a single character string (e.g., `"C"`, `"R"`) representing the keyboard key. In the GUI, this key (typically combined with `Ctrl`) is used as a shortcut to set or toggle the corresponding file type for selected items in the Preview Table.
|
||||
*Example:*
|
||||
```json
|
||||
"MAP_COL": {
|
||||
"description": "Color/Albedo Map",
|
||||
"color": "#ffaa00",
|
||||
"examples": ["_col.", "_basecolor.", "albedo", "diffuse"],
|
||||
"standard_type": "COL",
|
||||
"bit_depth_rule": "force_8bit",
|
||||
"is_grayscale": false,
|
||||
"keybind": "C"
|
||||
},
|
||||
```
|
||||
Note: The `bit_depth_rule` property in `FILE_TYPE_DEFINITIONS` is the primary source for determining bit depth handling for a given map type.
|
||||
|
||||
5. **Supplier Settings (`config/suppliers.json`):** This JSON file stores settings specific to different asset suppliers. It is now structured as a dictionary where keys are supplier names and values are objects containing supplier-specific configurations.
|
||||
* **Structure:**
|
||||
```json
|
||||
{
|
||||
"SupplierName1": {
|
||||
"setting_key1": "value",
|
||||
"setting_key2": "value"
|
||||
},
|
||||
"SupplierName2": {
|
||||
"setting_key1": "value"
|
||||
}
|
||||
}
|
||||
```
|
||||
* **`normal_map_type` Property:** A key setting within each supplier's object is `normal_map_type`, specifying whether normal maps from this supplier use "OpenGL" or "DirectX" conventions.
|
||||
*Example:*
|
||||
```json
|
||||
{
|
||||
"Poliigon": {
|
||||
"normal_map_type": "DirectX"
|
||||
},
|
||||
"Dimensiva": {
|
||||
"normal_map_type": "OpenGL"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
6. **LLM Settings (`config/llm_settings.json`):** This JSON file contains settings specifically related to the LLM predictor, such as the API endpoint, model name, prompt template, and examples. These settings are managed through the GUI using the `LLMEditorWidget`.
|
||||
|
||||
7. **Preset Files (`Presets/*.json`):** These JSON files define source-specific rules and overrides. They contain patterns to interpret filenames, classify map types, handle variants, define naming conventions, and specify other source-specific behaviors. Preset settings override values from `app_settings.json` and `user_settings.json` where applicable.
|
||||
|
||||
|
||||
### Configuration Loading and Access
|
||||
|
||||
The `configuration.py` module contains the `Configuration` class and standalone functions for loading and saving settings.
|
||||
|
||||
* **`Configuration` Class:** This is the primary class used by the processing engine and other core components. When initialized with a `preset_name`, it loads settings in the following order, with later files overriding earlier ones for shared keys:
|
||||
1. `config/app_settings.json` (Base Defaults)
|
||||
2. `config/user_settings.json` (User Overrides - if exists)
|
||||
3. `config/asset_type_definitions.json` (Asset Type Definitions)
|
||||
4. `config/file_type_definitions.json` (File Type Definitions)
|
||||
5. `config/llm_settings.json` (LLM Settings)
|
||||
6. `Presets/{preset_name}.json` (Preset Overrides)
|
||||
|
||||
The loaded settings are merged into internal dictionaries, and most are accessible via instance properties (e.g., `config.output_base_dir`, `config.llm_endpoint_url`, `config.get_asset_type_definitions()`). Regex patterns defined in the merged configuration are pre-compiled for performance.
|
||||
|
||||
* **`load_base_config()` function:** This standalone function is primarily used by the GUI for initial setup and displaying default/user-overridden settings before a specific preset is selected. It loads and merges the following files:
|
||||
1. `config/app_settings.json`
|
||||
2. `config/user_settings.json` (if exists)
|
||||
3. `config/asset_type_definitions.json`
|
||||
4. `config/file_type_definitions.json`
|
||||
|
||||
It returns a single dictionary containing the combined settings and definitions.
|
||||
|
||||
* **Saving Functions:**
|
||||
* `save_base_config(settings_dict)`: Saves the provided dictionary to `config/app_settings.json`. (Used less frequently now for user-driven saves).
|
||||
* `save_user_config(settings_dict)`: Saves the provided dictionary to `config/user_settings.json`. Used by `ConfigEditorDialog`.
|
||||
* `save_llm_config(settings_dict)`: Saves the provided dictionary to `config/llm_settings.json`. Used by `LLMEditorWidget`.
|
||||
|
||||
## Supplier Management (`config/suppliers.json`)
|
||||
|
||||
A file, `config/suppliers.json`, is used to store a persistent list of known supplier names. This file is a simple JSON array of strings.
|
||||
|
||||
* **Purpose:** Provides a list of suggestions for the "Supplier" field in the GUI's Unified View, enabling auto-completion.
|
||||
* **Management:** The GUI's `SupplierSearchDelegate` is responsible for loading this list on startup, adding new, unique supplier names entered by the user, and saving the updated list back to the file.
|
||||
|
||||
## GUI Configuration Editors
|
||||
|
||||
The GUI provides dedicated editors for modifying configuration files:
|
||||
|
||||
* **`ConfigEditorDialog` (`gui/config_editor_dialog.py`):** Edits user-configurable application settings.
|
||||
* **`LLMEditorWidget` (`gui/llm_editor_widget.py`):** Edits the LLM-specific settings.
|
||||
|
||||
### `ConfigEditorDialog` (`gui/config_editor_dialog.py`)
|
||||
|
||||
The GUI includes a dedicated editor for modifying user-configurable settings. This is implemented in `gui/config_editor_dialog.py`.
|
||||
|
||||
* **Purpose:** Provides a user-friendly interface for viewing the effective application settings (defaults + user overrides + definitions) and editing the user-specific overrides.
|
||||
* **Implementation:** The dialog loads the effective settings using `load_base_config()`. It presents relevant settings in a tabbed layout ("General", "Output & Naming", etc.). When saving, it now performs a **granular save**: it loads the current content of `config/user_settings.json`, identifies only the settings that were changed by the user during the current dialog session (by comparing against the initial state), updates only those specific values in the loaded `user_settings.json` content, and saves the modified content back to `config/user_settings.json` using `save_user_config()`. This preserves any other settings in `user_settings.json` that were not touched. The dialog displays definitions from `asset_type_definitions.json` and `file_type_definitions.json` but does not save changes to these files.
|
||||
* **Limitations:** Currently, editing complex fields like `IMAGE_RESOLUTIONS` or the full details of `MAP_MERGE_RULES` via the UI is not fully supported for saving to `user_settings.json`.
|
||||
|
||||
### `LLMEditorWidget` (`gui/llm_editor_widget.py`)
|
||||
|
||||
* **Purpose:** Provides a user-friendly interface for viewing and editing the LLM settings defined in `config/llm_settings.json`.
|
||||
* **Implementation:** Uses tabs for "Prompt Settings" and "API Settings". Allows editing the prompt, managing examples, and configuring API details. When saving, it also performs a **granular save**: it loads the current content of `config/llm_settings.json`, identifies only the settings changed by the user in the current session, updates only those values, and saves the modified content back to `config/llm_settings.json` using `configuration.save_llm_config()`.
|
||||
|
||||
## Preset File Structure (`Presets/*.json`)
|
||||
|
||||
Preset files are the primary way to adapt the tool to new asset sources. Developers should use `Presets/_template.json` as a starting point. Key fields include:
|
||||
|
||||
* `supplier_name`: The name of the asset source (e.g., `"Poliigon"`). Used for output directory naming.
|
||||
* `map_type_mapping`: A list of dictionaries, each mapping source filename patterns/keywords to a specific file type. The `target_type` for this mapping **must** be a key from the `FILE_TYPE_DEFINITIONS` now located in `config/file_type_definitions.json`.
|
||||
* `target_type`: The specific file type key from `FILE_TYPE_DEFINITIONS` (e.g., `"MAP_COL"`, `"MAP_NORM_GL"`, `"MAP_RGH"`). This replaces previous alias-based systems. The common aliases like "COL" or "NRM" are now derived from the `standard_type` property within `FILE_TYPE_DEFINITIONS` but are not used directly for `target_type`.
|
||||
* `keywords`: A list of filename patterns (regex or fnmatch-style wildcards) used to identify this map type. The order of keywords within this list, and the order of dictionaries in the `map_type_mapping` list, determines the priority for assigning variant suffixes (`-1`, `-2`, etc.) when multiple files match the same `target_type`.
|
||||
* `bit_depth_variants`: A dictionary mapping standard map types (e.g., `"NRM"`) to a pattern identifying its high bit-depth variant (e.g., `"*_NRM16*.tif"`). Files matching these patterns are prioritized over their standard counterparts.
|
||||
* `map_bit_depth_rules`: Defines how to handle the bit depth of source maps. Can specify a default behavior (`"respect"` or `"force_8bit"`) and overrides for specific map types.
|
||||
* `model_patterns`: A list of regex patterns to identify model files (e.g., `".*\\.fbx"`, `".*\\.obj"`).
|
||||
* `move_to_extra_patterns`: A list of regex patterns for files that should be moved directly to the `Extra/` output subdirectory without further processing.
|
||||
* `source_naming_convention`: Rules for extracting the base asset name and potentially the archetype from source filenames or directory structures (e.g., using separators and indices).
|
||||
* `asset_category_rules`: Keywords or patterns used to determine the asset category (e.g., identifying `"Decal"` based on keywords).
|
||||
* `archetype_rules`: Keywords or patterns used to determine the asset archetype (e.g., identifying `"Wood"` or `"Metal"`).
|
||||
|
||||
Careful definition of these patterns and rules, especially the regex in `map_type_mapping`, `bit_depth_variants`, `model_patterns`, and `move_to_extra_patterns`, is essential for correct asset processing.
|
||||
|
||||
**Note on Data Passing:** As mentioned in the Architecture documentation, major changes to the data passing mechanisms between the GUI, Main (CLI orchestration), and `AssetProcessor` modules are currently being planned. The descriptions of how configuration data is handled and passed within this document reflect the current state and will require review and updates once the plan for these changes is finalized.
|
||||
The `configuration.py` module contains the `Configuration` class (for loading/merging settings for processing) and standalone functions like `load_base_config()` (for accessing `app_settings.json` directly) and `save_llm_config()` / `save_base_config()` (for writing settings back to files). Note that the old `config.py` file has been deleted.
|
||||
|
||||
## Supplier Management (`config/suppliers.json`)
|
||||
|
||||
|
||||
@ -50,44 +50,27 @@ These stages are executed sequentially once for each asset before the core item
|
||||
|
||||
### 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)). 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).
|
||||
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:
|
||||
|
||||
1. **[`PrepareProcessingItemsStage`](processing/pipeline/stages/prepare_processing_items.py:10)** (`processing/pipeline/stages/prepare_processing_items.py`):
|
||||
* **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.
|
||||
* 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`.
|
||||
* **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`.
|
||||
* **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 `item` in `context.processing_items`:
|
||||
|
||||
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.
|
||||
2. **[`RegularMapProcessorStage`](processing/pipeline/stages/regular_map_processor.py:18)** (`processing/pipeline/stages/regular_map_processor.py`):
|
||||
* **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`.
|
||||
|
||||
3. **[`MergedTaskProcessorStage`](processing/pipeline/stages/merged_task_processor.py:68)** (`processing/pipeline/stages/merged_task_processor.py`):
|
||||
* **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 [`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).
|
||||
* **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.
|
||||
* **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`.
|
||||
|
||||
4. **[`InitialScalingStage`](processing/pipeline/stages/initial_scaling.py:14)** (`processing/pipeline/stages/initial_scaling.py`):
|
||||
* **Responsibility**: (Executed per item)
|
||||
* 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.
|
||||
* **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.
|
||||
* **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`.
|
||||
|
||||
5. **[`SaveVariantsStage`](processing/pipeline/stages/save_variants.py:15)** (`processing/pipeline/stages/save_variants.py`):
|
||||
* **Responsibility**: (Executed per item) Saves the (potentially scaled) `current_image_data`.
|
||||
* **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`.
|
||||
* **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.
|
||||
* **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.
|
||||
|
||||
### Post-Item Stages
|
||||
|
||||
|
||||
@ -10,13 +10,13 @@ The GUI is built using `PySide6`, which provides Python bindings for the Qt fram
|
||||
|
||||
The `MainWindow` class acts as the central **coordinator** for the GUI application. It is responsible for:
|
||||
|
||||
* Setting up the main application window structure and menu bar, including actions to launch configuration and definition editors.
|
||||
* Setting up the main application window structure and menu bar.
|
||||
* **Layout:** Arranging the main GUI components using a `QSplitter`.
|
||||
* **Left Pane:** Contains the preset selection controls (from `PresetEditorWidget`) permanently displayed at the top. Below this, a `QStackedWidget` switches between the preset JSON editor (also from `PresetEditorWidget`) and the `LLMEditorWidget`.
|
||||
* **Right Pane:** Contains the `MainPanelWidget`.
|
||||
* Instantiating and managing the major GUI widgets:
|
||||
* `PresetEditorWidget` (`gui/preset_editor_widget.py`): Provides the preset selector and the JSON editor parts.
|
||||
* `LLMEditorWidget` (`gui/llm_editor_widget.py`): Provides the editor for LLM settings (from `config/llm_settings.json`).
|
||||
* `LLMEditorWidget` (`gui/llm_editor_widget.py`): Provides the editor for LLM settings.
|
||||
* `MainPanelWidget` (`gui/main_panel_widget.py`): Contains the rule hierarchy view and processing controls.
|
||||
* `LogConsoleWidget` (`gui/log_console_widget.py`): Displays application logs.
|
||||
* Instantiating key models and handlers:
|
||||
@ -198,24 +198,13 @@ The `LogConsoleWidget` displays logs captured by a custom `QtLogHandler` from Py
|
||||
|
||||
The GUI provides a "Cancel" button. Cancellation logic for the actual processing is now likely handled within the `main.ProcessingTask` or the code that manages it, as the `ProcessingHandler` has been removed. The GUI button would signal this external task manager.
|
||||
|
||||
## Application Preferences Editor (`gui/config_editor_dialog.py`)
|
||||
## GUI Configuration Editor (`gui/config_editor_dialog.py`)
|
||||
|
||||
A dedicated dialog for editing user-overridable application settings. It loads base settings from `config/app_settings.json` and saves user overrides to `config/user_settings.json`.
|
||||
A dedicated dialog for editing `config/app_settings.json`.
|
||||
|
||||
* **Functionality:** Provides a tabbed interface to edit various application settings, including general paths, output/naming patterns, image processing options (like resolutions and compression), and map merging rules. It no longer includes editors for Asset Type or File Type Definitions.
|
||||
* **Integration:** Launched by `MainWindow` via the "Edit" -> "Preferences..." menu.
|
||||
* **Persistence:** Saves changes to `config/user_settings.json`. Changes require an application restart to take effect in processing logic.
|
||||
* **Functionality:** Loads `config/app_settings.json`, presents in tabs, allows editing basic fields, definitions tables (with color editing), and merge rules list/detail.
|
||||
* **Limitations:** Editing complex fields like `IMAGE_RESOLUTIONS` or full `MAP_MERGE_RULES` details might still be limited.
|
||||
* **Integration:** Launched by `MainWindow` ("Edit" -> "Preferences...").
|
||||
* **Persistence:** Saves changes to `config/app_settings.json`. Requires application restart for changes to affect processing logic loaded by the `Configuration` class.
|
||||
|
||||
The refactored GUI separates concerns into distinct widgets and handlers, coordinated by the `MainWindow`. Background tasks use `QThreadPool` and `QRunnable`. The `UnifiedViewModel` focuses on data presentation and simple edits, delegating complex restructuring to the `AssetRestructureHandler`.
|
||||
|
||||
## Definitions Editor (`gui/definitions_editor_dialog.py`)
|
||||
|
||||
A new dedicated dialog for managing core application definitions that are separate from general user preferences.
|
||||
|
||||
* **Purpose:** Provides a structured UI for editing Asset Type Definitions, File Type Definitions, and Supplier Settings.
|
||||
* **Structure:** Uses a `QTabWidget` with three tabs:
|
||||
* **Asset Type Definitions:** Manages definitions from `config/asset_type_definitions.json`. Presents a list of asset types and allows editing their description, color, and examples.
|
||||
* **File Type Definitions:** Manages definitions from `config/file_type_definitions.json`. Presents a list of file types and allows editing their description, color, examples, standard type, bit depth rule, grayscale status, and keybind.
|
||||
* **Supplier Settings:** Manages settings from `config/suppliers.json`. Presents a list of suppliers and allows editing supplier-specific settings (e.g., Normal Map Type).
|
||||
* **Integration:** Launched by `MainWindow` via the "Edit" -> "Edit Definitions..." menu.
|
||||
* **Persistence:** Saves changes directly to the respective configuration files (`config/asset_type_definitions.json`, `config/file_type_definitions.json`, `config/suppliers.json`). Some changes may require an application restart.
|
||||
The refactored GUI separates concerns into distinct widgets and handlers, coordinated by the `MainWindow`. Background tasks use `QThreadPool` and `QRunnable`. The `UnifiedViewModel` focuses on data presentation and simple edits, delegating complex restructuring to the `AssetRestructureHandler`.
|
||||
@ -1,127 +0,0 @@
|
||||
# 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
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"preset_name": "Dinesen",
|
||||
"preset_name": "Dinesen Custom",
|
||||
"supplier_name": "Dinesen",
|
||||
"notes": "Preset for standard Poliigon downloads. Prioritizes _xxx16 files. Moves previews etc. to Extra/. Assumes Metal/Rough workflow.",
|
||||
"source_naming": {
|
||||
@ -10,7 +10,11 @@
|
||||
},
|
||||
"glossiness_keywords": [
|
||||
"GLOSS"
|
||||
]
|
||||
],
|
||||
"bit_depth_variants": {
|
||||
"NRM": "*_NRM16*",
|
||||
"DISP": "*_DISP16*"
|
||||
}
|
||||
},
|
||||
"move_to_extra_patterns": [
|
||||
"*_Preview*",
|
||||
@ -21,8 +25,7 @@
|
||||
"*.pdf",
|
||||
"*.url",
|
||||
"*.htm*",
|
||||
"*_Fabric.*",
|
||||
"*_DISP_*METALNESS*"
|
||||
"*_Fabric.*"
|
||||
],
|
||||
"map_type_mapping": [
|
||||
{
|
||||
@ -43,11 +46,6 @@
|
||||
"NORM*",
|
||||
"NRM*",
|
||||
"N"
|
||||
],
|
||||
"priority_keywords": [
|
||||
"*_NRM16*",
|
||||
"*_NM16*",
|
||||
"*Normal16*"
|
||||
]
|
||||
},
|
||||
{
|
||||
@ -77,14 +75,6 @@
|
||||
"DISP",
|
||||
"HEIGHT",
|
||||
"BUMP"
|
||||
],
|
||||
"priority_keywords": [
|
||||
"*_DISP16*",
|
||||
"*_DSP16*",
|
||||
"*DSP16*",
|
||||
"*DISP16*",
|
||||
"*Displacement16*",
|
||||
"*Height16*"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@ -10,7 +10,11 @@
|
||||
},
|
||||
"glossiness_keywords": [
|
||||
"GLOSS"
|
||||
]
|
||||
],
|
||||
"bit_depth_variants": {
|
||||
"NRM": "*_NRM16*",
|
||||
"DISP": "*_DISP16*"
|
||||
}
|
||||
},
|
||||
"move_to_extra_patterns": [
|
||||
"*_Preview*",
|
||||
@ -21,8 +25,7 @@
|
||||
"*.pdf",
|
||||
"*.url",
|
||||
"*.htm*",
|
||||
"*_Fabric.*",
|
||||
"*_Albedo*"
|
||||
"*_Fabric.*"
|
||||
],
|
||||
"map_type_mapping": [
|
||||
{
|
||||
@ -30,7 +33,6 @@
|
||||
"keywords": [
|
||||
"COLOR*",
|
||||
"COL",
|
||||
"COL-*",
|
||||
"DIFFUSE",
|
||||
"DIF",
|
||||
"ALBEDO"
|
||||
@ -41,13 +43,7 @@
|
||||
"keywords": [
|
||||
"NORMAL*",
|
||||
"NORM*",
|
||||
"NRM*",
|
||||
"N"
|
||||
],
|
||||
"priority_keywords": [
|
||||
"*_NRM16*",
|
||||
"*_NM16*",
|
||||
"*Normal16*"
|
||||
"NRM*"
|
||||
]
|
||||
},
|
||||
{
|
||||
@ -61,7 +57,8 @@
|
||||
"target_type": "MAP_GLOSS",
|
||||
"keywords": [
|
||||
"GLOSS"
|
||||
]
|
||||
],
|
||||
"is_gloss_source": true
|
||||
},
|
||||
{
|
||||
"target_type": "MAP_AO",
|
||||
@ -77,14 +74,6 @@
|
||||
"DISP",
|
||||
"HEIGHT",
|
||||
"BUMP"
|
||||
],
|
||||
"priority_keywords": [
|
||||
"*_DISP16*",
|
||||
"*_DSP16*",
|
||||
"*DSP16*",
|
||||
"*DISP16*",
|
||||
"*Displacement16*",
|
||||
"*Height16*"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@ -1,107 +0,0 @@
|
||||
# Configuration System Refactoring Plan
|
||||
|
||||
This document outlines the plan for refactoring the configuration system of the Asset Processor Tool.
|
||||
|
||||
## Overall Goals
|
||||
|
||||
1. **Decouple Definitions:** Separate `ASSET_TYPE_DEFINITIONS` and `FILE_TYPE_DEFINITIONS` from the main `config/app_settings.json` into dedicated files.
|
||||
2. **Introduce User Overrides:** Allow users to override base settings via a new `config/user_settings.json` file.
|
||||
3. **Improve GUI Saving:** (Lower Priority) Make GUI configuration saving more targeted to avoid overwriting unrelated settings when saving changes from `ConfigEditorDialog` or `LLMEditorWidget`.
|
||||
|
||||
## Proposed Plan Phases
|
||||
|
||||
**Phase 1: Decouple Definitions**
|
||||
|
||||
1. **Create New Definition Files:**
|
||||
* Create `config/asset_type_definitions.json`.
|
||||
* Create `config/file_type_definitions.json`.
|
||||
2. **Migrate Content:**
|
||||
* Move `ASSET_TYPE_DEFINITIONS` object from `config/app_settings.json` to `config/asset_type_definitions.json`.
|
||||
* Move `FILE_TYPE_DEFINITIONS` object from `config/app_settings.json` to `config/file_type_definitions.json`.
|
||||
3. **Update `configuration.py`:**
|
||||
* Add constants for new definition file paths.
|
||||
* Modify `Configuration` class to load these new files.
|
||||
* Update property methods (e.g., `get_asset_type_definitions`, `get_file_type_definitions_with_examples`) to use data from the new definition dictionaries.
|
||||
* Adjust validation (`_validate_configs`) as needed.
|
||||
4. **Update GUI & `load_base_config()`:**
|
||||
* Modify `load_base_config()` to load and return a combined dictionary including `app_settings.json` and the two new definition files.
|
||||
* Update GUI components relying on `load_base_config()` to ensure they receive the necessary definition data.
|
||||
|
||||
**Phase 2: Implement User Overrides**
|
||||
|
||||
1. **Define `user_settings.json`:**
|
||||
* Establish `config/user_settings.json` for user-specific overrides, mirroring parts of `app_settings.json`.
|
||||
2. **Update `configuration.py` Loading:**
|
||||
* In `Configuration.__init__`, load `app_settings.json`, then definition files, then attempt to load and deep merge `user_settings.json` (user settings override base).
|
||||
* Load presets *after* the base+user merge (presets override combined base+user).
|
||||
* Modify `load_base_config()` to also load and merge `user_settings.json` after `app_settings.json`.
|
||||
3. **Update GUI Editors:**
|
||||
* Modify `ConfigEditorDialog` to load the effective settings (base+user) but save changes *only* to `config/user_settings.json`.
|
||||
* `LLMEditorWidget` continues targeting `llm_settings.json`.
|
||||
|
||||
**Phase 3: Granular GUI Saving (Lower Priority)**
|
||||
|
||||
1. **Refactor Saving Logic:**
|
||||
* In `ConfigEditorDialog` and `LLMEditorWidget`:
|
||||
* Load the current target file (`user_settings.json` or `llm_settings.json`).
|
||||
* Identify specific setting(s) changed by the user in the GUI session.
|
||||
* Update only those specific key(s) in the loaded dictionary.
|
||||
* Write the entire modified dictionary back to the target file, preserving untouched settings.
|
||||
|
||||
## Proposed File Structure & Loading Flow
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph Config Files
|
||||
A[config/asset_type_definitions.json]
|
||||
B[config/file_type_definitions.json]
|
||||
C[config/app_settings.json (Base Defaults)]
|
||||
D[config/user_settings.json (User Overrides)]
|
||||
E[config/llm_settings.json]
|
||||
F[config/suppliers.json]
|
||||
G[Presets/*.json]
|
||||
end
|
||||
|
||||
subgraph Code
|
||||
H[configuration.py]
|
||||
I[GUI]
|
||||
J[Processing Engine / Pipeline]
|
||||
K[LLM Handlers]
|
||||
end
|
||||
|
||||
subgraph Loading Flow (Configuration Class)
|
||||
L(Load Asset Types) --> H
|
||||
M(Load File Types) --> H
|
||||
N(Load Base Settings) --> P(Merge Base + User)
|
||||
O(Load User Settings) --> P
|
||||
P --> R(Merge Preset Overrides)
|
||||
Q(Load LLM Settings) --> H
|
||||
R --> T(Final Config Object)
|
||||
G -- Load Preset --> R
|
||||
H -- Contains --> T
|
||||
end
|
||||
|
||||
subgraph Loading Flow (GUI - load_base_config)
|
||||
L2(Load Asset Types) --> U(Return Merged Defaults + Defs)
|
||||
M2(Load File Types) --> U
|
||||
N2(Load Base Settings) --> V(Merge Base + User)
|
||||
O2(Load User Settings) --> V
|
||||
V --> U
|
||||
I -- Calls --> U
|
||||
end
|
||||
|
||||
|
||||
T -- Used by --> J
|
||||
T -- Used by --> K
|
||||
|
||||
I -- Edits --> D
|
||||
I -- Edits --> E
|
||||
I -- Manages --> F
|
||||
|
||||
style A fill:#f9f,stroke:#333,stroke-width:2px
|
||||
style B fill:#f9f,stroke:#333,stroke-width:2px
|
||||
style C fill:#ccf,stroke:#333,stroke-width:2px
|
||||
style D fill:#9cf,stroke:#333,stroke-width:2px
|
||||
style E fill:#ccf,stroke:#333,stroke-width:2px
|
||||
style F fill:#9cf,stroke:#333,stroke-width:2px
|
||||
style G fill:#ffc,stroke:#333,stroke-width:2px
|
||||
96
ProjectNotes/MAP_Prefix_Enforcement_Plan.md
Normal file
96
ProjectNotes/MAP_Prefix_Enforcement_Plan.md
Normal file
@ -0,0 +1,96 @@
|
||||
# Plan: Enforcing "MAP_" Prefix for Internal Processing and Standard Type for Output Naming
|
||||
|
||||
**Date:** 2025-05-13
|
||||
|
||||
**I. Goal:**
|
||||
The primary goal is to ensure that for all internal processing, the system *exclusively* uses `FileRule.item_type` values that start with the "MAP_" prefix (e.g., "MAP_COL", "MAP_NRM"). The "standard type" (e.g., "COL", "NRM") associated with these "MAP_" types (as defined in `config/app_settings.json`) should *only* be used during the file saving stages for output naming. Any `FileRule` whose `item_type` does not start with "MAP_" (and isn't a special type like "EXTRA" or "MODEL") should be skipped by the relevant map processing stages.
|
||||
|
||||
**II. Current State Analysis Summary:**
|
||||
|
||||
* **Output Naming:** The use of "standard type" for output filenames via the `get_filename_friendly_map_type` utility in `SaveVariantsStage` and `OutputOrganizationStage` is **correct** and already meets the requirement.
|
||||
* **Internal "MAP_" Prefix Usage:**
|
||||
* Some stages like `GlossToRoughConversionStage` correctly check for "MAP_" prefixes (e.g., `processing_map_type.startswith("MAP_GLOSS")`).
|
||||
* Other stages like `RegularMapProcessorStage` and `MergedTaskProcessorStage` (and its helpers) implicitly expect "MAP_" prefixed types for their internal regex-based logic but lack explicit checks to skip items if the prefix is missing.
|
||||
* Stages like `AlphaExtractionToMaskStage` and `NormalMapGreenChannelStage` currently use non-"MAP_" prefixed "standard types" (e.g., "NORMAL", "ALBEDO") when reading from `context.processed_maps_details` for their decision-making logic.
|
||||
* The `PrepareProcessingItemsStage` adds `FileRule`s to the processing queue without filtering based on the "MAP_" prefix in `item_type`.
|
||||
* **Data Consistency in `AssetProcessingContext`:**
|
||||
* `FileRule.item_type` is the field that should hold the "MAP_" prefixed type from the initial rule generation.
|
||||
* `context.processed_maps_details` entries can contain various map type representations:
|
||||
* `map_type`: Often stores the "standard type" (e.g., "Roughness", "MASK", "NORMAL").
|
||||
* `processing_map_type` / `internal_map_type`: Generally seem to store the "MAP_" prefixed type. This needs to be consistent.
|
||||
* **Configuration (`config/app_settings.json`):**
|
||||
* `FILE_TYPE_DEFINITIONS` correctly use "MAP_" prefixed keys.
|
||||
* `MAP_MERGE_RULES` need to be reviewed to ensure their `output_map_type` and input map types are "MAP_" prefixed.
|
||||
|
||||
**III. Proposed Changes (Code Identification & Recommendations):**
|
||||
|
||||
**A. Enforce "MAP_" Prefix for Processing Items (Skipping Logic):**
|
||||
The core requirement is that processing stages should skip `FileRule` items if their `item_type` doesn't start with "MAP_".
|
||||
|
||||
1. **`RegularMapProcessorStage` (`processing/pipeline/stages/regular_map_processor.py`):**
|
||||
* **Identify:** In the `execute` method, `initial_internal_map_type` is derived from `file_rule.item_type_override` or `file_rule.item_type`.
|
||||
* **Recommend:** Add an explicit check after determining `initial_internal_map_type`. If `initial_internal_map_type` does not start with `"MAP_"`, the stage should log a warning, set the `result.status` to "Skipped (Invalid Type)" or similar, and return `result` early, effectively skipping processing for this item.
|
||||
|
||||
2. **`MergedTaskProcessorStage` (`processing/pipeline/stages/merged_task_processor.py`):**
|
||||
* **Identify:** This stage processes `MergeTaskDefinition`s. The definitions for these tasks (input types, output type) come from `MAP_MERGE_RULES` in `config/app_settings.json`. The stage uses `required_map_type_from_rule` for its inputs.
|
||||
* **Recommend:**
|
||||
* **Configuration First:** Review all entries in `MAP_MERGE_RULES` in `config/app_settings.json`.
|
||||
* Ensure the `output_map_type` for each rule (e.g., "MAP_NRMRGH") starts with "MAP_".
|
||||
* Ensure all map type values within the `inputs` dictionary (e.g., `"R": "MAP_NRM"`) start with "MAP_".
|
||||
* **Stage Logic:** In the `execute` method, when iterating through `merge_inputs_config.items()`, check if `required_map_type_from_rule` starts with `"MAP_"`. If not, log a warning and either:
|
||||
* Skip loading/processing this specific input channel (potentially using its fallback if the overall merge can still proceed).
|
||||
* Or, if a non-"MAP_" input is critical, fail the entire merge task for this asset.
|
||||
* The helper `_apply_in_memory_transformations` already uses regex expecting "MAP_" prefixes; this will naturally fail or misbehave if inputs are not "MAP_" prefixed, reinforcing the need for the check above.
|
||||
|
||||
**B. Standardize Map Type Fields and Usage in `context.processed_maps_details`:**
|
||||
Ensure consistency in how "MAP_" prefixed types are stored and accessed within `context.processed_maps_details` for internal logic (not naming).
|
||||
|
||||
1. **Recommendation:** Establish a single, consistent field name within `context.processed_maps_details` to store the definitive "MAP_" prefixed internal map type (e.g., `internal_map_type` or `processing_map_type`). All stages that perform logic based on the specific *kind* of map (e.g., transformations, source selection) should read from this standardized field. The `map_type` field can continue to store the "standard type" (e.g., "Roughness") primarily for informational/metadata purposes if needed, but not for core processing logic.
|
||||
|
||||
2. **`AlphaExtractionToMaskStage` (`processing/pipeline/stages/alpha_extraction_to_mask.py`):**
|
||||
* **Identify:**
|
||||
* Checks for existing MASK map using `file_rule.map_type == "MASK"`. (Discrepancy: `FileRule` uses `item_type`).
|
||||
* Searches for suitable source maps using `details.get('map_type') in self.SUITABLE_SOURCE_MAP_TYPES` where `SUITABLE_SOURCE_MAP_TYPES` are standard types like "ALBEDO".
|
||||
* When adding new details, it sets `map_type: "MASK"` and the new `FileRule` gets `item_type="MAP_MASK"`.
|
||||
* **Recommend:**
|
||||
* Change the check for an existing MASK map to `file_rule.item_type == "MAP_MASK"`.
|
||||
* Modify the source map search to use the standardized "MAP_" prefixed field from `details` (e.g., `details.get('internal_map_type')`) and update `SUITABLE_SOURCE_MAP_TYPES` to be "MAP_" prefixed (e.g., "MAP_COL", "MAP_ALBEDO").
|
||||
* When adding new details for the created MASK map to `context.processed_maps_details`, ensure the standardized "MAP_" prefixed field is set to "MAP_MASK", and `map_type` (if kept) is "MASK".
|
||||
|
||||
3. **`NormalMapGreenChannelStage` (`processing/pipeline/stages/normal_map_green_channel.py`):**
|
||||
* **Identify:** Checks `map_details.get('map_type') == "NORMAL"`.
|
||||
* **Recommend:** Change this check to use the standardized "MAP_" prefixed field from `map_details` (e.g., `map_details.get('internal_map_type')`) and verify if it `startswith("MAP_NRM")`.
|
||||
|
||||
4. **`GlossToRoughConversionStage` (`processing/pipeline/stages/gloss_to_rough_conversion.py`):**
|
||||
* **Identify:** This stage already uses `processing_map_type.startswith("MAP_GLOSS")` and updates `processing_map_type` to "MAP_ROUGH" in `map_details`. It also updates the `FileRule.item_type` correctly.
|
||||
* **Recommend:** This stage is largely consistent. Ensure the field it reads/writes (`processing_map_type`) aligns with the chosen standardized "MAP_" prefixed field for `processed_maps_details`.
|
||||
|
||||
**C. Review Orchestration Logic (Conceptual):**
|
||||
* When the orchestrator populates `context.processed_maps_details` after stages like `SaveVariantsStage`, ensure it stores the "MAP_" prefixed `internal_map_type` (from `SaveVariantsInput`) into the chosen standardized field in `processed_maps_details`.
|
||||
|
||||
**IV. Testing Recommendations:**
|
||||
|
||||
* Create test cases with `AssetRule`s containing `FileRule`s where `item_type` is intentionally set to a non-"MAP_" prefixed value (e.g., "COLOR_MAP", "TEXTURE_ROUGH"). Verify that `RegularMapProcessorStage` skips these.
|
||||
* Modify `MAP_MERGE_RULES` in a test configuration:
|
||||
* Set an `output_map_type` to a non-"MAP_" value.
|
||||
* Set an input map type (e.g., for channel "R") to a non-"MAP_" value.
|
||||
* Verify that `MergedTaskProcessorStage` correctly handles these (e.g., fails the task, skips the input, logs warnings).
|
||||
* Test `AlphaExtractionToMaskStage`:
|
||||
* With an existing `FileRule` having `item_type="MAP_MASK"` to ensure extraction is skipped.
|
||||
* With source maps having "MAP_COL" (with alpha) as their `internal_map_type` in `processed_maps_details` to ensure they are correctly identified as sources.
|
||||
* Test `NormalMapGreenChannelStage` with a normal map having "MAP_NRM" as its `internal_map_type` in `processed_maps_details` to ensure it's processed.
|
||||
* Verify that output filenames continue to use the "standard type" (e.g., "COL", "ROUGH", "NRM") correctly.
|
||||
|
||||
**V. Mermaid Diagram (Illustrative Flow for `FileRule` Processing):**
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[AssetRule with FileRules] --> B{FileRuleFilterStage};
|
||||
B -- files_to_process --> C{PrepareProcessingItemsStage};
|
||||
C -- processing_items (FileRule) --> D{PipelineOrchestrator};
|
||||
D -- FileRule --> E(RegularMapProcessorStage);
|
||||
E --> F{Check FileRule.item_type};
|
||||
F -- Starts with "MAP_"? --> G[Process Map];
|
||||
F -- No --> H[Skip Map / Log Warning];
|
||||
G --> I[...subsequent stages...];
|
||||
H --> I;
|
||||
72
ProjectNotes/PipelineRefactoringPlan.md
Normal file
72
ProjectNotes/PipelineRefactoringPlan.md
Normal file
@ -0,0 +1,72 @@
|
||||
# Processing Pipeline Refactoring Plan
|
||||
|
||||
## 1. Problem Summary
|
||||
|
||||
The current processing pipeline, particularly the `IndividualMapProcessingStage`, exhibits maintainability challenges:
|
||||
|
||||
* **High Complexity:** The stage handles too many responsibilities (loading, merging, transformations, scaling, saving).
|
||||
* **Duplicated Logic:** Image transformations (Gloss-to-Rough, Normal Green Invert) are duplicated within the stage instead of relying solely on dedicated stages or being handled consistently.
|
||||
* **Tight Coupling:** Heavy reliance on the large, mutable `AssetProcessingContext` object creates implicit dependencies and makes isolated testing difficult.
|
||||
|
||||
## 2. Refactoring Goals
|
||||
|
||||
* Improve code readability and understanding.
|
||||
* Enhance maintainability by localizing changes and removing duplication.
|
||||
* Increase testability through smaller, focused components with clear interfaces.
|
||||
* Clarify data dependencies between pipeline stages.
|
||||
* Adhere more closely to the Single Responsibility Principle (SRP).
|
||||
|
||||
## 3. Proposed New Pipeline Stages
|
||||
|
||||
Replace the existing `IndividualMapProcessingStage` with the following sequence of smaller, focused stages, executed by the `PipelineOrchestrator` for each processing item:
|
||||
|
||||
1. **`PrepareProcessingItemsStage`:**
|
||||
* **Responsibility:** Identifies and lists all items (`FileRule`, `MergeTaskDefinition`) to be processed from the main context.
|
||||
* **Output:** Updates `context.processing_items`.
|
||||
|
||||
2. **`RegularMapProcessorStage`:** (Handles `FileRule` items)
|
||||
* **Responsibility:** Loads source image, determines internal map type (with suffix), applies relevant transformations (Gloss-to-Rough, Normal Green Invert), determines original metadata.
|
||||
* **Output:** `ProcessedRegularMapData` object containing transformed image data and metadata.
|
||||
|
||||
3. **`MergedTaskProcessorStage`:** (Handles `MergeTaskDefinition` items)
|
||||
* **Responsibility:** Loads input images, applies transformations to inputs, handles fallbacks/resizing, performs merge operation.
|
||||
* **Output:** `ProcessedMergedMapData` object containing merged image data and metadata.
|
||||
|
||||
4. **`InitialScalingStage`:** (Optional)
|
||||
* **Responsibility:** Applies configured scaling (e.g., POT downscale) to the processed image data received from the previous stage.
|
||||
* **Output:** Scaled image data.
|
||||
|
||||
5. **`SaveVariantsStage`:**
|
||||
* **Responsibility:** Takes the final processed (and potentially scaled) image data and orchestrates saving variants using the `save_image_variants` utility.
|
||||
* **Output:** List of saved file details (`saved_files_details`).
|
||||
|
||||
## 4. Proposed Data Flow
|
||||
|
||||
* **Input/Output Objects:** Key stages (`RegularMapProcessor`, `MergedTaskProcessor`, `InitialScaling`, `SaveVariants`) will use specific Input and Output dataclasses for clearer interfaces.
|
||||
* **Orchestrator Role:** The `PipelineOrchestrator` manages the overall flow. It calls stages, passes necessary data (extracting image data references and metadata from previous stage outputs to create inputs for the next), receives output objects, and integrates final results (like saved file details) back into the main `AssetProcessingContext`.
|
||||
* **Image Data Handling:** Large image arrays (`np.ndarray`) are passed primarily via stage return values (Output objects) and used as inputs to subsequent stages, managed by the Orchestrator. They are not stored long-term in the main `AssetProcessingContext`.
|
||||
* **Main Context:** The `AssetProcessingContext` remains for overall state (rules, paths, configuration access, final status tracking) and potentially for simpler stages with minimal side effects.
|
||||
|
||||
## 5. Visualization (Conceptual)
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph Proposed Pipeline Stages
|
||||
Start --> Prep[PrepareProcessingItemsStage]
|
||||
Prep --> ItemLoop{Loop per Item}
|
||||
ItemLoop -- FileRule --> RegProc[RegularMapProcessorStage]
|
||||
ItemLoop -- MergeTask --> MergeProc[MergedTaskProcessorStage]
|
||||
RegProc --> Scale(InitialScalingStage)
|
||||
MergeProc --> Scale
|
||||
Scale --> Save[SaveVariantsStage]
|
||||
Save --> UpdateContext[Update Main Context w/ Results]
|
||||
UpdateContext --> ItemLoop
|
||||
end
|
||||
```
|
||||
|
||||
## 6. Benefits
|
||||
|
||||
* Improved Readability & Understanding.
|
||||
* Enhanced Maintainability & Reduced Risk.
|
||||
* Better Testability.
|
||||
* Clearer Dependencies.
|
||||
@ -1,105 +0,0 @@
|
||||
# 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.
|
||||
@ -1,62 +0,0 @@
|
||||
# Issue: List item selection not working in Definitions Editor
|
||||
|
||||
**Date:** 2025-05-13
|
||||
|
||||
**Affected File:** [`gui/definitions_editor_dialog.py`](gui/definitions_editor_dialog.py)
|
||||
|
||||
**Problem Description:**
|
||||
User mouse clicks on items within the `QListWidget` instances (for Asset Types, File Types, and Suppliers) in the Definitions Editor dialog do not trigger item selection or the `currentItemChanged` signal. The first item is selected by default and its details are displayed correctly. Programmatic selection of items (e.g., via a diagnostic button) *does* correctly trigger the `currentItemChanged` signal and updates the UI detail views. The issue is specific to user-initiated mouse clicks for selection after the initial load.
|
||||
|
||||
**Debugging Steps Taken & Findings:**
|
||||
|
||||
1. **Initial Analysis:**
|
||||
* Reviewed GUI internals documentation ([`Documentation/02_Developer_Guide/06_GUI_Internals.md`](Documentation/02_Developer_Guide/06_GUI_Internals.md)) and [`gui/definitions_editor_dialog.py`](gui/definitions_editor_dialog.py) source code.
|
||||
* Confirmed signal connections (`currentItemChanged` to display slots) are made.
|
||||
|
||||
2. **Logging in Display Slots (`_display_*_details`):**
|
||||
* Added logging to display slots. Confirmed they are called for the initial (default) item selection.
|
||||
* No further calls to these slots occur on user clicks, indicating `currentItemChanged` is not firing.
|
||||
|
||||
3. **Color Swatch Palette Role:**
|
||||
* Investigated and corrected `QPalette.ColorRole` for color swatches (reverted from `Background` to `Window`). This fixed an `AttributeError` but did not resolve the selection issue.
|
||||
|
||||
4. **Robust Error Handling in Display Slots:**
|
||||
* Wrapped display slot logic in `try...finally` blocks with detailed logging. Confirmed slots complete without error for initial selection and signals for detail widgets are reconnected.
|
||||
|
||||
5. **Diagnostic Lambda for `currentItemChanged`:**
|
||||
* Added a lambda logger to `currentItemChanged` alongside the main display slot.
|
||||
* Confirmed both lambda and display slot fire for initial programmatic selection.
|
||||
* Neither fires for subsequent user clicks. This proved the `QListWidget` itself was not emitting the signal.
|
||||
|
||||
6. **Explicit `setEnabled` and `setSelectionMode` on `QListWidget`:**
|
||||
* Explicitly set these properties. No change in behavior.
|
||||
|
||||
7. **Explicit `setEnabled` and `setFocusPolicy(Qt.ClickFocus)` on `tab_page` (parent of `QListWidget` layout):**
|
||||
* This change **allowed programmatic selection via a diagnostic button to correctly fire `currentItemChanged` and update the UI**.
|
||||
* However, user mouse clicks still did not work and did not fire the signal.
|
||||
|
||||
8. **Event Filter Investigation:**
|
||||
* **Filter on `QListWidget`:** Did NOT receive mouse press/release events from user clicks.
|
||||
* **Filter on `tab_page` (parent of `QListWidget`'s layout):** Did NOT receive mouse press/release events.
|
||||
* **Filter on `self.tab_widget` (QTabWidget):** DID receive mouse press/release events.
|
||||
* Modified `self.tab_widget`'s event filter to return `False` for events over the current page, attempting to ensure propagation.
|
||||
* **Result:** With the modified `tab_widget` filter, an event filter re-added to `asset_type_list_widget` *did* start receiving mouse press/release events. **However, `asset_type_list_widget` still did not emit `currentItemChanged` from these user clicks.**
|
||||
|
||||
9. **`DebugListWidget` (Subclassing `QListWidget`):**
|
||||
* Created `DebugListWidget` overriding `mousePressEvent` with logging.
|
||||
* Used `DebugListWidget` for `asset_type_list_widget`.
|
||||
* **Initial user report indicated that `DebugListWidget.mousePressEvent` logs were NOT appearing for user clicks.** This means that even with the `QTabWidget` event filter attempting to propagate events, and the `asset_type_list_widget`'s filter (from step 8) confirming it received them, the `mousePressEvent` of the `QListWidget` itself was not being triggered by those propagated events. This is the current mystery.
|
||||
|
||||
**Current Status:**
|
||||
- Programmatic selection works and fires signals.
|
||||
- User clicks are received by an event filter on `asset_type_list_widget` (after `QTabWidget` filter modification) but do not result in `mousePressEvent` being called on the `QListWidget` (or `DebugListWidget`) itself, and thus no `currentItemChanged` signal is emitted.
|
||||
- The issue seems to be a very low-level event processing problem specifically for user mouse clicks within the `QListWidget` instances when they are children of the `QTabWidget` pages, even when events appear to reach the list widget via an event filter.
|
||||
|
||||
**Next Steps (When Resuming):**
|
||||
1. Re-verify the logs from the `DebugListWidget.mousePressEvent` test. If it's truly not being called despite its event filter seeing events, this is extremely unusual.
|
||||
2. Simplify the `_create_tab_pane` method drastically for one tab:
|
||||
* Remove the right-hand pane.
|
||||
* Add the `DebugListWidget` directly to the `tab_page`'s layout without the intermediate `left_pane_layout`.
|
||||
3. Consider if any styles applied to `QListWidget` or its parents via stylesheets could be interfering with hit testing or event processing (unlikely for this specific symptom, but possible).
|
||||
4. Explore alternative ways to populate/manage the `QListWidget` or its items if a subtle corruption is occurring.
|
||||
5. If all else fails, consider replacing the `QListWidget` with a `QListView` and a `QStringListModel` as a more fundamental change to see if the issue is specific to `QListWidget` in this context.
|
||||
@ -1,6 +1,6 @@
|
||||
# Asset Processing Utility
|
||||
|
||||
This tool streamlines the organisation of raw 3D asset source files from supplies (archives or folders) into a configurable library format.
|
||||
This tool streamlines the conversion 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.
|
||||
|
||||
## Features
|
||||
|
||||
Binary file not shown.
@ -1,57 +0,0 @@
|
||||
{
|
||||
"source_rules": [
|
||||
{
|
||||
"input_path": "BoucleChunky001.zip",
|
||||
"supplier_identifier": "Dinesen",
|
||||
"preset_name": "Dinesen",
|
||||
"assets": [
|
||||
{
|
||||
"asset_name": "BoucleChunky001",
|
||||
"asset_type": "Surface",
|
||||
"files": [
|
||||
{
|
||||
"file_path": "BoucleChunky001_AO_1K_METALNESS.png",
|
||||
"item_type": "MAP_AO",
|
||||
"target_asset_name_override": "BoucleChunky001"
|
||||
},
|
||||
{
|
||||
"file_path": "BoucleChunky001_COL_1K_METALNESS.png",
|
||||
"item_type": "MAP_COL",
|
||||
"target_asset_name_override": "BoucleChunky001"
|
||||
},
|
||||
{
|
||||
"file_path": "BoucleChunky001_DISP16_1K_METALNESS.png",
|
||||
"item_type": "MAP_DISP",
|
||||
"target_asset_name_override": "BoucleChunky001"
|
||||
},
|
||||
{
|
||||
"file_path": "BoucleChunky001_DISP_1K_METALNESS.png",
|
||||
"item_type": "EXTRA",
|
||||
"target_asset_name_override": "BoucleChunky001"
|
||||
},
|
||||
{
|
||||
"file_path": "BoucleChunky001_Fabric.png",
|
||||
"item_type": "EXTRA",
|
||||
"target_asset_name_override": "BoucleChunky001"
|
||||
},
|
||||
{
|
||||
"file_path": "BoucleChunky001_METALNESS_1K_METALNESS.png",
|
||||
"item_type": "MAP_METAL",
|
||||
"target_asset_name_override": "BoucleChunky001"
|
||||
},
|
||||
{
|
||||
"file_path": "BoucleChunky001_NRM_1K_METALNESS.png",
|
||||
"item_type": "MAP_NRM",
|
||||
"target_asset_name_override": "BoucleChunky001"
|
||||
},
|
||||
{
|
||||
"file_path": "BoucleChunky001_ROUGHNESS_1K_METALNESS.png",
|
||||
"item_type": "MAP_ROUGH",
|
||||
"target_asset_name_override": "BoucleChunky001"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -1,280 +0,0 @@
|
||||
{
|
||||
"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": []
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
@ -1,44 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,219 +0,0 @@
|
||||
{
|
||||
"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": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,267 +0,0 @@
|
||||
{
|
||||
"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."
|
||||
}
|
||||
@ -1,11 +0,0 @@
|
||||
{
|
||||
"Dimensiva": {
|
||||
"normal_map_type": "OpenGL"
|
||||
},
|
||||
"Dinesen": {
|
||||
"normal_map_type": "OpenGL"
|
||||
},
|
||||
"Poliigon": {
|
||||
"normal_map_type": "OpenGL"
|
||||
}
|
||||
}
|
||||
890
autotest.py
890
autotest.py
@ -1,890 +0,0 @@
|
||||
import argparse
|
||||
import sys
|
||||
import logging
|
||||
import logging.handlers
|
||||
import time
|
||||
import json
|
||||
import shutil # Import shutil for directory operations
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Any
|
||||
|
||||
from PySide6.QtCore import QCoreApplication, QTimer, Slot, QEventLoop, QObject, Signal
|
||||
from PySide6.QtWidgets import QApplication, QListWidgetItem
|
||||
|
||||
# Add project root to sys.path
|
||||
project_root = Path(__file__).resolve().parent
|
||||
if str(project_root) not in sys.path:
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
try:
|
||||
from main import App
|
||||
from gui.main_window import MainWindow
|
||||
from rule_structure import SourceRule # Assuming SourceRule is in rule_structure.py
|
||||
except ImportError as e:
|
||||
print(f"Error importing project modules: {e}")
|
||||
print(f"Ensure that the script is run from the project root or that the project root is in PYTHONPATH.")
|
||||
print(f"Current sys.path: {sys.path}")
|
||||
sys.exit(1)
|
||||
|
||||
# Global variable for the memory log handler
|
||||
autotest_memory_handler = None
|
||||
|
||||
# Custom Log Filter for Concise Output
|
||||
class InfoSummaryFilter(logging.Filter):
|
||||
# Keywords that identify INFO messages to *allow* for concise output
|
||||
SUMMARY_KEYWORDS_PRECISE = [
|
||||
"Test run completed",
|
||||
"Test succeeded",
|
||||
"Test failed",
|
||||
"Rule comparison successful",
|
||||
"Rule comparison failed",
|
||||
"ProcessingEngine finished. Summary:",
|
||||
"Autotest Context:",
|
||||
"Parsed CLI arguments:",
|
||||
"Prediction completed successfully.",
|
||||
"Processing completed.",
|
||||
"Signal 'all_tasks_finished' received",
|
||||
"final status:", # To catch "Asset '...' final status:"
|
||||
"User settings file not found:",
|
||||
"MainPanelWidget: Default output directory set to:",
|
||||
# Search related (as per original filter)
|
||||
"Searching logs for term",
|
||||
"Search term ",
|
||||
"Found ",
|
||||
"No tracebacks found in the logs.",
|
||||
"--- End Log Analysis ---",
|
||||
"Log analysis completed.",
|
||||
]
|
||||
# Patterns for case-insensitive rejection
|
||||
REJECT_PATTERNS_LOWER = [
|
||||
# Original debug prefixes (ensure these are still relevant or merge if needed)
|
||||
"debug:", "orchestrator_trace:", "configuration_debug:", "app_debug:", "output_org_debug:",
|
||||
# Iterative / Per-item / Per-file details / Intermediate steps
|
||||
": item ", # Catches "Asset '...', Item X/Y"
|
||||
"item successfully processed and saved",
|
||||
", file '", # Catches "Asset '...', File '...'"
|
||||
": processing regular map",
|
||||
": found source file:",
|
||||
": determined source bit depth:",
|
||||
"successfully processed regular map",
|
||||
"successfully created mergetaskdefinition",
|
||||
": preparing processing items",
|
||||
": finished preparing items. found",
|
||||
": starting core item processing loop",
|
||||
", task '",
|
||||
": processing merge task",
|
||||
"loaded from context:",
|
||||
"using dimensions from first loaded input",
|
||||
"successfully merged inputs into image",
|
||||
"successfully processed merge task",
|
||||
"mergedtaskprocessorstage result",
|
||||
"calling savevariantsstage",
|
||||
"savevariantsstage result",
|
||||
"adding final details to context",
|
||||
": finished core item processing loop",
|
||||
": copied variant",
|
||||
": copied extra file",
|
||||
": successfully organized",
|
||||
": output organization complete.",
|
||||
": metadata saved to",
|
||||
"worker thread: starting processing for rule:",
|
||||
"preparing workspace for input:",
|
||||
"input is a supported archive",
|
||||
"calling processingengine.process with rule",
|
||||
"calculated sha5 for",
|
||||
"calculated next incrementing value for",
|
||||
"verify: processingengine.process called",
|
||||
": effective supplier set to",
|
||||
": metadata initialized.",
|
||||
"path",
|
||||
"\\asset_processor",
|
||||
": file rules queued for processing",
|
||||
"successfully loaded base application settings",
|
||||
"successfully loaded and merged asset_type_definitions",
|
||||
"successfully loaded and merged file_type_definitions",
|
||||
"starting rule-based prediction for:",
|
||||
"rule-based prediction finished successfully for",
|
||||
"finished rule-based prediction run for",
|
||||
"updating model with rule-based results for source:",
|
||||
"debug task ",
|
||||
"worker thread: finished processing for rule:",
|
||||
"task finished signal received for",
|
||||
# Autotest step markers (not global summaries)
|
||||
]
|
||||
|
||||
def filter(self, record):
|
||||
# Allow CRITICAL, ERROR, WARNING unconditionally
|
||||
if record.levelno >= logging.WARNING:
|
||||
return True
|
||||
|
||||
if record.levelno == logging.INFO:
|
||||
msg = record.getMessage()
|
||||
msg_lower = msg.lower() # For case-insensitive pattern rejection
|
||||
|
||||
# 1. Explicitly REJECT if message contains verbose patterns (case-insensitive)
|
||||
for pattern in self.REJECT_PATTERNS_LOWER: # Use the new list
|
||||
if pattern in msg_lower:
|
||||
return False # Reject
|
||||
|
||||
# 2. Then, if not rejected, ALLOW only if message contains precise summary keywords
|
||||
for keyword in self.SUMMARY_KEYWORDS_PRECISE: # Use the new list
|
||||
if keyword in msg: # Original message for case-sensitive summary keywords if needed
|
||||
return True # Allow
|
||||
|
||||
# 3. Reject all other INFO messages that don't match precise summary keywords
|
||||
return False
|
||||
|
||||
# Reject levels below INFO (e.g., DEBUG) by default for this handler
|
||||
return False
|
||||
|
||||
# --- Root Logger Configuration for Concise Console Output ---
|
||||
def setup_autotest_logging():
|
||||
"""
|
||||
Configures the root logger for concise console output for autotest.py.
|
||||
This ensures that only essential summary information, warnings, and errors
|
||||
are displayed on the console by default.
|
||||
"""
|
||||
root_logger = logging.getLogger()
|
||||
|
||||
# 1. Remove all existing handlers from the root logger.
|
||||
# This prevents interference from other logging configurations.
|
||||
for handler in root_logger.handlers[:]:
|
||||
root_logger.removeHandler(handler)
|
||||
handler.close() # Close handler before removing
|
||||
|
||||
# 2. Set the root logger's level to DEBUG to capture everything for the memory handler.
|
||||
# The console handler will still filter down to INFO/selected.
|
||||
root_logger.setLevel(logging.DEBUG) # Changed from INFO to DEBUG
|
||||
|
||||
# 3. Create a new StreamHandler for sys.stdout (for concise console output).
|
||||
console_handler = logging.StreamHandler(sys.stdout)
|
||||
|
||||
# 4. Set this console handler's level to INFO.
|
||||
# The filter will then decide which INFO messages to display on console.
|
||||
console_handler.setLevel(logging.INFO)
|
||||
|
||||
# 5. Apply the enhanced InfoSummaryFilter to the console handler.
|
||||
info_filter = InfoSummaryFilter()
|
||||
console_handler.addFilter(info_filter)
|
||||
|
||||
# 6. Set a concise formatter for the console handler.
|
||||
formatter = logging.Formatter('[%(levelname)s] %(message)s')
|
||||
console_handler.setFormatter(formatter)
|
||||
|
||||
# 7. Add this newly configured console handler to the root_logger.
|
||||
root_logger.addHandler(console_handler)
|
||||
|
||||
# 8. Setup the MemoryHandler
|
||||
global autotest_memory_handler # Declare usage of global
|
||||
autotest_memory_handler = logging.handlers.MemoryHandler(
|
||||
capacity=20000, # Increased capacity
|
||||
flushLevel=logging.CRITICAL + 1, # Prevent automatic flushing
|
||||
target=None # Does not flush to another handler
|
||||
)
|
||||
autotest_memory_handler.setLevel(logging.DEBUG) # Capture all logs from DEBUG up
|
||||
# Not adding a formatter here, will format in _process_and_display_logs
|
||||
|
||||
# 9. Add the memory handler to the root logger.
|
||||
root_logger.addHandler(autotest_memory_handler)
|
||||
|
||||
# Call the setup function early in the script's execution.
|
||||
setup_autotest_logging()
|
||||
|
||||
# Logger for autotest.py's own messages.
|
||||
# Messages from this logger will propagate to the root logger and be filtered
|
||||
# by the console_handler configured above.
|
||||
# Setting its level to DEBUG allows autotest.py to generate DEBUG messages,
|
||||
# which won't appear on the concise console (due to handler's INFO level)
|
||||
# but can be captured by other handlers (e.g., the GUI's log console).
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.setLevel(logging.DEBUG) # Ensure autotest.py can generate DEBUGs for other handlers
|
||||
|
||||
# Note: The GUI's log console (e.g., self.main_window.log_console.log_console_output)
|
||||
# is assumed to capture all logs (including DEBUG) from various modules.
|
||||
# The _process_and_display_logs function then uses these comprehensive logs for the --search feature.
|
||||
# This root logger setup primarily makes autotest.py's direct console output concise,
|
||||
# ensuring that only filtered, high-level information appears on stdout by default.
|
||||
# --- End of Root Logger Configuration ---
|
||||
|
||||
# --- Argument Parsing ---
|
||||
def parse_arguments():
|
||||
"""Parses command-line arguments for the autotest script."""
|
||||
parser = argparse.ArgumentParser(description="Automated test script for Asset Processor GUI.")
|
||||
parser.add_argument(
|
||||
"--zipfile",
|
||||
type=Path,
|
||||
default=project_root / "TestFiles" / "BoucleChunky001.zip",
|
||||
help="Path to the test asset ZIP file. Default: TestFiles/BoucleChunky001.zip"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--preset",
|
||||
type=str,
|
||||
default="Dinesen", # This should match a preset name in the application
|
||||
help="Name of the preset to use. Default: Dinesen"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--expectedrules",
|
||||
type=Path,
|
||||
default=project_root / "TestFiles" / "Test-BoucleChunky001.json",
|
||||
help="Path to the JSON file with expected rules. Default: TestFiles/Test-BoucleChunky001.json"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--outputdir",
|
||||
type=Path,
|
||||
default=project_root / "TestFiles" / "TestOutputs" / "BoucleChunkyOutput",
|
||||
help="Path for processing output. Default: TestFiles/TestOutputs/BoucleChunkyOutput"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--search",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Optional log search term. Default: None"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--additional-lines",
|
||||
type=int,
|
||||
default=0,
|
||||
help="Context lines for log search. Default: 0"
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
class AutoTester(QObject):
|
||||
"""
|
||||
Handles the automated testing process for the Asset Processor GUI.
|
||||
"""
|
||||
# Define signals if needed, e.g., for specific test events
|
||||
# test_step_completed = Signal(str)
|
||||
|
||||
def __init__(self, app_instance: App, cli_args: argparse.Namespace):
|
||||
super().__init__()
|
||||
self.app_instance: App = app_instance
|
||||
self.main_window: MainWindow = app_instance.main_window
|
||||
self.cli_args: argparse.Namespace = cli_args
|
||||
self.event_loop = QEventLoop(self)
|
||||
self.prediction_poll_timer = QTimer(self)
|
||||
self.expected_rules_data: Dict[str, Any] = {}
|
||||
self.test_step: str = "INIT" # Possible values: INIT, LOADING_ZIP, SELECTING_PRESET, AWAITING_PREDICTION, PREDICTION_COMPLETE, COMPARING_RULES, STARTING_PROCESSING, AWAITING_PROCESSING, PROCESSING_COMPLETE, CHECKING_OUTPUT, ANALYZING_LOGS, DONE
|
||||
|
||||
if not self.main_window:
|
||||
logger.error("MainWindow instance not found in App. Cannot proceed.")
|
||||
self.cleanup_and_exit(success=False)
|
||||
return
|
||||
|
||||
# Connect signals
|
||||
if hasattr(self.app_instance, 'all_tasks_finished') and isinstance(self.app_instance.all_tasks_finished, Signal):
|
||||
self.app_instance.all_tasks_finished.connect(self._on_all_tasks_finished)
|
||||
else:
|
||||
logger.warning("App instance does not have 'all_tasks_finished' signal or it's not a Signal. Processing completion might not be detected.")
|
||||
|
||||
self._load_expected_rules()
|
||||
|
||||
def _load_expected_rules(self) -> None:
|
||||
"""Loads the expected rules from the JSON file specified by cli_args."""
|
||||
self.test_step = "LOADING_EXPECTED_RULES"
|
||||
logger.debug(f"Loading expected rules from: {self.cli_args.expectedrules}")
|
||||
try:
|
||||
with open(self.cli_args.expectedrules, 'r') as f:
|
||||
self.expected_rules_data = json.load(f)
|
||||
logger.debug("Expected rules loaded successfully.")
|
||||
except FileNotFoundError:
|
||||
logger.error(f"Expected rules file not found: {self.cli_args.expectedrules}")
|
||||
self.cleanup_and_exit(success=False)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Error decoding expected rules JSON: {e}")
|
||||
self.cleanup_and_exit(success=False)
|
||||
except Exception as e:
|
||||
logger.error(f"An unexpected error occurred while loading expected rules: {e}")
|
||||
self.cleanup_and_exit(success=False)
|
||||
|
||||
def run_test(self) -> None:
|
||||
"""Orchestrates the test steps."""
|
||||
# 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
|
||||
logger.error("Expected rules not loaded. Aborting test.")
|
||||
self.cleanup_and_exit(success=False)
|
||||
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
|
||||
# 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
|
||||
self.test_step = "LOADING_ZIP"
|
||||
logger.info(f"Step 1: Loading ZIP file: {self.cli_args.zipfile}") # KEEP INFO - Passes filter
|
||||
if not self.cli_args.zipfile.exists():
|
||||
logger.error(f"ZIP file not found: {self.cli_args.zipfile}")
|
||||
self.cleanup_and_exit(success=False)
|
||||
return
|
||||
try:
|
||||
# Assuming add_input_paths can take a list of strings or Path objects
|
||||
self.main_window.add_input_paths([str(self.cli_args.zipfile)])
|
||||
logger.debug("ZIP file loading initiated.")
|
||||
except Exception as e:
|
||||
logger.error(f"Error during ZIP file loading: {e}")
|
||||
self.cleanup_and_exit(success=False)
|
||||
return
|
||||
|
||||
# Step 2: Select Preset
|
||||
self.test_step = "SELECTING_PRESET"
|
||||
# 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_list_widget = self.main_window.preset_editor_widget.editor_preset_list
|
||||
for i in range(preset_list_widget.count()):
|
||||
item = preset_list_widget.item(i)
|
||||
if item and item.text() == preset_to_use: # Use preset_to_use
|
||||
preset_list_widget.setCurrentItem(item)
|
||||
logger.debug(f"Preset '{preset_to_use}' selected.")
|
||||
print(f"DEBUG: Successfully selected preset '{item.text()}' in GUI.")
|
||||
preset_found = True
|
||||
break
|
||||
if not preset_found:
|
||||
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())]
|
||||
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)
|
||||
return
|
||||
|
||||
# Step 3: Await Prediction Completion
|
||||
self.test_step = "AWAITING_PREDICTION"
|
||||
logger.debug("Step 3: Awaiting prediction completion...")
|
||||
self.prediction_poll_timer.timeout.connect(self._check_prediction_status)
|
||||
self.prediction_poll_timer.start(500) # Poll every 500ms
|
||||
|
||||
# Use a QTimer to allow event loop to process while waiting for this step
|
||||
# This ensures that the _check_prediction_status can be called.
|
||||
# We will exit this event_loop from _check_prediction_status when prediction is done.
|
||||
logger.debug("Starting event loop for prediction...")
|
||||
self.event_loop.exec() # This loop is quit by _check_prediction_status
|
||||
self.prediction_poll_timer.stop()
|
||||
logger.debug("Event loop for prediction finished.")
|
||||
|
||||
|
||||
if self.test_step != "PREDICTION_COMPLETE":
|
||||
logger.error(f"Prediction did not complete as expected. Current step: {self.test_step}")
|
||||
# Check if there were any pending predictions that never cleared
|
||||
if hasattr(self.main_window, '_pending_predictions'):
|
||||
logger.error(f"Pending predictions at timeout: {self.main_window._pending_predictions}")
|
||||
self.cleanup_and_exit(success=False)
|
||||
return
|
||||
logger.info("Prediction completed successfully.") # KEEP INFO - Passes filter
|
||||
|
||||
# Step 4: Retrieve & Compare Rulelist
|
||||
self.test_step = "COMPARING_RULES"
|
||||
logger.info("Step 4: Retrieving and Comparing Rules...") # KEEP INFO - Passes filter
|
||||
actual_source_rules_list: List[SourceRule] = self.main_window.unified_model.get_all_source_rules()
|
||||
actual_rules_obj = actual_source_rules_list # Keep the SourceRule list for processing
|
||||
|
||||
comparable_actual_rules = self._convert_rules_to_comparable(actual_source_rules_list)
|
||||
|
||||
if not self._compare_rules(comparable_actual_rules, self.expected_rules_data):
|
||||
logger.error("Rule comparison failed. See logs for details.")
|
||||
self.cleanup_and_exit(success=False)
|
||||
return
|
||||
logger.info("Rule comparison successful.") # KEEP INFO - Passes filter
|
||||
|
||||
# Step 5: Start Processing
|
||||
self.test_step = "START_PROCESSING"
|
||||
logger.info("Step 5: Starting Processing...") # KEEP INFO - Passes filter
|
||||
processing_settings = {
|
||||
"output_dir": str(self.cli_args.outputdir), # Ensure it's a string for JSON/config
|
||||
"overwrite": True,
|
||||
"workers": 1,
|
||||
"blender_enabled": False # Basic test, no Blender
|
||||
}
|
||||
try:
|
||||
Path(self.cli_args.outputdir).mkdir(parents=True, exist_ok=True)
|
||||
logger.debug(f"Ensured output directory exists: {self.cli_args.outputdir}")
|
||||
except Exception as e:
|
||||
logger.error(f"Could not create output directory {self.cli_args.outputdir}: {e}")
|
||||
self.cleanup_and_exit(success=False)
|
||||
return
|
||||
|
||||
if hasattr(self.main_window, 'start_backend_processing') and isinstance(self.main_window.start_backend_processing, Signal):
|
||||
logger.debug(f"Emitting start_backend_processing with rules count: {len(actual_rules_obj)} and settings: {processing_settings}")
|
||||
self.main_window.start_backend_processing.emit(actual_rules_obj, processing_settings)
|
||||
else:
|
||||
logger.error("'start_backend_processing' signal not found on MainWindow. Cannot start processing.")
|
||||
self.cleanup_and_exit(success=False)
|
||||
return
|
||||
|
||||
# Step 6: Await Processing Completion
|
||||
self.test_step = "AWAIT_PROCESSING"
|
||||
logger.debug("Step 6: Awaiting processing completion...")
|
||||
self.event_loop.exec() # This loop is quit by _on_all_tasks_finished
|
||||
|
||||
if self.test_step != "PROCESSING_COMPLETE":
|
||||
logger.error(f"Processing did not complete as expected. Current step: {self.test_step}")
|
||||
self.cleanup_and_exit(success=False)
|
||||
return
|
||||
logger.info("Processing completed.") # KEEP INFO - Passes filter
|
||||
|
||||
# Step 7: Check Output Path
|
||||
self.test_step = "CHECK_OUTPUT"
|
||||
logger.info(f"Step 7: Checking output path: {self.cli_args.outputdir}") # KEEP INFO - Passes filter
|
||||
output_path = Path(self.cli_args.outputdir)
|
||||
if not output_path.exists() or not output_path.is_dir():
|
||||
logger.error(f"Output directory {output_path} does not exist or is not a directory.")
|
||||
self.cleanup_and_exit(success=False)
|
||||
return
|
||||
|
||||
output_items = list(output_path.iterdir())
|
||||
if not output_items:
|
||||
logger.warning(f"Output directory {output_path} is empty. This might be a test failure depending on the case.")
|
||||
# For a more specific check, one might iterate through actual_rules_obj
|
||||
# and verify if subdirectories matching asset_name exist.
|
||||
# e.g. for asset_rule in source_rule.assets:
|
||||
# expected_asset_dir = output_path / asset_rule.asset_name
|
||||
# if not expected_asset_dir.is_dir(): logger.error(...)
|
||||
else:
|
||||
logger.debug(f"Found {len(output_items)} item(s) in output directory:")
|
||||
for item in output_items:
|
||||
logger.debug(f" - {item.name} ({'dir' if item.is_dir() else 'file'})")
|
||||
logger.info("Output path check completed.") # KEEP INFO - Passes filter
|
||||
|
||||
# Step 8: Retrieve & Analyze Logs
|
||||
self.test_step = "CHECK_LOGS"
|
||||
logger.debug("Step 8: Retrieving and Analyzing Logs...")
|
||||
all_logs_text = ""
|
||||
if self.main_window.log_console and self.main_window.log_console.log_console_output:
|
||||
all_logs_text = self.main_window.log_console.log_console_output.toPlainText()
|
||||
else:
|
||||
logger.warning("Log console or output widget not found. Cannot retrieve logs.")
|
||||
|
||||
|
||||
# Final Step
|
||||
logger.info("Test run completed successfully.") # KEEP INFO - Passes filter
|
||||
self.cleanup_and_exit(success=True)
|
||||
|
||||
@Slot()
|
||||
def _check_prediction_status(self) -> None:
|
||||
"""Polls the main window for pending predictions."""
|
||||
# logger.debug(f"Checking prediction status. Pending: {self.main_window._pending_predictions if hasattr(self.main_window, '_pending_predictions') else 'N/A'}")
|
||||
if hasattr(self.main_window, '_pending_predictions'):
|
||||
if not self.main_window._pending_predictions: # Assuming _pending_predictions is a list/dict that's empty when done
|
||||
logger.debug("No pending predictions. Prediction assumed complete.")
|
||||
self.test_step = "PREDICTION_COMPLETE"
|
||||
if self.event_loop.isRunning():
|
||||
self.event_loop.quit()
|
||||
# else:
|
||||
# logger.debug(f"Still awaiting predictions: {len(self.main_window._pending_predictions)} remaining.")
|
||||
else:
|
||||
logger.warning("'_pending_predictions' attribute not found on MainWindow. Cannot check prediction status automatically.")
|
||||
# As a fallback, if the attribute is missing, we might assume prediction is instant or needs manual check.
|
||||
# For now, let's assume it means it's done if the attribute is missing, but this is risky.
|
||||
# A better approach would be to have a clear signal from MainWindow when predictions are done.
|
||||
self.test_step = "PREDICTION_COMPLETE" # Risky assumption
|
||||
if self.event_loop.isRunning():
|
||||
self.event_loop.quit()
|
||||
|
||||
|
||||
@Slot(int, int, int)
|
||||
def _on_all_tasks_finished(self, processed_count: int, skipped_count: int, failed_count: int) -> None:
|
||||
"""Slot for App.all_tasks_finished signal."""
|
||||
logger.info(f"Signal 'all_tasks_finished' received: Processed={processed_count}, Skipped={skipped_count}, Failed={failed_count}") # KEEP INFO - Passes filter
|
||||
|
||||
if self.test_step == "AWAIT_PROCESSING":
|
||||
logger.debug("Processing completion signal received.") # Covered by the summary log above
|
||||
if failed_count > 0:
|
||||
logger.error(f"Processing finished with {failed_count} failed task(s).")
|
||||
# Even if tasks failed, the test might pass based on output checks.
|
||||
# The error is logged for information.
|
||||
self.test_step = "PROCESSING_COMPLETE"
|
||||
if self.event_loop.isRunning():
|
||||
self.event_loop.quit()
|
||||
else:
|
||||
logger.warning(f"Signal 'all_tasks_finished' received at an unexpected test step: '{self.test_step}'. Counts: P={processed_count}, S={skipped_count}, F={failed_count}")
|
||||
|
||||
|
||||
def _convert_rules_to_comparable(self, source_rules_list: List[SourceRule]) -> Dict[str, Any]:
|
||||
"""
|
||||
Converts a list of SourceRule objects to a dictionary structure
|
||||
suitable for comparison with the expected_rules.json.
|
||||
"""
|
||||
logger.debug(f"Converting {len(source_rules_list)} SourceRule objects to comparable dictionary...")
|
||||
comparable_sources_list = []
|
||||
for source_rule_obj in source_rules_list:
|
||||
comparable_asset_list = []
|
||||
# source_rule_obj.assets is List[AssetRule]
|
||||
for asset_rule_obj in source_rule_obj.assets:
|
||||
comparable_file_list = []
|
||||
# asset_rule_obj.files is List[FileRule]
|
||||
for file_rule_obj in asset_rule_obj.files:
|
||||
comparable_file_list.append({
|
||||
"file_path": file_rule_obj.file_path,
|
||||
"item_type": file_rule_obj.item_type,
|
||||
"target_asset_name_override": file_rule_obj.target_asset_name_override
|
||||
})
|
||||
comparable_asset_list.append({
|
||||
"asset_name": asset_rule_obj.asset_name,
|
||||
"asset_type": asset_rule_obj.asset_type,
|
||||
"files": comparable_file_list
|
||||
})
|
||||
comparable_sources_list.append({
|
||||
"input_path": Path(source_rule_obj.input_path).name, # Use only the filename
|
||||
"supplier_identifier": source_rule_obj.supplier_identifier,
|
||||
"preset_name": source_rule_obj.preset_name, # This is the actual preset name from the SourceRule object
|
||||
"assets": comparable_asset_list
|
||||
})
|
||||
logger.debug("Conversion to comparable dictionary finished.")
|
||||
return {"source_rules": comparable_sources_list}
|
||||
|
||||
def _compare_rule_item(self, actual_item: Dict[str, Any], expected_item: Dict[str, Any], item_type_name: str, parent_context: str = "") -> bool:
|
||||
"""
|
||||
Recursively compares an individual actual rule item dictionary with an expected rule item dictionary.
|
||||
Logs differences and returns True if they match, False otherwise.
|
||||
"""
|
||||
item_match = True
|
||||
|
||||
identifier = ""
|
||||
if item_type_name == "SourceRule":
|
||||
identifier = expected_item.get('input_path', f'UnknownSource_at_{parent_context}')
|
||||
elif item_type_name == "AssetRule":
|
||||
identifier = expected_item.get('asset_name', f'UnknownAsset_at_{parent_context}')
|
||||
elif item_type_name == "FileRule":
|
||||
identifier = expected_item.get('file_path', f'UnknownFile_at_{parent_context}')
|
||||
|
||||
current_context = f"{parent_context}/{identifier}" if parent_context else identifier
|
||||
|
||||
# Log Extra Fields: Iterate through keys in actual_item.
|
||||
# If a key is in actual_item but not in expected_item (and is not a list container like "assets" or "files"),
|
||||
# log this as an informational message.
|
||||
for key in actual_item.keys():
|
||||
if key not in expected_item and key not in ["assets", "files"]:
|
||||
logger.debug(f"Field '{key}' present in actual {item_type_name} ({current_context}) but not specified in expected. Value: '{actual_item[key]}'")
|
||||
|
||||
# Check Expected Fields: Iterate through keys in expected_item.
|
||||
for key, expected_value in expected_item.items():
|
||||
if key not in actual_item:
|
||||
logger.error(f"Missing expected field '{key}' in actual {item_type_name} ({current_context}).")
|
||||
item_match = False
|
||||
continue # Continue to check other fields in the expected_item
|
||||
|
||||
actual_value = actual_item[key]
|
||||
|
||||
if key == "assets": # List of AssetRule dictionaries
|
||||
if not self._compare_list_of_rules(actual_value, expected_value, "AssetRule", current_context, "asset_name"):
|
||||
item_match = False
|
||||
elif key == "files": # List of FileRule dictionaries
|
||||
if not self._compare_list_of_rules(actual_value, expected_value, "FileRule", current_context, "file_path"):
|
||||
item_match = False
|
||||
else: # Regular field comparison
|
||||
if actual_value != expected_value:
|
||||
# Handle None vs "None" string for preset_name specifically if it's a common issue
|
||||
if key == "preset_name" and actual_value is None and expected_value == "None":
|
||||
logger.debug(f"Field '{key}' in {item_type_name} ({current_context}): Actual is None, Expected is string \"None\". Treating as match for now.")
|
||||
elif key == "target_asset_name_override" and actual_value is not None and expected_value is None:
|
||||
# If actual has a value (e.g. parent asset name) and expected is null/None,
|
||||
# this is a mismatch according to strict comparison.
|
||||
# For a more lenient check, this logic could be adjusted here.
|
||||
# Current strict comparison will flag this as error, which is what the logs show.
|
||||
logger.error(f"Value mismatch for field '{key}' in {item_type_name} ({current_context}): Actual='{actual_value}', Expected='{expected_value}'.")
|
||||
item_match = False
|
||||
else:
|
||||
logger.error(f"Value mismatch for field '{key}' in {item_type_name} ({current_context}): Actual='{actual_value}', Expected='{expected_value}'.")
|
||||
item_match = False
|
||||
|
||||
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:
|
||||
"""
|
||||
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').
|
||||
Order independent for matching, but logs count mismatches.
|
||||
"""
|
||||
list_match = True
|
||||
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.")
|
||||
return False
|
||||
|
||||
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)}.")
|
||||
list_match = False # Count mismatch is an error
|
||||
# If counts differ, we still try to match what we can to provide more detailed feedback,
|
||||
# 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
|
||||
matched_expected_keys = set()
|
||||
|
||||
for expected_item in expected_list:
|
||||
expected_key_value = expected_item.get(item_key_field)
|
||||
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)
|
||||
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
|
||||
|
||||
|
||||
return list_match
|
||||
|
||||
|
||||
def _compare_rules(self, actual_rules_data: Dict[str, Any], expected_rules_data: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
Compares the actual rule data (converted from live SourceRule objects)
|
||||
with the expected rule data (loaded from JSON).
|
||||
"""
|
||||
logger.debug("Comparing actual rules with expected rules...")
|
||||
|
||||
actual_source_rules = actual_rules_data.get("source_rules", []) if actual_rules_data else []
|
||||
expected_source_rules = expected_rules_data.get("source_rules", []) if expected_rules_data else []
|
||||
|
||||
if not isinstance(actual_source_rules, list):
|
||||
logger.error(f"Actual 'source_rules' is not a list. Found type: {type(actual_source_rules)}. Comparison aborted.")
|
||||
return False # Cannot compare if actual data is malformed
|
||||
if not isinstance(expected_source_rules, list):
|
||||
logger.error(f"Expected 'source_rules' is not a list. Found type: {type(expected_source_rules)}. Test configuration error. Comparison aborted.")
|
||||
return False # Test setup error
|
||||
|
||||
if not expected_source_rules and not actual_source_rules:
|
||||
logger.debug("Both expected and actual source rules lists are empty. Considered a match.")
|
||||
return True
|
||||
|
||||
if len(actual_source_rules) != len(expected_source_rules):
|
||||
logger.error(f"Mismatch in the number of source rules. Actual: {len(actual_source_rules)}, Expected: {len(expected_source_rules)}.")
|
||||
# Optionally, log more details about which list is longer/shorter or identifiers if available
|
||||
return False
|
||||
|
||||
overall_match_status = True
|
||||
for i in range(len(expected_source_rules)):
|
||||
actual_sr = actual_source_rules[i]
|
||||
expected_sr = expected_source_rules[i]
|
||||
|
||||
# For context, use input_path or an index
|
||||
source_rule_context = expected_sr.get('input_path', f"SourceRule_index_{i}")
|
||||
|
||||
if not self._compare_rule_item(actual_sr, expected_sr, "SourceRule", parent_context=source_rule_context):
|
||||
overall_match_status = False
|
||||
# Continue checking other source rules to log all discrepancies
|
||||
|
||||
if overall_match_status:
|
||||
logger.debug("All rules match the expected criteria.") # Covered by "Rule comparison successful" summary
|
||||
else:
|
||||
logger.warning("One or more rules did not match the expected criteria. See logs above for details.")
|
||||
|
||||
return overall_match_status
|
||||
|
||||
def _process_and_display_logs(self, logs_text: str) -> None: # logs_text is no longer the primary source for search
|
||||
"""
|
||||
Processes and displays logs, potentially filtering them if --search is used.
|
||||
Also checks for tracebacks.
|
||||
Sources logs from the in-memory handler for search and detailed analysis.
|
||||
"""
|
||||
logger.debug("--- Log Analysis ---")
|
||||
global autotest_memory_handler # Access the global handler
|
||||
log_records = []
|
||||
if autotest_memory_handler and autotest_memory_handler.buffer:
|
||||
log_records = autotest_memory_handler.buffer
|
||||
|
||||
formatted_log_lines = []
|
||||
# Define a consistent formatter, similar to what might be expected or useful for search
|
||||
record_formatter = logging.Formatter('%(asctime)s [%(levelname)s] %(name)s: %(message)s')
|
||||
# Default asctime format includes milliseconds.
|
||||
|
||||
|
||||
for record in log_records:
|
||||
formatted_log_lines.append(record_formatter.format(record))
|
||||
|
||||
lines_for_search_and_traceback = formatted_log_lines
|
||||
|
||||
if not lines_for_search_and_traceback:
|
||||
logger.warning("No log records found in memory handler. No analysis to perform.")
|
||||
# Still check the console logs_text for tracebacks if it exists, as a fallback
|
||||
# or if some critical errors didn't make it to the memory handler (unlikely with DEBUG level)
|
||||
if logs_text:
|
||||
logger.debug("Checking provided logs_text (from console) for tracebacks as a fallback.")
|
||||
console_lines = logs_text.splitlines()
|
||||
traceback_found_console = False
|
||||
for i, line in enumerate(console_lines):
|
||||
if line.strip().startswith("Traceback (most recent call last):"):
|
||||
logger.error(f"!!! TRACEBACK DETECTED in console logs_text around line {i+1} !!!")
|
||||
traceback_found_console = True
|
||||
if traceback_found_console:
|
||||
logger.warning("A traceback was found in the console logs_text.")
|
||||
else:
|
||||
logger.info("No tracebacks found in the console logs_text either.")
|
||||
logger.info("--- End Log Analysis ---")
|
||||
return
|
||||
|
||||
traceback_found = False
|
||||
|
||||
if self.cli_args.search:
|
||||
logger.info(f"Searching {len(lines_for_search_and_traceback)} in-memory log lines for term '{self.cli_args.search}' with {self.cli_args.additional_lines} context lines.")
|
||||
matched_line_indices = [i for i, line in enumerate(lines_for_search_and_traceback) if self.cli_args.search in line]
|
||||
|
||||
if not matched_line_indices:
|
||||
logger.info(f"Search term '{self.cli_args.search}' not found in in-memory logs.")
|
||||
else:
|
||||
logger.info(f"Found {len(matched_line_indices)} match(es) for '{self.cli_args.search}' in in-memory logs:")
|
||||
collected_lines_to_print = set()
|
||||
for match_idx in matched_line_indices:
|
||||
start_idx = max(0, match_idx - self.cli_args.additional_lines)
|
||||
end_idx = min(len(lines_for_search_and_traceback), match_idx + self.cli_args.additional_lines + 1)
|
||||
for i in range(start_idx, end_idx):
|
||||
# Use i directly as index for lines_for_search_and_traceback, line number is for display
|
||||
collected_lines_to_print.add(f"L{i+1:05d}: {lines_for_search_and_traceback[i]}")
|
||||
|
||||
print("--- Filtered Log Output (from Memory Handler) ---")
|
||||
for line_to_print in sorted(list(collected_lines_to_print)):
|
||||
print(line_to_print)
|
||||
print("--- End Filtered Log Output ---")
|
||||
# Removed: else block that showed last N lines by default (as per original instruction for this section)
|
||||
|
||||
# Traceback Check (on lines_for_search_and_traceback)
|
||||
for i, line in enumerate(lines_for_search_and_traceback):
|
||||
if line.strip().startswith("Traceback (most recent call last):") or "Traceback (most recent call last):" in line : # More robust check
|
||||
logger.error(f"!!! TRACEBACK DETECTED in in-memory logs around line index {i} !!!")
|
||||
logger.error(f"Line content: {line}")
|
||||
traceback_found = True
|
||||
|
||||
if traceback_found:
|
||||
logger.warning("A traceback was found in the in-memory logs. This usually indicates a significant issue.")
|
||||
else:
|
||||
logger.info("No tracebacks found in the in-memory logs.") # This refers to the comprehensive memory logs
|
||||
|
||||
logger.info("--- End Log Analysis ---")
|
||||
|
||||
def cleanup_and_exit(self, success: bool = True) -> None:
|
||||
"""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
|
||||
if autotest_memory_handler:
|
||||
logger.debug("Clearing memory log handler buffer and removing handler.")
|
||||
autotest_memory_handler.buffer = [] # Clear buffer
|
||||
logging.getLogger().removeHandler(autotest_memory_handler) # Remove handler
|
||||
autotest_memory_handler.close() # MemoryHandler close is a no-op but good practice
|
||||
autotest_memory_handler = None
|
||||
|
||||
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()
|
||||
if q_app:
|
||||
q_app.quit()
|
||||
sys.exit(0 if success else 1)
|
||||
|
||||
# --- Main Execution ---
|
||||
def main():
|
||||
"""Main function to run the autotest script."""
|
||||
cli_args = parse_arguments()
|
||||
# Logger is configured above, this will now use the new filtered setup
|
||||
#logger.info(f"Parsed CLI arguments: {cli_args}") # KEEP INFO - Passes filter
|
||||
|
||||
# Clean and ensure output directory exists
|
||||
output_dir_path = Path(cli_args.outputdir)
|
||||
logger.debug(f"Preparing output directory: {output_dir_path}")
|
||||
try:
|
||||
if output_dir_path.exists():
|
||||
logger.debug(f"Output directory {output_dir_path} exists. Cleaning its contents...")
|
||||
for item in output_dir_path.iterdir():
|
||||
if item.is_dir():
|
||||
shutil.rmtree(item)
|
||||
logger.debug(f"Removed directory: {item}")
|
||||
else:
|
||||
item.unlink()
|
||||
logger.debug(f"Removed file: {item}")
|
||||
logger.debug(f"Contents of {output_dir_path} cleaned.")
|
||||
else:
|
||||
logger.debug(f"Output directory {output_dir_path} does not exist. Creating it.")
|
||||
|
||||
output_dir_path.mkdir(parents=True, exist_ok=True) # Ensure it exists after cleaning/if it didn't exist
|
||||
logger.debug(f"Output directory {output_dir_path} is ready.")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Could not prepare output directory {output_dir_path}: {e}", exc_info=True)
|
||||
sys.exit(1)
|
||||
|
||||
# Initialize QApplication
|
||||
# Use QCoreApplication if no GUI elements are directly interacted with by the test logic itself,
|
||||
# but QApplication is needed if MainWindow or its widgets are constructed and used.
|
||||
# Since MainWindow is instantiated by App, QApplication is appropriate.
|
||||
q_app = QApplication.instance()
|
||||
if not q_app:
|
||||
q_app = QApplication(sys.argv)
|
||||
if not q_app: # Still no app
|
||||
logger.error("Failed to initialize QApplication.")
|
||||
sys.exit(1)
|
||||
|
||||
logger.debug("Initializing main.App()...")
|
||||
try:
|
||||
# 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.
|
||||
# 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:
|
||||
logger.error(f"Failed to initialize main.App or load preset: {e}", exc_info=True)
|
||||
sys.exit(1)
|
||||
|
||||
if not app_instance.main_window:
|
||||
logger.error("main.App initialized, but main_window is None. Cannot proceed with test.")
|
||||
sys.exit(1)
|
||||
|
||||
logger.debug("Initializing AutoTester...")
|
||||
try:
|
||||
tester = AutoTester(app_instance, cli_args)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize AutoTester: {e}", exc_info=True)
|
||||
sys.exit(1)
|
||||
|
||||
# Use QTimer.singleShot to start the test after the Qt event loop has started.
|
||||
# This ensures that the Qt environment is fully set up.
|
||||
logger.debug("Scheduling test run...")
|
||||
QTimer.singleShot(0, tester.run_test)
|
||||
|
||||
logger.debug("Starting Qt application event loop...")
|
||||
exit_code = q_app.exec()
|
||||
logger.debug(f"Qt application event loop finished with exit code: {exit_code}")
|
||||
sys.exit(exit_code)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -1,167 +0,0 @@
|
||||
# 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.
|
||||
@ -1,4 +1,246 @@
|
||||
{
|
||||
"ASSET_TYPE_DEFINITIONS": {
|
||||
"Surface": {
|
||||
"description": "A single Standard PBR material set for a surface.",
|
||||
"color": "#1f3e5d",
|
||||
"examples": [
|
||||
"Set: Wood01_COL + Wood01_NRM + WOOD01_ROUGH",
|
||||
"Set: Dif_Concrete + Normal_Concrete + Refl_Concrete"
|
||||
]
|
||||
},
|
||||
"Model": {
|
||||
"description": "A set that contains models, can include PBR textureset",
|
||||
"color": "#b67300",
|
||||
"examples": [
|
||||
"Single = Chair.fbx",
|
||||
"Set = Plant02.fbx + Plant02_col + Plant02_SSS"
|
||||
]
|
||||
},
|
||||
"Decal": {
|
||||
"description": "A alphamasked textureset",
|
||||
"color": "#68ac68",
|
||||
"examples": [
|
||||
"Set = DecalGraffiti01_Col + DecalGraffiti01_Alpha",
|
||||
"Single = DecalLeakStain03"
|
||||
]
|
||||
},
|
||||
"Atlas": {
|
||||
"description": "A texture, name usually hints that it's an atlas",
|
||||
"color": "#955b8b",
|
||||
"examples": [
|
||||
"Set = FoliageAtlas01_col + FoliageAtlas01_nrm"
|
||||
]
|
||||
},
|
||||
"UtilityMap": {
|
||||
"description": "A useful image-asset consisting of only a single texture. Therefor each Utilitymap can only contain a single item.",
|
||||
"color": "#706b87",
|
||||
"examples": [
|
||||
"Single = imperfection.png",
|
||||
"Single = smudges.png",
|
||||
"Single = scratches.tif"
|
||||
]
|
||||
}
|
||||
},
|
||||
"FILE_TYPE_DEFINITIONS": {
|
||||
"MAP_COL": {
|
||||
"description": "Color/Albedo Map",
|
||||
"color": "#ffaa00",
|
||||
"examples": [
|
||||
"_col.",
|
||||
"_basecolor.",
|
||||
"albedo",
|
||||
"diffuse"
|
||||
],
|
||||
"standard_type": "COL",
|
||||
"bit_depth_rule": "force_8bit",
|
||||
"is_grayscale": false,
|
||||
"keybind": "C"
|
||||
},
|
||||
"MAP_NRM": {
|
||||
"description": "Normal Map",
|
||||
"color": "#cca2f1",
|
||||
"examples": [
|
||||
"_nrm.",
|
||||
"_normal."
|
||||
],
|
||||
"standard_type": "NRM",
|
||||
"bit_depth_rule": "respect",
|
||||
"is_grayscale": false,
|
||||
"keybind": "N"
|
||||
},
|
||||
"MAP_METAL": {
|
||||
"description": "Metalness Map",
|
||||
"color": "#dcf4f2",
|
||||
"examples": [
|
||||
"_metal.",
|
||||
"_met."
|
||||
],
|
||||
"standard_type": "METAL",
|
||||
"bit_depth_rule": "force_8bit",
|
||||
"is_grayscale": true,
|
||||
"keybind": "M"
|
||||
},
|
||||
"MAP_ROUGH": {
|
||||
"description": "Roughness Map",
|
||||
"color": "#bfd6bf",
|
||||
"examples": [
|
||||
"_rough.",
|
||||
"_rgh.",
|
||||
"_gloss"
|
||||
],
|
||||
"standard_type": "ROUGH",
|
||||
"bit_depth_rule": "force_8bit",
|
||||
"is_grayscale": true,
|
||||
"keybind": "R"
|
||||
},
|
||||
"MAP_GLOSS": {
|
||||
"description": "Glossiness Map",
|
||||
"color": "#d6bfd6",
|
||||
"examples": [
|
||||
"_gloss.",
|
||||
"_gls."
|
||||
],
|
||||
"standard_type": "GLOSS",
|
||||
"bit_depth_rule": "force_8bit",
|
||||
"is_grayscale": true,
|
||||
"keybind": "R"
|
||||
},
|
||||
"MAP_AO": {
|
||||
"description": "Ambient Occlusion Map",
|
||||
"color": "#e3c7c7",
|
||||
"examples": [
|
||||
"_ao.",
|
||||
"_ambientocclusion."
|
||||
],
|
||||
"standard_type": "AO",
|
||||
"bit_depth_rule": "force_8bit",
|
||||
"is_grayscale": true
|
||||
},
|
||||
"MAP_DISP": {
|
||||
"description": "Displacement/Height Map",
|
||||
"color": "#c6ddd5",
|
||||
"examples": [
|
||||
"_disp.",
|
||||
"_height."
|
||||
],
|
||||
"standard_type": "DISP",
|
||||
"bit_depth_rule": "respect",
|
||||
"is_grayscale": true,
|
||||
"keybind": "D"
|
||||
},
|
||||
"MAP_REFL": {
|
||||
"description": "Reflection/Specular Map",
|
||||
"color": "#c2c2b9",
|
||||
"examples": [
|
||||
"_refl.",
|
||||
"_specular."
|
||||
],
|
||||
"standard_type": "REFL",
|
||||
"bit_depth_rule": "force_8bit",
|
||||
"is_grayscale": true,
|
||||
"keybind": "M"
|
||||
},
|
||||
"MAP_SSS": {
|
||||
"description": "Subsurface Scattering Map",
|
||||
"color": "#a0d394",
|
||||
"examples": [
|
||||
"_sss.",
|
||||
"_subsurface."
|
||||
],
|
||||
"standard_type": "SSS",
|
||||
"bit_depth_rule": "respect",
|
||||
"is_grayscale": true
|
||||
},
|
||||
"MAP_FUZZ": {
|
||||
"description": "Fuzz/Sheen Map",
|
||||
"color": "#a2d1da",
|
||||
"examples": [
|
||||
"_fuzz.",
|
||||
"_sheen."
|
||||
],
|
||||
"standard_type": "FUZZ",
|
||||
"bit_depth_rule": "force_8bit",
|
||||
"is_grayscale": true
|
||||
},
|
||||
"MAP_IDMAP": {
|
||||
"description": "ID Map (for masking)",
|
||||
"color": "#ca8fb4",
|
||||
"examples": [
|
||||
"_id.",
|
||||
"_matid."
|
||||
],
|
||||
"standard_type": "IDMAP",
|
||||
"bit_depth_rule": "force_8bit",
|
||||
"is_grayscale": false
|
||||
},
|
||||
"MAP_MASK": {
|
||||
"description": "Generic Mask Map",
|
||||
"color": "#c6e2bf",
|
||||
"examples": [
|
||||
"_mask."
|
||||
],
|
||||
"standard_type": "MASK",
|
||||
"bit_depth_rule": "force_8bit",
|
||||
"is_grayscale": true
|
||||
},
|
||||
"MAP_IMPERFECTION": {
|
||||
"description": "Imperfection Map (scratches, dust)",
|
||||
"color": "#e6d1a6",
|
||||
"examples": [
|
||||
"_imp.",
|
||||
"_imperfection.",
|
||||
"splatter",
|
||||
"scratches",
|
||||
"smudges",
|
||||
"hairs",
|
||||
"fingerprints"
|
||||
],
|
||||
"standard_type": "IMPERFECTION",
|
||||
"bit_depth_rule": "force_8bit",
|
||||
"is_grayscale": true
|
||||
},
|
||||
"MODEL": {
|
||||
"description": "3D Model File",
|
||||
"color": "#3db2bd",
|
||||
"examples": [
|
||||
".fbx",
|
||||
".obj"
|
||||
],
|
||||
"standard_type": "",
|
||||
"bit_depth_rule": "",
|
||||
"is_grayscale": false
|
||||
},
|
||||
"EXTRA": {
|
||||
"description": "asset previews or metadata",
|
||||
"color": "#8c8c8c",
|
||||
"examples": [
|
||||
".txt",
|
||||
".zip",
|
||||
"preview.",
|
||||
"_flat.",
|
||||
"_sphere.",
|
||||
"_Cube.",
|
||||
"thumb"
|
||||
],
|
||||
"standard_type": "",
|
||||
"bit_depth_rule": "",
|
||||
"is_grayscale": false,
|
||||
"keybind": "E"
|
||||
},
|
||||
"FILE_IGNORE": {
|
||||
"description": "File to be ignored",
|
||||
"color": "#673d35",
|
||||
"examples": [
|
||||
"Thumbs.db",
|
||||
".DS_Store"
|
||||
],
|
||||
"standard_type": "",
|
||||
"bit_depth_rule": "",
|
||||
"is_grayscale": false,
|
||||
"keybind": "X"
|
||||
}
|
||||
},
|
||||
"TARGET_FILENAME_PATTERN": "{base_name}_{map_type}_{resolution}.{ext}",
|
||||
"RESPECT_VARIANT_MAP_TYPES": [
|
||||
"COL"
|
||||
],
|
||||
@ -37,7 +279,7 @@
|
||||
"G": 0.5,
|
||||
"B": 0.5
|
||||
},
|
||||
"bit_depth_policy": "preserve"
|
||||
"output_bit_depth": "respect_inputs"
|
||||
}
|
||||
],
|
||||
"CALCULATE_STATS_RESOLUTION": "1K",
|
||||
@ -45,10 +287,7 @@
|
||||
"TEMP_DIR_PREFIX": "_PROCESS_ASSET_",
|
||||
"INITIAL_SCALING_MODE": "POT_DOWNSCALE",
|
||||
"MERGE_DIMENSION_MISMATCH_STRATEGY": "USE_LARGEST",
|
||||
"ENABLE_LOW_RESOLUTION_FALLBACK": true,
|
||||
"LOW_RESOLUTION_THRESHOLD": 512,
|
||||
"general_settings": {
|
||||
"invert_normal_map_green_channel_globally": false,
|
||||
"app_version": "Pre-Alpha"
|
||||
"invert_normal_map_green_channel_globally": false
|
||||
}
|
||||
}
|
||||
@ -1,44 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,221 +0,0 @@
|
||||
{
|
||||
"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_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_NRMRGH": {
|
||||
"bit_depth_policy": "preserve",
|
||||
"color": "#abcdef",
|
||||
"description": "Packed Normal + Roughness + Metallic Map",
|
||||
"examples": [
|
||||
"_nrmrgh."
|
||||
],
|
||||
"is_grayscale": false,
|
||||
"keybind": "",
|
||||
"standard_type": "NRMRGH"
|
||||
},
|
||||
"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": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -3,256 +3,256 @@
|
||||
{
|
||||
"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"
|
||||
"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"
|
||||
"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"
|
||||
"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"
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@ -1,11 +1,5 @@
|
||||
{
|
||||
"Dimensiva": {
|
||||
"normal_map_type": "OpenGL"
|
||||
},
|
||||
"Dinesen": {
|
||||
"normal_map_type": "OpenGL"
|
||||
},
|
||||
"Poliigon": {
|
||||
"normal_map_type": "OpenGL"
|
||||
}
|
||||
}
|
||||
[
|
||||
"Dimensiva",
|
||||
"Dinesen",
|
||||
"Poliigon"
|
||||
]
|
||||
735
configuration.py
735
configuration.py
@ -1,42 +1,20 @@
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
import logging
|
||||
import re
|
||||
import collections.abc
|
||||
from typing import Optional, Union
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# This BASE_DIR is primarily for fallback when not bundled or for locating bundled resources relative to the script.
|
||||
_SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
BASE_DIR = Path(__file__).parent
|
||||
APP_SETTINGS_PATH = BASE_DIR / "config" / "app_settings.json"
|
||||
LLM_SETTINGS_PATH = BASE_DIR / "config" / "llm_settings.json"
|
||||
PRESETS_DIR = BASE_DIR / "Presets"
|
||||
|
||||
class ConfigurationError(Exception):
|
||||
"""Custom exception for configuration loading errors."""
|
||||
pass
|
||||
|
||||
def _get_user_config_path_placeholder() -> Optional[Path]:
|
||||
"""
|
||||
Placeholder function. In a real scenario, this would retrieve the
|
||||
saved user configuration path (e.g., from a settings file).
|
||||
Returns None if not set, triggering first-time setup behavior.
|
||||
"""
|
||||
# For this subtask, we assume this path is determined externally and passed to Configuration.
|
||||
# If we were to implement the settings.ini check here, it would look like:
|
||||
# try:
|
||||
# app_data_dir = Path(os.getenv('APPDATA')) / "AssetProcessor"
|
||||
# settings_ini = app_data_dir / "settings.ini"
|
||||
# if settings_ini.exists():
|
||||
# with open(settings_ini, 'r') as f:
|
||||
# path_str = f.read().strip()
|
||||
# return Path(path_str)
|
||||
# except Exception:
|
||||
# return None
|
||||
return None
|
||||
|
||||
|
||||
def _get_base_map_type(target_map_string: str) -> str:
|
||||
"""Extracts the base map type (e.g., 'COL') from a potentially numbered string ('COL-1')."""
|
||||
# Use regex to find the leading alphabetical part
|
||||
@ -86,310 +64,29 @@ def _fnmatch_to_regex(pattern: str) -> str:
|
||||
# For filename matching, we usually want to find the pattern, not match the whole string.
|
||||
return res
|
||||
|
||||
def _deep_merge_dicts(base_dict: dict, override_dict: dict) -> dict:
|
||||
"""
|
||||
Recursively merges override_dict into base_dict.
|
||||
If a key exists in both and both values are dicts, it recursively merges them.
|
||||
Otherwise, the value from override_dict takes precedence.
|
||||
Modifies base_dict in place and returns it.
|
||||
"""
|
||||
for key, value in override_dict.items():
|
||||
if isinstance(value, collections.abc.Mapping):
|
||||
node = base_dict.get(key) # Use .get() to avoid creating empty dicts if not needed for override
|
||||
if isinstance(node, collections.abc.Mapping):
|
||||
_deep_merge_dicts(node, value) # node is base_dict[key], modified in place
|
||||
else:
|
||||
# If base_dict[key] is not a dict or doesn't exist, override it
|
||||
base_dict[key] = value
|
||||
else:
|
||||
base_dict[key] = value
|
||||
return base_dict
|
||||
|
||||
|
||||
class Configuration:
|
||||
"""
|
||||
Loads and provides access to core settings combined with a specific preset,
|
||||
managing bundled and user-specific configuration paths.
|
||||
Loads and provides access to core settings combined with a specific preset.
|
||||
"""
|
||||
BASE_DIR_APP_BUNDLED_CONFIG_SUBDIR_NAME = "config"
|
||||
PRESETS_DIR_APP_BUNDLED_NAME = "Presets"
|
||||
USER_SETTINGS_FILENAME = "user_settings.json"
|
||||
APP_SETTINGS_FILENAME = "app_settings.json"
|
||||
ASSET_TYPE_DEFINITIONS_FILENAME = "asset_type_definitions.json"
|
||||
FILE_TYPE_DEFINITIONS_FILENAME = "file_type_definitions.json"
|
||||
LLM_SETTINGS_FILENAME = "llm_settings.json"
|
||||
SUPPLIERS_CONFIG_FILENAME = "suppliers.json"
|
||||
USER_CONFIG_SUBDIR_NAME = "config" # Subdirectory within user's chosen config root for most jsons
|
||||
USER_PRESETS_SUBDIR_NAME = "Presets" # Subdirectory within user's chosen config root for presets
|
||||
|
||||
def __init__(self, preset_name: str, base_dir_user_config: Optional[Path] = None, is_first_run_setup: bool = False):
|
||||
def __init__(self, preset_name: str):
|
||||
"""
|
||||
Loads core config, user overrides, and the specified preset file.
|
||||
Loads core config and the specified preset file.
|
||||
|
||||
Args:
|
||||
preset_name: The name of the preset (without .json extension).
|
||||
base_dir_user_config: The root path for user-specific configurations.
|
||||
If None, loading of user-specific files will be skipped or may fail.
|
||||
is_first_run_setup: Flag indicating if this is part of the initial setup
|
||||
process where user config dir might be empty and fallbacks
|
||||
should not aggressively try to copy from bundle until UI confirms.
|
||||
|
||||
Raises:
|
||||
ConfigurationError: If critical configurations cannot be loaded/validated.
|
||||
ConfigurationError: If core config or preset cannot be loaded/validated.
|
||||
"""
|
||||
log.debug(f"Initializing Configuration with preset: '{preset_name}', user_config_dir: '{base_dir_user_config}', first_run_flag: {is_first_run_setup}")
|
||||
self._preset_filename_stem = preset_name
|
||||
self.base_dir_user_config: Optional[Path] = base_dir_user_config
|
||||
self.is_first_run_setup = is_first_run_setup
|
||||
self.base_dir_app_bundled: Path = self._determine_base_dir_app_bundled()
|
||||
|
||||
log.info(f"Determined BASE_DIR_APP_BUNDLED: {self.base_dir_app_bundled}")
|
||||
log.info(f"Using BASE_DIR_USER_CONFIG: {self.base_dir_user_config}")
|
||||
|
||||
# 1. Load core application settings (always from bundled)
|
||||
app_settings_path = self.base_dir_app_bundled / self.BASE_DIR_APP_BUNDLED_CONFIG_SUBDIR_NAME / self.APP_SETTINGS_FILENAME
|
||||
self._core_settings: dict = self._load_json_file(
|
||||
app_settings_path,
|
||||
is_critical=True,
|
||||
description="Core application settings"
|
||||
)
|
||||
|
||||
# 2. Load user settings (from user config dir, if provided)
|
||||
user_settings_overrides: dict = {}
|
||||
if self.base_dir_user_config:
|
||||
user_settings_file_path = self.base_dir_user_config / self.USER_SETTINGS_FILENAME
|
||||
user_settings_overrides = self._load_json_file(
|
||||
user_settings_file_path,
|
||||
is_critical=False, # Not critical if missing, especially on first run
|
||||
description=f"User settings from {user_settings_file_path}"
|
||||
) or {} # Ensure it's a dict
|
||||
else:
|
||||
log.info(f"{self.USER_SETTINGS_FILENAME} not loaded: User config directory not set.")
|
||||
|
||||
# 3. Deep merge user settings onto core settings
|
||||
if user_settings_overrides:
|
||||
log.info(f"Applying user setting overrides to core settings.")
|
||||
_deep_merge_dicts(self._core_settings, user_settings_overrides)
|
||||
|
||||
# 4. Load other definition files (from user config dir, with fallback from bundled)
|
||||
self._asset_type_definitions: dict = self._load_definition_file_with_fallback(
|
||||
self.ASSET_TYPE_DEFINITIONS_FILENAME, "ASSET_TYPE_DEFINITIONS"
|
||||
)
|
||||
self._file_type_definitions: dict = self._load_definition_file_with_fallback(
|
||||
self.FILE_TYPE_DEFINITIONS_FILENAME, "FILE_TYPE_DEFINITIONS"
|
||||
)
|
||||
|
||||
# --- Migration Logic for file_type_definitions.json ---
|
||||
# Moved from _load_definition_file_with_fallback to ensure execution
|
||||
if isinstance(self._file_type_definitions, dict):
|
||||
log.debug(f"Applying migration logic for old bit depth terminology in {self.FILE_TYPE_DEFINITIONS_FILENAME}")
|
||||
for map_type_key, definition in self._file_type_definitions.items():
|
||||
if isinstance(definition, dict):
|
||||
# Check for old key "bit_depth_rule"
|
||||
if "bit_depth_rule" in definition:
|
||||
old_rule = definition.pop("bit_depth_rule") # Remove old key
|
||||
new_policy = old_rule # Start with the old value
|
||||
if old_rule == "respect":
|
||||
new_policy = "preserve" # Map old value to new
|
||||
elif old_rule == "respect_inputs":
|
||||
new_policy = "preserve" # Map old value to new (though this shouldn't be in FTD)
|
||||
elif old_rule == "":
|
||||
new_policy = "" # Keep empty string
|
||||
# "force_8bit" and "force_16bit" values remain the same
|
||||
|
||||
definition["bit_depth_policy"] = new_policy # Add new key with migrated value
|
||||
log.warning(f"Migrated old 'bit_depth_rule': '{old_rule}' to 'bit_depth_policy': '{new_policy}' for map type '{map_type_key}' in {self.FILE_TYPE_DEFINITIONS_FILENAME}. Please update your configuration file.")
|
||||
|
||||
# Also check for old value "respect" under the new key, in case the key was manually renamed but value wasn't
|
||||
if "bit_depth_policy" in definition and definition["bit_depth_policy"] == "respect":
|
||||
definition["bit_depth_policy"] = "preserve"
|
||||
log.warning(f"Migrated old 'bit_depth_policy' value 'respect' to 'preserve' for map type '{map_type_key}' in {self.FILE_TYPE_DEFINITIONS_FILENAME}. Please update your configuration file.")
|
||||
|
||||
# --- Migration Logic for app_settings.json (MAP_MERGE_RULES) ---
|
||||
# This needs to happen after core settings are loaded and potentially merged with user settings,
|
||||
# so it might be better placed in __init__ after the merge, or in a dedicated method called by __init__.
|
||||
# For now, let's focus on the file_type_definitions.json issue causing the autotest warnings.
|
||||
# The app_settings.json migration can be a separate step if needed, but the primary issue
|
||||
# seems to be with file_type_definitions.json loading in the test context.
|
||||
|
||||
self._llm_settings: dict = self._load_definition_file_with_fallback(
|
||||
self.LLM_SETTINGS_FILENAME, None # LLM settings might be flat (no root key)
|
||||
)
|
||||
self._suppliers_config: dict = self._load_definition_file_with_fallback(
|
||||
self.SUPPLIERS_CONFIG_FILENAME, None # Suppliers config is flat
|
||||
)
|
||||
|
||||
# 5. Load preset settings (from user config dir, with fallback from bundled)
|
||||
self._preset_settings: dict = self._load_preset_with_fallback(self._preset_filename_stem)
|
||||
|
||||
self.actual_internal_preset_name = self._preset_settings.get("preset_name", self._preset_filename_stem)
|
||||
log.info(f"Configuration instance: Loaded preset file '{self._preset_filename_stem}.json', internal preset_name is '{self.actual_internal_preset_name}'")
|
||||
|
||||
# 6. Validate and compile (after all base/user/preset settings are established)
|
||||
log.debug(f"Initializing Configuration with preset: '{preset_name}'")
|
||||
self.preset_name = preset_name
|
||||
self._core_settings: dict = self._load_core_config()
|
||||
self._llm_settings: dict = self._load_llm_config()
|
||||
self._preset_settings: dict = self._load_preset(preset_name)
|
||||
self._validate_configs()
|
||||
self._compile_regex_patterns()
|
||||
log.info(f"Configuration loaded successfully using preset: '{self.actual_internal_preset_name}'")
|
||||
|
||||
def _determine_base_dir_app_bundled(self) -> Path:
|
||||
"""Determines the base directory for bundled application resources."""
|
||||
if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'):
|
||||
# Running in a PyInstaller bundle
|
||||
log.debug(f"Running as bundled app, _MEIPASS: {sys._MEIPASS}")
|
||||
return Path(sys._MEIPASS)
|
||||
else:
|
||||
# Running as a script
|
||||
log.debug(f"Running as script, using _SCRIPT_DIR: {_SCRIPT_DIR}")
|
||||
return _SCRIPT_DIR
|
||||
|
||||
def _ensure_dir_exists(self, dir_path: Path):
|
||||
"""Ensures a directory exists, creating it if necessary."""
|
||||
try:
|
||||
if not dir_path.exists():
|
||||
log.info(f"Directory not found, creating: {dir_path}")
|
||||
dir_path.mkdir(parents=True, exist_ok=True)
|
||||
elif not dir_path.is_dir():
|
||||
raise ConfigurationError(f"Expected directory but found file: {dir_path}")
|
||||
except OSError as e:
|
||||
raise ConfigurationError(f"Failed to create or access directory {dir_path}: {e}")
|
||||
|
||||
def _copy_default_if_missing(self, user_target_path: Path, bundled_source_subdir: str, filename: str) -> bool:
|
||||
"""
|
||||
Copies a default file from the bundled location to the user config directory
|
||||
if it's missing in the user directory. This is for post-first-time-setup fallback.
|
||||
"""
|
||||
if not self.base_dir_user_config:
|
||||
log.error(f"Cannot copy default for '{filename}': base_dir_user_config is not set.")
|
||||
return False
|
||||
|
||||
if user_target_path.exists():
|
||||
log.debug(f"User file '{user_target_path}' already exists. No copy needed from bundle.")
|
||||
return False
|
||||
|
||||
# This fallback copy should NOT happen during the initial UI-driven setup phase
|
||||
# where the UI is responsible for the first population of the user directory.
|
||||
# It's for subsequent runs where a user might have deleted a file.
|
||||
if self.is_first_run_setup:
|
||||
log.debug(f"'{filename}' missing in user dir during first_run_setup phase. UI should handle initial copy. Skipping fallback copy.")
|
||||
return False # File is missing, but UI should handle it.
|
||||
|
||||
bundled_file_path = self.base_dir_app_bundled / bundled_source_subdir / filename
|
||||
if not bundled_file_path.is_file():
|
||||
log.warning(f"Default bundled file '{bundled_file_path}' not found. Cannot copy to user location '{user_target_path}'.")
|
||||
return False
|
||||
|
||||
log.warning(f"User file '{user_target_path}' is missing. Attempting to restore from bundled default: '{bundled_file_path}'.")
|
||||
try:
|
||||
self._ensure_dir_exists(user_target_path.parent)
|
||||
shutil.copy2(bundled_file_path, user_target_path)
|
||||
log.info(f"Successfully copied '{bundled_file_path}' to '{user_target_path}'.")
|
||||
return True # File was copied
|
||||
except Exception as e:
|
||||
log.error(f"Failed to copy '{bundled_file_path}' to '{user_target_path}': {e}")
|
||||
return False # Copy failed
|
||||
|
||||
def _load_json_file(self, file_path: Optional[Path], is_critical: bool = False, description: str = "configuration") -> dict:
|
||||
"""Loads a JSON file, handling errors. Returns empty dict if not found and not critical."""
|
||||
if not file_path:
|
||||
if is_critical:
|
||||
raise ConfigurationError(f"Critical {description} file path is not defined.")
|
||||
log.debug(f"{description} file path is not defined. Returning empty dict.")
|
||||
return {}
|
||||
|
||||
log.debug(f"Attempting to load {description} from: {file_path}")
|
||||
if not file_path.is_file():
|
||||
if is_critical:
|
||||
raise ConfigurationError(f"Critical {description} file not found: {file_path}")
|
||||
log.info(f"{description} file not found: {file_path}. Returning empty dict.")
|
||||
return {}
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
settings = json.load(f)
|
||||
log.debug(f"{description} loaded successfully from {file_path}.")
|
||||
return settings
|
||||
except json.JSONDecodeError as e:
|
||||
msg = f"Failed to parse {description} file {file_path}: Invalid JSON - {e}"
|
||||
if is_critical: raise ConfigurationError(msg)
|
||||
log.warning(msg + ". Returning empty dict.")
|
||||
return {}
|
||||
except Exception as e:
|
||||
msg = f"Failed to read {description} file {file_path}: {e}"
|
||||
if is_critical: raise ConfigurationError(msg)
|
||||
log.warning(msg + ". Returning empty dict.")
|
||||
return {}
|
||||
|
||||
def _load_definition_file_with_fallback(self, filename: str, root_key: Optional[str] = None) -> dict:
|
||||
"""
|
||||
Loads a definition JSON file from the user config subdir.
|
||||
If not found and not first_run_setup, attempts to copy from bundled config subdir and then loads it.
|
||||
If base_dir_user_config is not set, loads directly from bundled (read-only).
|
||||
"""
|
||||
data = {}
|
||||
user_file_path = None
|
||||
|
||||
if self.base_dir_user_config:
|
||||
user_file_path = self.base_dir_user_config / self.USER_CONFIG_SUBDIR_NAME / filename
|
||||
data = self._load_json_file(user_file_path, is_critical=False, description=f"User {filename}")
|
||||
|
||||
if not data: # If not found or failed to load from user path
|
||||
# Attempt fallback copy only if not in the initial setup phase by UI
|
||||
# and if the file was genuinely missing (not a parse error for an existing file)
|
||||
if not user_file_path.exists() and not self.is_first_run_setup:
|
||||
if self._copy_default_if_missing(user_file_path, self.BASE_DIR_APP_BUNDLED_CONFIG_SUBDIR_NAME, filename):
|
||||
data = self._load_json_file(user_file_path, is_critical=False, description=f"User {filename} after copy")
|
||||
else:
|
||||
# No user_config_dir, load directly from bundled (read-only)
|
||||
log.warning(f"User config directory not set. Loading '{filename}' from bundled defaults (read-only).")
|
||||
bundled_path = self.base_dir_app_bundled / self.BASE_DIR_APP_BUNDLED_CONFIG_SUBDIR_NAME / filename
|
||||
data = self._load_json_file(bundled_path, is_critical=False, description=f"Bundled {filename}")
|
||||
|
||||
if not data:
|
||||
# If still no data, it's an issue, especially for critical definitions
|
||||
is_critical_def = filename in [self.ASSET_TYPE_DEFINITIONS_FILENAME, self.FILE_TYPE_DEFINITIONS_FILENAME]
|
||||
err_msg = f"Failed to load '{filename}' from user dir '{user_file_path if user_file_path else 'N/A'}' or bundled defaults. Critical functionality may be affected."
|
||||
if is_critical_def: raise ConfigurationError(err_msg)
|
||||
log.error(err_msg)
|
||||
return {}
|
||||
|
||||
if root_key:
|
||||
if root_key not in data:
|
||||
raise ConfigurationError(f"Key '{root_key}' not found in loaded {filename} data: {data.keys()}")
|
||||
content = data[root_key]
|
||||
# Ensure content is a dictionary if a root_key is expected to yield one
|
||||
if not isinstance(content, dict):
|
||||
raise ConfigurationError(f"Content under root key '{root_key}' in {filename} must be a dictionary, got {type(content)}.")
|
||||
return content
|
||||
return data # For flat files
|
||||
|
||||
|
||||
|
||||
def _load_preset_with_fallback(self, preset_name_stem: str) -> dict:
|
||||
"""
|
||||
Loads a preset JSON file from the user's Presets subdir.
|
||||
If not found and not first_run_setup, attempts to copy from bundled Presets and then loads it.
|
||||
If base_dir_user_config is not set, loads directly from bundled (read-only).
|
||||
"""
|
||||
preset_filename = f"{preset_name_stem}.json"
|
||||
preset_data = {}
|
||||
user_preset_file_path = None
|
||||
|
||||
if self.base_dir_user_config:
|
||||
user_presets_dir = self.base_dir_user_config / self.USER_PRESETS_SUBDIR_NAME
|
||||
user_preset_file_path = user_presets_dir / preset_filename
|
||||
preset_data = self._load_json_file(user_preset_file_path, is_critical=False, description=f"User preset '{preset_filename}'")
|
||||
|
||||
if not preset_data: # If not found or failed to load
|
||||
if not user_preset_file_path.exists() and not self.is_first_run_setup:
|
||||
if self._copy_default_if_missing(user_preset_file_path, self.PRESETS_DIR_APP_BUNDLED_NAME, preset_filename):
|
||||
preset_data = self._load_json_file(user_preset_file_path, is_critical=False, description=f"User preset '{preset_filename}' after copy")
|
||||
else:
|
||||
log.warning(f"User config directory not set. Loading preset '{preset_filename}' from bundled defaults (read-only).")
|
||||
bundled_presets_dir = self.base_dir_app_bundled / self.PRESETS_DIR_APP_BUNDLED_NAME
|
||||
bundled_preset_file_path = bundled_presets_dir / preset_filename
|
||||
# Presets are generally critical for operation if one is specified
|
||||
preset_data = self._load_json_file(bundled_preset_file_path, is_critical=True, description=f"Bundled preset '{preset_filename}'")
|
||||
|
||||
if not preset_data:
|
||||
raise ConfigurationError(f"Preset file '{preset_filename}' could not be loaded from user dir '{user_preset_file_path if user_preset_file_path else 'N/A'}' or bundled defaults.")
|
||||
return preset_data
|
||||
log.info(f"Configuration loaded successfully using preset: '{self.preset_name}'")
|
||||
|
||||
|
||||
def _compile_regex_patterns(self):
|
||||
@ -398,8 +95,8 @@ class Configuration:
|
||||
self.compiled_extra_regex: list[re.Pattern] = []
|
||||
self.compiled_model_regex: list[re.Pattern] = []
|
||||
self.compiled_bit_depth_regex_map: dict[str, re.Pattern] = {}
|
||||
# Map: base_map_type -> list of tuples: (compiled_regex, original_keyword, rule_index, is_priority)
|
||||
self.compiled_map_keyword_regex: dict[str, list[tuple[re.Pattern, str, int, bool]]] = {}
|
||||
# Map: base_map_type -> list of tuples: (compiled_regex, original_keyword, rule_index)
|
||||
self.compiled_map_keyword_regex: dict[str, list[tuple[re.Pattern, str, int]]] = {}
|
||||
|
||||
for pattern in self.move_to_extra_patterns:
|
||||
try:
|
||||
@ -434,53 +131,28 @@ class Configuration:
|
||||
|
||||
for rule_index, mapping_rule in enumerate(self.map_type_mapping):
|
||||
if not isinstance(mapping_rule, dict) or \
|
||||
'target_type' not in mapping_rule: # Removed 'keywords' check here as it's handled below
|
||||
log.warning(f"Skipping invalid map_type_mapping rule at index {rule_index}: {mapping_rule}. Expected dict with 'target_type'.")
|
||||
'target_type' not in mapping_rule or \
|
||||
'keywords' not in mapping_rule or \
|
||||
not isinstance(mapping_rule['keywords'], list):
|
||||
log.warning(f"Skipping invalid map_type_mapping rule at index {rule_index}: {mapping_rule}. Expected dict with 'target_type' and 'keywords' list.")
|
||||
continue
|
||||
|
||||
target_type = mapping_rule['target_type'].upper()
|
||||
|
||||
# Ensure 'keywords' exists and is a list, default to empty list if not found or not a list
|
||||
regular_keywords = mapping_rule.get('keywords', [])
|
||||
if not isinstance(regular_keywords, list):
|
||||
log.warning(f"Rule {rule_index} for target '{target_type}' has 'keywords' but it's not a list. Treating as empty.")
|
||||
regular_keywords = []
|
||||
|
||||
priority_keywords = mapping_rule.get('priority_keywords', []) # Optional, defaults to empty list
|
||||
if not isinstance(priority_keywords, list):
|
||||
log.warning(f"Rule {rule_index} for target '{target_type}' has 'priority_keywords' but it's not a list. Treating as empty.")
|
||||
priority_keywords = []
|
||||
source_keywords = mapping_rule['keywords']
|
||||
|
||||
# Process regular keywords
|
||||
for keyword in regular_keywords:
|
||||
if not isinstance(keyword, str):
|
||||
log.warning(f"Skipping non-string regular keyword '{keyword}' in rule {rule_index} for target '{target_type}'.")
|
||||
continue
|
||||
try:
|
||||
kw_regex_part = _fnmatch_to_regex(keyword)
|
||||
# Ensure the keyword is treated as a whole word or is at the start/end of a segment
|
||||
regex_str = rf"(?:^|{separator})({kw_regex_part})(?:$|{separator})"
|
||||
compiled_regex = re.compile(regex_str, re.IGNORECASE)
|
||||
# Add False for is_priority
|
||||
temp_compiled_map_regex[target_type].append((compiled_regex, keyword, rule_index, False))
|
||||
log.debug(f" Compiled regular keyword '{keyword}' (rule {rule_index}) for target '{target_type}' as regex: {regex_str}")
|
||||
except re.error as e:
|
||||
log.warning(f"Failed to compile regular map keyword regex '{keyword}' for target type '{target_type}': {e}. Skipping keyword.")
|
||||
|
||||
# Process priority keywords
|
||||
for keyword in priority_keywords:
|
||||
for keyword in source_keywords:
|
||||
if not isinstance(keyword, str):
|
||||
log.warning(f"Skipping non-string priority keyword '{keyword}' in rule {rule_index} for target '{target_type}'.")
|
||||
continue
|
||||
log.warning(f"Skipping non-string keyword '{keyword}' in rule {rule_index} for target '{target_type}'.")
|
||||
continue
|
||||
try:
|
||||
kw_regex_part = _fnmatch_to_regex(keyword)
|
||||
regex_str = rf"(?:^|{separator})({kw_regex_part})(?:$|{separator})"
|
||||
compiled_regex = re.compile(regex_str, re.IGNORECASE)
|
||||
# Add True for is_priority
|
||||
temp_compiled_map_regex[target_type].append((compiled_regex, keyword, rule_index, True))
|
||||
log.debug(f" Compiled priority keyword '{keyword}' (rule {rule_index}) for target '{target_type}' as regex: {regex_str}")
|
||||
temp_compiled_map_regex[target_type].append((compiled_regex, keyword, rule_index))
|
||||
log.debug(f" Compiled keyword '{keyword}' (rule {rule_index}) for target '{target_type}' as regex: {regex_str}")
|
||||
except re.error as e:
|
||||
log.warning(f"Failed to compile priority map keyword regex '{keyword}' for target type '{target_type}': {e}. Skipping keyword.")
|
||||
log.warning(f"Failed to compile map keyword regex '{keyword}' for target type '{target_type}': {e}. Skipping keyword.")
|
||||
|
||||
self.compiled_map_keyword_regex = dict(temp_compiled_map_regex)
|
||||
log.debug(f"Compiled map keyword regex keys: {list(self.compiled_map_keyword_regex.keys())}")
|
||||
@ -488,22 +160,64 @@ class Configuration:
|
||||
log.debug("Finished compiling regex patterns.")
|
||||
|
||||
|
||||
def _load_core_config(self) -> dict:
|
||||
"""Loads settings from the core app_settings.json file."""
|
||||
log.debug(f"Loading core config from: {APP_SETTINGS_PATH}")
|
||||
if not APP_SETTINGS_PATH.is_file():
|
||||
raise ConfigurationError(f"Core configuration file not found: {APP_SETTINGS_PATH}")
|
||||
try:
|
||||
with open(APP_SETTINGS_PATH, 'r', encoding='utf-8') as f:
|
||||
settings = json.load(f)
|
||||
log.debug(f"Core config loaded successfully.")
|
||||
return settings
|
||||
except json.JSONDecodeError as e:
|
||||
raise ConfigurationError(f"Failed to parse core configuration file {APP_SETTINGS_PATH}: Invalid JSON - {e}")
|
||||
except Exception as e:
|
||||
raise ConfigurationError(f"Failed to read core configuration file {APP_SETTINGS_PATH}: {e}")
|
||||
|
||||
def _load_llm_config(self) -> dict:
|
||||
"""Loads settings from the llm_settings.json file."""
|
||||
log.debug(f"Loading LLM config from: {LLM_SETTINGS_PATH}")
|
||||
if not LLM_SETTINGS_PATH.is_file():
|
||||
# Log a warning but don't raise an error, allow fallback if possible
|
||||
log.warning(f"LLM configuration file not found: {LLM_SETTINGS_PATH}. LLM features might be disabled or use defaults.")
|
||||
return {}
|
||||
try:
|
||||
with open(LLM_SETTINGS_PATH, 'r', encoding='utf-8') as f:
|
||||
settings = json.load(f)
|
||||
log.debug(f"LLM config loaded successfully.")
|
||||
return settings
|
||||
except json.JSONDecodeError as e:
|
||||
log.error(f"Failed to parse LLM configuration file {LLM_SETTINGS_PATH}: Invalid JSON - {e}")
|
||||
return {}
|
||||
except Exception as e:
|
||||
log.error(f"Failed to read LLM configuration file {LLM_SETTINGS_PATH}: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
def _load_preset(self, preset_name: str) -> dict:
|
||||
"""Loads the specified preset JSON file."""
|
||||
log.debug(f"Loading preset: '{preset_name}' from {PRESETS_DIR}")
|
||||
if not PRESETS_DIR.is_dir():
|
||||
raise ConfigurationError(f"Presets directory not found: {PRESETS_DIR}")
|
||||
|
||||
preset_file = PRESETS_DIR / f"{preset_name}.json"
|
||||
if not preset_file.is_file():
|
||||
raise ConfigurationError(f"Preset file not found: {preset_file}")
|
||||
|
||||
try:
|
||||
with open(preset_file, 'r', encoding='utf-8') as f:
|
||||
preset_data = json.load(f)
|
||||
log.debug(f"Preset '{preset_name}' loaded successfully.")
|
||||
return preset_data
|
||||
except json.JSONDecodeError as e:
|
||||
raise ConfigurationError(f"Failed to parse preset file {preset_file}: Invalid JSON - {e}")
|
||||
except Exception as e:
|
||||
raise ConfigurationError(f"Failed to read preset file {preset_file}: {e}")
|
||||
|
||||
def _validate_configs(self):
|
||||
"""Performs basic validation checks on loaded settings."""
|
||||
log.debug("Validating loaded configurations...")
|
||||
|
||||
# Validate new definition files first
|
||||
if not isinstance(self._asset_type_definitions, dict):
|
||||
raise ConfigurationError("Asset type definitions were not loaded correctly or are not a dictionary.")
|
||||
if not self._asset_type_definitions: # Check if empty
|
||||
raise ConfigurationError("Asset type definitions are empty.")
|
||||
|
||||
if not isinstance(self._file_type_definitions, dict):
|
||||
raise ConfigurationError("File type definitions were not loaded correctly or are not a dictionary.")
|
||||
if not self._file_type_definitions: # Check if empty
|
||||
raise ConfigurationError("File type definitions are empty.")
|
||||
|
||||
# Preset validation
|
||||
required_preset_keys = [
|
||||
"preset_name", "supplier_name", "source_naming", "map_type_mapping",
|
||||
@ -511,44 +225,34 @@ class Configuration:
|
||||
]
|
||||
for key in required_preset_keys:
|
||||
if key not in self._preset_settings:
|
||||
raise ConfigurationError(f"Preset file '{self._preset_filename_stem}.json' (internal name: '{self.actual_internal_preset_name}') is missing required key: '{key}'.")
|
||||
raise ConfigurationError(f"Preset '{self.preset_name}' is missing required key: '{key}'.")
|
||||
|
||||
# Validate map_type_mapping structure (new format)
|
||||
if not isinstance(self._preset_settings['map_type_mapping'], list):
|
||||
raise ConfigurationError(f"Preset file '{self._preset_filename_stem}.json': 'map_type_mapping' must be a list.")
|
||||
raise ConfigurationError(f"Preset '{self.preset_name}': 'map_type_mapping' must be a list.")
|
||||
for index, rule in enumerate(self._preset_settings['map_type_mapping']):
|
||||
if not isinstance(rule, dict):
|
||||
raise ConfigurationError(f"Preset file '{self._preset_filename_stem}.json': Rule at index {index} in 'map_type_mapping' must be a dictionary.")
|
||||
raise ConfigurationError(f"Preset '{self.preset_name}': Rule at index {index} in 'map_type_mapping' must be a dictionary.")
|
||||
if 'target_type' not in rule or not isinstance(rule['target_type'], str):
|
||||
raise ConfigurationError(f"Preset file '{self._preset_filename_stem}.json': Rule at index {index} in 'map_type_mapping' is missing 'target_type' string.")
|
||||
|
||||
valid_file_type_keys = self._file_type_definitions.keys()
|
||||
raise ConfigurationError(f"Preset '{self.preset_name}': Rule at index {index} in 'map_type_mapping' is missing 'target_type' string.")
|
||||
|
||||
valid_file_type_keys = self._core_settings.get('FILE_TYPE_DEFINITIONS', {}).keys()
|
||||
if rule['target_type'] not in valid_file_type_keys:
|
||||
raise ConfigurationError(
|
||||
f"Preset file '{self._preset_filename_stem}.json': Rule at index {index} in 'map_type_mapping' "
|
||||
f"Preset '{self.preset_name}': Rule at index {index} in 'map_type_mapping' "
|
||||
f"has an invalid 'target_type': '{rule['target_type']}'. "
|
||||
f"Must be one of {list(valid_file_type_keys)}."
|
||||
)
|
||||
|
||||
# 'keywords' is optional if 'priority_keywords' is present and not empty,
|
||||
# but if 'keywords' IS present, it must be a list of strings.
|
||||
if 'keywords' in rule:
|
||||
if not isinstance(rule['keywords'], list):
|
||||
raise ConfigurationError(f"Preset file '{self._preset_filename_stem}.json': Rule at index {index} in 'map_type_mapping' has 'keywords' but it's not a list.")
|
||||
for kw_index, keyword in enumerate(rule['keywords']):
|
||||
if not isinstance(keyword, str):
|
||||
raise ConfigurationError(f"Preset file '{self._preset_filename_stem}.json': Keyword at index {kw_index} in rule {index} ('{rule['target_type']}') must be a string.")
|
||||
elif not ('priority_keywords' in rule and rule['priority_keywords']): # if 'keywords' is not present, 'priority_keywords' must be
|
||||
raise ConfigurationError(f"Preset file '{self._preset_filename_stem}.json': Rule at index {index} in 'map_type_mapping' must have 'keywords' or non-empty 'priority_keywords'.")
|
||||
if 'keywords' not in rule or not isinstance(rule['keywords'], list):
|
||||
raise ConfigurationError(f"Preset '{self.preset_name}': Rule at index {index} in 'map_type_mapping' is missing 'keywords' list.")
|
||||
for kw_index, keyword in enumerate(rule['keywords']):
|
||||
if not isinstance(keyword, str):
|
||||
raise ConfigurationError(f"Preset '{self.preset_name}': Keyword at index {kw_index} in rule {index} ('{rule['target_type']}') must be a string.")
|
||||
|
||||
# Validate priority_keywords if present
|
||||
if 'priority_keywords' in rule:
|
||||
if not isinstance(rule['priority_keywords'], list):
|
||||
raise ConfigurationError(f"Preset file '{self._preset_filename_stem}.json': Rule at index {index} in 'map_type_mapping' has 'priority_keywords' but it's not a list.")
|
||||
for prio_kw_index, prio_keyword in enumerate(rule['priority_keywords']):
|
||||
if not isinstance(prio_keyword, str):
|
||||
raise ConfigurationError(f"Preset file '{self._preset_filename_stem}.json': Priority keyword at index {prio_kw_index} in rule {index} ('{rule['target_type']}') must be a string.")
|
||||
|
||||
if not isinstance(self._core_settings.get('TARGET_FILENAME_PATTERN'), str):
|
||||
raise ConfigurationError("Core config 'TARGET_FILENAME_PATTERN' must be a string.")
|
||||
if not isinstance(self._core_settings.get('OUTPUT_DIRECTORY_PATTERN'), str):
|
||||
raise ConfigurationError("Core config 'OUTPUT_DIRECTORY_PATTERN' must be a string.")
|
||||
if not isinstance(self._core_settings.get('OUTPUT_FILENAME_PATTERN'), str):
|
||||
@ -557,7 +261,7 @@ class Configuration:
|
||||
raise ConfigurationError("Core config 'IMAGE_RESOLUTIONS' must be a dictionary.")
|
||||
|
||||
# Validate DEFAULT_ASSET_CATEGORY
|
||||
valid_asset_type_keys = self._asset_type_definitions.keys()
|
||||
valid_asset_type_keys = self._core_settings.get('ASSET_TYPE_DEFINITIONS', {}).keys()
|
||||
default_asset_category_value = self._core_settings.get('DEFAULT_ASSET_CATEGORY')
|
||||
if not default_asset_category_value:
|
||||
raise ConfigurationError("Core config 'DEFAULT_ASSET_CATEGORY' is missing.")
|
||||
@ -582,20 +286,9 @@ class Configuration:
|
||||
|
||||
|
||||
@property
|
||||
def supplier_name(self) -> str: # From preset
|
||||
def supplier_name(self) -> str:
|
||||
return self._preset_settings.get('supplier_name', 'DefaultSupplier')
|
||||
|
||||
@property
|
||||
def suppliers_config(self) -> dict: # From suppliers.json
|
||||
"""Returns the loaded suppliers configuration."""
|
||||
return self._suppliers_config
|
||||
|
||||
@property
|
||||
def internal_display_preset_name(self) -> str:
|
||||
"""Returns the 'preset_name' field from within the loaded preset JSON,
|
||||
or falls back to the filename stem if not present."""
|
||||
return self.actual_internal_preset_name
|
||||
|
||||
|
||||
@property
|
||||
def default_asset_category(self) -> str:
|
||||
"""Gets the default asset category from core settings."""
|
||||
@ -725,27 +418,26 @@ class Configuration:
|
||||
"""Gets the list of map types that must always be saved losslessly."""
|
||||
return self._core_settings.get('FORCE_LOSSLESS_MAP_TYPES', [])
|
||||
|
||||
def get_bit_depth_policy(self, map_type_input: str) -> str:
|
||||
def get_bit_depth_rule(self, map_type_input: str) -> str:
|
||||
"""
|
||||
Gets the bit depth policy ('force_8bit', 'force_16bit', 'preserve', '') for a given map type identifier.
|
||||
Gets the bit depth rule ('respect', 'force_8bit', 'force_16bit') for a given map type identifier.
|
||||
The map_type_input can be an FTD key (e.g., "MAP_COL") or a suffixed FTD key (e.g., "MAP_COL-1").
|
||||
"""
|
||||
if not self._file_type_definitions: # Check if the attribute exists and is not empty
|
||||
log.warning("File type definitions not loaded. Cannot determine bit depth policy.")
|
||||
return "preserve" # Defaulting to 'preserve' as per refactor plan Phase 1 completion
|
||||
if not self._core_settings or 'FILE_TYPE_DEFINITIONS' not in self._core_settings:
|
||||
log.warning("FILE_TYPE_DEFINITIONS not found in core settings. Cannot determine bit depth rule.")
|
||||
return "respect"
|
||||
|
||||
file_type_definitions = self._file_type_definitions
|
||||
file_type_definitions = self._core_settings['FILE_TYPE_DEFINITIONS']
|
||||
|
||||
# 1. Try direct match with map_type_input as FTD key
|
||||
definition = file_type_definitions.get(map_type_input)
|
||||
if definition:
|
||||
policy = definition.get('bit_depth_policy')
|
||||
# Valid policies include the empty string
|
||||
if policy in ['force_8bit', 'force_16bit', 'preserve', '']:
|
||||
return policy
|
||||
rule = definition.get('bit_depth_rule')
|
||||
if rule in ['respect', 'force_8bit', 'force_16bit']:
|
||||
return rule
|
||||
else:
|
||||
log.warning(f"FTD key '{map_type_input}' found, but 'bit_depth_policy' is missing or invalid: '{policy}'. Defaulting to 'preserve'.")
|
||||
return "preserve"
|
||||
log.warning(f"FTD key '{map_type_input}' found, but 'bit_depth_rule' is missing or invalid: '{rule}'. Defaulting to 'respect'.")
|
||||
return "respect"
|
||||
|
||||
# 2. Try to derive base FTD key by stripping common variant suffixes
|
||||
# Regex to remove trailing suffixes like -<digits>, -<alphanum>, _<alphanum>
|
||||
@ -753,17 +445,17 @@ class Configuration:
|
||||
if base_ftd_key_candidate != map_type_input:
|
||||
definition = file_type_definitions.get(base_ftd_key_candidate)
|
||||
if definition:
|
||||
policy = definition.get('bit_depth_policy')
|
||||
if policy in ['force_8bit', 'force_16bit', 'preserve', '']:
|
||||
log.debug(f"Derived base FTD key '{base_ftd_key_candidate}' from '{map_type_input}' and found bit depth policy: {policy}")
|
||||
return policy
|
||||
rule = definition.get('bit_depth_rule')
|
||||
if rule in ['respect', 'force_8bit', 'force_16bit']:
|
||||
log.debug(f"Derived base FTD key '{base_ftd_key_candidate}' from '{map_type_input}' and found bit depth rule: {rule}")
|
||||
return rule
|
||||
else:
|
||||
log.warning(f"Derived base FTD key '{base_ftd_key_candidate}' from '{map_type_input}', but 'bit_depth_policy' is missing/invalid: '{policy}'. Defaulting to 'preserve'.")
|
||||
return "preserve"
|
||||
log.warning(f"Derived base FTD key '{base_ftd_key_candidate}' from '{map_type_input}', but 'bit_depth_rule' is missing/invalid: '{rule}'. Defaulting to 'respect'.")
|
||||
return "respect"
|
||||
|
||||
# If no match found after trying direct and derived keys
|
||||
log.warning(f"Map type identifier '{map_type_input}' (or its derived base) not found in FILE_TYPE_DEFINITIONS. Defaulting bit depth policy to 'preserve'.")
|
||||
return "preserve"
|
||||
log.warning(f"Map type identifier '{map_type_input}' (or its derived base) not found in FILE_TYPE_DEFINITIONS. Defaulting bit depth rule to 'respect'.")
|
||||
return "respect"
|
||||
|
||||
def get_16bit_output_formats(self) -> tuple[str, str]:
|
||||
"""Gets the primary and fallback format names for 16-bit output."""
|
||||
@ -781,8 +473,8 @@ class Configuration:
|
||||
from FILE_TYPE_DEFINITIONS.
|
||||
"""
|
||||
aliases = set()
|
||||
# _file_type_definitions is guaranteed to be a dict by the loader
|
||||
for _key, definition in self._file_type_definitions.items():
|
||||
file_type_definitions = self._core_settings.get('FILE_TYPE_DEFINITIONS', {})
|
||||
for _key, definition in file_type_definitions.items():
|
||||
if isinstance(definition, dict):
|
||||
standard_type = definition.get('standard_type')
|
||||
if standard_type and isinstance(standard_type, str) and standard_type.strip():
|
||||
@ -790,16 +482,16 @@ class Configuration:
|
||||
return sorted(list(aliases))
|
||||
|
||||
def get_asset_type_definitions(self) -> dict:
|
||||
"""Returns the _asset_type_definitions dictionary."""
|
||||
return self._asset_type_definitions
|
||||
"""Returns the ASSET_TYPE_DEFINITIONS dictionary from core settings."""
|
||||
return self._core_settings.get('ASSET_TYPE_DEFINITIONS', {})
|
||||
|
||||
def get_asset_type_keys(self) -> list:
|
||||
"""Returns a list of valid asset type keys from core settings."""
|
||||
return list(self.get_asset_type_definitions().keys())
|
||||
|
||||
def get_file_type_definitions_with_examples(self) -> dict:
|
||||
"""Returns the _file_type_definitions dictionary (including descriptions and examples)."""
|
||||
return self._file_type_definitions
|
||||
"""Returns the FILE_TYPE_DEFINITIONS dictionary (including descriptions and examples) from core settings."""
|
||||
return self._core_settings.get('FILE_TYPE_DEFINITIONS', {})
|
||||
|
||||
def get_file_type_keys(self) -> list:
|
||||
"""Returns a list of valid file type keys from core settings."""
|
||||
@ -841,81 +533,8 @@ class Configuration:
|
||||
return self._llm_settings.get('llm_request_timeout', 120)
|
||||
|
||||
@property
|
||||
def app_version(self) -> Optional[str]:
|
||||
"""Returns the application version from general_settings."""
|
||||
gs = self._core_settings.get('general_settings')
|
||||
if isinstance(gs, dict):
|
||||
return gs.get('app_version')
|
||||
return None
|
||||
|
||||
@property
|
||||
def enable_low_resolution_fallback(self) -> bool:
|
||||
"""Gets the setting for enabling low-resolution fallback."""
|
||||
return self._core_settings.get('ENABLE_LOW_RESOLUTION_FALLBACK', True)
|
||||
|
||||
@property
|
||||
def low_resolution_threshold(self) -> int:
|
||||
"""Gets the pixel dimension threshold for low-resolution fallback."""
|
||||
return self._core_settings.get('LOW_RESOLUTION_THRESHOLD', 512)
|
||||
|
||||
@property
|
||||
def FILE_TYPE_DEFINITIONS(self) -> dict: # Kept for compatibility if used directly
|
||||
return self._file_type_definitions
|
||||
|
||||
# --- Save Methods ---
|
||||
def _save_json_to_user_config(self, data_to_save: dict, filename: str, subdir: Optional[str] = None, is_root_key_data: Optional[str] = None):
|
||||
"""Helper to save a dictionary to a JSON file in the user config directory."""
|
||||
if not self.base_dir_user_config:
|
||||
raise ConfigurationError(f"Cannot save {filename}: User config directory (base_dir_user_config) is not set.")
|
||||
|
||||
target_dir = self.base_dir_user_config
|
||||
if subdir:
|
||||
target_dir = target_dir / subdir
|
||||
|
||||
self._ensure_dir_exists(target_dir)
|
||||
path = target_dir / filename
|
||||
|
||||
data_for_json = {is_root_key_data: data_to_save} if is_root_key_data else data_to_save
|
||||
|
||||
log.debug(f"Saving data to: {path}")
|
||||
try:
|
||||
with open(path, 'w', encoding='utf-8') as f:
|
||||
json.dump(data_for_json, f, indent=4)
|
||||
log.info(f"Data saved successfully to {path}")
|
||||
except Exception as e:
|
||||
log.error(f"Failed to save file {path}: {e}")
|
||||
raise ConfigurationError(f"Failed to save {filename}: {e}")
|
||||
|
||||
def save_user_settings(self, settings_dict: dict):
|
||||
"""Saves the provided settings dictionary to user_settings.json in the user config directory."""
|
||||
self._save_json_to_user_config(settings_dict, self.USER_SETTINGS_FILENAME)
|
||||
|
||||
def save_llm_settings(self, settings_dict: dict):
|
||||
"""Saves LLM settings to the user config directory's 'config' subdir."""
|
||||
self._save_json_to_user_config(settings_dict, self.LLM_SETTINGS_FILENAME, subdir=self.USER_CONFIG_SUBDIR_NAME)
|
||||
|
||||
def save_asset_type_definitions(self, data: dict):
|
||||
"""Saves asset type definitions to the user config directory's 'config' subdir."""
|
||||
self._save_json_to_user_config(data, self.ASSET_TYPE_DEFINITIONS_FILENAME, subdir=self.USER_CONFIG_SUBDIR_NAME, is_root_key_data="ASSET_TYPE_DEFINITIONS")
|
||||
|
||||
def save_file_type_definitions(self, data: dict):
|
||||
"""Saves file type definitions to the user config directory's 'config' subdir."""
|
||||
self._save_json_to_user_config(data, self.FILE_TYPE_DEFINITIONS_FILENAME, subdir=self.USER_CONFIG_SUBDIR_NAME, is_root_key_data="FILE_TYPE_DEFINITIONS")
|
||||
|
||||
def save_supplier_settings(self, data: dict):
|
||||
"""Saves supplier settings to the user config directory's 'config' subdir."""
|
||||
self._save_json_to_user_config(data, self.SUPPLIERS_CONFIG_FILENAME, subdir=self.USER_CONFIG_SUBDIR_NAME)
|
||||
|
||||
def save_preset(self, preset_data: dict, preset_name_stem: str):
|
||||
"""Saves a preset to the user config directory's 'Presets' subdir."""
|
||||
if not preset_name_stem:
|
||||
raise ConfigurationError("Preset name stem cannot be empty for saving.")
|
||||
preset_filename = f"{preset_name_stem}.json"
|
||||
# Ensure the preset_data itself contains the correct 'preset_name' field
|
||||
# or update it before saving if necessary.
|
||||
# For example: preset_data['preset_name'] = preset_name_stem
|
||||
self._save_json_to_user_config(preset_data, preset_filename, subdir=self.USER_PRESETS_SUBDIR_NAME)
|
||||
|
||||
def FILE_TYPE_DEFINITIONS(self) -> dict:
|
||||
return self._core_settings.get('FILE_TYPE_DEFINITIONS', {})
|
||||
|
||||
@property
|
||||
def keybind_config(self) -> dict[str, list[str]]:
|
||||
@ -925,8 +544,8 @@ class Configuration:
|
||||
Example: {'C': ['MAP_COL'], 'R': ['MAP_ROUGH', 'MAP_GLOSS']}
|
||||
"""
|
||||
keybinds = {}
|
||||
# _file_type_definitions is guaranteed to be a dict by the loader
|
||||
for ftd_key, ftd_value in self._file_type_definitions.items():
|
||||
file_type_defs = self._core_settings.get('FILE_TYPE_DEFINITIONS', {})
|
||||
for ftd_key, ftd_value in file_type_defs.items():
|
||||
if isinstance(ftd_value, dict) and 'keybind' in ftd_value:
|
||||
key = ftd_value['keybind']
|
||||
if key not in keybinds:
|
||||
@ -940,60 +559,50 @@ class Configuration:
|
||||
# For now, we rely on the order they appear in the config.
|
||||
return keybinds
|
||||
|
||||
# The global load_base_config() is effectively replaced by Configuration.__init__
|
||||
# Global save/load functions for individual files are refactored to be methods
|
||||
# of the Configuration class or called by them, using instance paths.
|
||||
|
||||
# For example, to get a list of preset names, one might need a static method
|
||||
# or a function that knows about both bundled and user preset directories.
|
||||
def get_available_preset_names(base_dir_user_config: Optional[Path], base_dir_app_bundled: Path) -> list[str]:
|
||||
def load_base_config() -> dict:
|
||||
"""
|
||||
Gets a list of available preset names (stems) by looking in user presets
|
||||
and then bundled presets. User presets take precedence.
|
||||
Loads only the base configuration from app_settings.json.
|
||||
Does not load presets or perform merging/validation.
|
||||
"""
|
||||
preset_names = set()
|
||||
|
||||
# Check user presets first
|
||||
if base_dir_user_config:
|
||||
user_presets_dir = base_dir_user_config / Configuration.USER_PRESETS_SUBDIR_NAME
|
||||
if user_presets_dir.is_dir():
|
||||
for f in user_presets_dir.glob("*.json"):
|
||||
preset_names.add(f.stem)
|
||||
|
||||
# Check bundled presets
|
||||
bundled_presets_dir = base_dir_app_bundled / Configuration.PRESETS_DIR_APP_BUNDLED_NAME
|
||||
if bundled_presets_dir.is_dir():
|
||||
for f in bundled_presets_dir.glob("*.json"):
|
||||
preset_names.add(f.stem) # Adds if not already present from user dir
|
||||
|
||||
if not preset_names:
|
||||
log.warning("No preset files found in user or bundled preset directories.")
|
||||
# Consider adding a default/template preset if none are found, or ensure one always exists in bundle.
|
||||
# For now, return empty list.
|
||||
|
||||
return sorted(list(preset_names))
|
||||
if not APP_SETTINGS_PATH.is_file():
|
||||
log.error(f"Base configuration file not found: {APP_SETTINGS_PATH}")
|
||||
# Return empty dict or raise a specific error if preferred
|
||||
# For now, return empty dict to allow GUI to potentially start with defaults
|
||||
return {}
|
||||
try:
|
||||
with open(APP_SETTINGS_PATH, 'r', encoding='utf-8') as f:
|
||||
settings = json.load(f)
|
||||
return settings
|
||||
except json.JSONDecodeError as e:
|
||||
log.error(f"Failed to parse base configuration file {APP_SETTINGS_PATH}: Invalid JSON - {e}")
|
||||
return {}
|
||||
except Exception as e:
|
||||
log.error(f"Failed to read base configuration file {APP_SETTINGS_PATH}: {e}")
|
||||
return {}
|
||||
|
||||
# Global functions like load_asset_definitions, save_asset_definitions etc.
|
||||
# are now instance methods of the Configuration class (e.g., self.save_asset_type_definitions).
|
||||
# If any external code was calling these global functions, it will need to be updated
|
||||
# to instantiate a Configuration object and call its methods, or these global
|
||||
# functions need to be carefully adapted to instantiate Configuration internally
|
||||
# or accept a Configuration instance.
|
||||
|
||||
# For now, let's assume the primary interaction is via Configuration instance.
|
||||
# The old global functions below this point are effectively deprecated by the class methods.
|
||||
# I will remove them to avoid confusion and ensure all save/load operations
|
||||
# are managed through the Configuration instance with correct path context.
|
||||
|
||||
# Removing old global load/save functions as their logic is now
|
||||
# part of the Configuration class or replaced by its new loading/saving mechanisms.
|
||||
# load_base_config() - Replaced by Configuration.__init__()
|
||||
# save_llm_config(settings_dict: dict) - Replaced by Configuration.save_llm_settings()
|
||||
# save_user_config(settings_dict: dict) - Replaced by Configuration.save_user_settings()
|
||||
# save_base_config(settings_dict: dict) - Bundled app_settings.json should be read-only.
|
||||
# load_asset_definitions() -> dict - Replaced by Configuration._load_definition_file_with_fallback() logic
|
||||
# save_asset_definitions(data: dict) - Replaced by Configuration.save_asset_type_definitions()
|
||||
# load_file_type_definitions() -> dict - Replaced by Configuration._load_definition_file_with_fallback() logic
|
||||
# save_file_type_definitions(data: dict) - Replaced by Configuration.save_file_type_definitions()
|
||||
# load_supplier_settings() -> dict - Replaced by Configuration._load_definition_file_with_fallback() logic
|
||||
# save_supplier_settings(data: dict) - Replaced by Configuration.save_supplier_settings()
|
||||
def save_llm_config(settings_dict: dict):
|
||||
"""
|
||||
Saves the provided LLM settings dictionary to llm_settings.json.
|
||||
"""
|
||||
log.debug(f"Saving LLM config to: {LLM_SETTINGS_PATH}")
|
||||
try:
|
||||
with open(LLM_SETTINGS_PATH, 'w', encoding='utf-8') as f:
|
||||
json.dump(settings_dict, f, indent=4)
|
||||
# Use info level for successful save
|
||||
log.info(f"LLM config saved successfully to {LLM_SETTINGS_PATH}")
|
||||
except Exception as e:
|
||||
log.error(f"Failed to save LLM configuration file {LLM_SETTINGS_PATH}: {e}")
|
||||
# Re-raise as ConfigurationError to signal failure upstream
|
||||
raise ConfigurationError(f"Failed to save LLM configuration: {e}")
|
||||
def save_base_config(settings_dict: dict):
|
||||
"""
|
||||
Saves the provided settings dictionary to app_settings.json.
|
||||
"""
|
||||
log.debug(f"Saving base config to: {APP_SETTINGS_PATH}")
|
||||
try:
|
||||
with open(APP_SETTINGS_PATH, 'w', encoding='utf-8') as f:
|
||||
json.dump(settings_dict, f, indent=4)
|
||||
log.debug(f"Base config saved successfully.")
|
||||
except Exception as e:
|
||||
log.error(f"Failed to save base configuration file {APP_SETTINGS_PATH}: {e}")
|
||||
raise ConfigurationError(f"Failed to save configuration: {e}")
|
||||
|
||||
BIN
context_portal/conport_vector_data/3712b223-f80b-4c07-b57a-5cf7c8175c86/data_level0.bin
(Stored with Git LFS)
BIN
context_portal/conport_vector_data/3712b223-f80b-4c07-b57a-5cf7c8175c86/data_level0.bin
(Stored with Git LFS)
Binary file not shown.
BIN
context_portal/conport_vector_data/3712b223-f80b-4c07-b57a-5cf7c8175c86/header.bin
(Stored with Git LFS)
BIN
context_portal/conport_vector_data/3712b223-f80b-4c07-b57a-5cf7c8175c86/header.bin
(Stored with Git LFS)
Binary file not shown.
BIN
context_portal/conport_vector_data/3712b223-f80b-4c07-b57a-5cf7c8175c86/length.bin
(Stored with Git LFS)
BIN
context_portal/conport_vector_data/3712b223-f80b-4c07-b57a-5cf7c8175c86/length.bin
(Stored with Git LFS)
Binary file not shown.
BIN
context_portal/conport_vector_data/chroma.sqlite3
(Stored with Git LFS)
BIN
context_portal/conport_vector_data/chroma.sqlite3
(Stored with Git LFS)
Binary file not shown.
BIN
context_portal/context.db
(Stored with Git LFS)
BIN
context_portal/context.db
(Stored with Git LFS)
Binary file not shown.
@ -1,137 +0,0 @@
|
||||
# Plan for New Definitions Editor UI
|
||||
|
||||
## 1. Overview
|
||||
|
||||
This document outlines the plan to create a new, dedicated UI for managing "Asset Type Definitions", "File Type Definitions", and "Supplier Settings". This editor will provide a more structured and user-friendly way to manage these core application configurations, which are currently stored in separate JSON files.
|
||||
|
||||
## 2. General Design Principles
|
||||
|
||||
* **Dedicated Dialog:** The editor will be a new `QDialog` (e.g., `DefinitionsEditorDialog`).
|
||||
* **Access Point:** Launched from the `MainWindow` menu bar (e.g., under a "Definitions" menu or "Edit" -> "Edit Definitions...").
|
||||
* **Tabbed Interface:** The dialog will use a `QTabWidget` to separate the management of different definition types.
|
||||
* **List/Details View:** Each tab will generally follow a two-pane layout:
|
||||
* **Left Pane:** A `QListWidget` displaying the primary keys or names of the definitions (e.g., asset type names, file type IDs, supplier names). Includes "Add" and "Remove" buttons for managing these primary entries.
|
||||
* **Right Pane:** A details area (e.g., `QGroupBox` with a `QFormLayout`) that shows the specific settings for the item selected in the left-pane list.
|
||||
* **Data Persistence:** The dialog will load from and save to the respective JSON configuration files:
|
||||
* Asset Types: `config/asset_type_definitions.json`
|
||||
* File Types: `config/file_type_definitions.json`
|
||||
* Supplier Settings: `config/suppliers.json` (This file will be refactored from a simple list to a dictionary of supplier objects).
|
||||
* **User Experience:** Standard "Save" and "Cancel" buttons, with a check for unsaved changes.
|
||||
|
||||
## 3. Tab-Specific Plans
|
||||
|
||||
### 3.1. Asset Type Definitions Tab
|
||||
|
||||
* **Manages:** `config/asset_type_definitions.json`
|
||||
* **UI Sketch:**
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph AssetTypeTab [Asset Type Definitions Tab]
|
||||
direction LR
|
||||
AssetList[QListWidget (Asset Type Keys e.g., "Surface")] --> AssetDetailsGroup{Details for Selected Asset Type};
|
||||
end
|
||||
|
||||
subgraph AssetDetailsGroup
|
||||
direction TB
|
||||
Desc[Description: QTextEdit]
|
||||
Color[Color: QPushButton ("Choose Color...") + Color Swatch Display]
|
||||
Examples[Examples: QListWidget + Add/Remove Example Buttons]
|
||||
end
|
||||
AssetActions["Add Asset Type (Prompt for Name)\nRemove Selected Asset Type"] --> AssetList
|
||||
```
|
||||
* **Details:**
|
||||
* **Left Pane:** `QListWidget` for asset type names. "Add Asset Type" (prompts for new key) and "Remove Selected Asset Type" buttons.
|
||||
* **Right Pane (Details):**
|
||||
* `description`: `QTextEdit`.
|
||||
* `color`: `QPushButton` opening `QColorDialog`, with an adjacent `QLabel` to display the color swatch.
|
||||
* `examples`: `QListWidget` with "Add Example" (`QInputDialog.getText`) and "Remove Selected Example" buttons.
|
||||
|
||||
### 3.2. File Type Definitions Tab
|
||||
|
||||
* **Manages:** `config/file_type_definitions.json`
|
||||
* **UI Sketch:**
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph FileTypeTab [File Type Definitions Tab]
|
||||
direction LR
|
||||
FileList[QListWidget (File Type Keys e.g., "MAP_COL")] --> FileDetailsGroup{Details for Selected File Type};
|
||||
end
|
||||
|
||||
subgraph FileDetailsGroup
|
||||
direction TB
|
||||
DescF[Description: QTextEdit]
|
||||
ColorF[Color: QPushButton ("Choose Color...") + Color Swatch Display]
|
||||
ExamplesF[Examples: QListWidget + Add/Remove Example Buttons]
|
||||
StdType[Standard Type: QLineEdit]
|
||||
BitDepth[Bit Depth Rule: QComboBox ("respect", "force_8bit", "force_16bit")]
|
||||
IsGrayscale[Is Grayscale: QCheckBox]
|
||||
Keybind[Keybind: QLineEdit (1 char)]
|
||||
end
|
||||
FileActions["Add File Type (Prompt for ID)\nRemove Selected File Type"] --> FileList
|
||||
```
|
||||
* **Details:**
|
||||
* **Left Pane:** `QListWidget` for file type IDs. "Add File Type" (prompts for new key) and "Remove Selected File Type" buttons.
|
||||
* **Right Pane (Details):**
|
||||
* `description`: `QTextEdit`.
|
||||
* `color`: `QPushButton` opening `QColorDialog`, with an adjacent `QLabel` for color swatch.
|
||||
* `examples`: `QListWidget` with "Add Example" and "Remove Selected Example" buttons.
|
||||
* `standard_type`: `QLineEdit`.
|
||||
* `bit_depth_rule`: `QComboBox` (options: "respect", "force_8bit", "force_16bit").
|
||||
* `is_grayscale`: `QCheckBox`.
|
||||
* `keybind`: `QLineEdit` (validation for single character recommended).
|
||||
|
||||
### 3.3. Supplier Settings Tab
|
||||
|
||||
* **Manages:** `config/suppliers.json` (This file will be refactored to a dictionary structure, e.g., `{"SupplierName": {"normal_map_type": "OpenGL", ...}}`).
|
||||
* **UI Sketch:**
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph SupplierTab [Supplier Settings Tab]
|
||||
direction LR
|
||||
SupplierList[QListWidget (Supplier Names)] --> SupplierDetailsGroup{Details for Selected Supplier};
|
||||
end
|
||||
|
||||
subgraph SupplierDetailsGroup
|
||||
direction TB
|
||||
NormalMapType[Normal Map Type: QComboBox ("OpenGL", "DirectX")]
|
||||
%% Future supplier-specific settings can be added here
|
||||
end
|
||||
SupplierActions["Add Supplier (Prompt for Name)\nRemove Selected Supplier"] --> SupplierList
|
||||
```
|
||||
* **Details:**
|
||||
* **Left Pane:** `QListWidget` for supplier names. "Add Supplier" (prompts for new name) and "Remove Selected Supplier" buttons.
|
||||
* **Right Pane (Details):**
|
||||
* `normal_map_type`: `QComboBox` (options: "OpenGL", "DirectX"). Default for new suppliers: "OpenGL".
|
||||
* *(Space for future supplier-specific settings).*
|
||||
* **Data Handling Note for `config/suppliers.json`:**
|
||||
* The editor will load from and save to `config/suppliers.json` using the new dictionary format (supplier name as key, object of settings as value).
|
||||
* Initial implementation might require `config/suppliers.json` to be manually updated to this new format if it currently exists as a simple list. Alternatively, the editor could attempt an automatic conversion on first load if the old list format is detected, or prompt the user. For the first pass, assuming the editor works with the new format is simpler.
|
||||
|
||||
## 4. Implementation Steps (High-Level)
|
||||
|
||||
1. **(Potentially Manual First Step) Refactor `config/suppliers.json`:** If `config/suppliers.json` exists as a list, manually convert it to the new dictionary structure (e.g., `{"SupplierName": {"normal_map_type": "OpenGL"}}`) before starting UI development for this tab, or plan for the editor to handle this conversion.
|
||||
2. **Create `DefinitionsEditorDialog` Class:** Inherit from `QDialog`.
|
||||
3. **Implement UI Structure:** Main `QTabWidget`, and for each tab, the two-pane layout with `QListWidget`, `QGroupBox` for details, and relevant input widgets (`QLineEdit`, `QTextEdit`, `QComboBox`, `QCheckBox`, `QPushButton`).
|
||||
4. **Implement Loading Logic:**
|
||||
* For each tab, read data from its corresponding JSON file.
|
||||
* Populate the left-pane `QListWidget` with the primary keys/names.
|
||||
* Store the full data structure internally (e.g., in dictionaries within the dialog instance).
|
||||
5. **Implement Display Logic:**
|
||||
* When an item is selected in a `QListWidget`, populate the right-pane detail fields with the data for that item.
|
||||
6. **Implement Editing Logic:**
|
||||
* Ensure that changes made in the detail fields (text edits, combobox selections, checkbox states, color choices, list example modifications) update the corresponding internal data structure for the currently selected item.
|
||||
7. **Implement Add/Remove Functionality:**
|
||||
* For each definition type (Asset Type, File Type, Supplier), implement the "Add" and "Remove" buttons.
|
||||
* "Add": Prompt for a unique key/name, create a new default entry in the internal data, and add it to the `QListWidget`.
|
||||
* "Remove": Remove the selected item from the `QListWidget` and the internal data.
|
||||
* For "examples" lists within Asset and File types, implement their "Add Example" and "Remove Selected Example" buttons.
|
||||
8. **Implement Saving Logic:**
|
||||
* When the main "Save" button is clicked:
|
||||
* Write the (potentially modified) Asset Type definitions data structure to `config/asset_type_definitions.json`.
|
||||
* Write File Type definitions to `config/file_type_definitions.json`.
|
||||
* Write Supplier settings (in the new dictionary format) to `config/suppliers.json`.
|
||||
* Consider creating new dedicated save functions in `configuration.py` for each of these files if they don't already exist or if existing ones are not suitable.
|
||||
9. **Implement Unsaved Changes Check & Cancel Logic.**
|
||||
10. **Integrate Dialog Launch:** Add a menu action in `MainWindow.py` to open the `DefinitionsEditorDialog`.
|
||||
|
||||
This plan provides a comprehensive approach to creating a dedicated editor for these crucial application definitions.
|
||||
@ -1,113 +0,0 @@
|
||||
# Refactoring Plan for Preferences Window (ConfigEditorDialog)
|
||||
|
||||
## 1. Overview
|
||||
|
||||
This document outlines the plan to refactor the preferences window (`gui/config_editor_dialog.py`). The primary goal is to address issues related to misaligned scope, poor user experience for certain data types, and incomplete interactivity. The refactoring will focus on making the `ConfigEditorDialog` a robust editor for settings in `config/app_settings.json` that are intended to be overridden by the user via `config/user_settings.json`.
|
||||
|
||||
## 2. Assessment Summary
|
||||
|
||||
* **Misaligned Scope:** The dialog currently includes UI for "Asset Type Definitions" and "File Type Definitions". However, these are managed in separate dedicated JSON files ([`config/asset_type_definitions.json`](config/asset_type_definitions.json) and [`config/file_type_definitions.json`](config/file_type_definitions.json)) and are not saved by this dialog (which targets `config/user_settings.json`).
|
||||
* **Poor UX for Data Types:**
|
||||
* Lists (e.g., `RESPECT_VARIANT_MAP_TYPES`) are edited as comma-separated strings.
|
||||
* Dictionary-like structures (e.g., `IMAGE_RESOLUTIONS`) are handled inconsistently (JSON defines as dict, UI attempts list-of-pairs).
|
||||
* Editing complex list-of-objects (e.g., `MAP_MERGE_RULES`) is functionally incomplete.
|
||||
* **Incomplete Interactivity:** Many table-based editors lack "Add/Remove Row" functionality and proper cell delegates for intuitive editing.
|
||||
* **LLM Settings:** Confirmed to be correctly managed by the separate `LLMEditorWidget` and `config/llm_settings.json`, so they are out of scope for this specific dialog refactor.
|
||||
|
||||
## 3. Refactoring Phases and Plan Details
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[Start: Current State] --> B{Phase 1: Correct Scope & Critical UX/Data Fixes};
|
||||
B --> C{Phase 2: Enhance MAP_MERGE_RULES Editor};
|
||||
C --> D{Phase 3: General UX & Table Interactivity};
|
||||
D --> E[End: Refactored Preferences Window];
|
||||
|
||||
subgraph "Phase 1: Correct Scope & Critical UX/Data Fixes"
|
||||
B1[Remove Definitions Editing from ConfigEditorDialog]
|
||||
B2[Improve List Editing for RESPECT_VARIANT_MAP_TYPES]
|
||||
B3[Fix IMAGE_RESOLUTIONS Handling (Dictionary)]
|
||||
B4[Handle Simple Nested Settings (e.g., general_settings)]
|
||||
end
|
||||
|
||||
subgraph "Phase 2: Enhance MAP_MERGE_RULES Editor"
|
||||
C1[Implement Add/Remove for Merge Rules]
|
||||
C2[Improve Rule Detail Editing (ComboBoxes, SpinBoxes)]
|
||||
end
|
||||
|
||||
subgraph "Phase 3: General UX & Table Interactivity"
|
||||
D1[Implement IMAGE_RESOLUTIONS Table Add/Remove Buttons]
|
||||
D2[Implement Necessary Table Cell Delegates (e.g., for IMAGE_RESOLUTIONS values)]
|
||||
D3[Review/Refine Tab Layout & Widget Grouping]
|
||||
end
|
||||
|
||||
B --> B1; B --> B2; B --> B3; B --> B4;
|
||||
C --> C1; C --> C2;
|
||||
D --> D1; D --> D2; D --> D3;
|
||||
```
|
||||
|
||||
### Phase 1: Correct Scope & Critical UX/Data Fixes (in `gui/config_editor_dialog.py`)
|
||||
|
||||
1. **Remove Definitions Editing:**
|
||||
* **Action:** In `populate_definitions_tab`, remove the inner `QTabWidget` and the code that creates/populates the "Asset Types" and "File Types" tables.
|
||||
* The `DEFAULT_ASSET_CATEGORY` `QComboBox` (for the setting from `app_settings.json`) should remain. Its items should be populated using keys obtained from the `Configuration` class (which loads the actual `ASSET_TYPE_DEFINITIONS` from its dedicated file).
|
||||
* **Rationale:** Simplifies the dialog to settings managed via `user_settings.json`. Editing of the full definition files requires dedicated UI (see Future Enhancements note).
|
||||
|
||||
2. **Improve `RESPECT_VARIANT_MAP_TYPES` Editing:**
|
||||
* **Action:** In `populate_output_naming_tab`, replace the `QLineEdit` for `RESPECT_VARIANT_MAP_TYPES` with a `QListWidget` and "Add"/"Remove" buttons.
|
||||
* "Add" button: Use `QInputDialog.getItem` with items populated from `Configuration.get_file_type_keys()` (or similar method accessing loaded `FILE_TYPE_DEFINITIONS`) to allow users to select a valid file type key.
|
||||
* "Remove" button: Remove the selected item from the `QListWidget`.
|
||||
* Update `save_settings` to read the list of strings from this `QListWidget`.
|
||||
* Update `populate_widgets_from_settings` to populate this `QListWidget`.
|
||||
|
||||
3. **Fix `IMAGE_RESOLUTIONS` Handling:**
|
||||
* **Action:** In `populate_image_processing_tab`:
|
||||
* The `QTableWidget` for `IMAGE_RESOLUTIONS` should have two columns: "Name" (string, for the dictionary key) and "Resolution (px)" (integer, for the dictionary value).
|
||||
* In `populate_image_resolutions_table`, ensure it correctly populates from the dictionary structure in `self.settings['IMAGE_RESOLUTIONS']` (from `app_settings.json`).
|
||||
* In `save_settings`, ensure it correctly reads data from the table and reconstructs the `IMAGE_RESOLUTIONS` dictionary (e.g., `{"4K": 4096, "2K": 2048}`) when saving to `user_settings.json`.
|
||||
* ComboBoxes `CALCULATE_STATS_RESOLUTION` and `RESOLUTION_THRESHOLD_FOR_JPG` should be populated with the *keys* (names like "4K", "2K") from the `IMAGE_RESOLUTIONS` dictionary. `RESOLUTION_THRESHOLD_FOR_JPG` should also include "Never" and "Always" options. The `save_settings` method needs to correctly map these special ComboBox values back to appropriate storable values if necessary (e.g., sentinel numbers or specific strings if the backend configuration expects them for "Never"/"Always").
|
||||
|
||||
4. **Handle Simple Nested Settings (e.g., `general_settings`):**
|
||||
* **Action:** For `general_settings.invert_normal_map_green_channel_globally` (from `config/app_settings.json`):
|
||||
* Add a `QCheckBox` labeled "Invert Normal Map Green Channel Globally" to an appropriate tab (e.g., "Image Processing" or a "General" tab after layout review).
|
||||
* Update `populate_widgets_from_settings` to read `self.settings.get('general_settings', {}).get('invert_normal_map_green_channel_globally', False)`.
|
||||
* Update `save_settings` to write this value back to `target_file_content.setdefault('general_settings', {})['invert_normal_map_green_channel_globally'] = widget.isChecked()`.
|
||||
|
||||
### Phase 2: Enhance `MAP_MERGE_RULES` Editor (in `gui/config_editor_dialog.py`)
|
||||
|
||||
1. **Rule Management:**
|
||||
* **Action:** In `populate_map_merging_tab`:
|
||||
* Connect the "Add Rule" button:
|
||||
* Create a default new rule dictionary (e.g., `{"output_map_type": "NEW_RULE", "inputs": {}, "defaults": {}, "output_bit_depth": "respect_inputs"}`).
|
||||
* Add it to the internal list of rules that will be saved (e.g., a copy of `self.settings['MAP_MERGE_RULES']` that gets modified).
|
||||
* Add a new `QListWidgetItem` for it and select it to display its details.
|
||||
* Connect the "Remove Rule" button:
|
||||
* Remove the selected rule from the internal list and the `QListWidget`.
|
||||
* Clear the details panel.
|
||||
|
||||
2. **Rule Details Panel Improvements (`display_merge_rule_details`):**
|
||||
* **`output_map_type`:** Change the `QLineEdit` to a `QComboBox`. Populate its items from `Configuration.get_file_type_keys()`.
|
||||
* **`inputs` Table:** The "Input Map Type" column cells should use a `QComboBox` delegate, populated with `Configuration.get_file_type_keys()` plus an empty/None option.
|
||||
* **`defaults` Table:** The "Default Value" column cells should use a `QDoubleSpinBox` delegate (e.g., range 0.0 to 1.0, or 0-255 if appropriate for specific channel types).
|
||||
* Ensure changes in these detail editors update the underlying rule data associated with the selected `QListWidgetItem` and the internal list of rules.
|
||||
|
||||
### Phase 3: General UX & Table Interactivity (in `gui/config_editor_dialog.py`)
|
||||
|
||||
1. **Implement `IMAGE_RESOLUTIONS` Table Add/Remove Buttons:**
|
||||
* **Action:** In `populate_image_processing_tab`, connect the "Add Row" and "Remove Row" buttons for the `IMAGE_RESOLUTIONS` table.
|
||||
* "Add Row": Prompt for "Name" (string) and "Resolution (px)" (integer).
|
||||
* "Remove Row": Remove the selected row from the table and the underlying data.
|
||||
2. **Implement Necessary Table Cell Delegates:**
|
||||
* **Action:** For the `IMAGE_RESOLUTIONS` table, the "Resolution (px)" column should use a `QSpinBox` delegate or a `QLineEdit` with integer validation to ensure correct data input.
|
||||
3. **Review/Refine Tab Layout & Widget Grouping:**
|
||||
* **Action:** After the functional changes, review the overall layout of tabs and the grouping of settings within `gui/config_editor_dialog.py`.
|
||||
* Ensure settings from `config/app_settings.json` are logically placed and clearly labeled.
|
||||
* Verify widget labels are descriptive and tooltips are helpful where needed.
|
||||
* Confirm correct mapping between UI widgets and the keys in `app_settings.json` (e.g., `OUTPUT_FILENAME_PATTERN` vs. `TARGET_FILENAME_PATTERN`).
|
||||
|
||||
## 4. Future Enhancements (Out of Scope for this Refactor)
|
||||
|
||||
* **Dedicated Editors for Definitions:** As per user feedback, if `ASSET_TYPE_DEFINITIONS` and `FILE_TYPE_DEFINITIONS` require UI-based editing, dedicated dialogs/widgets should be created. These would read from and save to their respective files ([`config/asset_type_definitions.json`](config/asset_type_definitions.json) and [`config/file_type_definitions.json`](config/file_type_definitions.json)) and could adopt a list/details UI similar to the `MAP_MERGE_RULES` editor.
|
||||
* **Live Updates:** Consider mechanisms for applying some settings without requiring an application restart, if feasible for specific settings.
|
||||
|
||||
This plan aims to create a more focused, usable, and correct preferences window.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,7 +1,7 @@
|
||||
from pathlib import Path
|
||||
from PySide6.QtWidgets import QStyledItemDelegate, QLineEdit, QComboBox
|
||||
from PySide6.QtCore import Qt, QModelIndex
|
||||
from configuration import Configuration, ConfigurationError # Keep load_base_config for SupplierSearchDelegate
|
||||
from configuration import Configuration, ConfigurationError, load_base_config # Keep load_base_config for SupplierSearchDelegate
|
||||
from PySide6.QtWidgets import QListWidgetItem
|
||||
|
||||
import json
|
||||
@ -126,15 +126,12 @@ class SupplierSearchDelegate(QStyledItemDelegate):
|
||||
"""Loads the list of known suppliers from the JSON config file."""
|
||||
try:
|
||||
with open(SUPPLIERS_CONFIG_PATH, 'r') as f:
|
||||
suppliers_data = json.load(f) # Renamed variable for clarity
|
||||
if isinstance(suppliers_data, list):
|
||||
suppliers = json.load(f)
|
||||
if isinstance(suppliers, list):
|
||||
# Ensure all items are strings
|
||||
return sorted([str(s) for s in suppliers_data if isinstance(s, str)])
|
||||
elif isinstance(suppliers_data, dict): # ADDED: Handle dictionary case
|
||||
# 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 sorted([str(s) for s in suppliers if isinstance(s, str)])
|
||||
else:
|
||||
log.warning(f"'{SUPPLIERS_CONFIG_PATH}' does not contain a valid list. Starting fresh.")
|
||||
return []
|
||||
except FileNotFoundError:
|
||||
log.info(f"'{SUPPLIERS_CONFIG_PATH}' not found. Starting with an empty supplier list.")
|
||||
|
||||
@ -1,388 +0,0 @@
|
||||
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()
|
||||
@ -1,7 +1,6 @@
|
||||
# gui/llm_editor_widget.py
|
||||
import json
|
||||
import logging
|
||||
import copy # Added for deepcopy
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QTabWidget, QPlainTextEdit, QGroupBox,
|
||||
QHBoxLayout, QPushButton, QFormLayout, QLineEdit, QDoubleSpinBox,
|
||||
@ -10,7 +9,7 @@ from PySide6.QtWidgets import (
|
||||
from PySide6.QtCore import Slot as pyqtSlot, Signal as pyqtSignal # Use PySide6 equivalents
|
||||
|
||||
# Assuming configuration module exists and has relevant functions later
|
||||
from configuration import ConfigurationError
|
||||
from configuration import save_llm_config, ConfigurationError
|
||||
# For now, define path directly for initial structure
|
||||
LLM_CONFIG_PATH = "config/llm_settings.json"
|
||||
|
||||
@ -25,7 +24,6 @@ class LLMEditorWidget(QWidget):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._unsaved_changes = False
|
||||
self.original_llm_settings = {} # Initialize original_llm_settings
|
||||
self._init_ui()
|
||||
self._connect_signals()
|
||||
self.save_button.setEnabled(False) # Initially disabled
|
||||
@ -133,7 +131,6 @@ class LLMEditorWidget(QWidget):
|
||||
try:
|
||||
with open(LLM_CONFIG_PATH, 'r', encoding='utf-8') as f:
|
||||
settings = json.load(f)
|
||||
self.original_llm_settings = copy.deepcopy(settings) # Store a deep copy
|
||||
|
||||
# Populate Prompt Settings
|
||||
self.prompt_editor.setPlainText(settings.get("llm_predictor_prompt", ""))
|
||||
@ -162,9 +159,9 @@ class LLMEditorWidget(QWidget):
|
||||
logger.info("LLM settings loaded successfully.")
|
||||
|
||||
except FileNotFoundError:
|
||||
logger.warning(f"LLM settings file not found: {LLM_CONFIG_PATH}. Using defaults.")
|
||||
logger.warning(f"LLM settings file not found: {LLM_CONFIG_PATH}. Using defaults and disabling editor.")
|
||||
QMessageBox.warning(self, "Load Error",
|
||||
f"LLM settings file not found:\n{LLM_CONFIG_PATH}\n\nNew settings will be created if you save.")
|
||||
f"LLM settings file not found:\n{LLM_CONFIG_PATH}\n\nPlease ensure the file exists. Using default values.")
|
||||
# Reset to defaults (optional, or leave fields empty)
|
||||
self.prompt_editor.clear()
|
||||
self.endpoint_url_edit.clear()
|
||||
@ -172,21 +169,19 @@ class LLMEditorWidget(QWidget):
|
||||
self.model_name_edit.clear()
|
||||
self.temperature_spinbox.setValue(0.7)
|
||||
self.timeout_spinbox.setValue(120)
|
||||
self.original_llm_settings = {} # Start with empty original settings if file not found
|
||||
# self.setEnabled(False) # Disabling might be too harsh if user wants to create settings
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Error decoding JSON from {LLM_CONFIG_PATH}: {e}")
|
||||
QMessageBox.critical(self, "Load Error",
|
||||
f"Failed to parse LLM settings file:\n{LLM_CONFIG_PATH}\n\nError: {e}\n\nPlease check the file for syntax errors. Editor will be disabled.")
|
||||
self.setEnabled(False) # Disable editor on critical load error
|
||||
self.original_llm_settings = {} # Reset original settings on JSON error
|
||||
|
||||
except Exception as e: # Catch other potential errors during loading/populating
|
||||
logger.error(f"An unexpected error occurred loading LLM settings: {e}", exc_info=True)
|
||||
QMessageBox.critical(self, "Load Error",
|
||||
f"An unexpected error occurred while loading settings:\n{e}\n\nEditor will be disabled.")
|
||||
self.setEnabled(False)
|
||||
self.original_llm_settings = {} # Reset original settings on other errors
|
||||
|
||||
|
||||
# Reset unsaved changes flag and disable save button after loading
|
||||
@ -206,38 +201,26 @@ class LLMEditorWidget(QWidget):
|
||||
"""Gather data from UI, save to JSON file, and handle errors."""
|
||||
logger.info("Attempting to save LLM settings...")
|
||||
|
||||
# 1.a. Load Current Target File
|
||||
target_file_content = {}
|
||||
try:
|
||||
with open(LLM_CONFIG_PATH, 'r', encoding='utf-8') as f:
|
||||
target_file_content = json.load(f)
|
||||
except FileNotFoundError:
|
||||
logger.info(f"{LLM_CONFIG_PATH} not found. Will create a new one.")
|
||||
target_file_content = {} # Start with an empty dict if file doesn't exist
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Error decoding existing {LLM_CONFIG_PATH}: {e}. Starting with an empty config for save.")
|
||||
QMessageBox.warning(self, "Warning",
|
||||
f"Could not parse existing LLM settings file ({LLM_CONFIG_PATH}).\n"
|
||||
f"Any pre-existing settings in that file might be overwritten if you save now.\nError: {e}")
|
||||
target_file_content = {} # Start fresh if current file is corrupt
|
||||
|
||||
# 1.b. Gather current UI settings into current_llm_settings
|
||||
current_llm_settings = {}
|
||||
settings_dict = {}
|
||||
parsed_examples = []
|
||||
has_errors = False # For example parsing
|
||||
has_errors = False
|
||||
|
||||
current_llm_settings["llm_endpoint_url"] = self.endpoint_url_edit.text().strip()
|
||||
current_llm_settings["llm_api_key"] = self.api_key_edit.text() # Keep as is
|
||||
current_llm_settings["llm_model_name"] = self.model_name_edit.text().strip()
|
||||
current_llm_settings["llm_temperature"] = self.temperature_spinbox.value()
|
||||
current_llm_settings["llm_request_timeout"] = self.timeout_spinbox.value()
|
||||
current_llm_settings["llm_predictor_prompt"] = self.prompt_editor.toPlainText().strip()
|
||||
# Gather API Settings
|
||||
settings_dict["llm_endpoint_url"] = self.endpoint_url_edit.text().strip()
|
||||
settings_dict["llm_api_key"] = self.api_key_edit.text() # Keep as is, don't strip
|
||||
settings_dict["llm_model_name"] = self.model_name_edit.text().strip()
|
||||
settings_dict["llm_temperature"] = self.temperature_spinbox.value()
|
||||
settings_dict["llm_request_timeout"] = self.timeout_spinbox.value()
|
||||
|
||||
# Gather Prompt Settings
|
||||
settings_dict["llm_predictor_prompt"] = self.prompt_editor.toPlainText().strip()
|
||||
|
||||
# Gather and Parse Examples
|
||||
for i in range(self.examples_tab_widget.count()):
|
||||
example_editor = self.examples_tab_widget.widget(i)
|
||||
if isinstance(example_editor, QTextEdit):
|
||||
example_text = example_editor.toPlainText().strip()
|
||||
if not example_text:
|
||||
if not example_text: # Skip empty examples silently
|
||||
continue
|
||||
try:
|
||||
parsed_example = json.loads(example_text)
|
||||
@ -248,64 +231,40 @@ class LLMEditorWidget(QWidget):
|
||||
logger.warning(f"Invalid JSON in '{tab_name}': {e}. Skipping example.")
|
||||
QMessageBox.warning(self, "Invalid Example",
|
||||
f"The content in '{tab_name}' is not valid JSON and will not be saved.\n\nError: {e}\n\nPlease correct it or remove the tab.")
|
||||
# Optionally switch to the tab with the error:
|
||||
# self.examples_tab_widget.setCurrentIndex(i)
|
||||
else:
|
||||
logger.warning(f"Widget at index {i} in examples tab is not a QTextEdit. Skipping.")
|
||||
logger.warning(f"Widget at index {i} in examples tab is not a QTextEdit. Skipping.")
|
||||
|
||||
|
||||
if has_errors:
|
||||
logger.warning("LLM settings not saved due to invalid JSON in examples.")
|
||||
return
|
||||
# Keep save button enabled if there were errors, allowing user to fix and retry
|
||||
# self.save_button.setEnabled(True)
|
||||
# self._unsaved_changes = True
|
||||
return # Stop saving process
|
||||
|
||||
current_llm_settings["llm_predictor_examples"] = parsed_examples
|
||||
settings_dict["llm_predictor_examples"] = parsed_examples
|
||||
|
||||
# 1.c. Identify Changes and Update Target File Content
|
||||
changed_settings_count = 0
|
||||
for key, current_value in current_llm_settings.items():
|
||||
original_value = self.original_llm_settings.get(key)
|
||||
|
||||
# Special handling for lists (e.g., examples) - direct comparison works
|
||||
# For other types, direct comparison also works.
|
||||
# This includes new keys present in current_llm_settings but not in original_llm_settings
|
||||
if key not in self.original_llm_settings or current_value != original_value:
|
||||
target_file_content[key] = current_value
|
||||
logger.debug(f"Setting '{key}' changed or added. Old: '{original_value}', New: '{current_value}'")
|
||||
changed_settings_count +=1
|
||||
|
||||
if changed_settings_count == 0 and self._unsaved_changes:
|
||||
logger.info("Save called, but no actual changes detected compared to original loaded settings.")
|
||||
# If _unsaved_changes was true, it means UI interaction happened,
|
||||
# but values might have been reverted to original.
|
||||
# We still proceed to save target_file_content as it might contain
|
||||
# values from a file that was modified externally since last load.
|
||||
# Or, if the file didn't exist, it will now be created with current UI values.
|
||||
|
||||
# 1.d. Save Updated Content
|
||||
# Save the dictionary to file
|
||||
try:
|
||||
# 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)
|
||||
|
||||
save_llm_config(settings_dict)
|
||||
QMessageBox.information(self, "Save Successful", f"LLM settings saved to:\n{LLM_CONFIG_PATH}")
|
||||
|
||||
# Update original_llm_settings to reflect the newly saved state
|
||||
self.original_llm_settings = copy.deepcopy(target_file_content)
|
||||
|
||||
self.save_button.setEnabled(False)
|
||||
self._unsaved_changes = False
|
||||
self.settings_saved.emit()
|
||||
self.settings_saved.emit() # Notify MainWindow or others
|
||||
logger.info("LLM settings saved successfully.")
|
||||
|
||||
except (IOError, OSError) as e:
|
||||
logger.error(f"Failed to write LLM settings file {LLM_CONFIG_PATH}: {e}")
|
||||
QMessageBox.critical(self, "Save Error", f"Could not write LLM settings file.\n\nError: {e}")
|
||||
self.save_button.setEnabled(True) # Keep save enabled
|
||||
except ConfigurationError as e:
|
||||
logger.error(f"Failed to save LLM settings: {e}")
|
||||
QMessageBox.critical(self, "Save Error", f"Could not save LLM settings.\n\nError: {e}")
|
||||
# Keep save button enabled as save failed
|
||||
self.save_button.setEnabled(True)
|
||||
self._unsaved_changes = True
|
||||
except Exception as e:
|
||||
except Exception as e: # Catch unexpected errors during save
|
||||
logger.error(f"An unexpected error occurred during LLM settings save: {e}", exc_info=True)
|
||||
QMessageBox.critical(self, "Save Error", f"An unexpected error occurred while saving settings:\n{e}")
|
||||
self.save_button.setEnabled(True) # Keep save enabled
|
||||
self.save_button.setEnabled(True)
|
||||
self._unsaved_changes = True
|
||||
|
||||
# --- Example Management Slots ---
|
||||
|
||||
@ -24,9 +24,6 @@ class LLMPredictionHandler(BasePredictionHandler):
|
||||
Handles the interaction with an LLM for predicting asset structures
|
||||
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
|
||||
|
||||
# Changed 'config: Configuration' to 'settings: dict'
|
||||
@ -310,67 +307,54 @@ class LLMPredictionHandler(BasePredictionHandler):
|
||||
valid_file_types = list(self.settings.get('file_type_definitions', {}).keys())
|
||||
asset_rules_map: Dict[str, AssetRule] = {} # Maps group_name to AssetRule
|
||||
|
||||
# --- Map LLM File Analysis for Quick Lookup ---
|
||||
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:
|
||||
# --- Process Individual Files and Build Rules ---
|
||||
for file_data in response_data["individual_file_analysis"]:
|
||||
# Check for cancellation within the loop
|
||||
if self._is_cancelled:
|
||||
log.info("LLM prediction cancelled during response parsing (files).")
|
||||
return []
|
||||
|
||||
file_data = llm_file_map.pop(file_path_rel, None) # Get data if exists, remove from map
|
||||
if not isinstance(file_data, dict):
|
||||
log.warning(f"Skipping invalid file data entry (not a dict): {file_data}")
|
||||
continue
|
||||
|
||||
if file_data:
|
||||
# --- File found in LLM output - Use LLM Classification ---
|
||||
file_type = file_data.get("classified_file_type")
|
||||
group_name = file_data.get("proposed_asset_group_name") # Can be string or null
|
||||
file_path_rel = file_data.get("relative_file_path")
|
||||
file_type = file_data.get("classified_file_type")
|
||||
group_name = file_data.get("proposed_asset_group_name") # Can be string or null
|
||||
|
||||
# Validate file_type against definitions, unless it's FILE_IGNORE
|
||||
if not file_type or not isinstance(file_type, str):
|
||||
log.warning(f"Missing or invalid 'classified_file_type' for file '{file_path_rel}' from LLM. Defaulting to {self.FILE_UNCLASSIFIED_BY_LLM}.")
|
||||
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"
|
||||
# --- Validate File Data ---
|
||||
if not file_path_rel or not isinstance(file_path_rel, str):
|
||||
log.warning(f"Missing or invalid 'relative_file_path' in file data: {file_data}. Skipping file.")
|
||||
continue
|
||||
|
||||
# Handle FILE_IGNORE explicitly - do not create a rule for it
|
||||
if file_type == "FILE_IGNORE":
|
||||
log.debug(f"Ignoring file as per LLM prediction: {file_path_rel}")
|
||||
continue
|
||||
if not file_type or not isinstance(file_type, str):
|
||||
log.warning(f"Missing or invalid 'classified_file_type' for file '{file_path_rel}'. Skipping file.")
|
||||
continue
|
||||
|
||||
# Determine group name and 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}) from LLM. Assigning to default asset.")
|
||||
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
|
||||
# Handle FILE_IGNORE explicitly
|
||||
if file_type == "FILE_IGNORE":
|
||||
log.debug(f"Ignoring file as per LLM prediction: {file_path_rel}")
|
||||
continue # Skip creating a rule for this file
|
||||
|
||||
else:
|
||||
# --- File NOT found in LLM output - Assign Default Classification ---
|
||||
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 = self.FILE_UNCLASSIFIED_BY_LLM
|
||||
group_name = "Unclassified Files" # Default group name
|
||||
asset_type = "UtilityMap" # Default asset type
|
||||
# Validate file_type against definitions
|
||||
if file_type not in valid_file_types:
|
||||
log.warning(f"Invalid predicted_file_type '{file_type}' for file '{file_path_rel}'. Defaulting to EXTRA.")
|
||||
file_type = "EXTRA"
|
||||
|
||||
# --- Handle Grouping and 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 ---
|
||||
try:
|
||||
@ -389,34 +373,25 @@ class LLMPredictionHandler(BasePredictionHandler):
|
||||
# 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}'.")
|
||||
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)
|
||||
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 ---
|
||||
file_rule = FileRule(
|
||||
file_path=file_path_abs,
|
||||
item_type=file_type,
|
||||
item_type_override=file_type, # Initial override based on classification (LLM or default)
|
||||
item_type_override=file_type, # Initial override based on LLM
|
||||
target_asset_name_override=group_name,
|
||||
output_format_override=None,
|
||||
resolution_override=None,
|
||||
channel_merge_instructions={}
|
||||
)
|
||||
file_rule.parent_asset = asset_rule # Set parent back-reference
|
||||
asset_rule.files.append(file_rule)
|
||||
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
|
||||
if not source_rule.assets:
|
||||
log.warning(f"LLM prediction for '{self.input_source_identifier}' resulted in zero valid assets after processing actual file list.")
|
||||
log.warning(f"LLM prediction for '{self.input_source_identifier}' resulted in zero valid assets after parsing.")
|
||||
|
||||
return [source_rule] # Return list containing the single SourceRule
|
||||
|
||||
@ -23,8 +23,15 @@ from .unified_view_model import UnifiedViewModel
|
||||
|
||||
from rule_structure import SourceRule, AssetRule, FileRule
|
||||
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__)
|
||||
from configuration import Configuration, ConfigurationError # Import Configuration class and Error
|
||||
|
||||
class MainPanelWidget(QWidget):
|
||||
"""
|
||||
@ -50,7 +57,7 @@ class MainPanelWidget(QWidget):
|
||||
|
||||
blender_settings_changed = Signal(bool, str, str)
|
||||
|
||||
def __init__(self, config: Configuration, unified_model: UnifiedViewModel, parent=None, file_type_keys: list[str] | None = None):
|
||||
def __init__(self, unified_model: UnifiedViewModel, parent=None, file_type_keys: list[str] | None = None):
|
||||
"""
|
||||
Initializes the MainPanelWidget.
|
||||
|
||||
@ -60,7 +67,6 @@ class MainPanelWidget(QWidget):
|
||||
file_type_keys: A list of available file type names (keys from FILE_TYPE_DEFINITIONS).
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self._config = config # Store the Configuration object
|
||||
self.unified_model = unified_model
|
||||
self.file_type_keys = file_type_keys if file_type_keys else []
|
||||
self.llm_processing_active = False
|
||||
@ -85,19 +91,21 @@ class MainPanelWidget(QWidget):
|
||||
output_layout.addWidget(self.browse_output_button)
|
||||
main_layout.addLayout(output_layout)
|
||||
|
||||
try:
|
||||
# Access configuration directly from the stored object
|
||||
# Use the output_directory_pattern from the Configuration object
|
||||
output_pattern = self._config.output_directory_pattern
|
||||
# Assuming the pattern is relative to the project root for the default
|
||||
default_output_dir = (self.project_root / output_pattern).resolve()
|
||||
self.output_path_edit.setText(str(default_output_dir))
|
||||
log.info(f"MainPanelWidget: Default output directory set to: {default_output_dir} based on pattern '{output_pattern}'")
|
||||
except ConfigurationError as e:
|
||||
log.error(f"MainPanelWidget: Configuration Error setting default output directory: {e}")
|
||||
self.output_path_edit.setText("")
|
||||
except Exception as e:
|
||||
log.exception(f"MainPanelWidget: Unexpected Error setting default output directory: {e}")
|
||||
if load_base_config:
|
||||
try:
|
||||
base_config = load_base_config()
|
||||
output_base_dir_config = base_config.get('OUTPUT_BASE_DIR', '../Asset_Processor_Output')
|
||||
default_output_dir = (self.project_root / output_base_dir_config).resolve()
|
||||
self.output_path_edit.setText(str(default_output_dir))
|
||||
log.info(f"MainPanelWidget: Default output directory set to: {default_output_dir}")
|
||||
except ConfigurationError as e:
|
||||
log.error(f"MainPanelWidget: Error reading base configuration for default output directory: {e}")
|
||||
self.output_path_edit.setText("")
|
||||
except Exception as e:
|
||||
log.exception(f"MainPanelWidget: Error setting default output directory: {e}")
|
||||
self.output_path_edit.setText("")
|
||||
else:
|
||||
log.warning("MainPanelWidget: load_base_config not available to set default output path.")
|
||||
self.output_path_edit.setText("")
|
||||
|
||||
|
||||
@ -172,14 +180,19 @@ class MainPanelWidget(QWidget):
|
||||
materials_layout.addWidget(self.browse_materials_blend_button)
|
||||
blender_layout.addLayout(materials_layout)
|
||||
|
||||
try:
|
||||
# Use hardcoded defaults as Configuration object does not expose these via public interface
|
||||
default_ng_path = ''
|
||||
default_mat_path = ''
|
||||
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 "")
|
||||
except Exception as e:
|
||||
log.error(f"MainPanelWidget: Error setting default Blender paths: {e}")
|
||||
if load_base_config:
|
||||
try:
|
||||
base_config = load_base_config()
|
||||
default_ng_path = base_config.get('DEFAULT_NODEGROUP_BLEND_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.materials_blend_path_input.setText(default_mat_path if default_mat_path else "")
|
||||
except ConfigurationError as 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)
|
||||
|
||||
@ -27,7 +27,6 @@ from .llm_editor_widget import LLMEditorWidget
|
||||
from .log_console_widget import LogConsoleWidget
|
||||
from .main_panel_widget import MainPanelWidget
|
||||
|
||||
from .definitions_editor_dialog import DefinitionsEditorDialog
|
||||
# --- Backend Imports for Data Structures ---
|
||||
from rule_structure import SourceRule, AssetRule, FileRule
|
||||
|
||||
@ -46,13 +45,14 @@ if str(project_root) not in sys.path:
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
try:
|
||||
from configuration import Configuration, ConfigurationError
|
||||
from configuration import Configuration, ConfigurationError, load_base_config
|
||||
|
||||
|
||||
except ImportError as 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.")
|
||||
Configuration = None
|
||||
load_base_config = None
|
||||
ConfigurationError = Exception
|
||||
AssetProcessor = None
|
||||
RuleBasedPredictionHandler = None
|
||||
@ -96,9 +96,8 @@ class MainWindow(QMainWindow):
|
||||
start_prediction_signal = Signal(str, list, str)
|
||||
start_backend_processing = Signal(list, dict)
|
||||
|
||||
def __init__(self, config: Configuration):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.config = config # Store the Configuration object
|
||||
|
||||
self.setWindowTitle("Asset Processor Tool")
|
||||
self.resize(1200, 700)
|
||||
@ -132,7 +131,7 @@ class MainWindow(QMainWindow):
|
||||
self.setCentralWidget(self.splitter)
|
||||
|
||||
# --- Create Models ---
|
||||
self.unified_model = UnifiedViewModel(config=self.config)
|
||||
self.unified_model = UnifiedViewModel()
|
||||
# --- Instantiate Handlers that depend on the model ---
|
||||
self.restructure_handler = AssetRestructureHandler(self.unified_model, self)
|
||||
|
||||
@ -143,16 +142,17 @@ class MainWindow(QMainWindow):
|
||||
# --- Load File Type Definitions for Rule Editor ---
|
||||
file_type_keys = []
|
||||
try:
|
||||
# Access configuration directly from the stored object using public methods
|
||||
file_type_defs = self.config.get_file_type_definitions_with_examples()
|
||||
file_type_keys = list(file_type_defs.keys())
|
||||
log.info(f"Loaded {len(file_type_keys)} FILE_TYPE_DEFINITIONS keys for RuleEditor.")
|
||||
base_cfg_data = load_base_config()
|
||||
if base_cfg_data and "FILE_TYPE_DEFINITIONS" in base_cfg_data:
|
||||
file_type_keys = list(base_cfg_data["FILE_TYPE_DEFINITIONS"].keys())
|
||||
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:
|
||||
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 config, model, self (MainWindow) for context, and file_type_keys
|
||||
self.main_panel_widget = MainPanelWidget(config=self.config, unified_model=self.unified_model, parent=self, file_type_keys=file_type_keys)
|
||||
# Instantiate MainPanelWidget, passing the 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.log_console = LogConsoleWidget(self)
|
||||
|
||||
# --- Create Left Pane with Static Selector and Stacked Editor ---
|
||||
@ -214,8 +214,8 @@ class MainWindow(QMainWindow):
|
||||
}
|
||||
self.qt_key_to_ftd_map = {}
|
||||
try:
|
||||
# Access configuration directly from the stored object using public methods
|
||||
file_type_defs = self.config.get_file_type_definitions_with_examples()
|
||||
base_settings = load_base_config()
|
||||
file_type_defs = base_settings.get('FILE_TYPE_DEFINITIONS', {})
|
||||
for ftd_key, ftd_value in file_type_defs.items():
|
||||
if isinstance(ftd_value, dict) and 'keybind' in ftd_value:
|
||||
char_key = ftd_value['keybind']
|
||||
@ -310,7 +310,7 @@ class MainWindow(QMainWindow):
|
||||
log.info(f"Added {added_count} new asset paths: {newly_added_paths}")
|
||||
self.statusBar().showMessage(f"Added {added_count} asset(s). Updating preview...", 3000)
|
||||
|
||||
mode, selected_display_name, preset_file_path = self.preset_editor_widget.get_selected_preset_mode()
|
||||
mode, selected_preset_text = self.preset_editor_widget.get_selected_preset_mode()
|
||||
|
||||
if mode == "llm":
|
||||
log.info(f"LLM Interpretation selected. Preparing LLM prediction for {len(newly_added_paths)} new paths.")
|
||||
@ -329,9 +329,8 @@ class MainWindow(QMainWindow):
|
||||
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)
|
||||
# The handler manages starting its own processing internally.
|
||||
elif mode == "preset" and selected_display_name and preset_file_path:
|
||||
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.")
|
||||
elif mode == "preset" and selected_preset_text:
|
||||
log.info(f"Preset '{selected_preset_text}' selected. Triggering prediction for {len(newly_added_paths)} new paths.")
|
||||
if self.prediction_thread and not self.prediction_thread.isRunning():
|
||||
log.debug("Starting prediction thread from add_input_paths.")
|
||||
self.prediction_thread.start()
|
||||
@ -343,8 +342,7 @@ class MainWindow(QMainWindow):
|
||||
self._source_file_lists[input_path_str] = file_list
|
||||
self._pending_predictions.add(input_path_str)
|
||||
log.debug(f"Added '{input_path_str}' to pending predictions. Current pending: {self._pending_predictions}")
|
||||
# Pass the filename stem for loading, not the display name
|
||||
self.start_prediction_signal.emit(input_path_str, file_list, preset_name_for_loading)
|
||||
self.start_prediction_signal.emit(input_path_str, file_list, selected_preset_text)
|
||||
else:
|
||||
log.warning(f"Skipping prediction for {input_path_str} due to extraction error.")
|
||||
elif mode == "placeholder":
|
||||
@ -447,12 +445,7 @@ class MainWindow(QMainWindow):
|
||||
self.statusBar().showMessage("No assets added to process.", 3000)
|
||||
return
|
||||
|
||||
# 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()
|
||||
mode, selected_preset_name = self.preset_editor_widget.get_selected_preset_mode()
|
||||
|
||||
|
||||
output_dir_str = settings.get("output_dir")
|
||||
@ -700,7 +693,7 @@ class MainWindow(QMainWindow):
|
||||
log.error("RuleBasedPredictionHandler not loaded. Cannot update preview.")
|
||||
self.statusBar().showMessage("Error: Prediction components not loaded.", 5000)
|
||||
return
|
||||
mode, selected_display_name, preset_file_path = self.preset_editor_widget.get_selected_preset_mode()
|
||||
mode, selected_preset_name = self.preset_editor_widget.get_selected_preset_mode()
|
||||
|
||||
if mode == "placeholder":
|
||||
log.debug("Update preview called with placeholder preset selected. Showing existing raw inputs (detailed view).")
|
||||
@ -755,10 +748,9 @@ class MainWindow(QMainWindow):
|
||||
# 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'.
|
||||
|
||||
elif mode == "preset" and selected_display_name and preset_file_path:
|
||||
preset_name_for_loading = preset_file_path.stem
|
||||
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)
|
||||
elif mode == "preset" and selected_preset_name:
|
||||
log.info(f"[{time.time():.4f}] Requesting background preview update for {len(input_paths)} items using Preset='{selected_preset_name}'")
|
||||
self.statusBar().showMessage(f"Updating preview for '{selected_preset_name}'...", 0)
|
||||
|
||||
log.debug("Clearing accumulated rules for new standard preview batch.")
|
||||
self._accumulated_rules.clear()
|
||||
@ -771,8 +763,8 @@ class MainWindow(QMainWindow):
|
||||
for input_path_str in input_paths:
|
||||
file_list = self._extract_file_list(input_path_str)
|
||||
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, using preset file stem: {preset_name_for_loading}.")
|
||||
self.start_prediction_signal.emit(input_path_str, file_list, preset_name_for_loading) # Pass stem for loading
|
||||
log.debug(f"[{time.time():.4f}] Emitting start_prediction_signal for: {input_path_str} with {len(file_list)} files.")
|
||||
self.start_prediction_signal.emit(input_path_str, file_list, selected_preset_name)
|
||||
else:
|
||||
log.warning(f"[{time.time():.4f}] Skipping standard prediction signal for {input_path_str} due to extraction error.")
|
||||
else:
|
||||
@ -786,8 +778,7 @@ class MainWindow(QMainWindow):
|
||||
|
||||
if RuleBasedPredictionHandler and self.prediction_thread is None:
|
||||
self.prediction_thread = QThread(self)
|
||||
# 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 = RuleBasedPredictionHandler(input_source_identifier="", original_input_paths=[], preset_name="")
|
||||
self.prediction_handler.moveToThread(self.prediction_thread)
|
||||
|
||||
self.start_prediction_signal.connect(self.prediction_handler.run_prediction, Qt.ConnectionType.QueuedConnection)
|
||||
@ -870,11 +861,6 @@ class MainWindow(QMainWindow):
|
||||
self.preferences_action = QAction("&Preferences...", self)
|
||||
self.preferences_action.triggered.connect(self._open_config_editor)
|
||||
edit_menu.addAction(self.preferences_action)
|
||||
edit_menu.addSeparator()
|
||||
|
||||
self.definitions_editor_action = QAction("Edit Definitions...", self)
|
||||
self.definitions_editor_action.triggered.connect(self._open_definitions_editor)
|
||||
edit_menu.addAction(self.definitions_editor_action)
|
||||
|
||||
view_menu = self.menu_bar.addMenu("&View")
|
||||
|
||||
@ -918,17 +904,6 @@ class MainWindow(QMainWindow):
|
||||
log.exception(f"Error opening configuration editor dialog: {e}")
|
||||
QMessageBox.critical(self, "Error", f"An error occurred while opening the configuration editor:\n{e}")
|
||||
|
||||
@Slot() # PySide6.QtCore.Slot
|
||||
def _open_definitions_editor(self):
|
||||
log.debug("Opening Definitions Editor dialog.")
|
||||
try:
|
||||
# DefinitionsEditorDialog is imported at the top of the file
|
||||
dialog = DefinitionsEditorDialog(self)
|
||||
dialog.exec_() # Use exec_() for modal dialog
|
||||
log.debug("Definitions Editor dialog closed.")
|
||||
except Exception as e:
|
||||
log.exception(f"Error opening Definitions Editor dialog: {e}")
|
||||
QMessageBox.critical(self, "Error", f"An error occurred while opening the Definitions Editor:\n{e}")
|
||||
|
||||
@Slot(bool)
|
||||
def _toggle_log_console_visibility(self, checked):
|
||||
@ -1074,13 +1049,13 @@ class MainWindow(QMainWindow):
|
||||
log.debug(f"<-- Exiting _handle_prediction_completion for '{input_path}'")
|
||||
|
||||
|
||||
@Slot(str, str, Path) # mode, display_name, file_path (Path can be None)
|
||||
def _on_preset_selection_changed(self, mode: str, display_name: str | None, file_path: Path | None ):
|
||||
@Slot(str, str)
|
||||
def _on_preset_selection_changed(self, mode: str, preset_name: str | None):
|
||||
"""
|
||||
Handles changes in the preset editor selection (preset, LLM, placeholder).
|
||||
Switches between PresetEditorWidget and LLMEditorWidget.
|
||||
"""
|
||||
log.info(f"Preset selection changed: mode='{mode}', display_name='{display_name}', file_path='{file_path}'")
|
||||
log.info(f"Preset selection changed: mode='{mode}', preset_name='{preset_name}'")
|
||||
|
||||
if mode == "llm":
|
||||
log.debug("Switching editor stack to LLM Editor Widget.")
|
||||
@ -1102,11 +1077,11 @@ class MainWindow(QMainWindow):
|
||||
self.editor_stack.setCurrentWidget(self.preset_editor_widget.json_editor_container)
|
||||
# The PresetEditorWidget's internal logic handles disabling/clearing the editor fields.
|
||||
|
||||
if mode == "preset" and display_name: # Use display_name for window title
|
||||
if mode == "preset" and preset_name:
|
||||
# This might be redundant if the editor handles its own title updates on save/load
|
||||
# but good for consistency.
|
||||
unsaved = self.preset_editor_widget.editor_unsaved_changes
|
||||
self.setWindowTitle(f"Asset Processor Tool - {display_name}{'*' if unsaved else ''}")
|
||||
self.setWindowTitle(f"Asset Processor Tool - {preset_name}{'*' if unsaved else ''}")
|
||||
elif mode == "llm":
|
||||
self.setWindowTitle("Asset Processor Tool - LLM Interpretation")
|
||||
else:
|
||||
@ -1340,7 +1315,6 @@ def run_gui():
|
||||
"""Initializes and runs the Qt application."""
|
||||
print("--- Reached run_gui() ---")
|
||||
from PySide6.QtGui import QKeySequence
|
||||
from configuration import Configuration # Import Configuration here for instantiation
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
|
||||
@ -1352,16 +1326,7 @@ def run_gui():
|
||||
|
||||
app.setPalette(palette)
|
||||
|
||||
# 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 = MainWindow()
|
||||
window.show()
|
||||
sys.exit(app.exec())
|
||||
|
||||
|
||||
@ -6,7 +6,7 @@ import re
|
||||
import tempfile
|
||||
import zipfile
|
||||
from collections import defaultdict, Counter
|
||||
from typing import List, Dict, Any, Set, Tuple # Added Set, Tuple
|
||||
from typing import List, Dict, Any
|
||||
|
||||
# --- PySide6 Imports ---
|
||||
from PySide6.QtCore import QObject, Slot # Keep QObject for parent type hint, Slot for classify_files if kept as method
|
||||
@ -39,9 +39,10 @@ if not log.hasHandlers():
|
||||
|
||||
def classify_files(file_list: List[str], config: Configuration) -> Dict[str, List[Dict[str, Any]]]:
|
||||
"""
|
||||
Analyzes a list of files based on configuration rules to group them by asset
|
||||
and determine initial file properties, applying prioritization based on
|
||||
'priority_keywords' in map_type_mapping.
|
||||
Analyzes a list of files based on configuration rules using a two-pass approach
|
||||
to group them by asset and determine initial file properties.
|
||||
Pass 1: Identifies and classifies prioritized bit depth variants.
|
||||
Pass 2: Classifies extras, general maps (downgrading if primary exists), and ignores.
|
||||
|
||||
Args:
|
||||
file_list: List of absolute file paths.
|
||||
@ -52,21 +53,19 @@ def classify_files(file_list: List[str], config: Configuration) -> Dict[str, Lis
|
||||
Example:
|
||||
{
|
||||
'AssetName1': [
|
||||
{'file_path': '/path/to/AssetName1_DISP16.png', 'item_type': 'MAP_DISP', 'asset_name': 'AssetName1'},
|
||||
{'file_path': '/path/to/AssetName1_Color.png', 'item_type': 'MAP_COL', 'asset_name': 'AssetName1'}
|
||||
{'file_path': '/path/to/AssetName1_DISP16.png', 'item_type': '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': 'COL', 'asset_name': 'AssetName1'}
|
||||
],
|
||||
# ... 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.
|
||||
"""
|
||||
classified_files_info: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
|
||||
file_matches: Dict[str, List[Tuple[str, int, bool]]] = defaultdict(list) # {file_path: [(target_type, rule_index, is_priority), ...]}
|
||||
files_to_ignore: Set[str] = 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 ---
|
||||
temp_grouped_files = defaultdict(list)
|
||||
extra_files_to_associate = []
|
||||
primary_asset_names = set()
|
||||
primary_assignments = set()
|
||||
processed_in_pass1 = set()
|
||||
|
||||
# --- Validation ---
|
||||
if not file_list or not config:
|
||||
@ -74,20 +73,20 @@ def classify_files(file_list: List[str], config: Configuration) -> Dict[str, Lis
|
||||
return {}
|
||||
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.")
|
||||
# Proceeding might still classify EXTRA/FILE_IGNORE if those rules exist
|
||||
if not hasattr(config, 'compiled_extra_regex'):
|
||||
log.warning("Configuration object missing 'compiled_extra_regex'. Cannot classify extra files.")
|
||||
compiled_extra_regex = [] # Provide default to avoid errors
|
||||
else:
|
||||
compiled_extra_regex = getattr(config, 'compiled_extra_regex', [])
|
||||
if not hasattr(config, 'compiled_bit_depth_regex_map'):
|
||||
log.warning("Configuration object missing 'compiled_bit_depth_regex_map'. Cannot prioritize bit depth variants.")
|
||||
|
||||
compiled_map_regex = getattr(config, 'compiled_map_keyword_regex', {})
|
||||
# Note: compiled_bit_depth_regex_map is no longer used for primary classification logic here
|
||||
compiled_extra_regex = getattr(config, 'compiled_extra_regex', [])
|
||||
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_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 and {num_extra_rules} extra patterns.")
|
||||
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.")
|
||||
|
||||
# --- Asset Name Extraction Helper ---
|
||||
def get_asset_name(f_path: Path, cfg: Configuration) -> str:
|
||||
@ -121,179 +120,155 @@ 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}'.")
|
||||
return asset_name
|
||||
|
||||
# --- Pass 1: Collect all potential matches for each file ---
|
||||
# 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', [])
|
||||
|
||||
# --- Pass 1: Prioritized Bit Depth Variants ---
|
||||
log.debug("--- Starting Classification Pass 1: Prioritized Variants ---")
|
||||
for file_path_str in file_list:
|
||||
file_path = Path(file_path_str)
|
||||
filename = file_path.name
|
||||
asset_name = get_asset_name(file_path, config)
|
||||
processed = False
|
||||
|
||||
if "BoucleChunky001" in file_path_str:
|
||||
log.info(f"DEBUG_ROO: Processing file: {file_path_str}")
|
||||
for target_type, variant_regex in compiled_bit_depth_regex_map.items():
|
||||
match = variant_regex.search(filename)
|
||||
if match:
|
||||
log.debug(f"PASS 1: File '{filename}' matched PRIORITIZED bit depth variant for type '{target_type}'.")
|
||||
matched_item_type = target_type
|
||||
|
||||
# Check for EXTRA files first
|
||||
if (asset_name, matched_item_type) in primary_assignments:
|
||||
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_map = False
|
||||
|
||||
# 1. Check for Extra Files FIRST in Pass 2
|
||||
for extra_pattern in compiled_extra_regex:
|
||||
if extra_pattern.search(filename):
|
||||
if "BoucleChunky001_DISP_1K_METALNESS.png" in filename and extra_pattern.search(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)
|
||||
log.debug(f"PASS 2: File '{filename}' matched EXTRA pattern: {extra_pattern.pattern}")
|
||||
extra_files_to_associate.append((file_path_str, filename))
|
||||
is_extra = True
|
||||
break
|
||||
|
||||
if "BoucleChunky001_DISP_1K_METALNESS.png" in filename and not is_extra: # after the extra loop
|
||||
log.info(f"DEBUG_ROO: EXTRA CHECK FAILED for {filename}. is_extra: {is_extra}")
|
||||
|
||||
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 compiled_regex, original_keyword, rule_index, is_priority in patterns_list:
|
||||
match = compiled_regex.search(filename)
|
||||
if match:
|
||||
if "BoucleChunky001" in file_path_str:
|
||||
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}).")
|
||||
log.debug(f" PASS 1: File '{filename}' matched keyword '{original_keyword}' (priority: {is_priority}) for target type '{target_type}' (Rule Index: {rule_index}).")
|
||||
file_matches[file_path_str].append((target_type, rule_index, is_priority))
|
||||
|
||||
log.debug(f"--- Finished Pass 1. Collected matches for {len(file_matches)} files. ---")
|
||||
|
||||
# --- Pass 2: Determine Trumped Regular Matches ---
|
||||
# Identify which regular matches are trumped by a priority match for the same rule_index within the asset.
|
||||
log.debug("--- Starting Classification Pass 2: Determine Trumped Regular Matches ---")
|
||||
|
||||
trumped_regular_matches: Set[Tuple[str, int]] = set() # Set of (file_path_str, rule_index) pairs that are trumped
|
||||
|
||||
# First, determine which rule_indices have *any* priority match across the entire asset
|
||||
rule_index_has_priority_match_in_asset: Set[int] = set()
|
||||
for file_path_str, matches in file_matches.items():
|
||||
for match_target, match_rule_index, match_is_priority in matches:
|
||||
if match_is_priority:
|
||||
rule_index_has_priority_match_in_asset.add(match_rule_index)
|
||||
|
||||
log.debug(f" Rule indices with priority matches in asset: {sorted(list(rule_index_has_priority_match_in_asset))}")
|
||||
|
||||
# Then, for each file, check its matches against the rules that had priority matches
|
||||
for file_path_str in file_list:
|
||||
if file_path_str in files_classified_as_extra:
|
||||
continue
|
||||
|
||||
matches_for_this_file = file_matches.get(file_path_str, [])
|
||||
# 2. Check for General Map Files in Pass 2
|
||||
for target_type, patterns_list in compiled_map_regex.items():
|
||||
for compiled_regex, original_keyword, rule_index in patterns_list:
|
||||
match = compiled_regex.search(filename)
|
||||
if match:
|
||||
try:
|
||||
# map_type_mapping_list = config.map_type_mapping # Old gloss logic source
|
||||
# matched_rule_details = map_type_mapping_list[rule_index] # Old gloss logic source
|
||||
# is_gloss_flag = matched_rule_details.get('is_gloss_source', False) # Old gloss logic
|
||||
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}")
|
||||
|
||||
# Determine if this file has any priority match for a given rule_index
|
||||
file_has_priority_match_for_rule: Dict[int, bool] = defaultdict(bool)
|
||||
for match_target, match_rule_index, match_is_priority in matches_for_this_file:
|
||||
if match_is_priority:
|
||||
file_has_priority_match_for_rule[match_rule_index] = True
|
||||
# *** Crucial Check: Has a prioritized variant claimed this type? ***
|
||||
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
|
||||
|
||||
# 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
|
||||
temp_grouped_files[asset_name].append({
|
||||
'file_path': file_path_str,
|
||||
'item_type': matched_item_type,
|
||||
'asset_name': asset_name
|
||||
})
|
||||
is_map = True
|
||||
break
|
||||
if is_map:
|
||||
break
|
||||
|
||||
# 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.")
|
||||
# 3. Handle Unmatched Files in Pass 2 (Not Extra, Not Map)
|
||||
if not is_extra and not is_map:
|
||||
log.debug(f"PASS 2: File '{filename}' did not match any map/extra pattern. Grouping under asset '{asset_name}' as FILE_IGNORE.")
|
||||
temp_grouped_files[asset_name].append({
|
||||
'file_path': file_path_str,
|
||||
'item_type': "FILE_IGNORE",
|
||||
'asset_name': asset_name
|
||||
})
|
||||
|
||||
log.debug("--- Finished Pass 2 ---")
|
||||
|
||||
log.debug(f"--- Finished Pass 2. Identified {len(trumped_regular_matches)} trumped regular matches. ---")
|
||||
|
||||
# --- Pass 3: Final Assignment & Inter-Entry Resolution ---
|
||||
# Iterate through files, apply ignore rules, and then apply earliest rule wins for remaining valid matches.
|
||||
log.debug("--- Starting Classification Pass 3: Final Assignment ---")
|
||||
|
||||
final_file_assignments: Dict[str, str] = {} # {file_path: final_item_type}
|
||||
|
||||
|
||||
for file_path_str in file_list:
|
||||
# Check if the file was already classified as EXTRA in Pass 1 and added to classified_files_info
|
||||
if file_path_str in files_classified_as_extra:
|
||||
log.debug(f" Final Assignment: Skipping '{Path(file_path_str).name}' as it was already classified as EXTRA in Pass 1.")
|
||||
continue # Skip this file in Pass 3 as it's already handled
|
||||
|
||||
asset_name = get_asset_name(Path(file_path_str), config) # Need asset name for the final output structure
|
||||
|
||||
# Get valid matches for this file after considering intra-entry priority trumps regular
|
||||
valid_matches = []
|
||||
for match_target, match_rule_index, match_is_priority in file_matches.get(file_path_str, []):
|
||||
if (file_path_str, match_rule_index) not in trumped_regular_matches:
|
||||
valid_matches.append((match_target, match_rule_index, match_is_priority))
|
||||
log.debug(f" File '{Path(file_path_str).name}': Valid match - Target: '{match_target}', Rule Index: {match_rule_index}, Priority: {match_is_priority}")
|
||||
else:
|
||||
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}")
|
||||
|
||||
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}'.")
|
||||
# --- Determine Primary Asset Name for Extra Association (using Pass 1 results) ---
|
||||
final_primary_asset_name = None
|
||||
if primary_asset_names:
|
||||
primary_map_asset_names_pass1 = [
|
||||
f_info['asset_name']
|
||||
for asset_files in temp_grouped_files.values()
|
||||
for f_info in asset_files
|
||||
if f_info['asset_name'] in primary_asset_names and (f_info['asset_name'], f_info['item_type']) in primary_assignments
|
||||
]
|
||||
if primary_map_asset_names_pass1:
|
||||
name_counts = Counter(primary_map_asset_names_pass1)
|
||||
most_common_names = name_counts.most_common()
|
||||
final_primary_asset_name = most_common_names[0][0]
|
||||
if len(most_common_names) > 1 and most_common_names[0][1] == most_common_names[1][1]:
|
||||
tied_names = sorted([name for name, count in most_common_names if count == most_common_names[0][1]])
|
||||
final_primary_asset_name = tied_names[0]
|
||||
log.warning(f"Multiple primary asset names tied for most common based on Pass 1: {tied_names}. Using '{final_primary_asset_name}' for associating extra files.")
|
||||
log.debug(f"Determined primary asset name for extras based on Pass 1 primary maps: '{final_primary_asset_name}'")
|
||||
else:
|
||||
log.debug(f" File '{Path(file_path_str).name}'': No valid matches after filtering. Final type: '{final_item_type}'.")
|
||||
log.warning("Primary asset names set (from Pass 1) was populated, but no corresponding groups found. Falling back.")
|
||||
|
||||
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}')")
|
||||
if not final_primary_asset_name:
|
||||
if temp_grouped_files and extra_files_to_associate:
|
||||
fallback_name = sorted(temp_grouped_files.keys())[0]
|
||||
final_primary_asset_name = fallback_name
|
||||
log.warning(f"No primary map files found in Pass 1. Associating extras with first group found alphabetically: '{final_primary_asset_name}'.")
|
||||
elif extra_files_to_associate:
|
||||
log.warning(f"Could not determine any asset name to associate {len(extra_files_to_associate)} extra file(s) with. They will be ignored.")
|
||||
else:
|
||||
log.debug("No primary asset name determined (no maps or extras found).")
|
||||
|
||||
|
||||
log.debug(f"Classification complete. Found {len(classified_files_info)} potential assets.")
|
||||
# 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)
|
||||
# --- Associate Extra Files (collected in Pass 2) ---
|
||||
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}'")
|
||||
for file_path_str, filename in extra_files_to_associate:
|
||||
if not any(f['file_path'] == file_path_str for f in temp_grouped_files[final_primary_asset_name]):
|
||||
temp_grouped_files[final_primary_asset_name].append({
|
||||
'file_path': file_path_str,
|
||||
'item_type': "EXTRA",
|
||||
'asset_name': final_primary_asset_name
|
||||
})
|
||||
else:
|
||||
log.debug(f"Skipping duplicate association of extra file: {filename}")
|
||||
elif extra_files_to_associate:
|
||||
pass
|
||||
|
||||
|
||||
log.debug(f"Classification complete. Found {len(temp_grouped_files)} potential assets.")
|
||||
return dict(temp_grouped_files)
|
||||
|
||||
|
||||
class RuleBasedPredictionHandler(BasePredictionHandler):
|
||||
@ -303,19 +278,17 @@ class RuleBasedPredictionHandler(BasePredictionHandler):
|
||||
Inherits from BasePredictionHandler for common threading and signaling.
|
||||
"""
|
||||
|
||||
def __init__(self, config_obj: Configuration, input_source_identifier: str, original_input_paths: list[str], preset_name: str, parent: QObject = None):
|
||||
def __init__(self, input_source_identifier: str, original_input_paths: list[str], preset_name: str, parent: QObject = None):
|
||||
"""
|
||||
Initializes the rule-based handler with a Configuration object.
|
||||
Initializes the rule-based handler.
|
||||
|
||||
Args:
|
||||
config_obj: The main configuration object.
|
||||
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.
|
||||
preset_name: The name of the preset configuration to use.
|
||||
parent: The parent QObject.
|
||||
"""
|
||||
super().__init__(input_source_identifier, parent)
|
||||
self.config = config_obj # Store the Configuration object
|
||||
self.original_input_paths = original_input_paths
|
||||
self.preset_name = preset_name
|
||||
self._current_input_path = None
|
||||
@ -364,24 +337,16 @@ class RuleBasedPredictionHandler(BasePredictionHandler):
|
||||
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}")
|
||||
|
||||
# --- Use Provided Configuration ---
|
||||
# The Configuration object is now passed during initialization.
|
||||
# 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
|
||||
# --- Load Configuration ---
|
||||
config = Configuration(preset_name)
|
||||
log.info(f"Successfully loaded configuration for preset '{preset_name}'.")
|
||||
|
||||
if self._is_cancelled: raise RuntimeError("Prediction cancelled before classification.")
|
||||
|
||||
# --- Perform Classification ---
|
||||
self.status_update.emit(f"Classifying files for '{source_path.name}'...")
|
||||
try:
|
||||
# Use the stored config object
|
||||
classified_assets = classify_files(original_input_paths, self.config)
|
||||
classified_assets = classify_files(original_input_paths, config)
|
||||
except Exception as e:
|
||||
log.exception(f"Error during file classification for source '{input_source_identifier}': {e}")
|
||||
raise RuntimeError(f"Error classifying files: {e}") from e
|
||||
@ -398,29 +363,25 @@ class RuleBasedPredictionHandler(BasePredictionHandler):
|
||||
# --- Build the Hierarchy ---
|
||||
self.status_update.emit(f"Building rule hierarchy for '{source_path.name}'...")
|
||||
try:
|
||||
# Use the stored config object
|
||||
supplier_identifier = self.config.supplier_name
|
||||
supplier_identifier = config.supplier_name
|
||||
source_rule = SourceRule(
|
||||
input_path=input_source_identifier,
|
||||
supplier_identifier=supplier_identifier,
|
||||
# Use the internal display name from the stored config object
|
||||
preset_name=self.config.internal_display_preset_name
|
||||
preset_name=preset_name
|
||||
)
|
||||
asset_rules = []
|
||||
# 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()
|
||||
file_type_definitions = config._core_settings.get('FILE_TYPE_DEFINITIONS', {})
|
||||
|
||||
for asset_name, files_info in classified_assets.items():
|
||||
if self._is_cancelled: raise RuntimeError("Prediction cancelled during hierarchy building (assets).")
|
||||
if not files_info: continue
|
||||
|
||||
# Use the stored config object
|
||||
asset_category_rules = self.config.asset_category_rules
|
||||
asset_type_definitions = self.config.get_asset_type_definitions()
|
||||
asset_category_rules = config.asset_category_rules
|
||||
asset_type_definitions = config.get_asset_type_definitions()
|
||||
asset_type_keys = list(asset_type_definitions.keys())
|
||||
|
||||
# Initialize predicted_asset_type using the validated default from stored config
|
||||
predicted_asset_type = self.config.default_asset_category
|
||||
# Initialize predicted_asset_type using the validated default
|
||||
predicted_asset_type = config.default_asset_category
|
||||
log.debug(f"Asset '{asset_name}': Initial predicted_asset_type set to default: '{predicted_asset_type}'.")
|
||||
|
||||
# 1. Check asset_category_rules from preset
|
||||
@ -428,8 +389,7 @@ class RuleBasedPredictionHandler(BasePredictionHandler):
|
||||
|
||||
# Check for Model type based on file patterns
|
||||
if "Model" in asset_type_keys:
|
||||
# Use the stored config object
|
||||
model_patterns_regex = self.config.compiled_model_regex
|
||||
model_patterns_regex = config.compiled_model_regex
|
||||
for f_info in files_info:
|
||||
if f_info['item_type'] in ["EXTRA", "FILE_IGNORE"]:
|
||||
continue
|
||||
@ -442,7 +402,7 @@ class RuleBasedPredictionHandler(BasePredictionHandler):
|
||||
break
|
||||
if determined_by_rule:
|
||||
break
|
||||
|
||||
|
||||
# Check for Decal type based on keywords in asset name (if not already Model)
|
||||
if not determined_by_rule and "Decal" in asset_type_keys:
|
||||
decal_keywords = asset_category_rules.get('decal_keywords', [])
|
||||
@ -461,13 +421,12 @@ class RuleBasedPredictionHandler(BasePredictionHandler):
|
||||
pass
|
||||
|
||||
# 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 == self.config.default_asset_category and "Surface" in asset_type_keys:
|
||||
if not determined_by_rule and predicted_asset_type == config.default_asset_category and "Surface" in asset_type_keys:
|
||||
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
|
||||
# This check is primarily for PBR texture sets.
|
||||
# Use the stored config object
|
||||
material_indicators = {
|
||||
ft_key for ft_key, ft_def in self.config.get_file_type_definitions_with_examples().items()
|
||||
ft_key for ft_key, ft_def in 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"]
|
||||
}
|
||||
# Add common direct standard types as well for robustness
|
||||
@ -481,20 +440,20 @@ class RuleBasedPredictionHandler(BasePredictionHandler):
|
||||
has_material_map = True
|
||||
break
|
||||
# Check standard type if item_type is a key in FILE_TYPE_DEFINITIONS
|
||||
item_def = self.config.get_file_type_definitions_with_examples().get(item_type)
|
||||
item_def = config.get_file_type_definitions_with_examples().get(item_type)
|
||||
if item_def and item_def.get('standard_type') in material_indicators:
|
||||
has_material_map = True
|
||||
break
|
||||
|
||||
|
||||
if has_material_map:
|
||||
predicted_asset_type = "Surface"
|
||||
log.debug(f"Asset '{asset_name}' classified as 'Surface' due to material indicators.")
|
||||
|
||||
|
||||
# 3. Final validation: Ensure predicted_asset_type is a valid key.
|
||||
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. "
|
||||
f"Falling back to default: '{self.config.default_asset_category}'.")
|
||||
predicted_asset_type = self.config.default_asset_category
|
||||
f"Falling back to default: '{config.default_asset_category}'.")
|
||||
predicted_asset_type = config.default_asset_category
|
||||
|
||||
asset_rule = AssetRule(asset_name=asset_name, asset_type=predicted_asset_type)
|
||||
file_rules = []
|
||||
@ -504,23 +463,23 @@ class RuleBasedPredictionHandler(BasePredictionHandler):
|
||||
base_item_type = file_info['item_type']
|
||||
target_asset_name_override = file_info['asset_name']
|
||||
final_item_type = base_item_type
|
||||
# The classification logic now returns the final item_type directly,
|
||||
# including "FILE_IGNORE" and correctly prioritized MAP_ types.
|
||||
# No need for the old MAP_ prefixing logic here.
|
||||
if not base_item_type.startswith("MAP_") and base_item_type not in ["FILE_IGNORE", "EXTRA", "MODEL"]:
|
||||
final_item_type = f"MAP_{base_item_type}"
|
||||
|
||||
# Validate the final_item_type against definitions, unless it's EXTRA or 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.")
|
||||
if file_type_definitions and final_item_type not in file_type_definitions and base_item_type not in ["FILE_IGNORE", "EXTRA"]:
|
||||
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.")
|
||||
final_item_type = "FILE_IGNORE"
|
||||
|
||||
|
||||
# is_gloss_source_value = file_info.get('is_gloss_source', False) # Removed
|
||||
|
||||
file_rule = FileRule(
|
||||
file_path=file_info['file_path'],
|
||||
item_type=final_item_type,
|
||||
item_type_override=final_item_type, # item_type_override defaults to item_type
|
||||
item_type_override=final_item_type,
|
||||
target_asset_name_override=target_asset_name_override,
|
||||
output_format_override=None,
|
||||
# is_gloss_source=is_gloss_source_value if isinstance(is_gloss_source_value, bool) else False, # Removed
|
||||
resolution_override=None,
|
||||
channel_merge_instructions={},
|
||||
)
|
||||
@ -530,18 +489,6 @@ class RuleBasedPredictionHandler(BasePredictionHandler):
|
||||
source_rule.assets = asset_rules
|
||||
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:
|
||||
log.exception(f"Error building rule hierarchy for source '{input_source_identifier}': {e}")
|
||||
raise RuntimeError(f"Error building rule hierarchy: {e}") from e
|
||||
|
||||
@ -20,8 +20,7 @@ script_dir = Path(__file__).parent
|
||||
project_root = script_dir.parent
|
||||
PRESETS_DIR = project_root / "Presets"
|
||||
TEMPLATE_PATH = PRESETS_DIR / "_template.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"
|
||||
APP_SETTINGS_PATH_LOCAL = project_root / "config" / "app_settings.json"
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@ -36,8 +35,8 @@ class PresetEditorWidget(QWidget):
|
||||
# Signal emitted when presets list changes (saved, deleted, new)
|
||||
presets_changed_signal = Signal()
|
||||
# Signal emitted when the selected preset (or LLM/Placeholder) changes
|
||||
# Emits: mode ("preset", "llm", "placeholder"), display_name (str or None), file_path (Path or None)
|
||||
preset_selection_changed_signal = Signal(str, str, Path)
|
||||
# Emits: mode ("preset", "llm", "placeholder"), preset_name (str or None)
|
||||
preset_selection_changed_signal = Signal(str, str)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
@ -64,19 +63,18 @@ class PresetEditorWidget(QWidget):
|
||||
"""Loads FILE_TYPE_DEFINITIONS keys from app_settings.json."""
|
||||
keys = []
|
||||
try:
|
||||
if FILE_TYPE_DEFINITIONS_PATH.is_file():
|
||||
with open(FILE_TYPE_DEFINITIONS_PATH, 'r', encoding='utf-8') as f:
|
||||
if APP_SETTINGS_PATH_LOCAL.is_file():
|
||||
with open(APP_SETTINGS_PATH_LOCAL, 'r', encoding='utf-8') as 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", {})
|
||||
keys = list(ftd.keys())
|
||||
log.debug(f"Successfully loaded {len(keys)} FILE_TYPE_DEFINITIONS keys from {FILE_TYPE_DEFINITIONS_PATH}.")
|
||||
log.debug(f"Successfully loaded {len(keys)} FILE_TYPE_DEFINITIONS keys.")
|
||||
else:
|
||||
log.error(f"file_type_definitions.json not found at {FILE_TYPE_DEFINITIONS_PATH} for PresetEditorWidget.")
|
||||
log.error(f"app_settings.json not found at {APP_SETTINGS_PATH_LOCAL} for PresetEditorWidget.")
|
||||
except json.JSONDecodeError as e:
|
||||
log.error(f"Failed to parse file_type_definitions.json in PresetEditorWidget: {e}")
|
||||
log.error(f"Failed to parse app_settings.json in PresetEditorWidget: {e}")
|
||||
except Exception as e:
|
||||
log.error(f"Error loading FILE_TYPE_DEFINITIONS keys from {FILE_TYPE_DEFINITIONS_PATH} in PresetEditorWidget: {e}")
|
||||
log.error(f"Error loading FILE_TYPE_DEFINITIONS keys in PresetEditorWidget: {e}")
|
||||
return keys
|
||||
|
||||
def _init_ui(self):
|
||||
@ -296,22 +294,8 @@ class PresetEditorWidget(QWidget):
|
||||
log.warning(msg)
|
||||
else:
|
||||
for preset_path in presets:
|
||||
preset_display_name = preset_path.stem # Fallback
|
||||
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
|
||||
item = QListWidgetItem(preset_path.stem)
|
||||
item.setData(Qt.ItemDataRole.UserRole, preset_path)
|
||||
self.editor_preset_list.addItem(item)
|
||||
log.info(f"Loaded {len(presets)} presets into editor list.")
|
||||
|
||||
@ -539,8 +523,7 @@ class PresetEditorWidget(QWidget):
|
||||
log.debug(f"PresetEditor: currentItemChanged signal triggered. current: {current_item.text() if current_item else 'None'}")
|
||||
|
||||
mode = "placeholder"
|
||||
display_name_to_emit = None # Changed from preset_name
|
||||
file_path_to_emit = None # New variable for Path
|
||||
preset_name = None
|
||||
|
||||
# Check for unsaved changes before proceeding
|
||||
if self.check_unsaved_changes():
|
||||
@ -555,53 +538,41 @@ class PresetEditorWidget(QWidget):
|
||||
# Determine mode and preset name based on selection
|
||||
if current_item:
|
||||
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__":
|
||||
log.debug("Placeholder item selected.")
|
||||
self._clear_editor()
|
||||
self._set_editor_enabled(False)
|
||||
mode = "placeholder"
|
||||
display_name_to_emit = None
|
||||
file_path_to_emit = None
|
||||
self._last_valid_preset_name = None # Clear last valid name
|
||||
elif item_data == "__LLM__":
|
||||
log.debug("LLM Interpretation item selected.")
|
||||
self._clear_editor()
|
||||
self._set_editor_enabled(False)
|
||||
mode = "llm"
|
||||
display_name_to_emit = None # LLM mode has no specific preset display name
|
||||
file_path_to_emit = None
|
||||
# Keep _last_valid_preset_name as it was (it should be the display name)
|
||||
elif isinstance(item_data, Path): # item_data is the Path object for a preset
|
||||
log.debug(f"Loading preset for editing: {current_display_text}")
|
||||
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
|
||||
# Keep _last_valid_preset_name as it was
|
||||
elif isinstance(item_data, Path):
|
||||
log.debug(f"Loading preset for editing: {current_item.text()}")
|
||||
preset_path = item_data
|
||||
self._load_preset_for_editing(preset_path)
|
||||
self._last_valid_preset_name = preset_path.stem
|
||||
mode = "preset"
|
||||
display_name_to_emit = current_display_text
|
||||
file_path_to_emit = preset_file_path_obj
|
||||
else: # Should not happen if list is populated correctly
|
||||
preset_name = self._last_valid_preset_name
|
||||
else:
|
||||
log.error(f"Invalid data type for preset path: {type(item_data)}. Clearing editor.")
|
||||
self._clear_editor()
|
||||
self._set_editor_enabled(False)
|
||||
mode = "placeholder"
|
||||
display_name_to_emit = None
|
||||
file_path_to_emit = None
|
||||
mode = "placeholder" # Treat as placeholder on error
|
||||
self._last_valid_preset_name = None
|
||||
else: # No current_item (e.g., list cleared)
|
||||
else:
|
||||
log.debug("No preset selected. Clearing editor.")
|
||||
self._clear_editor()
|
||||
self._set_editor_enabled(False)
|
||||
mode = "placeholder"
|
||||
display_name_to_emit = None
|
||||
file_path_to_emit = None
|
||||
self._last_valid_preset_name = None
|
||||
|
||||
# Emit the signal with all three arguments
|
||||
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, display_name_to_emit, file_path_to_emit)
|
||||
# Emit the signal regardless of what was selected
|
||||
log.debug(f"Emitting preset_selection_changed_signal: mode='{mode}', preset_name='{preset_name}'")
|
||||
self.preset_selection_changed_signal.emit(mode, preset_name)
|
||||
|
||||
def _gather_editor_data(self) -> dict:
|
||||
"""Gathers data from all editor UI widgets and returns a dictionary."""
|
||||
@ -784,25 +755,22 @@ class PresetEditorWidget(QWidget):
|
||||
|
||||
# --- Public Access Methods for MainWindow ---
|
||||
|
||||
def get_selected_preset_mode(self) -> tuple[str, str | None, Path | None]:
|
||||
def get_selected_preset_mode(self) -> tuple[str, str | None]:
|
||||
"""
|
||||
Returns the current selection mode, display name, and file path for loading.
|
||||
Returns: tuple(mode_string, display_name_string_or_None, file_path_or_None)
|
||||
Returns the current selection mode and preset name (if applicable).
|
||||
Returns: tuple(mode_string, preset_name_string_or_None)
|
||||
mode_string can be "preset", "llm", "placeholder"
|
||||
"""
|
||||
current_item = self.editor_preset_list.currentItem()
|
||||
if current_item:
|
||||
item_data = current_item.data(Qt.ItemDataRole.UserRole)
|
||||
display_text = current_item.text() # This is now the internal name
|
||||
|
||||
if item_data == "__PLACEHOLDER__":
|
||||
return "placeholder", None, None
|
||||
return "placeholder", None
|
||||
elif item_data == "__LLM__":
|
||||
return "llm", None, None # LLM mode doesn't have a specific preset file path
|
||||
return "llm", None
|
||||
elif isinstance(item_data, Path):
|
||||
# For a preset, display_text is the internal name, item_data is the Path
|
||||
return "preset", display_text, item_data # Return internal name and path
|
||||
return "placeholder", None, None # Default or if no item selected
|
||||
return "preset", item_data.stem
|
||||
return "placeholder", None # Default or if no item selected
|
||||
|
||||
def get_last_valid_preset_name(self) -> str | None:
|
||||
"""
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
# gui/unified_view_model.py
|
||||
import logging
|
||||
log = logging.getLogger(__name__)
|
||||
from PySide6.QtCore import QAbstractItemModel, QModelIndex, Qt, Signal, Slot, QMimeData, QByteArray, QDataStream, QIODevice, QPersistentModelIndex
|
||||
from PySide6.QtCore import QAbstractItemModel, QModelIndex, Qt, Signal, Slot, QMimeData, QByteArray, QDataStream, QIODevice
|
||||
from PySide6.QtGui import QColor
|
||||
from pathlib import Path
|
||||
from rule_structure import SourceRule, AssetRule, FileRule
|
||||
from configuration import load_base_config
|
||||
from typing import List
|
||||
from configuration import Configuration # Import Configuration class
|
||||
|
||||
class CustomRoles:
|
||||
MapTypeRole = Qt.UserRole + 1
|
||||
@ -46,9 +46,8 @@ class UnifiedViewModel(QAbstractItemModel):
|
||||
# --- Drag and Drop MIME Type ---
|
||||
MIME_TYPE = "application/x-filerule-index-list"
|
||||
|
||||
def __init__(self, config: Configuration, parent=None):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._config = config # Store the Configuration object
|
||||
self._source_rules = []
|
||||
# self._display_mode removed
|
||||
self._asset_type_colors = {}
|
||||
@ -60,9 +59,9 @@ class UnifiedViewModel(QAbstractItemModel):
|
||||
def _load_definitions(self):
|
||||
"""Loads configuration and caches colors and type keys."""
|
||||
try:
|
||||
# Access configuration directly from the stored object using public methods
|
||||
asset_type_defs = self._config.get_asset_type_definitions()
|
||||
file_type_defs = self._config.get_file_type_definitions_with_examples()
|
||||
base_config = load_base_config()
|
||||
asset_type_defs = base_config.get('ASSET_TYPE_DEFINITIONS', {})
|
||||
file_type_defs = base_config.get('FILE_TYPE_DEFINITIONS', {})
|
||||
|
||||
# Cache Asset Type Definitions (Keys and Colors)
|
||||
self._asset_type_keys = sorted(list(asset_type_defs.keys()))
|
||||
@ -553,13 +552,6 @@ class UnifiedViewModel(QAbstractItemModel):
|
||||
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])
|
||||
|
||||
# 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 ---
|
||||
existing_assets_dict = {asset.asset_name: asset for asset in existing_source_rule.assets}
|
||||
@ -906,23 +898,37 @@ class UnifiedViewModel(QAbstractItemModel):
|
||||
encoded_data = QByteArray()
|
||||
stream = QDataStream(encoded_data, QIODevice.OpenModeFlag.WriteOnly)
|
||||
|
||||
# Store QPersistentModelIndex for robustness
|
||||
# Collect file paths of dragged FileRule items
|
||||
file_paths = []
|
||||
dragged_file_info = []
|
||||
for index in indexes:
|
||||
if index.isValid() and index.column() == 0:
|
||||
item = index.internalPointer()
|
||||
if isinstance(item, FileRule):
|
||||
file_paths.append(item.file_path)
|
||||
log.debug(f"mimeData: Added file path for file: {Path(item.file_path).name}")
|
||||
if not index.isValid() or index.column() != 0:
|
||||
continue
|
||||
item = index.internalPointer()
|
||||
if isinstance(item, FileRule):
|
||||
parent_index = self.parent(index)
|
||||
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()}")
|
||||
|
||||
# Write the number of items first, then each file path string
|
||||
stream.writeInt32(len(file_paths)) # Use writeInt32 for potentially more items
|
||||
for file_path in file_paths:
|
||||
stream.writeQString(file_path) # Use writeQString for strings
|
||||
else:
|
||||
log.warning(f"mimeData: Could not get parent index for FileRule at row {index.row()}")
|
||||
|
||||
# Write the number of items first, then each tuple
|
||||
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)
|
||||
log.debug(f"mimeData: Encoded {len(file_paths)} FileRule file paths.")
|
||||
log.debug(f"mimeData: Encoded {len(dragged_file_info)} FileRule indices.")
|
||||
return mime_data
|
||||
|
||||
def canDropMimeData(self, data: QMimeData, action: Qt.DropAction, row: int, column: int, parent: QModelIndex) -> bool:
|
||||
@ -957,68 +963,75 @@ class UnifiedViewModel(QAbstractItemModel):
|
||||
encoded_data = data.data(self.MIME_TYPE)
|
||||
stream = QDataStream(encoded_data, QIODevice.OpenModeFlag.ReadOnly)
|
||||
|
||||
# Read file paths from the stream
|
||||
dragged_file_paths = []
|
||||
num_items = stream.readInt32()
|
||||
log.debug(f"dropMimeData: Decoding {num_items} file paths.")
|
||||
num_items = stream.readInt8()
|
||||
source_indices_info = []
|
||||
for _ in range(num_items):
|
||||
dragged_file_paths.append(stream.readQString()) # Use readQString for strings
|
||||
source_row = stream.readInt8()
|
||||
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(dragged_file_paths)} file paths. Target Asset: '{target_asset_item.asset_name}'")
|
||||
log.debug(f"dropMimeData: Decoded {len(source_indices_info)} source indices. Target Asset: '{target_asset_item.asset_name}'")
|
||||
|
||||
if not dragged_file_paths:
|
||||
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.")
|
||||
if not source_indices_info:
|
||||
log.warning("dropMimeData: No valid source index information decoded.")
|
||||
return False
|
||||
|
||||
# Keep track of original parents that might become empty
|
||||
original_parents_to_check = set()
|
||||
original_parents = set()
|
||||
moved_files_new_indices = {}
|
||||
|
||||
# Process moves using the retrieved items and their current indices
|
||||
for file_item, source_file_index in dragged_items_with_indices:
|
||||
# Track original parent for cleanup using the parent back-reference
|
||||
old_parent_asset = getattr(file_item, 'parent_asset', None)
|
||||
if old_parent_asset and isinstance(old_parent_asset, AssetRule):
|
||||
source_rule = getattr(old_parent_asset, 'parent_source', None)
|
||||
if source_rule:
|
||||
# Store a hashable representation (tuple of identifiers)
|
||||
original_parents_to_check.add((source_rule.input_path, old_parent_asset.asset_name))
|
||||
else:
|
||||
log.warning(f"dropMimeData: Original parent asset '{old_parent_asset.asset_name}' has no parent source reference for cleanup tracking.")
|
||||
# --- BEGIN FIX: Reconstruct all source indices BEFORE the move loop ---
|
||||
source_indices_to_process = []
|
||||
log.debug("Reconstructing initial source indices...")
|
||||
for src_row, src_parent_row, src_grandparent_row in source_indices_info:
|
||||
grandparent_index = self.index(src_grandparent_row, 0, QModelIndex())
|
||||
if not grandparent_index.isValid():
|
||||
log.error(f"dropMimeData: Failed initial reconstruction of grandparent index (row {src_grandparent_row}). Skipping item.")
|
||||
continue
|
||||
old_parent_index = self.index(src_parent_row, 0, grandparent_index)
|
||||
if not old_parent_index.isValid():
|
||||
log.error(f"dropMimeData: Failed initial reconstruction of old parent index (row {src_parent_row}). Skipping item.")
|
||||
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
|
||||
@ -1030,25 +1043,15 @@ class UnifiedViewModel(QAbstractItemModel):
|
||||
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}'")
|
||||
file_item.target_asset_name_override = target_asset_item.asset_name
|
||||
# Need the *new* index of the moved file to emit dataChanged AND the override changed signal
|
||||
# Need the *new* index of the moved file to emit dataChanged
|
||||
try:
|
||||
# Find the new row of the file item within the target parent's list
|
||||
new_row = target_asset_item.files.index(file_item)
|
||||
# Create the index for the target asset column (for dataChanged)
|
||||
new_file_index_col0 = self.index(new_row, 0, parent)
|
||||
new_file_index_target_col = self.index(new_row, self.COL_TARGET_ASSET, parent)
|
||||
if new_file_index_target_col.isValid():
|
||||
moved_files_new_indices[file_item.file_path] = new_file_index_target_col
|
||||
else:
|
||||
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:
|
||||
log.error(f" Could not find moved file '{Path(file_item.file_path).name}' in target parent's list after move.")
|
||||
|
||||
@ -1064,43 +1067,24 @@ class UnifiedViewModel(QAbstractItemModel):
|
||||
self.dataChanged.emit(new_index, new_index, [Qt.DisplayRole, Qt.EditRole])
|
||||
|
||||
# --- Cleanup: Remove any original parent AssetRules that are now empty ---
|
||||
log.debug(f"dropMimeData: Checking original parents for cleanup: {[f'{path}/{name}' for path, name in original_parents_to_check]}")
|
||||
# Convert set to list to iterate
|
||||
for source_path, asset_name_to_check in list(original_parents_to_check):
|
||||
found_asset_rule_to_check = None
|
||||
# Find the AssetRule object based on source_path and asset_name
|
||||
for source_rule in self._source_rules:
|
||||
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
|
||||
log.debug(f"dropMimeData: Checking original parents for cleanup: {list(original_parents)}")
|
||||
for gp_row, asset_name in list(original_parents):
|
||||
try:
|
||||
if 0 <= gp_row < len(self._source_rules):
|
||||
source_rule = self._source_rules[gp_row]
|
||||
# Find the asset rule within the correct source rule
|
||||
asset_rule_to_check = next((asset for asset in source_rule.assets if asset.asset_name == asset_name), None)
|
||||
|
||||
if found_asset_rule_to_check:
|
||||
try:
|
||||
# Re-check if the asset is still in the model and is now empty
|
||||
# Use parent back-reference to find the source rule (should be the same as source_rule found above)
|
||||
source_rule = getattr(found_asset_rule_to_check, 'parent_source', None)
|
||||
if source_rule:
|
||||
# Check if the asset rule is still in its parent's list
|
||||
if found_asset_rule_to_check in source_rule.assets:
|
||||
if not found_asset_rule_to_check.files and found_asset_rule_to_check is not target_asset_item:
|
||||
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.")
|
||||
if asset_rule_to_check and not asset_rule_to_check.files and asset_rule_to_check != target_asset_item:
|
||||
log.info(f"dropMimeData: Attempting cleanup of now empty original parent: '{asset_rule_to_check.asset_name}'")
|
||||
if not self.removeAssetRule(asset_rule_to_check):
|
||||
log.warning(f"dropMimeData: Failed to remove empty original parent '{asset_rule_to_check.asset_name}'.")
|
||||
elif not asset_rule_to_check:
|
||||
log.warning(f"dropMimeData: Cleanup check failed. Could not find original parent asset '{asset_name}' in source rule at row {gp_row}.")
|
||||
else:
|
||||
log.warning(f"dropMimeData: Cleanup check failed. Invalid grandparent row index {gp_row} found in original_parents set.")
|
||||
except Exception as e:
|
||||
log.exception(f"dropMimeData: Error during cleanup check for parent '{asset_name}' (gp_row {gp_row}): {e}")
|
||||
|
||||
|
||||
return True
|
||||
387
main.py
387
main.py
@ -4,7 +4,6 @@ import time
|
||||
import os
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import re # Added for checking incrementing token
|
||||
from concurrent.futures import ProcessPoolExecutor, as_completed
|
||||
import subprocess
|
||||
import shutil
|
||||
@ -15,12 +14,11 @@ from typing import List, Dict, Tuple, Optional
|
||||
# --- Utility Imports ---
|
||||
from utils.hash_utils import calculate_sha256
|
||||
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 ---
|
||||
from PySide6.QtCore import QObject, Slot, QThreadPool, QRunnable, Signal
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QApplication, QDialog # Import QDialog for the setup dialog
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
# --- Backend Imports ---
|
||||
# Add current directory to sys.path for direct execution
|
||||
@ -46,10 +44,6 @@ try:
|
||||
from gui.main_window import 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...")
|
||||
from utils.workspace_utils import prepare_processing_workspace
|
||||
print("DEBUG: Successfully imported prepare_processing_workspace.")
|
||||
@ -244,15 +238,9 @@ class ProcessingTask(QRunnable):
|
||||
# output_dir should already be a Path object
|
||||
pattern = getattr(config, 'output_directory_pattern', None)
|
||||
if pattern:
|
||||
# Only call get_next_incrementing_value if the pattern contains an incrementing token
|
||||
if re.search(r"\[IncrementingValue\]|#+", pattern):
|
||||
log.debug(f"Incrementing token found in pattern '{pattern}'. Calculating next value for dir: {output_dir}")
|
||||
next_increment_str = get_next_incrementing_value(output_dir, pattern)
|
||||
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}")
|
||||
log.debug(f"Calculating next incrementing value for dir: {output_dir} using pattern: {pattern}")
|
||||
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.warning(f"Cannot calculate incrementing value: 'output_directory_pattern' not found in configuration for preset {config.preset_name}")
|
||||
except Exception as e:
|
||||
@ -306,61 +294,68 @@ class App(QObject):
|
||||
# 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)
|
||||
|
||||
def __init__(self, user_config_path: str):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.user_config_path = user_config_path # Store the determined user config path
|
||||
self.config_obj = None # Initialize config_obj to None
|
||||
self.processing_engine = None # Initialize processing_engine to None
|
||||
self.config_obj = None
|
||||
self.processing_engine = None
|
||||
self.main_window = None
|
||||
self.thread_pool = QThreadPool()
|
||||
self._active_tasks_count = 0
|
||||
self._task_results = {"processed": 0, "skipped": 0, "failed": 0}
|
||||
log.info(f"Maximum threads for pool: {self.thread_pool.maxThreadCount()}")
|
||||
|
||||
# Configuration, engine, and GUI are now initialized via load_preset
|
||||
log.debug("App initialized. Configuration, engine, and GUI will be loaded via load_preset.")
|
||||
self._load_config()
|
||||
self._init_engine()
|
||||
self._init_gui()
|
||||
|
||||
def _load_config(self, user_config_path: str, preset_name: str):
|
||||
"""
|
||||
Loads the configuration using the determined user config path and specified preset.
|
||||
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}'")
|
||||
def _load_config(self):
|
||||
"""Loads the base configuration using a default preset."""
|
||||
# The actual preset name comes from the GUI request later, but the engine
|
||||
# needs an initial valid configuration object.
|
||||
try:
|
||||
# Convert user_config_path string to a Path object before passing to Configuration
|
||||
user_config_path_obj = Path(user_config_path)
|
||||
# Instantiate Configuration with the determined user config path and the specified preset name
|
||||
self.config_obj = Configuration(preset_name=preset_name, base_dir_user_config=user_config_path_obj)
|
||||
log.info(f"App: Configuration loaded successfully with preset '{preset_name}'.")
|
||||
# Find the first available preset to use as a default
|
||||
preset_dir = Path(__file__).parent / "Presets"
|
||||
default_preset_name = None
|
||||
if preset_dir.is_dir():
|
||||
presets = sorted([f.stem for f in preset_dir.glob("*.json") if f.is_file() and not f.name.startswith('_')])
|
||||
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:
|
||||
log.error(f"App: Failed to load configuration with preset '{preset_name}': {e}")
|
||||
self.config_obj = None # Ensure config_obj is None on failure
|
||||
raise # Re-raise the exception
|
||||
log.error(f"Fatal: Failed to load base configuration using default preset: {e}")
|
||||
# In a real app, show this error to the user before exiting
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
log.exception(f"App: Unexpected error loading configuration with preset '{preset_name}': {e}")
|
||||
self.config_obj = None # Ensure config_obj is None on failure
|
||||
raise # Re-raise unexpected errors
|
||||
log.exception(f"Fatal: Unexpected error loading configuration: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
def _init_engine(self):
|
||||
"""Initializes the ProcessingEngine if config_obj is available."""
|
||||
"""Initializes the ProcessingEngine."""
|
||||
if self.config_obj:
|
||||
try:
|
||||
self.processing_engine = ProcessingEngine(self.config_obj)
|
||||
log.info("App: ProcessingEngine initialized.")
|
||||
log.info("ProcessingEngine initialized.")
|
||||
except Exception as e:
|
||||
log.exception(f"App: Failed to initialize ProcessingEngine: {e}")
|
||||
self.processing_engine = None # Ensure engine is None on failure
|
||||
# Depending on context, this might need to be a fatal error.
|
||||
# For now, log and set to None.
|
||||
log.exception(f"Fatal: Failed to initialize ProcessingEngine: {e}")
|
||||
# Show error and exit
|
||||
sys.exit(1)
|
||||
else:
|
||||
log.warning("App: Cannot initialize ProcessingEngine: config_obj is None.")
|
||||
self.processing_engine = None
|
||||
log.error("Fatal: Cannot initialize ProcessingEngine without configuration.")
|
||||
sys.exit(1)
|
||||
|
||||
def _init_gui(self):
|
||||
"""Initializes the MainWindow and connects signals if processing_engine is available."""
|
||||
if self.processing_engine and self.config_obj:
|
||||
# Pass the config object to MainWindow during initialization
|
||||
self.main_window = MainWindow(config=self.config_obj)
|
||||
"""Initializes the MainWindow and connects signals."""
|
||||
if self.processing_engine:
|
||||
self.main_window = MainWindow() # MainWindow now part of the App
|
||||
# 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
|
||||
connection_success = self.main_window.start_backend_processing.connect(self.on_processing_requested, Qt.ConnectionType.QueuedConnection)
|
||||
@ -371,53 +366,10 @@ class App(QObject):
|
||||
log.error("*********************************************************")
|
||||
# Connect the App's completion signal to the MainWindow's slot
|
||||
self.all_tasks_finished.connect(self.main_window.on_processing_finished)
|
||||
log.info("App: MainWindow initialized and signals connected.")
|
||||
log.info("MainWindow initialized and signals connected.")
|
||||
else:
|
||||
log.warning("App: Cannot initialize MainWindow: ProcessingEngine or config_obj is None.")
|
||||
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
|
||||
log.error("Fatal: Cannot initialize MainWindow without ProcessingEngine.")
|
||||
sys.exit(1)
|
||||
|
||||
@Slot(list, dict) # Slot to receive List[SourceRule] and processing_settings dict
|
||||
def on_processing_requested(self, source_rules: list, processing_settings: dict):
|
||||
@ -428,98 +380,139 @@ class App(QObject):
|
||||
log.info(f"VERIFY: App.on_processing_requested received {len(source_rules)} rules.")
|
||||
for i, rule in enumerate(source_rules):
|
||||
log.debug(f" VERIFY Rule {i}: Input='{rule.input_path}', Assets={len(rule.assets)}")
|
||||
|
||||
if not self.processing_engine:
|
||||
log.error("Processing engine not available. Cannot process request.")
|
||||
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))
|
||||
self.main_window.statusBar().showMessage("Error: Processing Engine not ready.", 5000)
|
||||
return
|
||||
|
||||
if not source_rules:
|
||||
log.warning("Processing requested with an empty rule list.")
|
||||
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)
|
||||
self.main_window.statusBar().showMessage("No rules to process.", 3000)
|
||||
return
|
||||
|
||||
# Reset task counter and results for this batch
|
||||
self._active_tasks_count = len(source_rules)
|
||||
self._task_results = {"processed": 0, "skipped": 0, "failed": 0}
|
||||
log.info(f"Initialized active task count to: {self._active_tasks_count}")
|
||||
log.debug(f"Initialized active task count to: {self._active_tasks_count}")
|
||||
|
||||
# Update GUI progress bar/status via MainPanelWidget
|
||||
if self.main_window and hasattr(self.main_window, 'main_panel_widget') and self.main_window.main_panel_widget:
|
||||
# Set maximum value of progress bar to total number of 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.")
|
||||
self.main_window.main_panel_widget.progress_bar.setMaximum(len(source_rules))
|
||||
self.main_window.main_panel_widget.progress_bar.setValue(0)
|
||||
self.main_window.main_panel_widget.progress_bar.setFormat(f"0/{len(source_rules)} tasks")
|
||||
|
||||
# Extract processing settings
|
||||
output_dir = Path(processing_settings.get("output_dir"))
|
||||
overwrite = processing_settings.get("overwrite", False)
|
||||
# Workers setting is used by QThreadPool itself, not passed to individual tasks
|
||||
# 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.
|
||||
# --- Get paths needed for ProcessingTask ---
|
||||
try:
|
||||
# Access output path via MainPanelWidget
|
||||
output_base_path_str = self.main_window.main_panel_widget.output_path_edit.text().strip()
|
||||
if not output_base_path_str:
|
||||
log.error("Cannot queue tasks: Output directory path is empty in the GUI.")
|
||||
self.main_window.statusBar().showMessage("Error: Output directory cannot be empty.", 5000)
|
||||
return
|
||||
output_base_path = Path(output_base_path_str)
|
||||
# Basic validation - check if it's likely a valid path structure (doesn't guarantee existence/writability here)
|
||||
if not output_base_path.is_absolute():
|
||||
# Or attempt to resolve relative to workspace? For now, require absolute from GUI.
|
||||
log.warning(f"Output path '{output_base_path}' is not absolute. Processing might fail if relative path is not handled correctly by engine.")
|
||||
# Consider resolving: output_base_path = Path.cwd() / output_base_path # If relative paths are allowed
|
||||
|
||||
# Submit tasks to the thread pool
|
||||
log.info(f"Submitting {len(source_rules)} processing tasks to the thread pool.")
|
||||
for rule in source_rules:
|
||||
# Create a ProcessingTask for each SourceRule
|
||||
# workspace_path, incrementing_value, and sha5_value are calculated within ProcessingTask.run
|
||||
task = ProcessingTask(
|
||||
engine=self.processing_engine,
|
||||
rule=rule,
|
||||
workspace_path=Path(rule.input_path), # Pass the original input path for workspace preparation
|
||||
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)
|
||||
workspace_path = Path(__file__).parent.resolve()
|
||||
log.debug(f"Using Workspace Path: {workspace_path}")
|
||||
log.debug(f"Using Output Base Path: {output_base_path}")
|
||||
|
||||
log.info("All processing tasks submitted to thread pool.")
|
||||
except Exception as e:
|
||||
log.exception(f"Error getting/validating paths for processing task: {e}")
|
||||
self.main_window.statusBar().showMessage(f"Error preparing paths: {e}", 5000)
|
||||
return
|
||||
# --- End Get paths ---
|
||||
|
||||
@Slot(str, str, object) # rule_input_path, status, result/error
|
||||
def _on_task_finished(self, rule_input_path: str, status: str, result_or_error: object):
|
||||
"""Slot to handle the completion of an individual processing task."""
|
||||
log.debug(f"DEBUG: App._on_task_finished slot entered for rule: {rule_input_path} with status: {status}")
|
||||
|
||||
# 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
|
||||
)
|
||||
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
|
||||
|
||||
# Update task results based on status
|
||||
log.debug(f"Active tasks remaining: {self._active_tasks_count}")
|
||||
|
||||
# Update overall results (basic counts for now)
|
||||
if status == "processed":
|
||||
self._task_results["processed"] += 1
|
||||
elif status == "skipped":
|
||||
elif status == "skipped": # Assuming engine might return 'skipped' status eventually
|
||||
self._task_results["skipped"] += 1
|
||||
elif status.startswith("failed"): # Catches "failed_preparation" and "failed_processing"
|
||||
else: # Count all other statuses (failed_preparation, failed_processing) as failed
|
||||
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}")
|
||||
|
||||
log.info(f"Task finished for {rule_input_path}. Status: {status}. Remaining tasks: {self._active_tasks_count}")
|
||||
log.debug(f"Current task results: Processed={self._task_results['processed']}, Skipped={self._task_results['skipped']}, Failed={self._task_results['failed']}")
|
||||
# Update progress bar via MainPanelWidget
|
||||
total_tasks = self.main_window.main_panel_widget.progress_bar.maximum()
|
||||
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 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.")
|
||||
# Update status for the specific file in the GUI (if needed)
|
||||
|
||||
|
||||
# Check if all tasks are finished
|
||||
if self._active_tasks_count <= 0: # Use <= 0 to handle potential errors leading to negative count
|
||||
if self._active_tasks_count == 0:
|
||||
log.info("All processing tasks finished.")
|
||||
# Emit the signal with the final counts
|
||||
self.all_tasks_finished.emit(
|
||||
@ -527,9 +520,6 @@ class App(QObject):
|
||||
self._task_results["skipped"],
|
||||
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:
|
||||
log.error("Error: Active task count went below zero!") # Should not happen
|
||||
|
||||
@ -541,14 +531,6 @@ class App(QObject):
|
||||
else:
|
||||
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__":
|
||||
parser = setup_arg_parser()
|
||||
@ -567,58 +549,9 @@ if __name__ == "__main__":
|
||||
log.info("No required CLI arguments detected, starting GUI mode.")
|
||||
# --- Run the GUI Application ---
|
||||
try:
|
||||
user_config_path = app_setup_utils.read_saved_user_config_path()
|
||||
log.debug(f"Read saved user config path: {user_config_path}")
|
||||
qt_app = QApplication(sys.argv)
|
||||
|
||||
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 = App()
|
||||
app_instance.run()
|
||||
|
||||
sys.exit(qt_app.exec())
|
||||
|
||||
20
monitor.py
20
monitor.py
@ -195,25 +195,17 @@ def _process_archive_task(archive_path: Path, output_dir: Path, processed_dir: P
|
||||
# Assuming config object has 'output_directory_pattern' attribute/key
|
||||
pattern = getattr(config, 'output_directory_pattern', None) # Use getattr for safety
|
||||
if pattern:
|
||||
if re.search(r"\[IncrementingValue\]|#+", pattern):
|
||||
log.debug(f"[Task:{archive_path.name}] Incrementing token found in pattern '{pattern}'. Calculating next value for dir: {output_dir}")
|
||||
next_increment_str = get_next_incrementing_value(output_dir, pattern)
|
||||
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
|
||||
log.debug(f"[Task:{archive_path.name}] Calculating next incrementing value for dir: {output_dir} using pattern: {pattern}")
|
||||
next_increment_str = get_next_incrementing_value(output_dir, pattern)
|
||||
log.info(f"[Task:{archive_path.name}] Calculated next incrementing value: {next_increment_str}")
|
||||
else:
|
||||
# Check if config is a dict as fallback (depends on load_config implementation)
|
||||
if isinstance(config, dict):
|
||||
pattern = config.get('output_directory_pattern')
|
||||
if pattern:
|
||||
if re.search(r"\[IncrementingValue\]|#+", pattern):
|
||||
log.debug(f"[Task:{archive_path.name}] Incrementing token found in pattern '{pattern}' (from dict). Calculating next value for dir: {output_dir}")
|
||||
next_increment_str = get_next_incrementing_value(output_dir, pattern)
|
||||
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
|
||||
log.debug(f"[Task:{archive_path.name}] Calculating next incrementing value for dir: {output_dir} using pattern (from dict): {pattern}")
|
||||
next_increment_str = get_next_incrementing_value(output_dir, pattern)
|
||||
log.info(f"[Task:{archive_path.name}] Calculated next incrementing value (from dict): {next_increment_str}")
|
||||
else:
|
||||
log.warning(f"[Task:{archive_path.name}] Cannot calculate incrementing value: 'output_directory_pattern' not found in configuration dictionary.")
|
||||
else:
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import dataclasses # Added import
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
@ -28,7 +27,6 @@ class ProcessedRegularMapData:
|
||||
original_bit_depth: Optional[int]
|
||||
original_dimensions: Optional[Tuple[int, int]] # (width, height)
|
||||
transformations_applied: List[str]
|
||||
resolution_key: Optional[str] = None # Added field
|
||||
status: str = "Processed"
|
||||
error_message: Optional[str] = None
|
||||
|
||||
@ -47,10 +45,9 @@ class ProcessedMergedMapData:
|
||||
@dataclass
|
||||
class InitialScalingInput:
|
||||
image_data: np.ndarray
|
||||
initial_scaling_mode: str # Moved before fields with defaults
|
||||
original_dimensions: Optional[Tuple[int, int]] # (width, height)
|
||||
resolution_key: Optional[str] = None # Added field
|
||||
# Configuration needed
|
||||
initial_scaling_mode: str
|
||||
|
||||
# Output for InitialScalingStage
|
||||
@dataclass
|
||||
@ -58,13 +55,12 @@ class InitialScalingOutput:
|
||||
scaled_image_data: np.ndarray
|
||||
scaling_applied: bool
|
||||
final_dimensions: Tuple[int, int] # (width, height)
|
||||
resolution_key: Optional[str] = None # Added field
|
||||
|
||||
# Input for SaveVariantsStage
|
||||
@dataclass
|
||||
class SaveVariantsInput:
|
||||
image_data: np.ndarray # Final data (potentially scaled)
|
||||
final_internal_map_type: str # Final internal type (e.g., MAP_ROUGH, MAP_COL-1)
|
||||
internal_map_type: str # Final internal type (e.g., MAP_ROUGH, MAP_COL-1)
|
||||
source_bit_depth_info: List[int]
|
||||
# Configuration needed
|
||||
output_filename_pattern_tokens: Dict[str, Any]
|
||||
|
||||
@ -8,7 +8,7 @@ from typing import List, Dict, Optional, Any, Union # Added Any, Union
|
||||
import numpy as np # Added numpy
|
||||
|
||||
from configuration import Configuration
|
||||
from rule_structure import SourceRule, AssetRule, FileRule, ProcessingItem # Added ProcessingItem
|
||||
from rule_structure import SourceRule, AssetRule, FileRule # Added FileRule
|
||||
|
||||
# Import new context classes and stages
|
||||
from .asset_context import (
|
||||
@ -200,224 +200,145 @@ class PipelineOrchestrator:
|
||||
current_image_data: Optional[np.ndarray] = None # Track current image data ref
|
||||
|
||||
try:
|
||||
# The 'item' is now expected to be a ProcessingItem or MergeTaskDefinition
|
||||
|
||||
if isinstance(item, ProcessingItem):
|
||||
item_key = f"{item.source_file_info_ref}_{item.map_type_identifier}_{item.resolution_key}"
|
||||
item_log_prefix = f"Asset '{asset_name}', ProcItem '{item_key}'"
|
||||
log.info(f"{item_log_prefix}: Starting processing.")
|
||||
|
||||
# Data for ProcessingItem is already loaded by PrepareProcessingItemsStage
|
||||
current_image_data = item.image_data
|
||||
current_dimensions = item.current_dimensions
|
||||
item_resolution_key = item.resolution_key
|
||||
|
||||
# Transformations (like gloss to rough, normal invert) are assumed to be applied
|
||||
# by RegularMapProcessorStage if it's still used, or directly in PrepareProcessingItemsStage
|
||||
# before creating the ProcessingItem, or a new dedicated transformation stage.
|
||||
# For now, assume item.image_data is ready for scaling/saving.
|
||||
|
||||
# Store initial ProcessingItem data as "processed_data" for consistency if RegularMapProcessor is bypassed
|
||||
# This is a simplification; a dedicated transformation stage would be cleaner.
|
||||
# For now, we assume transformations happened before or within PrepareProcessingItemsStage.
|
||||
# The 'processed_data' variable here is more of a placeholder for what would feed into scaling.
|
||||
|
||||
# Create a simple ProcessedRegularMapData-like structure for logging/details if needed,
|
||||
# or adapt the final_details population later.
|
||||
# For now, we'll directly use 'item' fields.
|
||||
|
||||
# 2. Scale (Optional)
|
||||
scaling_mode = getattr(context.config_obj, "INITIAL_SCALING_MODE", "NONE")
|
||||
# Pass the item's resolution_key to InitialScalingInput
|
||||
scale_input = InitialScalingInput(
|
||||
image_data=current_image_data,
|
||||
original_dimensions=current_dimensions,
|
||||
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)
|
||||
current_image_data = scaled_data_output.scaled_image_data
|
||||
current_dimensions = scaled_data_output.final_dimensions # Dimensions after scaling
|
||||
# The resolution_key from item is passed through by InitialScalingOutput
|
||||
output_resolution_key = scaled_data_output.resolution_key
|
||||
log.debug(f"{item_log_prefix}: InitialScalingStage output. Scaled: {scaled_data_output.scaling_applied}, New Dims: {current_dimensions}, Output ResKey: {output_resolution_key}")
|
||||
context.intermediate_results[item_key] = scaled_data_output
|
||||
|
||||
|
||||
# 3. Save Variants
|
||||
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.")
|
||||
context.processed_maps_details[item_key] = {"status": "Skipped", "notes": "No image data to save", "stage": "SaveVariantsStage"}
|
||||
continue
|
||||
|
||||
log.debug(f"{item_log_prefix}: Preparing to save variant with resolution key '{output_resolution_key}'...")
|
||||
|
||||
output_filename_tokens = {
|
||||
'asset_name': asset_name,
|
||||
'output_base_directory': context.engine_temp_dir,
|
||||
'supplier': context.effective_supplier or 'UnknownSupplier',
|
||||
'resolution': output_resolution_key # Use the key from the item/scaling stage
|
||||
}
|
||||
|
||||
# Determine image_resolutions argument for save_image_variants
|
||||
save_specific_resolutions = {}
|
||||
if output_resolution_key == "LOWRES":
|
||||
# For LOWRES, the "resolution value" is its actual dimension.
|
||||
# image_saving_utils needs a dict like {"LOWRES": 64} if current_dim is 64x64
|
||||
# Assuming current_dimensions[0] is width.
|
||||
save_specific_resolutions = {"LOWRES": current_dimensions[0] if current_dimensions else 0}
|
||||
log.debug(f"{item_log_prefix}: Preparing to save LOWRES variant. Dimensions: {current_dimensions}. Save resolutions arg: {save_specific_resolutions}")
|
||||
elif output_resolution_key in context.config_obj.image_resolutions:
|
||||
save_specific_resolutions = {output_resolution_key: context.config_obj.image_resolutions[output_resolution_key]}
|
||||
else:
|
||||
log.warning(f"{item_log_prefix}: Resolution key '{output_resolution_key}' not found in config.image_resolutions and not LOWRES. Saving might fail or use full res.")
|
||||
# Fallback: pass all configured resolutions, image_saving_utils will try to match by size.
|
||||
# This might not be ideal if the key is truly unknown.
|
||||
# Or, more strictly, fail here if key is unknown and not LOWRES.
|
||||
# For now, let image_saving_utils handle it by passing all.
|
||||
save_specific_resolutions = context.config_obj.image_resolutions
|
||||
|
||||
|
||||
save_input = SaveVariantsInput(
|
||||
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,
|
||||
# 1. Process (Load/Merge + Transform)
|
||||
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)
|
||||
}
|
||||
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"
|
||||
|
||||
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):
|
||||
# --- 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
|
||||
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)
|
||||
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
|
||||
# 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
|
||||
|
||||
# 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")
|
||||
# Check for processing failure
|
||||
if not processed_data or processed_data.status != "Processed":
|
||||
error_msg = processed_data.error_message if processed_data else "Processor returned None"
|
||||
log.error(f"{item_log_prefix}: Failed during processing stage. Error: {error_msg}")
|
||||
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
|
||||
context.intermediate_results[item_key] = processed_data
|
||||
current_image_data = processed_data.processed_image_data if isinstance(processed_data, ProcessedRegularMapData) else processed_data.merged_image_data
|
||||
current_dimensions = processed_data.original_dimensions if isinstance(processed_data, ProcessedRegularMapData) else processed_data.final_dimensions
|
||||
|
||||
# 2. Scale (Optional)
|
||||
scaling_mode = getattr(context.config_obj, "INITIAL_SCALING_MODE", "NONE")
|
||||
if scaling_mode != "NONE" and current_image_data is not None and current_image_data.size > 0:
|
||||
if isinstance(item, MergeTaskDefinition): # Log scaling call for merge tasks
|
||||
log.info(f"{item_log_prefix}: Calling InitialScalingStage for MergeTask '{item_key}' (Mode: {scaling_mode})...")
|
||||
log.debug(f"{item_log_prefix}: Applying initial scaling (Mode: {scaling_mode})...")
|
||||
scale_input = InitialScalingInput(
|
||||
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
|
||||
original_dimensions=current_dimensions, # Pass original/merged dims
|
||||
initial_scaling_mode=scaling_mode
|
||||
)
|
||||
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"
|
||||
# Update intermediate result and current image data reference
|
||||
context.intermediate_results[item_key] = scaled_data_output # Overwrite previous intermediate
|
||||
current_image_data = scaled_data_output.scaled_image_data # Use scaled data for saving
|
||||
log.debug(f"{item_log_prefix}: Scaling applied: {scaled_data_output.scaling_applied}. New Dims: {scaled_data_output.final_dimensions}")
|
||||
else:
|
||||
log.warning(f"{item_log_prefix}: Unknown item type in loop: {type(item)}. Skipping.")
|
||||
# Ensure some key exists to prevent KeyError if item_key was not set
|
||||
unknown_item_key = f"unknown_item_at_index_{item_index}"
|
||||
context.processed_maps_details[unknown_item_key] = {"status": "Skipped", "notes": f"Unknown item type {type(item)}"}
|
||||
log.debug(f"{item_log_prefix}: Initial scaling skipped (Mode: NONE or empty image).")
|
||||
# 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
|
||||
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.")
|
||||
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 # Next item
|
||||
|
||||
if isinstance(item, MergeTaskDefinition): # Log save call for merge tasks
|
||||
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 = {
|
||||
'asset_name': asset_name,
|
||||
'output_base_directory': context.engine_temp_dir, # Save variants to temp dir
|
||||
# Add other tokens from context/config as needed by the pattern
|
||||
'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):
|
||||
log.info(f"{item_log_prefix}: Adding final details to context.processed_maps_details for MergeTask '{item_key}'. Details: {final_details}")
|
||||
context.processed_maps_details[item_key] = final_details
|
||||
else:
|
||||
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
|
||||
continue
|
||||
item_status = "Failed" # Ensure item status reflects failure
|
||||
|
||||
except Exception as e:
|
||||
log.exception(f"Asset '{asset_name}', Item Loop Index {item_index}: Unhandled exception: {e}")
|
||||
log.exception(f"{item_log_prefix}: Unhandled exception during item processing loop: {e}")
|
||||
# Ensure details are recorded even on unhandled exception
|
||||
if item_key is not None:
|
||||
context.processed_maps_details[item_key] = {"status": "Failed", "notes": f"Unhandled Loop Error: {e}", "stage": "OrchestratorLoop"}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import logging
|
||||
from typing import Tuple, Optional # Added Optional
|
||||
from typing import Tuple
|
||||
|
||||
import cv2 # Assuming cv2 is available for interpolation flags
|
||||
import numpy as np
|
||||
@ -7,93 +7,77 @@ import numpy as np
|
||||
from .base_stage import ProcessingStage
|
||||
# Import necessary context classes and utils
|
||||
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
|
||||
import numpy as np
|
||||
import cv2 # Added cv2 for interpolation flags (already used implicitly by ipu.resize_image)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
class InitialScalingStage(ProcessingStage):
|
||||
"""
|
||||
Applies initial Power-of-Two (POT) downscaling to image data if configured
|
||||
and if the item is not already a 'LOWRES' variant.
|
||||
Applies initial scaling (e.g., Power-of-Two downscaling) to image data
|
||||
if configured via the InitialScalingInput.
|
||||
"""
|
||||
|
||||
def execute(self, input_data: InitialScalingInput) -> InitialScalingOutput:
|
||||
"""
|
||||
Applies POT scaling based on input_data.initial_scaling_mode,
|
||||
unless input_data.resolution_key is 'LOWRES'.
|
||||
Passes through the resolution_key.
|
||||
Applies scaling based on 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}'")
|
||||
log.debug(f"Initial Scaling Stage: Mode '{input_data.initial_scaling_mode}'.")
|
||||
|
||||
image_to_scale = input_data.image_data
|
||||
current_dimensions_wh = input_data.original_dimensions # Dimensions of the image_to_scale
|
||||
original_dims_wh = input_data.original_dimensions
|
||||
scaling_mode = input_data.initial_scaling_mode
|
||||
|
||||
output_resolution_key = input_data.resolution_key # Pass through the resolution key
|
||||
scaling_applied = False
|
||||
final_image_data = image_to_scale # Default to original if no scaling happens
|
||||
|
||||
if image_to_scale is None or image_to_scale.size == 0:
|
||||
log.warning(f"{log_prefix}: Input image data is None or empty. Skipping POT scaling.")
|
||||
log.warning("Initial Scaling Stage: Input image data is None or empty. Skipping.")
|
||||
# Return original (empty) data and indicate no scaling
|
||||
return InitialScalingOutput(
|
||||
scaled_image_data=np.array([]),
|
||||
scaling_applied=False,
|
||||
final_dimensions=(0, 0),
|
||||
resolution_key=output_resolution_key
|
||||
final_dimensions=(0, 0)
|
||||
)
|
||||
|
||||
if not current_dimensions_wh:
|
||||
log.warning(f"{log_prefix}: Original dimensions not provided for POT scaling. Using current image shape.")
|
||||
h_pre_pot_scale, w_pre_pot_scale = image_to_scale.shape[:2]
|
||||
if original_dims_wh is None:
|
||||
log.warning("Initial Scaling Stage: Original dimensions not provided. Using current image shape.")
|
||||
h_pre_scale, w_pre_scale = image_to_scale.shape[:2]
|
||||
original_dims_wh = (w_pre_scale, h_pre_scale)
|
||||
else:
|
||||
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
|
||||
w_pre_scale, h_pre_scale = original_dims_wh
|
||||
|
||||
# Skip POT scaling if the item is already a LOWRES variant or scaling mode is NONE
|
||||
if output_resolution_key == "LOWRES":
|
||||
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_pot_scale, h_pre_pot_scale):
|
||||
log.info(f"{log_prefix}: Applying POT Downscale from ({w_pre_pot_scale},{h_pre_pot_scale}) to ({pot_w},{pot_h}).")
|
||||
if scaling_mode == "POT_DOWNSCALE":
|
||||
pot_w = ipu.get_nearest_power_of_two_downscale(w_pre_scale)
|
||||
pot_h = ipu.get_nearest_power_of_two_downscale(h_pre_scale)
|
||||
|
||||
if (pot_w, pot_h) != (w_pre_scale, h_pre_scale):
|
||||
log.info(f"Initial Scaling: Applying POT Downscale from ({w_pre_scale},{h_pre_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)
|
||||
if resized_img is not None:
|
||||
final_image_data = resized_img
|
||||
scaling_applied = True
|
||||
log.debug(f"{log_prefix}: POT Downscale applied successfully.")
|
||||
log.debug("Initial Scaling: POT Downscale applied successfully.")
|
||||
else:
|
||||
log.warning(f"{log_prefix}: POT Downscale resize failed. Using pre-POT-scaled data.")
|
||||
log.warning("Initial Scaling: POT Downscale resize failed. Using original data.")
|
||||
# final_image_data remains image_to_scale
|
||||
else:
|
||||
log.info(f"{log_prefix}: Image already POT or smaller. No POT scaling needed.")
|
||||
log.info("Initial Scaling: POT Downscale - Image already POT or smaller. No 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:
|
||||
log.warning(f"{log_prefix}: Unknown INITIAL_SCALING_MODE '{scaling_mode}'. Defaulting to NONE (no scaling).")
|
||||
log.warning(f"Initial Scaling: Unknown INITIAL_SCALING_MODE '{scaling_mode}'. Defaulting to NONE.")
|
||||
# final_image_data remains image_to_scale
|
||||
|
||||
# Determine final dimensions
|
||||
if final_image_data is not None and final_image_data.size > 0:
|
||||
final_h, final_w = final_image_data.shape[:2]
|
||||
final_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([])
|
||||
final_h, final_w = final_image_data.shape[:2]
|
||||
final_dims_wh = (final_w, final_h)
|
||||
|
||||
return InitialScalingOutput(
|
||||
scaled_image_data=final_image_data,
|
||||
scaling_applied=scaling_applied,
|
||||
final_dimensions=final_dims_wh,
|
||||
resolution_key=output_resolution_key # Pass through the resolution key
|
||||
final_dimensions=final_dims_wh
|
||||
)
|
||||
@ -85,7 +85,6 @@ class MetadataInitializationStage(ProcessingStage):
|
||||
merged_maps_details.
|
||||
"""
|
||||
def execute(self, context: AssetProcessingContext) -> AssetProcessingContext:
|
||||
logger.debug(f"METADATA_INIT_DEBUG: Entry - context.output_base_path = {context.output_base_path}") # Added
|
||||
"""
|
||||
Executes the metadata initialization logic.
|
||||
|
||||
@ -148,15 +147,12 @@ class MetadataInitializationStage(ProcessingStage):
|
||||
context.asset_metadata['processing_start_time'] = datetime.datetime.now().isoformat()
|
||||
context.asset_metadata['status'] = "Pending"
|
||||
|
||||
app_version_value = None
|
||||
if context.config_obj and hasattr(context.config_obj, 'app_version'):
|
||||
app_version_value = context.config_obj.app_version
|
||||
|
||||
if app_version_value:
|
||||
context.asset_metadata['version'] = app_version_value
|
||||
if context.config_obj and hasattr(context.config_obj, 'general_settings') and \
|
||||
hasattr(context.config_obj.general_settings, 'app_version'):
|
||||
context.asset_metadata['version'] = context.config_obj.general_settings.app_version
|
||||
else:
|
||||
logger.warning("App version not found using config_obj.app_version. Setting version to 'N/A'.")
|
||||
context.asset_metadata['version'] = "N/A"
|
||||
logger.warning("App version not found in config_obj.general_settings. Setting version to 'N/A'.")
|
||||
context.asset_metadata['version'] = "N/A" # Default or placeholder
|
||||
|
||||
if context.incrementing_value is not None:
|
||||
context.asset_metadata['incrementing_value'] = context.incrementing_value
|
||||
@ -174,5 +170,4 @@ class MetadataInitializationStage(ProcessingStage):
|
||||
# Example of how you might log the full metadata for debugging:
|
||||
# logger.debug(f"Initialized metadata: {context.asset_metadata}")
|
||||
|
||||
logger.debug(f"METADATA_INIT_DEBUG: Exit - context.output_base_path = {context.output_base_path}") # Added
|
||||
return context
|
||||
@ -17,16 +17,8 @@ class OutputOrganizationStage(ProcessingStage):
|
||||
"""
|
||||
|
||||
def execute(self, context: AssetProcessingContext) -> AssetProcessingContext:
|
||||
asset_name_for_log_early = context.asset_rule.asset_name if hasattr(context, 'asset_rule') and context.asset_rule else "Unknown Asset (early)"
|
||||
log.info(f"OUTPUT_ORG_DEBUG: Stage execution started for asset '{asset_name_for_log_early}'.")
|
||||
logger.debug(f"OUTPUT_ORG_DEBUG: Entry - context.output_base_path = {context.output_base_path}") # Modified
|
||||
log.info(f"OUTPUT_ORG_DEBUG: Received context.config_obj.output_directory_base (raw from config) = {getattr(context.config_obj, 'output_directory_base', 'N/A')}")
|
||||
# resolved_base = "N/A"
|
||||
# if hasattr(context.config_obj, '_settings') and context.config_obj._settings.get('OUTPUT_BASE_DIR'):
|
||||
# base_dir_from_settings = context.config_obj._settings.get('OUTPUT_BASE_DIR')
|
||||
# Path resolution logic might be complex
|
||||
# log.info(f"OUTPUT_ORG_DEBUG: Received context.config_obj._settings.OUTPUT_BASE_DIR (resolved guess) = {resolved_base}")
|
||||
log.info(f"OUTPUT_ORG_DEBUG: context.processed_maps_details at start: {context.processed_maps_details}")
|
||||
log.info("OUTPUT_ORG: Stage execution started for asset '%s'", context.asset_rule.asset_name)
|
||||
log.info(f"OUTPUT_ORG: context.processed_maps_details at start: {context.processed_maps_details}")
|
||||
"""
|
||||
Copies temporary processed and merged files to their final output locations
|
||||
based on path patterns and updates AssetProcessingContext.
|
||||
@ -97,7 +89,6 @@ class OutputOrganizationStage(ProcessingStage):
|
||||
token_data_variant = {
|
||||
"assetname": asset_name_for_log,
|
||||
"supplier": context.effective_supplier or "DefaultSupplier",
|
||||
"asset_category": context.asset_rule.asset_type, # Used asset_type for asset_category token
|
||||
"maptype": base_map_type,
|
||||
"resolution": variant_resolution_key,
|
||||
"ext": variant_ext,
|
||||
@ -112,9 +103,7 @@ class OutputOrganizationStage(ProcessingStage):
|
||||
pattern_string=output_dir_pattern,
|
||||
token_data=token_data_variant_cleaned
|
||||
)
|
||||
logger.debug(f"OUTPUT_ORG_DEBUG: Variants - Using context.output_base_path = {context.output_base_path} for final_variant_path construction.") # Added
|
||||
final_variant_path = Path(context.output_base_path) / Path(relative_dir_path_str_variant) / Path(output_filename_variant)
|
||||
logger.debug(f"OUTPUT_ORG_DEBUG: Variants - Constructed final_variant_path = {final_variant_path}") # Added
|
||||
final_variant_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if final_variant_path.exists() and not overwrite_existing:
|
||||
@ -163,27 +152,24 @@ class OutputOrganizationStage(ProcessingStage):
|
||||
resolution_str = details.get('processed_resolution_name', details.get('original_resolution_name', 'resX'))
|
||||
|
||||
token_data = {
|
||||
"assetname": asset_name_for_log,
|
||||
"supplier": context.effective_supplier or "DefaultSupplier",
|
||||
"asset_category": context.asset_rule.asset_type, # Used asset_type for asset_category token
|
||||
"maptype": base_map_type,
|
||||
"resolution": resolution_str,
|
||||
"ext": temp_file_path.suffix.lstrip('.'),
|
||||
"incrementingvalue": getattr(context, 'incrementing_value', None),
|
||||
"sha5": getattr(context, 'sha5_value', None)
|
||||
}
|
||||
"assetname": asset_name_for_log,
|
||||
"supplier": context.effective_supplier or "DefaultSupplier",
|
||||
"maptype": base_map_type,
|
||||
"resolution": resolution_str,
|
||||
"ext": temp_file_path.suffix.lstrip('.'),
|
||||
"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}
|
||||
|
||||
output_filename = generate_path_from_pattern(output_filename_pattern_config, token_data_cleaned)
|
||||
|
||||
try:
|
||||
relative_dir_path_str = generate_path_from_pattern(
|
||||
pattern_string=output_dir_pattern,
|
||||
token_data=token_data_cleaned
|
||||
pattern_string=output_dir_pattern,
|
||||
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
|
||||
final_path = Path(context.output_base_path) / Path(relative_dir_path_str) / Path(output_filename)
|
||||
logger.debug(f"OUTPUT_ORG_DEBUG: SingleFile - Constructed final_path = {final_path}") # Added
|
||||
final_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if final_path.exists() and not overwrite_existing:
|
||||
@ -216,8 +202,9 @@ class OutputOrganizationStage(ProcessingStage):
|
||||
details['status'] = 'Organization Failed'
|
||||
|
||||
# --- Handle other statuses (Skipped, Failed, etc.) ---
|
||||
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.")
|
||||
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.")
|
||||
continue
|
||||
else:
|
||||
logger.debug(f"Asset '{asset_name_for_log}': No processed individual maps to organize.")
|
||||
|
||||
@ -244,27 +231,24 @@ class OutputOrganizationStage(ProcessingStage):
|
||||
# However, generate_path_from_pattern might expect them or handle their absence.
|
||||
# For the base asset directory, only assetname and supplier are typically primary.
|
||||
base_token_data = {
|
||||
"assetname": asset_name_for_log,
|
||||
"supplier": context.effective_supplier or "DefaultSupplier",
|
||||
"asset_category": context.asset_rule.asset_type, # Used asset_type for asset_category token
|
||||
# Add other tokens if your output_directory_pattern uses them at the asset level
|
||||
"incrementingvalue": getattr(context, 'incrementing_value', None),
|
||||
"sha5": getattr(context, 'sha5_value', None)
|
||||
"assetname": asset_name_for_log,
|
||||
"supplier": context.effective_supplier or "DefaultSupplier",
|
||||
# Add other tokens if your output_directory_pattern uses them at the asset level
|
||||
"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}
|
||||
|
||||
try:
|
||||
asset_base_output_dir_str = generate_path_from_pattern(
|
||||
pattern_string=output_dir_pattern, # Uses the same pattern as other maps for base dir
|
||||
token_data=base_token_data_cleaned
|
||||
pattern_string=output_dir_pattern, # Uses the same pattern as other maps for base dir
|
||||
token_data=base_token_data_cleaned
|
||||
)
|
||||
# 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
|
||||
final_dest_path = (Path(context.output_base_path) /
|
||||
Path(asset_base_output_dir_str) /
|
||||
Path(extra_subdir_name) /
|
||||
source_file_path.name) # Use original filename
|
||||
logger.debug(f"OUTPUT_ORG_DEBUG: ExtraFiles - Constructed final_dest_path = {final_dest_path}") # Added
|
||||
|
||||
final_dest_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
@ -1,69 +1,21 @@
|
||||
import logging
|
||||
from typing import List, Union, Optional, Tuple, Dict # Added Dict
|
||||
from pathlib import Path # Added Path
|
||||
from typing import List, Union, Optional
|
||||
|
||||
from .base_stage import ProcessingStage
|
||||
from ..asset_context import AssetProcessingContext, MergeTaskDefinition
|
||||
from rule_structure import FileRule, ProcessingItem # Added ProcessingItem
|
||||
from processing.utils import image_processing_utils as ipu # Added ipu
|
||||
from rule_structure import FileRule # Assuming FileRule is imported correctly
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
class PrepareProcessingItemsStage(ProcessingStage):
|
||||
"""
|
||||
Identifies and prepares a unified list of ProcessingItem and MergeTaskDefinition objects
|
||||
to be processed in subsequent stages. Performs initial validation and explodes
|
||||
FileRules into specific ProcessingItems for each required output variant.
|
||||
Identifies and prepares a unified list of items (FileRule, MergeTaskDefinition)
|
||||
to be processed in subsequent stages. Performs initial validation.
|
||||
"""
|
||||
|
||||
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:
|
||||
"""
|
||||
Populates context.processing_items with ProcessingItem and MergeTaskDefinition objects.
|
||||
Populates context.processing_items with FileRule and MergeTaskDefinition objects.
|
||||
"""
|
||||
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...")
|
||||
@ -73,135 +25,72 @@ class PrepareProcessingItemsStage(ProcessingStage):
|
||||
context.processing_items = []
|
||||
return context
|
||||
|
||||
# Output list will now be List[Union[ProcessingItem, MergeTaskDefinition]]
|
||||
items_to_process: List[Union[ProcessingItem, MergeTaskDefinition]] = []
|
||||
items_to_process: List[Union[FileRule, MergeTaskDefinition]] = []
|
||||
preparation_failed = False
|
||||
config = context.config_obj
|
||||
|
||||
# --- Process FileRules into ProcessingItems ---
|
||||
# --- Add regular files ---
|
||||
if context.files_to_process:
|
||||
# Validate source path early for regular files
|
||||
source_path_valid = True
|
||||
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.")
|
||||
log.error(f"Asset '{asset_name_for_log}': SourceRule or SourceRule.input_path is not set. Cannot process regular files.")
|
||||
source_path_valid = False
|
||||
preparation_failed = True
|
||||
preparation_failed = True # Mark as failed if source path is missing
|
||||
context.status_flags['prepare_items_failed_reason'] = "SourceRule.input_path missing"
|
||||
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 invalid.")
|
||||
log.error(f"Asset '{asset_name_for_log}': Workspace path '{context.workspace_path}' is not a valid directory. Cannot process regular files.")
|
||||
source_path_valid = False
|
||||
preparation_failed = True
|
||||
preparation_failed = True # Mark as failed if workspace path is bad
|
||||
context.status_flags['prepare_items_failed_reason'] = "Workspace path invalid"
|
||||
|
||||
if source_path_valid:
|
||||
for file_rule in context.files_to_process:
|
||||
log_prefix_fr = f"Asset '{asset_name_for_log}', FileRule '{file_rule.file_path}'"
|
||||
# Basic validation for FileRule itself
|
||||
if not file_rule.file_path:
|
||||
log.warning(f"{log_prefix_fr}: Skipping FileRule with empty file_path.")
|
||||
continue
|
||||
|
||||
item_type = file_rule.item_type_override or file_rule.item_type
|
||||
if not item_type or item_type == "EXTRA" or not item_type.startswith("MAP_"):
|
||||
log.debug(f"{log_prefix_fr}: Item type is '{item_type}'. Not creating map ProcessingItems.")
|
||||
# Optionally, create a different kind of ProcessingItem for EXTRAs if they need pipeline processing
|
||||
continue
|
||||
|
||||
source_image_path = context.workspace_path / file_rule.file_path
|
||||
if not source_image_path.is_file():
|
||||
log.error(f"{log_prefix_fr}: Source image file not found at '{source_image_path}'. Skipping this FileRule.")
|
||||
preparation_failed = True # Individual file error can contribute to overall stage failure
|
||||
context.status_flags.setdefault('prepare_items_file_errors', []).append(str(source_image_path))
|
||||
continue
|
||||
|
||||
# Load image data to get dimensions and for LOWRES variant
|
||||
# This data will be passed to subsequent stages via ProcessingItem.
|
||||
# Consider caching this load if RegularMapProcessorStage also loads.
|
||||
# For now, load here as dimensions are needed for LOWRES decision.
|
||||
log.debug(f"{log_prefix_fr}: Loading image from '{source_image_path}' to determine dimensions and prepare items.")
|
||||
source_image_data = ipu.load_image(str(source_image_path))
|
||||
if source_image_data is None:
|
||||
log.error(f"{log_prefix_fr}: Failed to load image from '{source_image_path}'. Skipping this FileRule.")
|
||||
preparation_failed = True
|
||||
context.status_flags.setdefault('prepare_items_file_errors', []).append(f"Failed to load {source_image_path}")
|
||||
continue
|
||||
|
||||
orig_h, orig_w = source_image_data.shape[:2]
|
||||
original_dimensions_wh = (orig_w, orig_h)
|
||||
source_bit_depth = ipu.get_image_bit_depth(str(source_image_path)) # Get bit depth from file
|
||||
source_channels = ipu.get_image_channels(source_image_data)
|
||||
log.warning(f"Asset '{asset_name_for_log}': Skipping FileRule with empty file_path.")
|
||||
continue # Skip this specific rule, but don't fail the whole stage
|
||||
items_to_process.append(file_rule)
|
||||
log.debug(f"Asset '{asset_name_for_log}': Added {len(context.files_to_process)} potential FileRule items.")
|
||||
else:
|
||||
log.warning(f"Asset '{asset_name_for_log}': Skipping addition of all FileRule items due to invalid source/workspace path.")
|
||||
|
||||
|
||||
# Determine standard resolutions to generate
|
||||
# This logic needs to be robust and consider file_rule.resolution_override, etc.
|
||||
# Using a placeholder _get_target_resolutions for now.
|
||||
target_resolutions = self._get_target_resolutions(orig_w, orig_h, config.image_resolutions, file_rule)
|
||||
# --- Add merged tasks ---
|
||||
# --- Add merged tasks from global configuration ---
|
||||
# merged_image_tasks are expected to be loaded into context.config_obj
|
||||
# by the Configuration class from app_settings.json.
|
||||
|
||||
merged_tasks_list = getattr(context.config_obj, 'map_merge_rules', None)
|
||||
|
||||
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):
|
||||
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):
|
||||
if isinstance(task_data, dict):
|
||||
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):
|
||||
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
|
||||
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}")
|
||||
continue # Skip this specific task
|
||||
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)
|
||||
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)
|
||||
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:
|
||||
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}")
|
||||
# ... (rest of merge task handling) ...
|
||||
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}")
|
||||
# The log for "Added X potential MergeTaskDefinition items" will be covered by the final log.
|
||||
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.intermediate_results = {} # Initialize intermediate results storage
|
||||
|
||||
|
||||
@ -37,7 +37,7 @@ class RegularMapProcessorStage(ProcessingStage):
|
||||
"""
|
||||
final_internal_map_type = initial_internal_map_type # Default
|
||||
|
||||
base_map_type_match = re.match(r"(MAP_[A-Z]+)", initial_internal_map_type)
|
||||
base_map_type_match = re.match(r"(MAP_[A-Z]{3})", initial_internal_map_type)
|
||||
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
|
||||
|
||||
@ -47,7 +47,7 @@ class RegularMapProcessorStage(ProcessingStage):
|
||||
peers_of_same_base_type = []
|
||||
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_base_match = re.match(r"(MAP_[A-Z]+)", fr_asset_item_type)
|
||||
fr_asset_base_match = re.match(r"(MAP_[A-Z]{3})", fr_asset_item_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)
|
||||
|
||||
@ -178,20 +178,12 @@ class RegularMapProcessorStage(ProcessingStage):
|
||||
log.debug(f"{log_prefix}: Loaded image {result.original_dimensions[0]}x{result.original_dimensions[1]}.")
|
||||
|
||||
# Get original bit depth
|
||||
# Determine original bit depth from the loaded image data's dtype
|
||||
dtype_to_bit_depth = {
|
||||
np.dtype('uint8'): 8,
|
||||
np.dtype('uint16'): 16,
|
||||
np.dtype('float32'): 32,
|
||||
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}")
|
||||
try:
|
||||
result.original_bit_depth = ipu.get_image_bit_depth(str(source_file_path_found))
|
||||
log.info(f"{log_prefix}: Determined source bit depth: {result.original_bit_depth}")
|
||||
except Exception as e:
|
||||
log.warning(f"{log_prefix}: Could not determine source bit depth for {source_file_path_found}: {e}. Setting to None.")
|
||||
result.original_bit_depth = None # Indicate failure to determine
|
||||
|
||||
# --- Apply Transformations ---
|
||||
transformed_image_data, final_map_type, transform_notes = ipu.apply_common_map_transformations(
|
||||
@ -205,24 +197,11 @@ class RegularMapProcessorStage(ProcessingStage):
|
||||
result.final_internal_map_type = final_map_type # Update if Gloss->Rough changed it
|
||||
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 ---
|
||||
result.status = "Processed"
|
||||
result.error_message = None
|
||||
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}")
|
||||
|
||||
log.info(f"{log_prefix}: Successfully processed regular map. Final type: '{result.final_internal_map_type}'.")
|
||||
|
||||
except Exception as e:
|
||||
log.exception(f"{log_prefix}: Unhandled exception during processing: {e}")
|
||||
result.status = "Failed"
|
||||
|
||||
@ -22,18 +22,9 @@ class SaveVariantsStage(ProcessingStage):
|
||||
"""
|
||||
Calls isu.save_image_variants with data from input_data.
|
||||
"""
|
||||
internal_map_type = input_data.final_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})"
|
||||
|
||||
internal_map_type = input_data.internal_map_type
|
||||
log_prefix = f"Save Variants Stage (Type: {internal_map_type})"
|
||||
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
|
||||
result = SaveVariantsOutput(
|
||||
@ -59,7 +50,7 @@ class SaveVariantsStage(ProcessingStage):
|
||||
|
||||
save_args = {
|
||||
"source_image_data": input_data.image_data,
|
||||
"final_internal_map_type": input_data.final_internal_map_type, # Pass the internal type identifier
|
||||
"base_map_type": base_map_type_friendly, # Use the friendly type
|
||||
"source_bit_depth_info": input_data.source_bit_depth_info,
|
||||
"image_resolutions": input_data.image_resolutions,
|
||||
"file_type_defs": input_data.file_type_defs,
|
||||
@ -73,11 +64,11 @@ class SaveVariantsStage(ProcessingStage):
|
||||
"resolution_threshold_for_jpg": input_data.resolution_threshold_for_jpg, # Added
|
||||
}
|
||||
|
||||
log.debug(f"{log_prefix}: Calling save_image_variants utility with args: {save_args}")
|
||||
log.debug(f"{log_prefix}: Calling save_image_variants utility.")
|
||||
saved_files_details: List[Dict] = isu.save_image_variants(**save_args)
|
||||
|
||||
if saved_files_details:
|
||||
log.info(f"{log_prefix}: Save utility completed successfully. Saved {len(saved_files_details)} variants: {[details.get('filepath') for details in saved_files_details]}")
|
||||
log.info(f"{log_prefix}: Save utility completed successfully. Saved {len(saved_files_details)} variants.")
|
||||
result.saved_files_details = saved_files_details
|
||||
result.status = "Processed"
|
||||
result.error_message = None
|
||||
|
||||
@ -194,16 +194,6 @@ def get_image_bit_depth(image_path_str: str) -> Optional[int]:
|
||||
print(f"Error getting bit depth for {image_path_str}: {e}")
|
||||
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]:
|
||||
"""
|
||||
Calculates min, max, mean for a given numpy image array.
|
||||
@ -304,11 +294,9 @@ def load_image(image_path: Union[str, Path], read_flag: int = cv2.IMREAD_UNCHANG
|
||||
try:
|
||||
img = cv2.imread(str(image_path), read_flag)
|
||||
if img is None:
|
||||
ipu_log.warning(f"Failed to load image: {image_path}")
|
||||
# print(f"Warning: Failed to load image: {image_path}") # Optional: for debugging utils
|
||||
return None
|
||||
|
||||
ipu_log.debug(f"Loaded image '{image_path}'. Initial dtype: {img.dtype}, shape: {img.shape}")
|
||||
|
||||
# Ensure RGB/RGBA for color images
|
||||
if len(img.shape) == 3:
|
||||
if img.shape[2] == 4: # BGRA from OpenCV
|
||||
@ -394,11 +382,8 @@ def save_image(
|
||||
path_obj = Path(image_path)
|
||||
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
|
||||
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 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)
|
||||
@ -418,8 +403,6 @@ def save_image(
|
||||
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)
|
||||
# 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.
|
||||
@ -467,8 +450,6 @@ def apply_common_map_transformations(
|
||||
current_image_data = image_data # Start with original data
|
||||
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
|
||||
# Check if the base type is Gloss (before suffix)
|
||||
base_map_type_match = re.match(r"(MAP_GLOSS)", processing_map_type)
|
||||
@ -503,8 +484,6 @@ def apply_common_map_transformations(
|
||||
current_image_data = invert_normal_map_green_channel(current_image_data)
|
||||
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
|
||||
|
||||
# --- Normal Map Utilities ---
|
||||
|
||||
@ -3,10 +3,7 @@ import cv2
|
||||
import numpy as np
|
||||
from pathlib import Path
|
||||
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
|
||||
# Assuming ipu is available in the same utils directory or parent
|
||||
try:
|
||||
@ -25,7 +22,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
def save_image_variants(
|
||||
source_image_data: np.ndarray,
|
||||
final_internal_map_type: str, # Use the internal map type identifier
|
||||
base_map_type: str, # Filename-friendly map type
|
||||
source_bit_depth_info: List[Optional[int]],
|
||||
image_resolutions: Dict[str, int],
|
||||
file_type_defs: Dict[str, Dict[str, Any]],
|
||||
@ -45,13 +42,14 @@ def save_image_variants(
|
||||
|
||||
Args:
|
||||
source_image_data (np.ndarray): High-res image data (in memory, potentially transformed).
|
||||
final_internal_map_type (str): Final internal map type (e.g., "MAP_COL", "MAP_NRM", "MAP_NRMRGH").
|
||||
base_map_type (str): Final map type (e.g., "COL", "ROUGH", "NORMAL", "MAP_NRMRGH").
|
||||
This is the filename-friendly map type.
|
||||
source_bit_depth_info (List[Optional[int]]): List of original source bit depth(s)
|
||||
(e.g., [8], [16], [8, 16]). Can contain None.
|
||||
image_resolutions (Dict[str, int]): Dictionary mapping resolution keys (e.g., "4K")
|
||||
to max dimensions (e.g., 4096).
|
||||
file_type_defs (Dict[str, Dict[str, Any]]): Dictionary defining properties for map types,
|
||||
including 'bit_depth_policy'.
|
||||
including 'bit_depth_rule'.
|
||||
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_fallback (str): Fallback file extension for 16-bit output.
|
||||
@ -66,8 +64,8 @@ def save_image_variants(
|
||||
|
||||
Returns:
|
||||
List[Dict[str, Any]]: A list of dictionaries, each containing details about a saved file.
|
||||
Example: [{'path': str, 'resolution_key': str, 'format': str,
|
||||
'bit_depth': int, 'dimensions': (w,h)}, ...]
|
||||
Example: [{'path': str, 'resolution_key': str, 'format': str,
|
||||
'bit_depth': int, 'dimensions': (w,h)}, ...]
|
||||
"""
|
||||
if ipu is None:
|
||||
logger.error("image_processing_utils is not available. Cannot save images.")
|
||||
@ -78,46 +76,30 @@ def save_image_variants(
|
||||
source_max_dim = max(source_h, source_w)
|
||||
|
||||
# 1. Use provided configuration inputs (already available as function arguments)
|
||||
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.info(f"SaveImageVariants: Starting for map type: {base_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: 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: Received resolution_threshold_for_jpg: {resolution_threshold_for_jpg}") # Log received threshold
|
||||
|
||||
# 2. Determine Target Bit Depth based on bit_depth_policy
|
||||
# Use the final_internal_map_type for lookup in file_type_defs
|
||||
bit_depth_policy = file_type_defs.get(final_internal_map_type, {}).get('bit_depth_policy', '')
|
||||
# 2. Determine Target Bit Depth
|
||||
target_bit_depth = 8 # Default
|
||||
bit_depth_rule = file_type_defs.get(base_map_type, {}).get('bit_depth_rule', 'force_8bit')
|
||||
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'
|
||||
|
||||
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":
|
||||
if bit_depth_rule == 'respect_inputs':
|
||||
# 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):
|
||||
target_bit_depth = 16
|
||||
logger.debug(f"SaveImageVariants: Policy 'preserve' applied, source > 8 found. Setting target_bit_depth = {target_bit_depth}")
|
||||
else:
|
||||
target_bit_depth = 8
|
||||
logger.debug(f"SaveImageVariants: Policy 'preserve' applied, no source > 8 found. Setting target_bit_depth = {target_bit_depth}")
|
||||
elif bit_depth_policy == "" or bit_depth_policy not in ["force_8bit", "force_16bit", "preserve"]:
|
||||
# Handle "" policy or any other unexpected/unknown value
|
||||
# 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}")
|
||||
logger.info(f"Bit depth rule 'respect_inputs' applied. Source bit depths: {source_bit_depth_info}. Target bit depth: {target_bit_depth}")
|
||||
else: # force_8bit
|
||||
target_bit_depth = 8
|
||||
logger.info(f"Bit depth rule 'force_8bit' applied. Target bit depth: {target_bit_depth}")
|
||||
|
||||
|
||||
# 3. Determine Output File Format(s)
|
||||
if target_bit_depth == 8:
|
||||
@ -135,28 +117,27 @@ def save_image_variants(
|
||||
output_ext = output_format_8bit.lstrip('.').lower()
|
||||
|
||||
current_output_ext = output_ext # Store the initial extension based on bit depth
|
||||
|
||||
# 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}")
|
||||
|
||||
|
||||
logger.info(f"SaveImageVariants: Determined target bit depth: {target_bit_depth}, Initial output format: {current_output_ext} for map type {base_map_type}")
|
||||
|
||||
# 4. Generate and Save Resolution Variants
|
||||
# Sort resolutions by max dimension descending
|
||||
sorted_resolutions = sorted(image_resolutions.items(), key=lambda item: item[1], reverse=True)
|
||||
|
||||
for res_key, res_max_dim in sorted_resolutions:
|
||||
logger.info(f"SaveImageVariants: Processing variant {res_key} ({res_max_dim}px) for {final_internal_map_type}")
|
||||
logger.info(f"SaveImageVariants: Processing variant {res_key} ({res_max_dim}px) for {base_map_type}")
|
||||
|
||||
# --- Prevent Upscaling ---
|
||||
# Skip this resolution variant if its target dimension is larger than the source image's largest dimension.
|
||||
if res_max_dim > source_max_dim:
|
||||
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).")
|
||||
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).")
|
||||
continue # Skip to the next resolution
|
||||
|
||||
# Calculate target dimensions for valid variants (equal or smaller than source)
|
||||
if source_max_dim == res_max_dim:
|
||||
# Use source dimensions if target is equal
|
||||
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 {final_internal_map_type} as target matches source.")
|
||||
logger.info(f"SaveImageVariants: Using source resolution ({source_w}x{source_h}) for {res_key} variant of {base_map_type} as target matches source.")
|
||||
else: # Downscale (source_max_dim > res_max_dim)
|
||||
# Downscale, maintaining aspect ratio
|
||||
aspect_ratio = source_w / source_h
|
||||
@ -166,14 +147,14 @@ def save_image_variants(
|
||||
else:
|
||||
target_h_res = res_max_dim
|
||||
target_w_res = max(1, int(res_max_dim * aspect_ratio)) # Ensure width is at least 1
|
||||
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})")
|
||||
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})")
|
||||
|
||||
|
||||
# Resize source_image_data (only if necessary)
|
||||
if (target_w_res, target_h_res) == (source_w, source_h):
|
||||
# No resize needed if dimensions match
|
||||
variant_data = source_image_data.copy() # Copy to avoid modifying original if needed later
|
||||
logger.debug(f"SaveImageVariants: No resize needed for {final_internal_map_type} {res_key}, using copy of source data.")
|
||||
logger.debug(f"SaveImageVariants: No resize needed for {base_map_type} {res_key}, using copy of source data.")
|
||||
else:
|
||||
# Perform resize only if dimensions differ (i.e., downscaling)
|
||||
interpolation_method = cv2.INTER_AREA # Good for downscaling
|
||||
@ -181,22 +162,21 @@ def save_image_variants(
|
||||
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
|
||||
raise ValueError("ipu.resize_image returned None")
|
||||
logger.debug(f"SaveImageVariants: Resized variant data shape for {final_internal_map_type} {res_key}: {variant_data.shape}")
|
||||
logger.debug(f"SaveImageVariants: Resized variant data shape for {base_map_type} {res_key}: {variant_data.shape}")
|
||||
except Exception as e:
|
||||
logger.error(f"SaveImageVariants: Error resizing image for {final_internal_map_type} {res_key} variant: {e}")
|
||||
logger.error(f"SaveImageVariants: Error resizing image for {base_map_type} {res_key} variant: {e}")
|
||||
continue # Skip this variant if resizing fails
|
||||
|
||||
# Filename Construction
|
||||
current_tokens = output_filename_pattern_tokens.copy()
|
||||
# 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['maptype'] = base_map_type
|
||||
current_tokens['resolution'] = res_key
|
||||
|
||||
# Determine final extension for this variant, considering JPG threshold
|
||||
final_variant_ext = current_output_ext
|
||||
|
||||
# --- Start JPG Threshold Logging ---
|
||||
logger.debug(f"SaveImageVariants: JPG Threshold Check for {final_internal_map_type} {res_key}:")
|
||||
logger.debug(f"SaveImageVariants: JPG Threshold Check for {base_map_type} {res_key}:")
|
||||
logger.debug(f" - target_bit_depth: {target_bit_depth}")
|
||||
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}")
|
||||
@ -218,7 +198,7 @@ def save_image_variants(
|
||||
|
||||
if cond_bit_depth and cond_threshold_not_none and cond_res_exceeded and cond_is_png:
|
||||
final_variant_ext = 'jpg'
|
||||
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.")
|
||||
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.")
|
||||
|
||||
current_tokens['ext'] = final_variant_ext
|
||||
|
||||
@ -236,14 +216,14 @@ def save_image_variants(
|
||||
continue # Skip this variant
|
||||
|
||||
output_path = output_base_directory / filename
|
||||
logger.info(f"SaveImageVariants: Constructed output path for {final_internal_map_type} {res_key}: {output_path}")
|
||||
logger.info(f"SaveImageVariants: Constructed output path for {base_map_type} {res_key}: {output_path}")
|
||||
|
||||
# Ensure parent directory exists
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
logger.debug(f"SaveImageVariants: Ensured directory exists for {final_internal_map_type} {res_key}: {output_path.parent}")
|
||||
logger.debug(f"SaveImageVariants: Ensured directory exists for {base_map_type} {res_key}: {output_path.parent}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"SaveImageVariants: Error constructing filepath for {final_internal_map_type} {res_key} variant: {e}")
|
||||
logger.error(f"SaveImageVariants: Error constructing filepath for {base_map_type} {res_key} variant: {e}")
|
||||
continue # Skip this variant if path construction fails
|
||||
|
||||
|
||||
@ -252,11 +232,11 @@ def save_image_variants(
|
||||
if final_variant_ext == 'jpg': # Check against final_variant_ext
|
||||
save_params_cv2.append(cv2.IMWRITE_JPEG_QUALITY)
|
||||
save_params_cv2.append(jpg_quality)
|
||||
logger.debug(f"SaveImageVariants: Using JPG quality: {jpg_quality} for {final_internal_map_type} {res_key}")
|
||||
logger.debug(f"SaveImageVariants: Using JPG quality: {jpg_quality} for {base_map_type} {res_key}")
|
||||
elif final_variant_ext == 'png': # Check against final_variant_ext
|
||||
save_params_cv2.append(cv2.IMWRITE_PNG_COMPRESSION)
|
||||
save_params_cv2.append(png_compression_level)
|
||||
logger.debug(f"SaveImageVariants: Using PNG compression level: {png_compression_level} for {final_internal_map_type} {res_key}")
|
||||
logger.debug(f"SaveImageVariants: Using PNG compression level: {png_compression_level} for {base_map_type} {res_key}")
|
||||
# Add other format specific parameters if needed (e.g., TIFF compression)
|
||||
|
||||
|
||||
@ -277,8 +257,7 @@ def save_image_variants(
|
||||
# Saving
|
||||
try:
|
||||
# ipu.save_image is expected to handle the actual cv2.imwrite call
|
||||
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}")
|
||||
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}")
|
||||
success = ipu.save_image(
|
||||
str(output_path),
|
||||
image_data_for_save,
|
||||
@ -286,7 +265,7 @@ def save_image_variants(
|
||||
params=save_params_cv2
|
||||
)
|
||||
if success:
|
||||
logger.info(f"SaveImageVariants: Successfully saved {final_internal_map_type} {res_key} variant to {output_path}")
|
||||
logger.info(f"SaveImageVariants: Successfully saved {base_map_type} {res_key} variant to {output_path}")
|
||||
# Collect details for the returned list
|
||||
saved_file_details.append({
|
||||
'path': str(output_path),
|
||||
@ -296,10 +275,10 @@ def save_image_variants(
|
||||
'dimensions': (target_w_res, target_h_res)
|
||||
})
|
||||
else:
|
||||
logger.error(f"SaveImageVariants: Failed to save {final_internal_map_type} {res_key} variant to {output_path} (ipu.save_image returned False)")
|
||||
logger.error(f"SaveImageVariants: Failed to save {base_map_type} {res_key} variant to {output_path} (ipu.save_image returned False)")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"SaveImageVariants: Error during ipu.save_image for {final_internal_map_type} {res_key} variant to {output_path}: {e}", exc_info=True)
|
||||
logger.error(f"SaveImageVariants: Error during ipu.save_image for {base_map_type} {res_key} variant to {output_path}: {e}", exc_info=True)
|
||||
# Continue to next variant even if one fails
|
||||
|
||||
|
||||
@ -309,7 +288,7 @@ def save_image_variants(
|
||||
|
||||
|
||||
# 5. Return List of Saved File Details
|
||||
logger.info(f"Finished saving variants for map type: {final_internal_map_type}. Saved {len(saved_file_details)} variants.")
|
||||
logger.info(f"Finished saving variants for map type: {base_map_type}. Saved {len(saved_file_details)} variants.")
|
||||
return saved_file_details
|
||||
|
||||
# Optional Helper Functions (can be added here if needed)
|
||||
|
||||
@ -1,44 +0,0 @@
|
||||
# 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.
|
||||
@ -1,7 +1,6 @@
|
||||
import dataclasses
|
||||
import json
|
||||
from typing import List, Dict, Any, Tuple, Optional
|
||||
import numpy as np # Added for ProcessingItem
|
||||
@dataclasses.dataclass
|
||||
class FileRule:
|
||||
file_path: str = None
|
||||
@ -11,15 +10,9 @@ class FileRule:
|
||||
resolution_override: Tuple[int, int] = None
|
||||
channel_merge_instructions: Dict[str, Any] = dataclasses.field(default_factory=dict)
|
||||
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:
|
||||
# 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)
|
||||
return json.dumps(dataclasses.asdict(self), indent=4)
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, json_string: str) -> 'FileRule':
|
||||
@ -33,14 +26,9 @@ class AssetRule:
|
||||
asset_type_override: str = None
|
||||
common_metadata: Dict[str, Any] = dataclasses.field(default_factory=dict)
|
||||
files: List[FileRule] = dataclasses.field(default_factory=list)
|
||||
parent_source: 'SourceRule' = None # Added parent back-reference
|
||||
|
||||
def to_json(self) -> str:
|
||||
# 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)
|
||||
return json.dumps(dataclasses.asdict(self), indent=4)
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, json_string: str) -> 'AssetRule':
|
||||
@ -66,43 +54,4 @@ class SourceRule:
|
||||
data = json.loads(json_string)
|
||||
# Manually deserialize nested AssetRule objects
|
||||
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)
|
||||
|
||||
@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
|
||||
return cls(**data)
|
||||
@ -1 +0,0 @@
|
||||
Asset Processor first-time setup complete.
|
||||
@ -1,280 +0,0 @@
|
||||
{
|
||||
"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": []
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
@ -1,280 +0,0 @@
|
||||
{
|
||||
"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": []
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
@ -1,270 +0,0 @@
|
||||
{
|
||||
"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": []
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
@ -1,44 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,210 +0,0 @@
|
||||
{
|
||||
"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": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,267 +0,0 @@
|
||||
{
|
||||
"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."
|
||||
}
|
||||
@ -1,11 +0,0 @@
|
||||
{
|
||||
"Dimensiva": {
|
||||
"normal_map_type": "OpenGL"
|
||||
},
|
||||
"Dinesen": {
|
||||
"normal_map_type": "OpenGL"
|
||||
},
|
||||
"Poliigon": {
|
||||
"normal_map_type": "OpenGL"
|
||||
}
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
{
|
||||
"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": {}
|
||||
}
|
||||
@ -1,66 +0,0 @@
|
||||
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")
|
||||
@ -9,7 +9,6 @@ from typing import Optional, Dict
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
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
|
||||
from the provided token_data dictionary.
|
||||
@ -55,8 +54,7 @@ def generate_path_from_pattern(pattern_string: str, token_data: dict) -> str:
|
||||
# Add variations like #### for IncrementingValue
|
||||
known_tokens_lc = {
|
||||
'assettype', 'supplier', 'assetname', 'resolution', 'ext',
|
||||
'incrementingvalue', '####', 'date', 'time', 'sha5', 'applicationpath',
|
||||
'asset_category'
|
||||
'incrementingvalue', '####', 'date', 'time', 'sha5', 'applicationpath'
|
||||
}
|
||||
|
||||
output_path = pattern_string
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user