Prototype > PreAlpha #67
@ -44,4 +44,14 @@ Preset files define supplier-specific rules for interpreting asset source files.
|
||||
|
||||
When processing assets, you must specify which preset to use. The tool then loads the core settings from `config/app_settings.json` and merges them with the rules from the selected preset to determine how to process the input.
|
||||
|
||||
A template preset file (`presets/_template.json`) is provided as a base for creating new presets.
|
||||
A template preset file (`presets/_template.json`) is provided as a base for creating new presets.
|
||||
## Global Output Path Configuration
|
||||
|
||||
The structure and naming of the output files generated by the tool are now controlled by two global settings defined exclusively in `config/app_settings.json`:
|
||||
|
||||
* `OUTPUT_DIRECTORY_PATTERN`: Defines the directory structure where processed assets will be saved.
|
||||
* `OUTPUT_FILENAME_PATTERN`: Defines the naming convention for the individual output files within the generated directory.
|
||||
|
||||
**Important:** These settings are global and apply to all processing tasks, regardless of the selected preset. They are **not** part of individual preset files and cannot be modified using the Preset Editor. You can view and edit these patterns in the main application preferences (**Edit** -> **Preferences...**).
|
||||
|
||||
These patterns use special tokens (e.g., `[assetname]`, `[maptype]`) that are replaced with actual values during processing. For a detailed explanation of how these patterns work together, the available tokens, and examples, please refer to the [Output Structure](./09_Output_Structure.md) section of the User Guide.
|
||||
@ -2,20 +2,65 @@
|
||||
|
||||
This document describes the directory structure and contents of the processed assets generated by the Asset Processor Tool.
|
||||
|
||||
Processed assets are saved to: `<output_base_directory>/<supplier_name>/<asset_name>/`
|
||||
Processed assets are saved to a location determined by two global settings defined in `config/app_settings.json`:
|
||||
|
||||
* `<output_base_directory>`: The base output directory configured in `config.py` or specified via CLI/GUI.
|
||||
* `<supplier_name>`: The name of the asset supplier, determined from the preset used.
|
||||
* `<asset_name>`: The name of the processed asset, determined from the source filename based on preset rules.
|
||||
* `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`.
|
||||
|
||||
These patterns use special tokens (explained below) that are replaced with actual values during processing. You can configure these patterns via the main application preferences (**Edit** -> **Preferences...** -> **Output & Naming** tab). They are global settings and are not part of individual presets.
|
||||
|
||||
### Available Tokens
|
||||
|
||||
The following tokens can be used in both `OUTPUT_DIRECTORY_PATTERN` and `OUTPUT_FILENAME_PATTERN`. Note that some tokens make more sense in one pattern than the other (e.g., `[maptype]` and `[ext]` are typically used in the filename pattern).
|
||||
|
||||
* `[Assettype]`: The type of asset (e.g., `Texture`, `Model`, `Surface`).
|
||||
* `[supplier]`: The supplier name (from the preset, e.g., `Poliigon`).
|
||||
* `[assetname]`: The main asset name (e.g., `RustyMetalPanel`).
|
||||
* `[resolution]`: Texture resolution (e.g., `1k`, `2k`, `4k`).
|
||||
* `[ext]`: The output file extension (e.g., `png`, `jpg`, `exr`). (Primarily for filename pattern)
|
||||
* `[IncrementingValue]` or `[####]`: A numerical value that increments based on existing directories matching the `OUTPUT_DIRECTORY_PATTERN` in the output base path. The number of `#` characters determines the zero-padding (e.g., `[###]` -> `001`, `002`). If `[IncrementingValue]` is used, it defaults to 4 digits of padding (`0001`, `0002`).
|
||||
* `[Date]`: Current date (`YYYYMMDD`).
|
||||
* `[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]`: Specific map type (e.g., `Albedo`, `Normal`). (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.
|
||||
* `[archetype]`: Asset archetype determined by preset rules.
|
||||
* `[variant]`: Asset variant identifier determined by preset rules.
|
||||
* `[source_filename]`: The original filename of the source file being processed.
|
||||
* `[source_basename]`: The original filename without the extension.
|
||||
* `[source_dirname]`: The directory containing the original source file.
|
||||
|
||||
### Example Output Paths
|
||||
|
||||
The final output path is constructed by combining the Base Output Directory (set in Preferences or via CLI) with the results of the two patterns.
|
||||
|
||||
**Example 1:**
|
||||
|
||||
* Base Output Directory: `/home/user/ProcessedAssets`
|
||||
* `OUTPUT_DIRECTORY_PATTERN`: `[supplier]/[assetname]/[resolution]`
|
||||
* `OUTPUT_FILENAME_PATTERN`: `[assetname]_[maptype]_[resolution].[ext]`
|
||||
* Resulting Path for an Albedo map: `/home/user/ProcessedAssets/Poliigon/WoodFloor001/4k/WoodFloor001_Albedo_4k.png`
|
||||
|
||||
**Example 2:**
|
||||
|
||||
* Base Output Directory: `Output` (relative path)
|
||||
* `OUTPUT_DIRECTORY_PATTERN`: `[Assettype]/[category]/[assetname]`
|
||||
* `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...** -> **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., `AssetName_Color_4K.png`, `AssetName_NRM_2K.exr`). These are the resized, format-converted, and bit-depth adjusted texture files.
|
||||
* Merged texture maps (e.g., `AssetName_NRMRGH_4K.png`). These are maps created by combining channels from different source maps based on the configured merge rules.
|
||||
* 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.
|
||||
* 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, 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).
|
||||
* `Extra/` (subdirectory): Contains source files that were not classified as maps or models but were explicitly marked to be moved to the extra directory based on preset rules (e.g., previews, documentation files).
|
||||
* `EXTRA/` (subdirectory): Contains source files not classified as maps or models but marked as "EXTRA" by preset rules (e.g., previews, documentation). These files are placed in an `EXTRA` folder *within* the directory generated by `OUTPUT_DIRECTORY_PATTERN`.
|
||||
* `Unrecognised/` (subdirectory): Contains source files that were not classified as maps, models, or explicitly marked as extra, and were not ignored.
|
||||
* `Ignored/` (subdirectory): Contains source files that were explicitly ignored during processing (e.g., an 8-bit Normal map when a 16-bit variant exists and is prioritized).
|
||||
@ -6,7 +6,7 @@ This document provides technical details about the configuration system and the
|
||||
|
||||
The tool utilizes a two-tiered configuration system managed by the `configuration.py` module:
|
||||
|
||||
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., default output paths, standard image resolutions, map merge rules, output format rules, Blender paths, `FILE_TYPE_DEFINITIONS`, `ASSET_TYPE_DEFINITIONS`).
|
||||
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.
|
||||
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.
|
||||
|
||||
@ -28,7 +28,7 @@ The `Configuration` class is central to the new configuration system. It is resp
|
||||
* It first loads the base application settings from `config/app_settings.json`.
|
||||
* It then loads the LLM-specific settings from `config/llm_settings.json`.
|
||||
* Finally, it loads the specified preset JSON file from the `Presets/` directory.
|
||||
* **Merging & Access:** The base settings from `app_settings.json` are merged with the preset rules. LLM settings are stored separately. All settings are accessible via instance properties (e.g., `config.target_filename_pattern`, `config.llm_endpoint_url`). Preset values generally override the base settings where applicable.
|
||||
* **Merging & Access:** The base settings from `app_settings.json` are merged with the preset rules. LLM settings are stored separately. Most settings are accessible via instance properties (e.g., `config.llm_endpoint_url`). Preset values generally override the base settings where applicable. **Exception:** The `OUTPUT_DIRECTORY_PATTERN` and `OUTPUT_FILENAME_PATTERN` are loaded *only* from `app_settings.json` and are accessed via `config.output_directory_pattern` and `config.output_filename_pattern` respectively; they are not defined in or overridden by presets.
|
||||
* **Validation (`_validate_configs`):** Performs basic structural validation on the loaded settings (base, LLM, and preset), checking for the presence of required keys and basic data types. Logs warnings for missing optional LLM keys.
|
||||
* **Regex Compilation (`_compile_regex_patterns`):** Compiles regex patterns defined in the merged configuration (from base settings and the preset) for performance. Compiled regex objects are stored as instance attributes (e.g., `self.compiled_map_keyword_regex`).
|
||||
* **LLM Settings Access:** The `Configuration` class provides direct property access (e.g., `config.llm_endpoint_url`, `config.llm_api_key`, `config.llm_model_name`, `config.llm_temperature`, `config.llm_request_timeout`, `config.llm_predictor_prompt`, `config.get_llm_examples()`) to allow components like the `LLMPredictionHandler` to easily access the necessary LLM configuration values loaded from `config/llm_settings.json`.
|
||||
@ -47,7 +47,7 @@ The GUI provides dedicated editors for modifying configuration files:
|
||||
The GUI includes a dedicated editor for modifying the `config/app_settings.json` file. This is implemented in `gui/config_editor_dialog.py`.
|
||||
|
||||
* **Purpose:** Provides a user-friendly interface for viewing and editing the core application settings defined in `app_settings.json`.
|
||||
* **Implementation:** The dialog loads the JSON content of `app_settings.json`, presents it in a tabbed layout ("General", "Output & Naming", etc.) using standard GUI widgets mapped to the JSON structure, and saves the changes back to the file. It supports editing basic fields, tables for definitions (`FILE_TYPE_DEFINITIONS`, `ASSET_TYPE_DEFINITIONS`), and a list/detail view for merge rules (`MAP_MERGE_RULES`). The definitions tables include dynamic color editing features.
|
||||
* **Implementation:** The dialog loads the JSON content of `app_settings.json`, presents it in a tabbed layout ("General", "Output & Naming", etc.) using standard GUI widgets mapped to the JSON structure, and saves the changes back to the file. The "Output & Naming" tab specifically handles the global `OUTPUT_DIRECTORY_PATTERN` and `OUTPUT_FILENAME_PATTERN`. It supports editing basic fields, tables for definitions (`FILE_TYPE_DEFINITIONS`, `ASSET_TYPE_DEFINITIONS`), and a list/detail view for merge rules (`MAP_MERGE_RULES`). The definitions tables include dynamic color editing features.
|
||||
* **Limitations:** Currently, editing complex fields like `IMAGE_RESOLUTIONS` or the full details of `MAP_MERGE_RULES` via the UI is not fully supported.
|
||||
* **Note:** Changes made through the `ConfigEditorDialog` are written directly to `config/app_settings.json` (using `save_base_config`) but require an application restart to be loaded and applied by the `Configuration` class during processing.
|
||||
|
||||
|
||||
@ -54,6 +54,7 @@ The pipeline steps are:
|
||||
* Writes data to `metadata.json` in the temporary workspace.
|
||||
|
||||
9. **Output Organization (`_organize_output_files`)**:
|
||||
* Determines the final output directory using the global `OUTPUT_DIRECTORY_PATTERN` and the final filename using the global `OUTPUT_FILENAME_PATTERN` (both from the `Configuration` object). The `utils.path_utils` module combines these with the base output directory and asset-specific data (like asset name, map type, resolution, etc.) to construct the full path for each file.
|
||||
* Creates the final structured output directory (`<output_base_dir>/<supplier_name>/<asset_name>/`), using the supplier name from the `SourceRule`.
|
||||
* Moves processed maps, merged maps, models, metadata, and other classified files from the temporary workspace to the final output directory.
|
||||
|
||||
|
||||
150
ProjectNotes/Architectureplan_token-data.md
Normal file
150
ProjectNotes/Architectureplan_token-data.md
Normal file
@ -0,0 +1,150 @@
|
||||
Implementation Plan: Path Token Data Generation
|
||||
This plan outlines the steps required to implement data generation/retrieval for the [IncrementingValue], ####, and [Sha5] path tokens used in OUTPUT_DIRECTORY_PATTERN and OUTPUT_FILENAME_PATTERN.
|
||||
|
||||
1. Goal Recap
|
||||
|
||||
Enable the use of [IncrementingValue] (or ####), [Time], and [Sha5] tokens within the output path patterns used by processing_engine.py. Implement logic to generate/retrieve data for these tokens and pass it to utils.path_utils.generate_path_from_pattern. Confirm handling of [Date] and [ApplicationPath].
|
||||
|
||||
2. Analysis Summary & Existing Token Handling
|
||||
|
||||
[Date], [Time], [ApplicationPath]: Handled automatically by utils/path_utils.py. No changes needed.
|
||||
[IncrementingValue] / ####: Requires data provision based on scanning existing output directories. Implementation detailed below.
|
||||
[Sha5]: Requires data provision (first 5 chars of SHA-256 hash of original input file). Implementation detailed below.
|
||||
Path Generation Points: _save_image() and _generate_metadata_file() in processing_engine.py.
|
||||
3. Implementation Plan per Token
|
||||
|
||||
3.1. [IncrementingValue] / #### (Directory Scan Logic)
|
||||
|
||||
Scope & Behavior: Determine the next available incrementing number by scanning existing directories in the final output_base_path that match the OUTPUT_DIRECTORY_PATTERN structure. The value represents the next sequence number globally across the pattern structure.
|
||||
Location: New utility function get_next_incrementing_value in utils/path_utils.py, called from orchestrating code (main.py / monitor.py).
|
||||
Mechanism:
|
||||
get_next_incrementing_value(output_base_path: Path, output_directory_pattern: str) -> str:
|
||||
Parses output_directory_pattern to find the incrementing token (#### or [IncrementingValue]) and determine padding digits.
|
||||
Constructs a glob pattern based on the pattern structure (e.g., [0-9][0-9]_* for ##_*).
|
||||
Uses output_base_path.glob() to find matching directories.
|
||||
Extracts numerical prefixes from matching directory names using regex.
|
||||
Finds the maximum existing integer value (or -1 if none).
|
||||
Calculates next_value = max_value + 1.
|
||||
Formats next_value as a zero-padded string based on the pattern's digits.
|
||||
Returns the formatted string.
|
||||
Orchestrator (main.py/monitor.py):
|
||||
Load Configuration to get OUTPUT_DIRECTORY_PATTERN.
|
||||
Get output_base_path.
|
||||
Call next_increment_str = get_next_incrementing_value(output_base_path, config.output_directory_pattern).
|
||||
Pass next_increment_str to ProcessingEngine.process as incrementing_value.
|
||||
Integration (processing_engine.py):
|
||||
Accept incrementing_value: Optional[str] in process signature.
|
||||
Store on self.current_incrementing_value.
|
||||
Add to token_data (key: 'incrementingvalue') in _save_image and _generate_metadata_file.
|
||||
3.2. [Sha5]
|
||||
|
||||
Scope & Behavior: Calculate SHA-256 hash of the original input source file, take the first 5 characters.
|
||||
Location: Orchestrating code (main.py / monitor.py) before ProcessingEngine invocation.
|
||||
Mechanism: Use new utility function calculate_sha256 in utils/hash_utils.py. Call this in the orchestrator, get the first 5 chars, pass to ProcessingEngine.process.
|
||||
Integration (processing_engine.py): Accept sha5_value: Optional[str] in process, store on self.current_sha5_value, add to token_data (key: 'sha5') in _save_image and _generate_metadata_file.
|
||||
4. Proposed Code Changes
|
||||
|
||||
4.1. utils/hash_utils.py (New File)
|
||||
|
||||
# utils/hash_utils.py
|
||||
import hashlib
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def calculate_sha256(file_path: Path) -> Optional[str]:
|
||||
"""Calculates the SHA-256 hash of a file."""
|
||||
# Implementation as detailed in the previous plan revision...
|
||||
if not isinstance(file_path, Path): return None
|
||||
if not file_path.is_file(): return None
|
||||
sha256_hash = hashlib.sha256()
|
||||
try:
|
||||
with open(file_path, "rb") as f:
|
||||
for byte_block in iter(lambda: f.read(4096), b""):
|
||||
sha256_hash.update(byte_block)
|
||||
return sha256_hash.hexdigest()
|
||||
except OSError as e:
|
||||
logger.error(f"Error reading file {file_path} for SHA-256: {e}", exc_info=True)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error calculating SHA-256 for {file_path}: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
python
|
||||
|
||||
⟼
|
||||
|
||||
4.2. utils/path_utils.py (Additions/Modifications)
|
||||
|
||||
# (In utils/path_utils.py)
|
||||
import re
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ... (existing generate_path_from_pattern function) ...
|
||||
|
||||
def get_next_incrementing_value(output_base_path: Path, output_directory_pattern: str) -> str:
|
||||
"""Determines the next incrementing value based on existing directories."""
|
||||
# Implementation as detailed in the previous plan revision...
|
||||
logger.debug(f"Calculating next increment value for pattern '{output_directory_pattern}' in '{output_base_path}'")
|
||||
match = re.match(r"(.*?)(\[IncrementingValue\]|(#+))(.*)", output_directory_pattern)
|
||||
if not match: return "00" # Default fallback
|
||||
prefix_pattern, increment_token, suffix_pattern = match.groups()
|
||||
num_digits = len(increment_token) if increment_token.startswith("#") else 2
|
||||
glob_increment_part = f"[{'0-9' * num_digits}]"
|
||||
glob_prefix = re.sub(r'\[[^\]]+\]', '*', prefix_pattern)
|
||||
glob_suffix = re.sub(r'\[[^\]]+\]', '*', suffix_pattern)
|
||||
glob_pattern = f"{glob_prefix}{glob_increment_part}{glob_suffix}"
|
||||
max_value = -1
|
||||
try:
|
||||
extract_prefix_re = re.escape(prefix_pattern)
|
||||
extract_suffix_re = re.escape(suffix_pattern)
|
||||
extract_regex = re.compile(rf"^{extract_prefix_re}(\d{{{num_digits}}}){extract_suffix_re}.*")
|
||||
for item in output_base_path.glob(glob_pattern):
|
||||
if item.is_dir():
|
||||
num_match = extract_regex.match(item.name)
|
||||
if num_match:
|
||||
try: max_value = max(max_value, int(num_match.group(1)))
|
||||
except (ValueError, IndexError): pass
|
||||
except Exception as e: logger.error(f"Error searching increment values: {e}", exc_info=True)
|
||||
next_value = max_value + 1
|
||||
format_string = f"{{:0{num_digits}d}}"
|
||||
next_value_str = format_string.format(next_value)
|
||||
logger.info(f"Determined next incrementing value: {next_value_str}")
|
||||
return next_value_str
|
||||
|
||||
python
|
||||
|
||||
⌄
|
||||
|
||||
⟼
|
||||
|
||||
4.3. main.py / monitor.py (Orchestration - Revised Call)
|
||||
|
||||
Imports: Add from utils.hash_utils import calculate_sha256, from utils.path_utils import get_next_incrementing_value.
|
||||
Before ProcessingEngine.process call:
|
||||
Get archive_path, output_dir.
|
||||
Load config = Configuration(...).
|
||||
full_sha = calculate_sha256(archive_path).
|
||||
sha5_value = full_sha[:5] if full_sha else None.
|
||||
next_increment_str = get_next_incrementing_value(output_dir, config.output_directory_pattern).
|
||||
Modify call: engine.process(..., incrementing_value=next_increment_str, sha5_value=sha5_value).
|
||||
4.4. processing_engine.py
|
||||
|
||||
Imports: Ensure Optional, logging, generate_path_from_pattern are imported.
|
||||
process Method:
|
||||
Update signature: def process(..., incrementing_value: Optional[str] = None, sha5_value: Optional[str] = None) -> ...:
|
||||
Store args: self.current_incrementing_value = incrementing_value, self.current_sha5_value = sha5_value.
|
||||
_save_image & _generate_metadata_file Methods:
|
||||
Before calling generate_path_from_pattern, add stored values to token_data:
|
||||
# Add new token data if available
|
||||
if hasattr(self, 'current_incrementing_value') and self.current_incrementing_value is not None:
|
||||
token_data['incrementingvalue'] = self.current_incrementing_value
|
||||
if hasattr(self, 'current_sha5_value') and self.current_sha5_value is not None:
|
||||
token_data['sha5'] = self.current_sha5_value
|
||||
log.debug(f"Token data for path generation: {token_data}")
|
||||
@ -228,6 +228,8 @@
|
||||
],
|
||||
"EXTRA_FILES_SUBDIR": "Extra",
|
||||
"OUTPUT_BASE_DIR": "../Asset_Processor_Output",
|
||||
"OUTPUT_DIRECTORY_PATTERN": "[supplier]/[assetname]",
|
||||
"OUTPUT_FILENAME_PATTERN": "[assetname]_[maptype]_[resolution].[ext]",
|
||||
"METADATA_FILENAME": "metadata.json",
|
||||
"DEFAULT_NODEGROUP_BLEND_PATH": "G:/02 Content/10-19 Content/19 Catalogs/19.01 Blender Asset Catalogue/_CustomLibraries/Nodes-Linked/PBRSET-Nodes-Testing.blend",
|
||||
"DEFAULT_MATERIALS_BLEND_PATH": "G:/02 Content/10-19 Content/19 Catalogs/19.01 Blender Asset Catalogue/_CustomLibraries/Materials-Append/PBR Materials-Testing.blend",
|
||||
|
||||
@ -281,6 +281,12 @@ class Configuration:
|
||||
# Core validation (check types or specific values if needed)
|
||||
if not isinstance(self._core_settings.get('TARGET_FILENAME_PATTERN'), str):
|
||||
raise ConfigurationError("Core config 'TARGET_FILENAME_PATTERN' must be a string.")
|
||||
# --- Start: Added validation for new output patterns ---
|
||||
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):
|
||||
raise ConfigurationError("Core config 'OUTPUT_FILENAME_PATTERN' must be a string.")
|
||||
# --- End: Added validation for new output patterns ---
|
||||
if not isinstance(self._core_settings.get('IMAGE_RESOLUTIONS'), dict):
|
||||
raise ConfigurationError("Core config 'IMAGE_RESOLUTIONS' must be a dictionary.")
|
||||
if not isinstance(self._core_settings.get('STANDARD_MAP_TYPES'), list):
|
||||
@ -320,9 +326,23 @@ class Configuration:
|
||||
def target_filename_pattern(self) -> str:
|
||||
return self._core_settings['TARGET_FILENAME_PATTERN'] # Assumes validation passed
|
||||
|
||||
@property
|
||||
def output_directory_pattern(self) -> str:
|
||||
"""Gets the output directory pattern ONLY from core settings."""
|
||||
# Default pattern if missing in core settings (should be caught by validation)
|
||||
default_pattern = "[supplier]/[assetname]"
|
||||
return self._core_settings.get('OUTPUT_DIRECTORY_PATTERN', default_pattern)
|
||||
|
||||
@property
|
||||
def output_filename_pattern(self) -> str:
|
||||
"""Gets the output filename pattern ONLY from core settings."""
|
||||
# Default pattern if missing in core settings (should be caught by validation)
|
||||
default_pattern = "[assetname]_[maptype]_[resolution].[ext]"
|
||||
return self._core_settings.get('OUTPUT_FILENAME_PATTERN', default_pattern)
|
||||
|
||||
@property
|
||||
def image_resolutions(self) -> dict[str, int]:
|
||||
return self._core_settings['IMAGE_RESOLUTIONS']
|
||||
return self._core_settings['IMAGE_RESOLUTIONS'] # Assumes validation passed
|
||||
|
||||
|
||||
@property
|
||||
|
||||
@ -125,6 +125,7 @@ class PresetEditorWidget(QWidget):
|
||||
form_layout.addRow("Preset Name:", self.editor_preset_name)
|
||||
form_layout.addRow("Supplier Name:", self.editor_supplier_name)
|
||||
form_layout.addRow("Notes:", self.editor_notes)
|
||||
|
||||
layout.addLayout(form_layout)
|
||||
|
||||
# Source Naming Group
|
||||
@ -335,7 +336,6 @@ class PresetEditorWidget(QWidget):
|
||||
self.editor_spin_base_name_idx.valueChanged.connect(self._mark_editor_unsaved)
|
||||
self.editor_spin_map_type_idx.valueChanged.connect(self._mark_editor_unsaved)
|
||||
# List/Table widgets are connected via helper functions
|
||||
|
||||
def check_unsaved_changes(self) -> bool:
|
||||
"""
|
||||
Checks for unsaved changes in the editor and prompts the user.
|
||||
|
||||
53
main.py
53
main.py
@ -14,6 +14,11 @@ import tempfile # Added for temporary workspace
|
||||
import zipfile # Added for zip extraction
|
||||
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
|
||||
# Configuration and Path are already imported below/above
|
||||
|
||||
# --- Qt Imports for Application Structure ---
|
||||
from PySide6.QtCore import QObject, Slot, QThreadPool, QRunnable, Signal # Added for App structure and threading
|
||||
from PySide6.QtCore import Qt # Added for ConnectionType
|
||||
@ -194,11 +199,55 @@ class ProcessingTask(QRunnable):
|
||||
log.info(f"Calling ProcessingEngine.process with rule for input: {self.rule.input_path}, prepared workspace: {prepared_workspace_path}, output: {self.output_base_path}")
|
||||
log.debug(f" Rule Details: {self.rule}") # Optional detailed log
|
||||
|
||||
# Pass rule positionally, prepared workspace, and output base path
|
||||
# --- Calculate SHA5 and Incrementing Value ---
|
||||
config = self.engine.config # Get config from the engine instance
|
||||
archive_path = self.rule.input_path
|
||||
output_dir = self.output_base_path # This is already a Path object from App.on_processing_requested
|
||||
|
||||
# Calculate SHA5
|
||||
sha5_value = None
|
||||
try:
|
||||
archive_path_obj = Path(archive_path)
|
||||
if archive_path_obj.is_file(): # Only calculate for files
|
||||
log.debug(f"Calculating SHA256 for file: {archive_path_obj}")
|
||||
full_sha = calculate_sha256(archive_path_obj)
|
||||
if full_sha:
|
||||
sha5_value = full_sha[:5]
|
||||
log.info(f"Calculated SHA5 for {archive_path}: {sha5_value}")
|
||||
else:
|
||||
log.warning(f"SHA256 calculation returned None for {archive_path}")
|
||||
elif archive_path_obj.is_dir():
|
||||
log.debug(f"Input path {archive_path} is a directory, skipping SHA5 calculation.")
|
||||
else:
|
||||
log.warning(f"Input path {archive_path} is not a valid file or directory for SHA5 calculation.")
|
||||
except FileNotFoundError:
|
||||
log.error(f"SHA5 calculation failed: File not found at {archive_path}")
|
||||
except Exception as e:
|
||||
log.exception(f"Error calculating SHA5 for {archive_path}: {e}")
|
||||
|
||||
# Calculate Incrementing Value
|
||||
next_increment_str = None
|
||||
try:
|
||||
# output_dir should already be a Path object
|
||||
pattern = getattr(config, 'output_directory_pattern', None)
|
||||
if pattern:
|
||||
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:
|
||||
log.exception(f"Error calculating next incrementing value for {output_dir}: {e}")
|
||||
# --- End Calculation ---
|
||||
|
||||
# Pass rule positionally, prepared workspace, output base path, and new values
|
||||
log.info(f"Calling engine.process with sha5='{sha5_value}', incrementing_value='{next_increment_str}'")
|
||||
result_or_error = self.engine.process(
|
||||
self.rule, # Pass rule as first positional argument
|
||||
workspace_path=prepared_workspace_path, # Use the prepared temp workspace
|
||||
output_base_path=self.output_base_path
|
||||
output_base_path=self.output_base_path,
|
||||
incrementing_value=next_increment_str, # Add new arg
|
||||
sha5_value=sha5_value # Add new arg
|
||||
)
|
||||
status = "processed" # Assume success if no exception
|
||||
log.info(f"Worker Thread: Finished processing for rule: {self.rule.input_path}, Status: {status}")
|
||||
|
||||
66
monitor.py
66
monitor.py
@ -12,6 +12,12 @@ from concurrent.futures import ThreadPoolExecutor
|
||||
from watchdog.observers.polling import PollingObserver as Observer # Use polling for better compatibility
|
||||
from watchdog.events import FileSystemEventHandler, FileCreatedEvent
|
||||
|
||||
# --- Utility Imports ---
|
||||
from utils.hash_utils import calculate_sha256
|
||||
from utils.path_utils import get_next_incrementing_value
|
||||
# Path is already imported
|
||||
# Configuration is imported below
|
||||
|
||||
# --- Import from local modules ---
|
||||
# Assuming standard project structure
|
||||
from configuration import load_config, ConfigurationError # Assuming load_config is here
|
||||
@ -189,8 +195,66 @@ def _process_archive_task(archive_path: Path, output_dir: Path, processed_dir: P
|
||||
# Pass necessary parts of the config to the engine
|
||||
engine = ProcessingEngine(config=config, output_base_dir=output_dir)
|
||||
log.info(f"[Task:{archive_path.name}] Running Processing Engine...")
|
||||
|
||||
# --- Calculate SHA5 and Incrementing Value ---
|
||||
# Config is loaded earlier (line ~170)
|
||||
# archive_path and output_dir are function arguments
|
||||
|
||||
# Calculate SHA5
|
||||
sha5_value = None
|
||||
try:
|
||||
# archive_path is already a Path object
|
||||
if archive_path.is_file(): # Only calculate for files
|
||||
log.debug(f"[Task:{archive_path.name}] Calculating SHA256 for file: {archive_path}")
|
||||
full_sha = calculate_sha256(archive_path)
|
||||
if full_sha:
|
||||
sha5_value = full_sha[:5]
|
||||
log.info(f"[Task:{archive_path.name}] Calculated SHA5: {sha5_value}")
|
||||
else:
|
||||
log.warning(f"[Task:{archive_path.name}] SHA256 calculation returned None for {archive_path}")
|
||||
# No need to check is_dir here as monitor only processes files based on SUPPORTED_SUFFIXES
|
||||
else:
|
||||
log.warning(f"[Task:{archive_path.name}] Input path {archive_path} is not a valid file for SHA5 calculation (unexpected).")
|
||||
except FileNotFoundError:
|
||||
log.error(f"[Task:{archive_path.name}] SHA5 calculation failed: File not found at {archive_path}")
|
||||
except Exception as e:
|
||||
log.exception(f"[Task:{archive_path.name}] Error calculating SHA5 for {archive_path}: {e}")
|
||||
|
||||
# Calculate Incrementing Value
|
||||
next_increment_str = None
|
||||
try:
|
||||
# output_dir is already a Path object
|
||||
# Assuming config object has 'output_directory_pattern' attribute/key
|
||||
pattern = getattr(config, 'output_directory_pattern', None) # Use getattr for safety
|
||||
if pattern:
|
||||
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:
|
||||
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:
|
||||
log.warning(f"[Task:{archive_path.name}] Cannot calculate incrementing value: 'output_directory_pattern' not found in configuration object.")
|
||||
except Exception as e:
|
||||
log.exception(f"[Task:{archive_path.name}] Error calculating next incrementing value for {output_dir}: {e}")
|
||||
# --- End Calculation ---
|
||||
|
||||
# The engine uses the source_rule to guide processing on the workspace files
|
||||
engine.run(workspace_path=temp_workspace_path, source_rule=source_rule)
|
||||
# Pass the new values to the engine's run method
|
||||
log.info(f"[Task:{archive_path.name}] Calling engine.run with sha5='{sha5_value}', incrementing_value='{next_increment_str}'")
|
||||
engine.run(
|
||||
workspace_path=temp_workspace_path,
|
||||
source_rule=source_rule,
|
||||
incrementing_value=next_increment_str, # Add new arg
|
||||
sha5_value=sha5_value # Add new arg
|
||||
)
|
||||
log.info(f"[Task:{archive_path.name}] Processing Engine finished successfully.")
|
||||
move_reason = "processed" # Set success reason
|
||||
|
||||
|
||||
@ -38,6 +38,7 @@ except ImportError:
|
||||
try:
|
||||
from configuration import Configuration, ConfigurationError
|
||||
from rule_structure import SourceRule, AssetRule, FileRule # Import necessary structures
|
||||
from utils.path_utils import generate_path_from_pattern # <-- ADDED IMPORT
|
||||
except ImportError:
|
||||
print("ERROR: Cannot import Configuration or rule_structure classes.")
|
||||
print("Ensure configuration.py and rule_structure.py are in the same directory or Python path.")
|
||||
@ -276,7 +277,15 @@ class ProcessingEngine:
|
||||
log.debug("ProcessingEngine initialized.")
|
||||
|
||||
|
||||
def process(self, source_rule: SourceRule, workspace_path: Path, output_base_path: Path, overwrite: bool = False) -> Dict[str, List[str]]:
|
||||
def process(
|
||||
self,
|
||||
source_rule: SourceRule,
|
||||
workspace_path: Path,
|
||||
output_base_path: Path,
|
||||
overwrite: bool = False,
|
||||
incrementing_value: Optional[str] = None, # <-- ADDED
|
||||
sha5_value: Optional[str] = None # <-- ADDED
|
||||
) -> Dict[str, List[str]]:
|
||||
"""
|
||||
Executes the processing pipeline for all assets defined in the SourceRule.
|
||||
|
||||
@ -285,6 +294,8 @@ class ProcessingEngine:
|
||||
workspace_path: The path to the directory containing the source files (e.g., extracted archive).
|
||||
output_base_path: The base directory where processed output will be saved.
|
||||
overwrite: If True, forces reprocessing even if output exists for an asset.
|
||||
incrementing_value: Optional incrementing value for path tokens.
|
||||
sha5_value: Optional SHA5 hash value for path tokens.
|
||||
|
||||
Returns:
|
||||
Dict[str, List[str]]: A dictionary summarizing the status of each asset:
|
||||
@ -304,6 +315,10 @@ class ProcessingEngine:
|
||||
log.info(f"ProcessingEngine starting process for {len(source_rule.assets)} asset(s) defined in SourceRule.")
|
||||
overall_status = {"processed": [], "skipped": [], "failed": []}
|
||||
self.loaded_data_cache = {} # Reset cache for this run
|
||||
# Store incoming optional values for use in path generation
|
||||
self.current_incrementing_value = incrementing_value
|
||||
self.current_sha5_value = sha5_value
|
||||
log.debug(f"Received incrementing_value: {self.current_incrementing_value}, sha5_value: {self.current_sha5_value}")
|
||||
|
||||
# Use a temporary directory for intermediate files (like saved maps)
|
||||
try:
|
||||
@ -605,16 +620,17 @@ class ProcessingEngine:
|
||||
return None, None
|
||||
|
||||
|
||||
def _save_image(self, image_data: np.ndarray, map_type: str, resolution_key: str, asset_base_name: str, source_info: dict, output_bit_depth_rule: str) -> Optional[Dict]:
|
||||
def _save_image(self, image_data: np.ndarray, supplier_name: str, asset_name: str, map_type: str, resolution_key: str, source_info: dict, output_bit_depth_rule: str) -> Optional[Dict]: # <-- UPDATED SIGNATURE
|
||||
"""
|
||||
Handles saving an image NumPy array to a temporary file within the engine's temp_dir.
|
||||
Handles saving an image NumPy array to a temporary file within the engine's temp_dir using token-based path generation.
|
||||
Uses static configuration from self.config_obj for formats, quality, etc.
|
||||
|
||||
Args:
|
||||
image_data: NumPy array containing the image data to save.
|
||||
supplier_name: The effective supplier name for the asset.
|
||||
asset_name: The name of the asset.
|
||||
map_type: The standard map type being saved (e.g., "COL", "NRMRGH").
|
||||
resolution_key: The resolution key (e.g., "4K").
|
||||
asset_base_name: The sanitized base name of the asset.
|
||||
source_info: Dictionary containing details about the source(s), e.g.,
|
||||
{'original_extension': '.tif', 'source_bit_depth': 16, 'involved_extensions': {'.tif', '.png'}, 'max_input_bit_depth': 16}
|
||||
output_bit_depth_rule: Rule for determining output bit depth ('respect', 'force_8bit', 'force_16bit', 'respect_inputs').
|
||||
@ -636,7 +652,7 @@ class ProcessingEngine:
|
||||
try:
|
||||
h, w = image_data.shape[:2]
|
||||
current_dtype = image_data.dtype
|
||||
log.debug(f"Saving {map_type} ({resolution_key}) for asset '{asset_base_name}'. Input shape: {image_data.shape}, dtype: {current_dtype}")
|
||||
log.debug(f"Saving {map_type} ({resolution_key}) for asset '{asset_name}'. Input shape: {image_data.shape}, dtype: {current_dtype}")
|
||||
|
||||
# --- Get Static Config Values ---
|
||||
config = self.config_obj # Alias for brevity
|
||||
@ -646,8 +662,11 @@ class ProcessingEngine:
|
||||
force_lossless_map_types = config.force_lossless_map_types
|
||||
jpg_quality = config.jpg_quality
|
||||
png_compression_level = config._core_settings.get('PNG_COMPRESSION_LEVEL', 6)
|
||||
target_filename_pattern = config.target_filename_pattern
|
||||
# target_filename_pattern = config.target_filename_pattern # <-- REMOVED (using new pattern)
|
||||
image_resolutions = config.image_resolutions
|
||||
# Get the new separate patterns from config
|
||||
output_directory_pattern = config.get('OUTPUT_DIRECTORY_PATTERN', '[supplier]/[assetname]')
|
||||
output_filename_pattern = config.get('OUTPUT_FILENAME_PATTERN', '[assetname]_[maptype]_[resolution].[ext]')
|
||||
|
||||
# --- 1. Determine Output Bit Depth ---
|
||||
source_bpc = source_info.get('source_bit_depth', 8) # Default to 8 if missing
|
||||
@ -764,15 +783,43 @@ class ProcessingEngine:
|
||||
log.error(f"Failed RGB->BGR conversion before save for {map_type} ({resolution_key}): {cvt_err}. Saving original RGB.")
|
||||
img_save_final = img_to_save # Fallback
|
||||
|
||||
# --- 5. Construct Filename & Save ---
|
||||
filename = target_filename_pattern.format(
|
||||
base_name=asset_base_name,
|
||||
map_type=map_type,
|
||||
resolution=resolution_key,
|
||||
ext=output_ext.lstrip('.')
|
||||
)
|
||||
output_path_temp = self.temp_dir / filename # Save to engine's temp dir
|
||||
log.debug(f"Attempting to save: {output_path_temp.name} (Format: {output_format}, Dtype: {img_save_final.dtype})")
|
||||
# --- 5. Construct Path using Token Pattern & Save ---
|
||||
token_data = {
|
||||
"supplier": _sanitize_filename(supplier_name),
|
||||
"assetname": _sanitize_filename(asset_name),
|
||||
"maptype": map_type,
|
||||
"resolution": resolution_key,
|
||||
"width": w,
|
||||
"height": h,
|
||||
"bitdepth": output_bit_depth,
|
||||
"ext": output_ext.lstrip('.')
|
||||
}
|
||||
# Add optional token data if available
|
||||
if hasattr(self, 'current_incrementing_value') and self.current_incrementing_value is not None:
|
||||
token_data['incrementingvalue'] = self.current_incrementing_value
|
||||
if hasattr(self, 'current_sha5_value') and self.current_sha5_value is not None:
|
||||
token_data['sha5'] = self.current_sha5_value
|
||||
log.debug(f"Token data for _save_image path generation: {token_data}") # DEBUG LOG
|
||||
|
||||
try:
|
||||
# Generate directory and filename separately
|
||||
relative_dir_path_str = generate_path_from_pattern(output_directory_pattern, token_data)
|
||||
filename_str = generate_path_from_pattern(output_filename_pattern, token_data)
|
||||
# Combine for the full relative path string needed for saving and returning
|
||||
full_relative_path_str = str(Path(relative_dir_path_str) / filename_str)
|
||||
except Exception as path_gen_err:
|
||||
log.error(f"Failed to generate output path using patterns '{output_directory_pattern}' / '{output_filename_pattern}' and data {token_data}: {path_gen_err}", exc_info=True)
|
||||
return None # Cannot proceed without a path
|
||||
|
||||
output_path_temp = self.temp_dir / full_relative_path_str # Save to engine's temp dir, preserving structure
|
||||
log.debug(f"Attempting to save to temporary path: {output_path_temp} (Format: {output_format}, Dtype: {img_save_final.dtype})")
|
||||
|
||||
# Ensure parent directory exists in temp (using the full path)
|
||||
try:
|
||||
output_path_temp.parent.mkdir(parents=True, exist_ok=True)
|
||||
except Exception as mkdir_err:
|
||||
log.error(f"Failed to create temporary directory {output_path_temp.parent}: {mkdir_err}", exc_info=True)
|
||||
return None
|
||||
|
||||
saved_successfully = False
|
||||
actual_format_saved = output_format
|
||||
@ -785,9 +832,21 @@ class ProcessingEngine:
|
||||
# --- Try Fallback ---
|
||||
if output_bit_depth == 16 and output_format.startswith("exr") and fallback_fmt_16 != output_format and fallback_fmt_16 == "png":
|
||||
log.warning(f"Attempting fallback PNG save for {map_type} {resolution_key}")
|
||||
actual_format_saved = "png"; output_ext = ".png";
|
||||
filename = target_filename_pattern.format(base_name=asset_base_name, map_type=map_type, resolution=resolution_key, ext="png")
|
||||
output_path_temp = self.temp_dir / filename
|
||||
actual_format_saved = "png"; output_ext = ".png"
|
||||
# Regenerate path with .png extension for fallback
|
||||
token_data_fallback = token_data.copy()
|
||||
token_data_fallback["ext"] = "png"
|
||||
try:
|
||||
# Regenerate directory and filename separately for fallback
|
||||
relative_dir_path_str_fb = generate_path_from_pattern(output_directory_pattern, token_data_fallback)
|
||||
filename_str_fb = generate_path_from_pattern(output_filename_pattern, token_data_fallback)
|
||||
full_relative_path_str_fb = str(Path(relative_dir_path_str_fb) / filename_str_fb)
|
||||
output_path_temp = self.temp_dir / full_relative_path_str_fb # Update temp path for fallback
|
||||
output_path_temp.parent.mkdir(parents=True, exist_ok=True) # Ensure dir exists
|
||||
except Exception as path_gen_err_fb:
|
||||
log.error(f"Failed to generate fallback PNG path: {path_gen_err_fb}", exc_info=True)
|
||||
return None # Cannot save fallback without path
|
||||
|
||||
save_params_fallback = [cv2.IMWRITE_PNG_COMPRESSION, png_compression_level]
|
||||
img_fallback = None; target_fallback_dtype = np.uint16
|
||||
|
||||
@ -819,8 +878,10 @@ class ProcessingEngine:
|
||||
|
||||
# --- 6. Return Result ---
|
||||
if saved_successfully:
|
||||
# Return the full relative path string generated by the patterns
|
||||
final_relative_path_str = full_relative_path_str_fb if actual_format_saved == "png" and output_format.startswith("exr") else full_relative_path_str
|
||||
return {
|
||||
"path": output_path_temp.relative_to(self.temp_dir), # Store relative path within engine's temp
|
||||
"path": final_relative_path_str, # Store relative path string
|
||||
"resolution": resolution_key,
|
||||
"width": w, "height": h,
|
||||
"bit_depth": output_bit_depth,
|
||||
@ -868,7 +929,7 @@ class ProcessingEngine:
|
||||
base_name = asset_name # Use the asset name from the rule
|
||||
|
||||
# --- Aspect Ratio Calculation Setup ---
|
||||
first_map_rule_for_aspect = next((fr for fr in asset_rule.files if fr.item_type_override is not None), None)
|
||||
first_map_rule_for_aspect = next((fr for fr in asset_rule.files if fr.item_type_override is not None and fr.item_type_override != "EXTRA"), None) # Exclude EXTRA
|
||||
orig_w_aspect, orig_h_aspect = None, None
|
||||
if first_map_rule_for_aspect:
|
||||
first_res_key = next(iter(resolutions)) # Use first resolution key
|
||||
@ -990,14 +1051,18 @@ class ProcessingEngine:
|
||||
log.warning(f"Skipping save for {file_rule.file_path}: item_type_override is None.")
|
||||
continue # Skip saving this file
|
||||
|
||||
# Get supplier name from metadata (set in process method)
|
||||
supplier_name = current_asset_metadata.get("supplier_name", "UnknownSupplier")
|
||||
|
||||
save_result = self._save_image(
|
||||
image_data=img_resized,
|
||||
map_type=save_map_type, # Use the determined map type for saving
|
||||
supplier_name=supplier_name, # <-- ADDED
|
||||
asset_name=base_name, # <-- ADDED (using base_name alias)
|
||||
map_type=save_map_type, # Use the determined map type for saving
|
||||
resolution_key=res_key,
|
||||
asset_base_name=base_name,
|
||||
source_info=source_info,
|
||||
output_bit_depth_rule=bit_depth_rule
|
||||
# _save_image uses self.config_obj for other settings
|
||||
# asset_base_name removed, _save_image uses self.config_obj for other settings
|
||||
)
|
||||
|
||||
# --- 5. Store Result ---
|
||||
@ -1254,14 +1319,18 @@ class ProcessingEngine:
|
||||
log.debug(f"Merged channels for '{output_map_type}' ({current_res_key}). Result shape: {merged_image_float32.shape}, dtype: {merged_image_float32.dtype}")
|
||||
|
||||
# --- Save Merged Map using Helper ---
|
||||
# Get supplier name from metadata (set in process method)
|
||||
supplier_name = current_asset_metadata.get("supplier_name", "UnknownSupplier")
|
||||
|
||||
save_result = self._save_image(
|
||||
image_data=merged_image_float32, # Pass the merged float32 data
|
||||
supplier_name=supplier_name, # <-- ADDED
|
||||
asset_name=base_name, # <-- ADDED (using base_name alias)
|
||||
map_type=output_map_type,
|
||||
resolution_key=current_res_key,
|
||||
asset_base_name=base_name,
|
||||
source_info=source_info_for_save, # Pass collected source info
|
||||
output_bit_depth_rule=rule_bit_depth # Pass the rule's requirement
|
||||
# _save_image uses self.config_obj for other settings
|
||||
# asset_base_name removed, _save_image uses self.config_obj for other settings
|
||||
)
|
||||
|
||||
# --- Record details locally ---
|
||||
@ -1282,10 +1351,10 @@ class ProcessingEngine:
|
||||
return merged_maps_details_asset
|
||||
|
||||
|
||||
def _generate_metadata_file(self, effective_supplier: str, asset_rule: AssetRule, current_asset_metadata: Dict, processed_maps_details_asset: Dict[str, Dict[str, Dict]], merged_maps_details_asset: Dict[str, Dict[str, Dict]]) -> Path:
|
||||
def _generate_metadata_file(self, effective_supplier: str, asset_rule: AssetRule, current_asset_metadata: Dict, processed_maps_details_asset: Dict[str, Dict[str, Dict]], merged_maps_details_asset: Dict[str, Dict[str, Dict]]) -> Tuple[Path, str]:
|
||||
"""
|
||||
Gathers metadata for a specific asset based on the AssetRule and processing results,
|
||||
and writes it to a temporary JSON file in the engine's temp_dir.
|
||||
and writes it to a temporary JSON file in the engine's temp_dir using separate directory/filename patterns.
|
||||
|
||||
Args:
|
||||
effective_supplier: The supplier name to use (override or original).
|
||||
@ -1295,7 +1364,7 @@ class ProcessingEngine:
|
||||
merged_maps_details_asset: Details of merged maps for this asset.
|
||||
|
||||
Returns:
|
||||
Path: The path to the generated temporary metadata file.
|
||||
Tuple[Path, str]: A tuple containing the relative directory Path object and the filename string within the temp_dir.
|
||||
"""
|
||||
if not self.temp_dir: raise ProcessingEngineError("Engine workspace (temp_dir) not setup.")
|
||||
asset_name = asset_rule.asset_name
|
||||
@ -1363,26 +1432,68 @@ class ProcessingEngine:
|
||||
for key in ["maps_present", "merged_maps", "shader_features", "source_files_in_extra"]:
|
||||
if key in final_metadata and isinstance(final_metadata[key], list): final_metadata[key].sort()
|
||||
|
||||
# Use asset name in temporary filename to avoid conflicts
|
||||
# Use static config for the base metadata filename
|
||||
temp_metadata_filename = f"{asset_name}_{self.config_obj.metadata_filename}"
|
||||
output_path = self.temp_dir / temp_metadata_filename
|
||||
log.debug(f"Writing metadata for asset '{asset_name}' to temporary file: {output_path}")
|
||||
# --- Generate Path and Save ---
|
||||
# Get the new separate patterns from config
|
||||
output_directory_pattern = self.config_obj.get('OUTPUT_DIRECTORY_PATTERN', '[supplier]/[assetname]')
|
||||
output_filename_pattern = self.config_obj.get('OUTPUT_FILENAME_PATTERN', '[assetname]_[maptype]_[resolution].[ext]')
|
||||
metadata_filename_base = self.config_obj.metadata_filename # e.g., "metadata.json"
|
||||
metadata_ext = Path(metadata_filename_base).suffix.lstrip('.') or 'json'
|
||||
metadata_maptype = Path(metadata_filename_base).stem # Use filename stem as maptype token
|
||||
|
||||
token_data = {
|
||||
"supplier": _sanitize_filename(effective_supplier),
|
||||
"assetname": _sanitize_filename(asset_name),
|
||||
"maptype": metadata_maptype, # Use filename stem for maptype token
|
||||
"resolution": "meta", # Use a fixed value for resolution token
|
||||
"width": 0, # Not applicable
|
||||
"height": 0, # Not applicable
|
||||
"bitdepth": 0, # Not applicable
|
||||
"ext": metadata_ext # Use extension from config filename
|
||||
}
|
||||
# Add optional token data if available
|
||||
if hasattr(self, 'current_incrementing_value') and self.current_incrementing_value is not None:
|
||||
token_data['incrementingvalue'] = self.current_incrementing_value
|
||||
if hasattr(self, 'current_sha5_value') and self.current_sha5_value is not None:
|
||||
token_data['sha5'] = self.current_sha5_value
|
||||
log.debug(f"Token data for _generate_metadata_file path generation: {token_data}") # DEBUG LOG
|
||||
|
||||
|
||||
try:
|
||||
with open(output_path, 'w', encoding='utf-8') as f:
|
||||
# Use a custom encoder if numpy types might be present (though they shouldn't be at this stage)
|
||||
# Generate directory and filename separately
|
||||
relative_dir_path_str = generate_path_from_pattern(output_directory_pattern, token_data)
|
||||
filename_str = generate_path_from_pattern(output_filename_pattern, token_data)
|
||||
# Combine for the full temporary path
|
||||
full_relative_path_str = str(Path(relative_dir_path_str) / filename_str)
|
||||
relative_dir_path = Path(relative_dir_path_str) # Keep the directory Path object
|
||||
except Exception as path_gen_err:
|
||||
log.error(f"Failed to generate metadata path using patterns '{output_directory_pattern}' / '{output_filename_pattern}' and data {token_data}: {path_gen_err}", exc_info=True)
|
||||
raise ProcessingEngineError(f"Failed to generate metadata path for asset '{asset_name}'") from path_gen_err
|
||||
|
||||
output_path_temp_abs = self.temp_dir / full_relative_path_str # Save to engine's temp dir, preserving structure
|
||||
log.debug(f"Writing metadata for asset '{asset_name}' to temporary file: {output_path_temp_abs}")
|
||||
|
||||
# Ensure parent directory exists in temp (using the full path)
|
||||
try:
|
||||
output_path_temp_abs.parent.mkdir(parents=True, exist_ok=True)
|
||||
except Exception as mkdir_err:
|
||||
log.error(f"Failed to create temporary directory {output_path_temp_abs.parent} for metadata: {mkdir_err}", exc_info=True)
|
||||
raise ProcessingEngineError(f"Failed to create temporary directory for metadata for asset '{asset_name}'") from mkdir_err
|
||||
|
||||
try:
|
||||
with open(output_path_temp_abs, 'w', encoding='utf-8') as f:
|
||||
json.dump(final_metadata, f, indent=4, ensure_ascii=False, sort_keys=True)
|
||||
log.info(f"Metadata file '{self.config_obj.metadata_filename}' generated successfully for asset '{asset_name}'.")
|
||||
return output_path # Return the path to the temporary file
|
||||
log.info(f"Metadata file '{filename_str}' generated successfully for asset '{asset_name}' at relative temp path '{full_relative_path_str}'.")
|
||||
# Return the RELATIVE directory Path object and the filename string
|
||||
return relative_dir_path, filename_str
|
||||
except Exception as e:
|
||||
raise ProcessingEngineError(f"Failed to write metadata file {output_path} for asset '{asset_name}': {e}") from e
|
||||
raise ProcessingEngineError(f"Failed to write metadata file {output_path_temp_abs} for asset '{asset_name}': {e}") from e
|
||||
|
||||
|
||||
def _organize_output_files(self, asset_rule: AssetRule, workspace_path: Path, supplier_identifier: str, output_base_path: Path, processed_maps_details_asset: Dict[str, Dict[str, Dict]], merged_maps_details_asset: Dict[str, Dict[str, Dict]], temp_metadata_path: Path):
|
||||
def _organize_output_files(self, asset_rule: AssetRule, workspace_path: Path, supplier_identifier: str, output_base_path: Path, processed_maps_details_asset: Dict[str, Dict[str, Dict]], merged_maps_details_asset: Dict[str, Dict[str, Dict]], temp_metadata_info: Tuple[Path, str]):
|
||||
"""
|
||||
Moves/copies processed files for a specific asset from the engine's temp dir
|
||||
and copies EXTRA files from the original workspace to the final output structure,
|
||||
based on the AssetRule and static config.
|
||||
using the relative paths generated by the token pattern.
|
||||
|
||||
Args:
|
||||
asset_rule: The AssetRule object for this asset.
|
||||
@ -1391,104 +1502,101 @@ class ProcessingEngine:
|
||||
output_base_path: The final base output directory.
|
||||
processed_maps_details_asset: Details of processed maps for this asset.
|
||||
merged_maps_details_asset: Details of merged maps for this asset.
|
||||
temp_metadata_path: Path to the temporary metadata file for this asset.
|
||||
temp_metadata_info: Tuple containing the relative directory Path and filename string for the metadata file within temp_dir.
|
||||
"""
|
||||
if not self.temp_dir or not self.temp_dir.exists(): raise ProcessingEngineError("Engine temp workspace missing.")
|
||||
asset_name = asset_rule.asset_name
|
||||
if not asset_name: raise ProcessingEngineError("Asset name missing for organization.")
|
||||
|
||||
# Get structure names from static config and arguments
|
||||
# supplier_name = self.config_obj.supplier_name # <<< ISSUE: Uses config supplier
|
||||
metadata_filename = self.config_obj.metadata_filename
|
||||
if not asset_name: raise ProcessingEngineError("Asset name missing for organization.")
|
||||
asset_name_sanitized = _sanitize_filename(asset_name) # Still useful for logging
|
||||
|
||||
# Get structure names from static config
|
||||
extra_subdir_name = self.config_obj.extra_files_subdir
|
||||
|
||||
# Use the supplier identifier passed from the SourceRule
|
||||
if not supplier_identifier:
|
||||
log.warning(f"Asset '{asset_name}': Supplier identifier missing in SourceRule. Using fallback 'UnknownSupplier'.")
|
||||
supplier_identifier = "UnknownSupplier"
|
||||
log.info(f"Organizing output files for asset '{asset_name_sanitized}' using generated paths relative to: {output_base_path}")
|
||||
|
||||
supplier_sanitized = _sanitize_filename(supplier_identifier) # Use the effective supplier passed in
|
||||
asset_name_sanitized = _sanitize_filename(asset_name)
|
||||
final_dir = output_base_path / supplier_sanitized / asset_name_sanitized
|
||||
log.info(f"Organizing output files for asset '{asset_name_sanitized}' (Supplier: '{supplier_identifier}') into: {final_dir}")
|
||||
# --- Helper for moving files from engine's temp dir to final output ---
|
||||
def _safe_move_to_final(src_rel_path_str: str | None, file_desc: str):
|
||||
"""Moves a file from temp to its final location based on its relative path string."""
|
||||
if not src_rel_path_str:
|
||||
log.warning(f"Asset '{asset_name_sanitized}': Missing src relative path string for {file_desc}. Cannot move.")
|
||||
return
|
||||
|
||||
try:
|
||||
# Overwrite logic is handled in the main process() method before calling this
|
||||
final_dir.mkdir(parents=True, exist_ok=True)
|
||||
except Exception as e:
|
||||
raise ProcessingEngineError(f"Failed to create final dir {final_dir} for asset '{asset_name_sanitized}': {e}") from e
|
||||
source_abs = self.temp_dir / src_rel_path_str # Absolute path in temp
|
||||
dest_abs = output_base_path / src_rel_path_str # Final absolute path
|
||||
|
||||
# --- Helper for moving files from engine's temp dir ---
|
||||
def _safe_move(src_rel_path: Path | None, dest_dir: Path, file_desc: str):
|
||||
if not src_rel_path: log.warning(f"Asset '{asset_name_sanitized}': Missing src relative path for {file_desc}."); return
|
||||
source_abs = self.temp_dir / src_rel_path # Path relative to engine's temp
|
||||
# Use the original filename from the source path for the destination
|
||||
dest_abs = dest_dir / src_rel_path.name
|
||||
try:
|
||||
if source_abs.exists():
|
||||
log.debug(f"Asset '{asset_name_sanitized}': Moving {file_desc}: {source_abs.name} -> {dest_dir.relative_to(output_base_path)}/")
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
# Ensure final destination directory exists
|
||||
dest_abs.parent.mkdir(parents=True, exist_ok=True)
|
||||
log.debug(f"Asset '{asset_name_sanitized}': Moving {file_desc}: {src_rel_path_str} -> {dest_abs.relative_to(output_base_path)}")
|
||||
shutil.move(str(source_abs), str(dest_abs))
|
||||
else: log.warning(f"Asset '{asset_name_sanitized}': Source file missing in engine temp for {file_desc}: {source_abs}")
|
||||
except Exception as e: log.error(f"Asset '{asset_name_sanitized}': Failed moving {file_desc} '{source_abs.name}': {e}", exc_info=True)
|
||||
else:
|
||||
log.warning(f"Asset '{asset_name_sanitized}': Source file missing in engine temp for {file_desc}: {source_abs}")
|
||||
except Exception as e:
|
||||
log.error(f"Asset '{asset_name_sanitized}': Failed moving {file_desc} '{src_rel_path_str}': {e}", exc_info=True)
|
||||
|
||||
# --- Move Processed/Merged Maps ---
|
||||
moved_map_count = 0
|
||||
for details_dict in [processed_maps_details_asset, merged_maps_details_asset]:
|
||||
for map_type, res_dict in details_dict.items():
|
||||
if 'error' in res_dict: continue
|
||||
# Skip if the whole map type failed (e.g., merge rule source missing)
|
||||
if isinstance(res_dict, dict) and 'error' in res_dict and len(res_dict) == 1:
|
||||
log.warning(f"Skipping move for map type '{map_type}' due to processing error: {res_dict['error']}")
|
||||
continue
|
||||
for res_key, details in res_dict.items():
|
||||
# Skip specific resolution errors
|
||||
if isinstance(details, str) and details.startswith("error_"):
|
||||
log.warning(f"Skipping move for {map_type} ({res_key}) due to error: {details}")
|
||||
continue
|
||||
if isinstance(details, dict) and 'path' in details:
|
||||
# details['path'] is relative to engine's temp dir
|
||||
_safe_move(details['path'], final_dir, f"{map_type} ({res_key})")
|
||||
|
||||
# --- Move Models (copy from original workspace) ---
|
||||
# Models are not processed/saved in temp, copy from original workspace
|
||||
# This requires the original workspace path, which isn't directly available here.
|
||||
# TODO: Revisit how models are handled. Should they be copied to temp first?
|
||||
# For now, assume models are handled by the caller or need adjustment.
|
||||
# log.warning("Model file organization not implemented in ProcessingEngine._organize_output_files yet.")
|
||||
# Find model FileRules and copy from workspace_path (passed to process)
|
||||
# This needs workspace_path access. Let's assume it's available via self for now, though it's not ideal.
|
||||
# Correction: workspace_path is not stored in self. Pass it down or handle differently.
|
||||
# Let's assume the caller handles model copying for now.
|
||||
# details['path'] is the relative path string within temp_dir
|
||||
relative_path_str = details['path']
|
||||
_safe_move_to_final(relative_path_str, f"{map_type} ({res_key})")
|
||||
moved_map_count += 1
|
||||
log.debug(f"Asset '{asset_name_sanitized}': Moved {moved_map_count} map files.")
|
||||
|
||||
# --- Move Metadata File ---
|
||||
if temp_metadata_path and temp_metadata_path.exists():
|
||||
# temp_metadata_path is absolute path within engine's temp dir
|
||||
final_metadata_path = final_dir / metadata_filename # Use standard name from config
|
||||
try:
|
||||
log.debug(f"Asset '{asset_name_sanitized}': Moving metadata file: {temp_metadata_path.name} -> {final_metadata_path.relative_to(output_base_path)}")
|
||||
shutil.move(str(temp_metadata_path), str(final_metadata_path))
|
||||
except Exception as e:
|
||||
log.error(f"Asset '{asset_name_sanitized}': Failed moving metadata file '{temp_metadata_path.name}': {e}", exc_info=True)
|
||||
if temp_metadata_info:
|
||||
relative_dir_path, filename = temp_metadata_info
|
||||
metadata_rel_path_str = str(relative_dir_path / filename)
|
||||
_safe_move_to_final(metadata_rel_path_str, "metadata file")
|
||||
else:
|
||||
log.warning(f"Asset '{asset_name_sanitized}': Temporary metadata file path missing or file does not exist: {temp_metadata_path}")
|
||||
log.warning(f"Asset '{asset_name_sanitized}': Temporary metadata info missing. Cannot move metadata file.")
|
||||
|
||||
# --- Handle "EXTRA" Files (copy from original workspace to final asset dir) ---
|
||||
# Determine the final asset directory based on the metadata's relative directory path
|
||||
final_asset_relative_dir = relative_dir_path if temp_metadata_info else None
|
||||
if final_asset_relative_dir is not None: # Check explicitly for None
|
||||
final_extra_dir_abs = output_base_path / final_asset_relative_dir / extra_subdir_name
|
||||
log.debug(f"Asset '{asset_name_sanitized}': Determined final EXTRA directory: {final_extra_dir_abs}")
|
||||
copied_extra_files = []
|
||||
for file_rule in asset_rule.files:
|
||||
# Copy files explicitly marked as EXTRA or those with no item_type_override (unmatched)
|
||||
if file_rule.item_type_override == "EXTRA" or file_rule.item_type_override is None:
|
||||
try:
|
||||
source_rel_path = Path(file_rule.file_path)
|
||||
source_abs = workspace_path / source_rel_path
|
||||
# Place in Extra subdir within the final asset dir, keep original name
|
||||
dest_abs = final_extra_dir_abs / source_rel_path.name
|
||||
|
||||
# --- Handle "EXTRA" Files (copy from original workspace) ---
|
||||
extra_dir = final_dir / extra_subdir_name
|
||||
copied_extra_files = []
|
||||
for file_rule in asset_rule.files:
|
||||
if file_rule.item_type_override == "EXTRA":
|
||||
try:
|
||||
source_rel_path = Path(file_rule.file_path)
|
||||
source_abs = workspace_path / source_rel_path
|
||||
dest_abs = extra_dir / source_rel_path.name # Place in Extra subdir, keep original name
|
||||
if source_abs.is_file():
|
||||
log.debug(f"Asset '{asset_name_sanitized}': Copying EXTRA/unmatched file: {source_rel_path} -> {final_extra_dir_abs.relative_to(output_base_path)}/")
|
||||
final_extra_dir_abs.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(str(source_abs), str(dest_abs)) # copy2 preserves metadata
|
||||
copied_extra_files.append(source_rel_path.name)
|
||||
elif source_abs.is_dir():
|
||||
log.debug(f"Asset '{asset_name_sanitized}': Skipping EXTRA/unmatched directory: {source_rel_path}")
|
||||
else:
|
||||
log.warning(f"Asset '{asset_name_sanitized}': Source file marked as EXTRA/unmatched not found in workspace: {source_abs}")
|
||||
except Exception as copy_err:
|
||||
log.error(f"Asset '{asset_name_sanitized}': Failed copying EXTRA/unmatched file '{file_rule.file_path}': {copy_err}", exc_info=True)
|
||||
|
||||
if source_abs.is_file():
|
||||
log.debug(f"Asset '{asset_name_sanitized}': Copying EXTRA file: {source_abs.name} -> {extra_dir.relative_to(output_base_path)}/")
|
||||
extra_dir.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(str(source_abs), str(dest_abs)) # copy2 preserves metadata
|
||||
copied_extra_files.append(source_rel_path.name)
|
||||
else:
|
||||
log.warning(f"Asset '{asset_name_sanitized}': Source file marked as EXTRA not found in workspace: {source_abs}")
|
||||
except Exception as copy_err:
|
||||
log.error(f"Asset '{asset_name_sanitized}': Failed copying EXTRA file '{file_rule.file_path}': {copy_err}", exc_info=True)
|
||||
|
||||
if copied_extra_files:
|
||||
log.info(f"Asset '{asset_name_sanitized}': Copied {len(copied_extra_files)} EXTRA file(s) to '{extra_subdir_name}' subdirectory.")
|
||||
if copied_extra_files:
|
||||
log.info(f"Asset '{asset_name_sanitized}': Copied {len(copied_extra_files)} EXTRA/unmatched file(s) to '{final_extra_dir_abs.relative_to(output_base_path)}' subdirectory.")
|
||||
else:
|
||||
log.warning(f"Asset '{asset_name_sanitized}': Could not determine final asset directory from metadata info '{temp_metadata_info}'. Skipping EXTRA file copying.")
|
||||
|
||||
|
||||
log.info(f"Finished organizing output for asset '{asset_name_sanitized}'.")
|
||||
|
||||
# --- End of ProcessingEngine Class ---
|
||||
46
utils/hash_utils.py
Normal file
46
utils/hash_utils.py
Normal file
@ -0,0 +1,46 @@
|
||||
import hashlib
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def calculate_sha256(file_path: Path) -> Optional[str]:
|
||||
"""
|
||||
Calculates the SHA-256 hash of a file.
|
||||
|
||||
Args:
|
||||
file_path: The path to the file.
|
||||
|
||||
Returns:
|
||||
The SHA-256 hash as a hexadecimal string, or None if an error occurs.
|
||||
"""
|
||||
if not isinstance(file_path, Path):
|
||||
try:
|
||||
file_path = Path(file_path)
|
||||
except TypeError:
|
||||
logger.error(f"Invalid file path type: {type(file_path)}. Expected Path object or string.")
|
||||
return None
|
||||
|
||||
if not file_path.is_file():
|
||||
logger.error(f"File not found or is not a regular file: {file_path}")
|
||||
return None
|
||||
|
||||
sha256_hash = hashlib.sha256()
|
||||
buffer_size = 65536 # Read in 64k chunks
|
||||
|
||||
try:
|
||||
with open(file_path, "rb") as f:
|
||||
while True:
|
||||
data = f.read(buffer_size)
|
||||
if not data:
|
||||
break
|
||||
sha256_hash.update(data)
|
||||
return sha256_hash.hexdigest()
|
||||
except IOError as e:
|
||||
logger.error(f"Error reading file {file_path}: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"An unexpected error occurred while hashing {file_path}: {e}")
|
||||
return None
|
||||
256
utils/path_utils.py
Normal file
256
utils/path_utils.py
Normal file
@ -0,0 +1,256 @@
|
||||
import os
|
||||
import sys
|
||||
import datetime
|
||||
import re
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def generate_path_from_pattern(pattern_string: str, token_data: dict) -> str:
|
||||
"""
|
||||
Generates a file path by replacing tokens in a pattern string with values
|
||||
from the provided token_data dictionary.
|
||||
|
||||
Args:
|
||||
pattern_string: The string containing tokens to be replaced (e.g.,
|
||||
"[Assettype]/[supplier]/[assetname]_[resolution].[ext]").
|
||||
token_data: A dictionary where keys are token names (without brackets,
|
||||
case-insensitive) and values are the replacement strings.
|
||||
Special tokens like 'IncrementingValue' or '####' should
|
||||
be provided here if used in the pattern.
|
||||
|
||||
Returns:
|
||||
The generated path string with tokens replaced.
|
||||
|
||||
Raises:
|
||||
ValueError: If a token required by the pattern (excluding date/time/apppath)
|
||||
is not found in token_data.
|
||||
KeyError: If internal logic fails to find expected date/time components.
|
||||
"""
|
||||
if not isinstance(pattern_string, str):
|
||||
raise TypeError("pattern_string must be a string")
|
||||
if not isinstance(token_data, dict):
|
||||
raise TypeError("token_data must be a dictionary")
|
||||
|
||||
# Normalize token keys in the input data for case-insensitive matching
|
||||
normalized_token_data = {k.lower(): v for k, v in token_data.items()}
|
||||
|
||||
# --- Prepare dynamic/default token values ---
|
||||
now = datetime.datetime.now()
|
||||
dynamic_tokens = {
|
||||
'date': now.strftime('%Y%m%d'),
|
||||
'time': now.strftime('%H%M%S'),
|
||||
# Provide a default ApplicationPath, can be overridden by token_data
|
||||
'applicationpath': os.path.abspath(os.getcwd())
|
||||
}
|
||||
|
||||
# Merge dynamic tokens with provided data, allowing overrides
|
||||
# Provided data takes precedence
|
||||
full_token_data = {**dynamic_tokens, **normalized_token_data}
|
||||
|
||||
# --- Define known tokens (lowercase) ---
|
||||
# Add variations like #### for IncrementingValue
|
||||
known_tokens_lc = {
|
||||
'assettype', 'supplier', 'assetname', 'resolution', 'ext',
|
||||
'incrementingvalue', '####', 'date', 'time', 'sha5', 'applicationpath'
|
||||
}
|
||||
|
||||
output_path = pattern_string
|
||||
|
||||
# --- Regex to find all tokens like [TokenName] ---
|
||||
token_pattern = re.compile(r'\[([^\]]+)\]')
|
||||
tokens_found = token_pattern.findall(pattern_string)
|
||||
|
||||
processed_tokens_lc = set()
|
||||
|
||||
for token_name in tokens_found:
|
||||
token_name_lc = token_name.lower()
|
||||
|
||||
# Handle alias #### for IncrementingValue
|
||||
lookup_key = 'incrementingvalue' if token_name_lc == '####' else token_name_lc
|
||||
|
||||
if lookup_key in processed_tokens_lc:
|
||||
continue # Already processed this token type
|
||||
|
||||
if lookup_key in full_token_data:
|
||||
replacement_value = str(full_token_data[lookup_key]) # Ensure string
|
||||
# Replace all occurrences of this token (case-insensitive original name)
|
||||
# We use a regex finditer to replace only the specific token format
|
||||
# to avoid replacing substrings within other words.
|
||||
current_token_pattern = re.compile(re.escape(f'[{token_name}]'), re.IGNORECASE)
|
||||
output_path = current_token_pattern.sub(replacement_value, output_path)
|
||||
processed_tokens_lc.add(lookup_key)
|
||||
elif lookup_key in known_tokens_lc:
|
||||
# Known token but not found in data (and not a dynamic one we generated)
|
||||
logger.warning(f"Token '[{token_name}]' found in pattern but not in token_data.")
|
||||
# Raise error for non-optional tokens if needed, or replace with placeholder
|
||||
# For now, let's raise an error to be explicit
|
||||
raise ValueError(f"Required token '[{token_name}]' not found in token_data.")
|
||||
else:
|
||||
# Token not recognized
|
||||
logger.warning(f"Unknown token '[{token_name}]' found in pattern string. Leaving it unchanged.")
|
||||
|
||||
# --- Final path cleaning (optional, e.g., normalize separators) ---
|
||||
# output_path = os.path.normpath(output_path) # Consider implications on mixed separators
|
||||
|
||||
return output_path
|
||||
def get_next_incrementing_value(output_base_path: Path, output_directory_pattern: str) -> str:
|
||||
"""Determines the next incrementing value based on existing directories."""
|
||||
# Implementation as detailed in the previous plan revision...
|
||||
logger.debug(f"Calculating next increment value for pattern '{output_directory_pattern}' in '{output_base_path}'")
|
||||
match = re.match(r"(.*?)(\[IncrementingValue\]|(#+))(.*)", output_directory_pattern)
|
||||
if not match:
|
||||
logger.warning(f"Could not find incrementing token ([IncrementingValue] or #+) in pattern '{output_directory_pattern}'. Defaulting to '00'.")
|
||||
return "00" # Default fallback if pattern doesn't contain the token
|
||||
|
||||
prefix_pattern, increment_token, suffix_pattern = match.groups()
|
||||
num_digits = len(increment_token) if increment_token.startswith("#") else 2 # Default to 2 for [IncrementingValue] if not specified otherwise
|
||||
logger.debug(f"Parsed pattern: prefix='{prefix_pattern}', token='{increment_token}' ({num_digits} digits), suffix='{suffix_pattern}'")
|
||||
|
||||
# Replace other tokens in prefix/suffix with '*' for globbing
|
||||
glob_prefix = re.sub(r'\[[^\]]+\]', '*', prefix_pattern)
|
||||
glob_suffix = re.sub(r'\[[^\]]+\]', '*', suffix_pattern)
|
||||
# Construct the glob pattern part for the number itself
|
||||
glob_increment_part = f"[{'0-9' * num_digits}]" # Matches exactly num_digits
|
||||
glob_pattern = f"{glob_prefix}{glob_increment_part}{glob_suffix}"
|
||||
logger.debug(f"Constructed glob pattern: {glob_pattern}")
|
||||
|
||||
max_value = -1
|
||||
try:
|
||||
# Prepare regex to extract the number from directory names matching the full pattern
|
||||
# Escape regex special characters in the literal parts of the pattern
|
||||
extract_prefix_re = re.escape(prefix_pattern)
|
||||
extract_suffix_re = re.escape(suffix_pattern)
|
||||
# The regex captures exactly num_digits between the escaped prefix and suffix
|
||||
extract_regex = re.compile(rf"^{extract_prefix_re}(\d{{{num_digits}}}){extract_suffix_re}.*")
|
||||
logger.debug(f"Constructed extraction regex: {extract_regex.pattern}")
|
||||
|
||||
if not output_base_path.is_dir():
|
||||
logger.warning(f"Output base path '{output_base_path}' does not exist or is not a directory. Cannot scan for existing values.")
|
||||
else:
|
||||
for item in output_base_path.glob(glob_pattern):
|
||||
if item.is_dir():
|
||||
logger.debug(f"Checking directory: {item.name}")
|
||||
num_match = extract_regex.match(item.name)
|
||||
if num_match:
|
||||
try:
|
||||
current_val = int(num_match.group(1))
|
||||
logger.debug(f"Extracted value {current_val} from {item.name}")
|
||||
max_value = max(max_value, current_val)
|
||||
except (ValueError, IndexError) as e:
|
||||
logger.warning(f"Could not parse number from matching directory '{item.name}': {e}")
|
||||
else:
|
||||
logger.debug(f"Directory '{item.name}' matched glob but not extraction regex.")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching for incrementing values using glob pattern '{glob_pattern}' in '{output_base_path}': {e}", exc_info=True)
|
||||
# Decide on fallback behavior - returning "00" might be safer than raising
|
||||
return "00" # Fallback on error during search
|
||||
|
||||
next_value = max_value + 1
|
||||
format_string = f"{{:0{num_digits}d}}"
|
||||
next_value_str = format_string.format(next_value)
|
||||
logger.info(f"Determined next incrementing value: {next_value_str} (Max found: {max_value})")
|
||||
return next_value_str
|
||||
|
||||
# --- Basic Unit Tests ---
|
||||
if __name__ == "__main__":
|
||||
print("Running basic tests for path_utils.generate_path_from_pattern...")
|
||||
|
||||
test_pattern_1 = "[Assettype]/[supplier]/[assetname]_[resolution]_[Date]_[Time].[ext]"
|
||||
test_data_1 = {
|
||||
"AssetType": "Texture",
|
||||
"supplier": "MegaScans",
|
||||
"assetName": "RustyMetalPanel",
|
||||
"Resolution": "4k",
|
||||
"EXT": "png",
|
||||
"Sha5": "abcde" # Included but not in pattern
|
||||
}
|
||||
expected_1_base = f"Texture/MegaScans/RustyMetalPanel_4k_"
|
||||
try:
|
||||
result_1 = generate_path_from_pattern(test_pattern_1, test_data_1)
|
||||
assert result_1.startswith(expected_1_base)
|
||||
assert result_1.endswith(".png")
|
||||
assert len(result_1.split('_')) == 5 # Check date and time were added
|
||||
print(f"PASS: Test 1 - Basic replacement: {result_1}")
|
||||
except Exception as e:
|
||||
print(f"FAIL: Test 1 - {e}")
|
||||
|
||||
test_pattern_2 = "Output/[assetname]/[assetname]_####.[ext]"
|
||||
test_data_2 = {
|
||||
"assetname": "WoodFloor",
|
||||
"IncrementingValue": "001",
|
||||
"ext": "jpg"
|
||||
}
|
||||
expected_2 = "Output/WoodFloor/WoodFloor_001.jpg"
|
||||
try:
|
||||
result_2 = generate_path_from_pattern(test_pattern_2, test_data_2)
|
||||
assert result_2 == expected_2
|
||||
print(f"PASS: Test 2 - IncrementingValue (####): {result_2}")
|
||||
except Exception as e:
|
||||
print(f"FAIL: Test 2 - {e}")
|
||||
|
||||
test_pattern_3 = "AppPath=[ApplicationPath]/[assetname].[ext]"
|
||||
test_data_3 = {"assetname": "Test", "ext": "txt"}
|
||||
expected_3_start = f"AppPath={os.path.abspath(os.getcwd())}/Test.txt"
|
||||
try:
|
||||
result_3 = generate_path_from_pattern(test_pattern_3, test_data_3)
|
||||
assert result_3 == expected_3_start
|
||||
print(f"PASS: Test 3 - ApplicationPath (default): {result_3}")
|
||||
except Exception as e:
|
||||
print(f"FAIL: Test 3 - {e}")
|
||||
|
||||
test_pattern_4 = "AppPath=[ApplicationPath]/[assetname].[ext]"
|
||||
test_data_4 = {"assetname": "Test", "ext": "txt", "ApplicationPath": "/custom/path"}
|
||||
expected_4 = "/custom/path/Test.txt" # Note: AppPath= part is replaced by the token logic
|
||||
# Correction: The pattern includes "AppPath=", so it should remain.
|
||||
expected_4_corrected = "AppPath=/custom/path/Test.txt"
|
||||
try:
|
||||
result_4 = generate_path_from_pattern(test_pattern_4, test_data_4)
|
||||
assert result_4 == expected_4_corrected
|
||||
print(f"PASS: Test 4 - ApplicationPath (override): {result_4}")
|
||||
except Exception as e:
|
||||
print(f"FAIL: Test 4 - {e}")
|
||||
|
||||
|
||||
test_pattern_5 = "[assetname]/[MissingToken].[ext]"
|
||||
test_data_5 = {"assetname": "FailureTest", "ext": "err"}
|
||||
try:
|
||||
generate_path_from_pattern(test_pattern_5, test_data_5)
|
||||
print("FAIL: Test 5 - Expected ValueError for missing token")
|
||||
except ValueError as e:
|
||||
assert "MissingToken" in str(e)
|
||||
print(f"PASS: Test 5 - Correctly raised ValueError for missing token: {e}")
|
||||
except Exception as e:
|
||||
print(f"FAIL: Test 5 - Incorrect exception type: {e}")
|
||||
|
||||
|
||||
test_pattern_6 = "[assetname]/[UnknownToken].[ext]"
|
||||
test_data_6 = {"assetname": "UnknownTest", "ext": "dat"}
|
||||
expected_6 = "UnknownTest/[UnknownToken].dat" # Unknown tokens are left as is
|
||||
try:
|
||||
# Capture warnings
|
||||
logging.basicConfig()
|
||||
with logging.catch_warnings(record=True) as w:
|
||||
result_6 = generate_path_from_pattern(test_pattern_6, test_data_6)
|
||||
assert result_6 == expected_6
|
||||
assert len(w) == 1
|
||||
assert "Unknown token '[UnknownToken]'" in str(w[0].message)
|
||||
print(f"PASS: Test 6 - Unknown token left unchanged: {result_6}")
|
||||
except Exception as e:
|
||||
print(f"FAIL: Test 6 - {e}")
|
||||
|
||||
test_pattern_7 = "[assetname]/[assetname].png" # Case check
|
||||
test_data_7 = {"AssetName": "CaseTest"}
|
||||
expected_7 = "CaseTest/CaseTest.png"
|
||||
try:
|
||||
result_7 = generate_path_from_pattern(test_pattern_7, test_data_7)
|
||||
assert result_7 == expected_7
|
||||
print(f"PASS: Test 7 - Case insensitivity: {result_7}")
|
||||
except Exception as e:
|
||||
print(f"FAIL: Test 7 - {e}")
|
||||
|
||||
|
||||
print("Basic tests finished.")
|
||||
Loading…
x
Reference in New Issue
Block a user