Asset-Frameworker/blenderscripts/create_nodegroups.py

1029 lines
51 KiB
Python

# Blender Script: Create/Update Node Groups from Asset Processor Output
# Version: 1.5
# Description: Scans a library processed by the Asset Processor Tool,
# reads metadata.json files, and creates/updates corresponding
# PBR node groups in the active Blender file.
# Changes v1.5:
# - Corrected aspect ratio calculation (`calculate_aspect_correction_factor`)
# to use actual image dimensions from a loaded reference image and the
# `aspect_ratio_change_string`, mirroring original script logic for
# "EVEN", "Xnnn", "Ynnn" formats.
# - Added logic in main loop to load reference image for dimensions.
# Changes v1.3:
# - Added logic to find the highest resolution present for an asset.
# - Added logic to set a "HighestResolution" Value node in the parent group
# (maps 1K->1.0, 2K->2.0, 4K->3.0, 8K->4.0).
# Changes v1.2:
# - Added Base64 encoding for child node group names (PBRTYPE_...).
# - Added fallback logic for reconstructing image paths with different extensions.
# - Added logic to set custom asset preview for new parent groups (using lowest res COL map).
# Changes v1.1:
# - Updated metadata parsing to match actual structure (using processed_map_resolutions, image_stats_1k, map_details).
# - Added logic to reconstruct image file paths based on metadata and assumed naming convention.
import bpy
import os
import json
from pathlib import Path
import time
import re # For parsing aspect ratio string
import base64 # For encoding node group names
import sys
# --- USER CONFIGURATION ---
# Path to the root output directory of the Asset Processor Tool
# Example: r"G:\Assets\Processed"
# IMPORTANT: This should point to the base directory containing supplier folders (e.g., Poliigon)
# This will be overridden by command-line arguments if provided.
PROCESSED_ASSET_LIBRARY_ROOT = None
# Names of the required node group templates in the Blender file
PARENT_TEMPLATE_NAME = "Template_PBRSET"
CHILD_TEMPLATE_NAME = "Template_PBRTYPE"
# Labels of specific nodes within the PARENT template
ASPECT_RATIO_NODE_LABEL = "AspectRatioCorrection" # Value node for UV X-scaling factor
STATS_NODE_PREFIX = "Histogram-" # Prefix for Combine XYZ nodes storing stats (e.g., "Histogram-ROUGH")
HIGHEST_RESOLUTION_NODE_LABEL = "HighestResolution" # Value node to store highest res index
# Enable/disable the manifest system to track processed assets/maps
# If enabled, requires the blend file to be saved.
ENABLE_MANIFEST = False # Disabled based on user feedback in previous run
# Assumed filename pattern for processed images.
# [assetname], [maptype], [resolution], [ext] will be replaced.
# This should match OUTPUT_FILENAME_PATTERN from app_settings.json.
IMAGE_FILENAME_PATTERN = "[assetname]_[maptype]_[resolution].[ext]"
# Fallback extensions to try if the primary format from metadata is not found
# Order matters - first found will be used.
FALLBACK_IMAGE_EXTENSIONS = ['png', 'jpg', 'exr', 'tif']
# Map type(s) to use for generating the asset preview AND for aspect ratio calculation reference
# The script will look for these in order and use the first one found.
REFERENCE_MAP_TYPES = ["COL", "COL-1", "COL-2"] # Used for preview and aspect calc
# Preferred resolution order for reference image (lowest first is often faster)
REFERENCE_RESOLUTION_ORDER = ["1K", "512", "2K", "4K"] # Adjust as needed
# Mapping from resolution string to numerical value for the HighestResolution node
RESOLUTION_VALUE_MAP = {"1K": 1.0, "2K": 2.0, "4K": 3.0, "8K": 4.0}
# Order to check resolutions to find the highest present (highest value first)
RESOLUTION_ORDER_DESC = ["8K", "4K", "2K", "1K"] # Add others like "512" if needed and map them in RESOLUTION_VALUE_MAP
# Map PBR type strings (from metadata) to Blender color spaces
# Add more mappings as needed based on your metadata types
PBR_COLOR_SPACE_MAP = {
"AO": "Non-Color", # Usually Non-Color, but depends on workflow
"COL": "sRGB",
"COL-1": "sRGB", # Handle variants if present in metadata
"COL-2": "sRGB",
"COL-3": "sRGB",
"DISP": "Non-Color",
"NRM": "Non-Color",
"REFL": "Non-Color", # Reflection/Specular
"ROUGH": "Non-Color",
"METAL": "Non-Color",
"OPC": "Non-Color", # Opacity/Alpha
"TRN": "Non-Color", # Transmission
"SSS": "sRGB", # Subsurface Color
"EMISS": "sRGB", # Emission Color
"NRMRGH": "Non-Color", # Added for merged map
"FUZZ": "Non-Color",
# Add other types like GLOSS, HEIGHT, etc. if needed
}
DEFAULT_COLOR_SPACE = "sRGB" # Fallback if map type not in the dictionary
# Map types for which stats should be applied (if found in metadata and node exists)
# Reads stats from the 'image_stats_1k' section of metadata.json
APPLY_STATS_FOR_MAP_TYPES = ["ROUGH", "DISP", "METAL", "AO", "REFL"] # Add others if needed
# Categories for which full nodegroup generation should occur
CATEGORIES_FOR_NODEGROUP_GENERATION = ["Surface", "Decal"]
# --- END USER CONFIGURATION ---
# --- Helper Functions ---
def encode_name_b64(name_str):
"""Encodes a string using URL-safe Base64 for node group names."""
try:
name_str = str(name_str)
return base64.urlsafe_b64encode(name_str.encode('utf-8')).decode('ascii')
except Exception as e:
print(f" Error base64 encoding '{name_str}': {e}")
return name_str # Fallback to original name on error
def find_nodes_by_label(node_tree, label, node_type=None):
"""Finds ALL nodes in a node tree matching the label and optionally type."""
if not node_tree:
return []
matching_nodes = []
for node in node_tree.nodes:
# Use node.label for labeled nodes, node.name for non-labeled (like Group Input/Output)
node_identifier = node.label if node.label else node.name
if node_identifier and node_identifier == label:
if node_type is None or node.bl_idname == node_type or node.type == node_type: # Check bl_idname and type for flexibility
matching_nodes.append(node)
return matching_nodes
def add_tag_if_new(asset_data, tag_name):
"""Adds a tag to the asset data if it's not None/empty and doesn't already exist."""
if not asset_data or not tag_name or not isinstance(tag_name, str):
return False
cleaned_tag_name = tag_name.strip()
if not cleaned_tag_name:
return False
# Check if tag already exists (case-insensitive check might be better sometimes)
if cleaned_tag_name not in [t.name for t in asset_data.tags]:
try:
asset_data.tags.new(cleaned_tag_name)
print(f" + Added Asset Tag: '{cleaned_tag_name}'")
return True
except Exception as e:
print(f" Error adding tag '{cleaned_tag_name}': {e}")
return False
return False # Tag already existed
def get_color_space(map_type):
"""Returns the appropriate Blender color space name for a given map type string."""
# Attempt to map map_type (e.g., "MAP_COL", "COL-1", "NRMRGH") to a standard type for color space lookup.
# PBR_COLOR_SPACE_MAP usually contains standard types like "COL", "NRM".
map_type_upper = map_type.upper()
# 1. Direct match (e.g., "NRMRGH", "COL")
if map_type_upper in PBR_COLOR_SPACE_MAP:
return PBR_COLOR_SPACE_MAP[map_type_upper]
# 2. Handle variants like "COL-1", "MAP_ROUGH-2"
# Try to get the part before a hyphen if a hyphen exists
base_type_candidate = map_type_upper.split('-')[0]
if base_type_candidate in PBR_COLOR_SPACE_MAP:
return PBR_COLOR_SPACE_MAP[base_type_candidate]
# 3. Handle cases like "MAP_COL" -> "COL"
# This is a simple heuristic. A more robust solution would involve access to FILE_TYPE_DEFINITIONS.
# For this script, we assume PBR_COLOR_SPACE_MAP might contain the direct standard_type.
# Example: if map_type is "MAP_DIFFUSE" and PBR_COLOR_SPACE_MAP has "DIFFUSE"
if base_type_candidate.startswith("MAP_") and len(base_type_candidate) > 4:
short_type = base_type_candidate[4:] # Get "COL" from "MAP_COL"
if short_type in PBR_COLOR_SPACE_MAP:
return PBR_COLOR_SPACE_MAP[short_type]
# Fallback if no specific rule found
return DEFAULT_COLOR_SPACE
def calculate_aspect_correction_factor(image_width, image_height, aspect_string):
"""
Calculates the UV X-axis scaling factor needed to correct distortion,
based on image dimensions and the aspect_ratio_change_string ("EVEN", "Xnnn", "Ynnn").
Mirrors the logic from the original POC script.
Returns 1.0 if dimensions are invalid or string is "EVEN" or invalid.
"""
if image_height <= 0 or image_width <= 0:
print(" Warn: Invalid image dimensions for aspect ratio calculation. Returning 1.0.")
return 1.0
current_aspect_ratio = image_width / image_height
if not aspect_string or aspect_string.upper() == "EVEN":
# If scaling was even, the correction factor is just the image's aspect ratio
# to make UVs match the image proportions.
return current_aspect_ratio
# Handle non-uniform scaling cases ("Xnnn", "Ynnn")
# Use search instead of match to find anywhere in string (though unlikely needed based on format)
match = re.search(r"([XY])(\d+)", aspect_string, re.IGNORECASE)
if not match:
print(f" Warn: Invalid Scaling string format '{aspect_string}'. Returning current ratio {current_aspect_ratio:.4f} as fallback.")
return current_aspect_ratio # Fallback to the image's own ratio
axis = match.group(1).upper()
try:
amount = int(match.group(2))
if amount <= 0:
print(f" Warn: Zero or negative Amount in Scaling string '{aspect_string}'. Returning current ratio {current_aspect_ratio:.4f}.")
return current_aspect_ratio
except ValueError:
print(f" Warn: Invalid Amount in Scaling string '{aspect_string}'. Returning current ratio {current_aspect_ratio:.4f}.")
return current_aspect_ratio
# Apply the non-uniform correction formula based on original script logic
scaling_factor_percent = amount / 100.0
correction_factor = current_aspect_ratio
try:
if axis == 'X':
if scaling_factor_percent == 0: raise ZeroDivisionError("X scaling factor is zero")
# If image was stretched horizontally (X > 1), divide UV.x by factor
correction_factor = current_aspect_ratio / scaling_factor_percent
elif axis == 'Y':
# If image was stretched vertically (Y > 1), multiply UV.x by factor
correction_factor = current_aspect_ratio * scaling_factor_percent
# No 'else' needed as regex ensures X or Y
except ZeroDivisionError as e:
print(f" Warn: Division by zero during aspect factor calculation ({e}). Returning current ratio {current_aspect_ratio:.4f}.")
return current_aspect_ratio
except Exception as e:
print(f" Error calculating aspect correction factor: {e}. Returning current ratio {current_aspect_ratio:.4f}.")
return current_aspect_ratio
return correction_factor
def reconstruct_image_path_with_fallback(asset_dir_path, asset_name, map_type, resolution, primary_format=None):
"""
Constructs the expected image file path.
If primary_format is provided, tries that first.
Then falls back to common extensions if the path doesn't exist or primary_format was None.
Returns the found path as a string, or None if not found.
"""
if not all([asset_dir_path, asset_name, map_type, resolution]):
print(f" !!! ERROR: Missing data for path reconstruction ({asset_name}/{map_type}/{resolution}).")
return None
found_path = None
# 1. Try the primary format if provided
if primary_format:
try:
filename = IMAGE_FILENAME_PATTERN.format(
assetname=asset_name,
maptype=map_type,
resolution=resolution,
ext=primary_format.lower()
)
primary_path = asset_dir_path / filename
if primary_path.is_file():
return str(primary_path)
except KeyError as e:
print(f" !!! ERROR: Missing key '{e}' in IMAGE_FILENAME_PATTERN. Cannot reconstruct path.")
return None # Cannot proceed without valid pattern
except Exception as e:
print(f" !!! ERROR reconstructing primary image path: {e}")
# Continue to fallback
# 2. Try fallback extensions
for ext in FALLBACK_IMAGE_EXTENSIONS:
# Skip if we already tried this extension as primary (and it failed)
if primary_format and ext.lower() == primary_format.lower():
continue
try:
fallback_filename = IMAGE_FILENAME_PATTERN.format(
assetname=asset_name,
maptype=map_type,
resolution=resolution,
ext=ext.lower()
)
fallback_path = asset_dir_path / fallback_filename
if fallback_path.is_file():
print(f" Found fallback path: {str(fallback_path)}")
return str(fallback_path) # Found it!
except KeyError:
# Should not happen if primary format worked, but handle defensively
print(f" !!! ERROR: Missing key in IMAGE_FILENAME_PATTERN during fallback. Cannot reconstruct path.")
return None
except Exception as e_fallback:
print(f" !!! ERROR reconstructing fallback image path ({ext}): {e_fallback}")
continue # Try next extension
# If we get here, neither primary nor fallbacks worked
if primary_format:
print(f" !!! ERROR: Could not find image file for {map_type}/{resolution} using primary format '{primary_format}' or fallbacks {FALLBACK_IMAGE_EXTENSIONS}.")
else:
print(f" !!! ERROR: Could not find image file for {map_type}/{resolution} using fallbacks {FALLBACK_IMAGE_EXTENSIONS}.")
return None # Not found after all checks
# --- Manifest Functions ---
def get_manifest_path(context):
"""Gets the expected path for the manifest JSON file."""
if not context or not context.blend_data or not context.blend_data.filepath:
return None # Cannot determine path if blend file is not saved
blend_path = Path(context.blend_data.filepath)
manifest_filename = f"{blend_path.stem}_manifest.json"
return blend_path.parent / manifest_filename
def load_manifest(context):
"""Loads the manifest data from the JSON file."""
if not ENABLE_MANIFEST:
return {} # Manifest disabled
manifest_path = get_manifest_path(context)
if not manifest_path:
print(" Manifest Info: Blend file not saved. Cannot load manifest.")
return {} # Cannot load without a path
if not manifest_path.exists():
print(f" Manifest Info: No manifest file found at '{manifest_path.name}'. Starting fresh.")
return {} # No manifest file exists yet
try:
with open(manifest_path, 'r', encoding='utf-8') as f:
data = json.load(f)
print(f" Manifest Loaded from: {manifest_path.name}")
# Basic validation (check if it's a dictionary)
if not isinstance(data, dict):
print(f"!!! WARNING: Manifest file '{manifest_path.name}' has invalid format (not a dictionary). Starting fresh. !!!")
return {}
return data
except json.JSONDecodeError:
print(f"!!! WARNING: Manifest file '{manifest_path.name}' is corrupted. Starting fresh. !!!")
return {}
except Exception as e:
print(f"!!! ERROR: Could not load manifest file '{manifest_path.name}': {e} !!!")
return {} # Treat as starting fresh on error
def save_manifest(context, manifest_data):
"""Saves the manifest data to the JSON file."""
if not ENABLE_MANIFEST or not manifest_data: # Don't save if disabled or empty
return False
manifest_path = get_manifest_path(context)
if not manifest_path:
print(" Manifest Error: Blend file not saved. Cannot save manifest.")
return False
try:
with open(manifest_path, 'w', encoding='utf-8') as f:
json.dump(manifest_data, f, indent=2, sort_keys=True) # Use indent and sort for readability
print(f" Manifest Saved to: {manifest_path.name}")
return True
except Exception as e:
print(f"!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\n"
f"!!! Manifest save FAILED to '{manifest_path.name}': {e} !!!\n"
f"!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!")
return False
def is_asset_processed(manifest_data, asset_name):
"""Checks if the entire asset (all its maps/resolutions) is marked as processed."""
if not ENABLE_MANIFEST: return False
# Basic check if asset entry exists. Detailed check happens at map level.
return asset_name in manifest_data
def is_map_processed(manifest_data, asset_name, map_type, resolution):
"""Checks if a specific map type and resolution for an asset is processed."""
if not ENABLE_MANIFEST: return False
return resolution in manifest_data.get(asset_name, {}).get(map_type, [])
def update_manifest(manifest_data, asset_name, map_type=None, resolution=None):
"""Updates the manifest dictionary in memory."""
if not ENABLE_MANIFEST: return False
# Ensure asset entry exists
if asset_name not in manifest_data:
manifest_data[asset_name] = {}
# If map_type and resolution are provided, update the specific map entry
if map_type and resolution:
if map_type not in manifest_data[asset_name]:
manifest_data[asset_name][map_type] = []
if resolution not in manifest_data[asset_name][map_type]:
manifest_data[asset_name][map_type].append(resolution)
manifest_data[asset_name][map_type].sort() # Keep sorted
return True # Indicate that a change was made
return False # No change made to this specific map/res
# --- Core Logic ---
def process_library(context, asset_library_root_override=None):
global ENABLE_MANIFEST # Declare intent to modify global if needed
global PROCESSED_ASSET_LIBRARY_ROOT # Allow modification of global
"""Scans the library, reads metadata, creates/updates node groups."""
start_time = time.time()
print(f"\n--- Starting Node Group Processing ({time.strftime('%Y-%m-%d %H:%M:%S')}) ---")
print(f" DEBUG: Received asset_library_root_override: {asset_library_root_override}")
# --- Determine Asset Library Root ---
if asset_library_root_override:
PROCESSED_ASSET_LIBRARY_ROOT = asset_library_root_override
print(f"Using asset library root from argument: '{PROCESSED_ASSET_LIBRARY_ROOT}'")
elif not PROCESSED_ASSET_LIBRARY_ROOT:
print("!!! ERROR: Processed asset library root not set in script and not provided via argument.")
print("--- Script aborted. ---")
return False
print(f" DEBUG: Using final PROCESSED_ASSET_LIBRARY_ROOT: {PROCESSED_ASSET_LIBRARY_ROOT}")
# --- Pre-run Checks ---
print("Performing pre-run checks...")
valid_setup = True
# 1. Check Library Root Path
root_path = Path(PROCESSED_ASSET_LIBRARY_ROOT)
if not root_path.is_dir():
print(f"!!! ERROR: Processed asset library root directory not found or not a directory:")
print(f"!!! '{PROCESSED_ASSET_LIBRARY_ROOT}'")
valid_setup = False
else:
print(f" Asset Library Root: '{root_path}'")
print(f" DEBUG: Checking for templates: '{PARENT_TEMPLATE_NAME}', '{CHILD_TEMPLATE_NAME}'")
# 2. Check Templates
template_parent = bpy.data.node_groups.get(PARENT_TEMPLATE_NAME)
template_child = bpy.data.node_groups.get(CHILD_TEMPLATE_NAME)
if not template_parent:
print(f"!!! ERROR: Parent template node group '{PARENT_TEMPLATE_NAME}' not found in this Blender file.")
valid_setup = False
if not template_child:
print(f"!!! ERROR: Child template node group '{CHILD_TEMPLATE_NAME}' not found in this Blender file.")
valid_setup = False
if template_parent and template_child:
print(f" Found Templates: '{PARENT_TEMPLATE_NAME}', '{CHILD_TEMPLATE_NAME}'")
print(f" DEBUG: Template Parent Found: {template_parent is not None}")
print(f" DEBUG: Template Child Found: {template_child is not None}")
# 3. Check Blend File Saved (if manifest enabled)
if ENABLE_MANIFEST and not context.blend_data.filepath:
print(f"!!! WARNING: Manifest is enabled, but the current Blender file is not saved.")
print(f"!!! Manifest cannot be loaded or saved. Processing will continue without manifest checks.")
ENABLE_MANIFEST = False # Disable manifest for this run
if not valid_setup:
print("\n--- Script aborted due to configuration errors. Please fix the issues above. ---")
return False
print("Pre-run checks passed.")
# --- End Pre-run Checks ---
manifest_data = load_manifest(context)
manifest_needs_saving = False
# --- Initialize Counters ---
metadata_files_found = 0
assets_processed = 0
assets_skipped_manifest = 0
parent_groups_created = 0
parent_groups_updated = 0
child_groups_created = 0
child_groups_updated = 0
images_loaded = 0
images_assigned = 0
maps_processed = 0
maps_skipped_manifest = 0
errors_encountered = 0
previews_set = 0
highest_res_set = 0
aspect_ratio_set = 0
# --- End Counters ---
print(f"\nScanning for metadata files in '{root_path}'...")
# --- Scan for metadata.json ---
# Scan one level deeper for supplier folders (e.g., Poliigon)
# Then scan within each supplier for asset folders containing metadata.json
metadata_paths = []
for supplier_dir in root_path.iterdir():
if supplier_dir.is_dir():
# Now look for asset folders inside the supplier directory
for asset_dir in supplier_dir.iterdir():
if asset_dir.is_dir():
metadata_file = asset_dir / 'metadata.json'
if metadata_file.is_file():
metadata_paths.append(metadata_file)
metadata_files_found = len(metadata_paths)
print(f"Found {metadata_files_found} metadata.json files.")
print(f" DEBUG: Metadata paths found: {metadata_paths}")
if metadata_files_found == 0:
print("No metadata files found. Nothing to process.")
print("--- Script Finished ---")
return True # No work needed is considered success
# --- Process Each Metadata File ---
for metadata_path in metadata_paths:
asset_dir_path = metadata_path.parent
print(f"\n--- Processing Metadata: {metadata_path.relative_to(root_path)} ---")
print(f" DEBUG: Processing file: {metadata_path}")
try:
with open(metadata_path, 'r', encoding='utf-8') as f:
metadata = json.load(f)
# --- Extract Key Info ---
asset_name = metadata.get("asset_name")
supplier_name = metadata.get("supplier_name")
archetype = metadata.get("archetype")
asset_category = metadata.get("category", "Unknown")
processed_resolutions = metadata.get("processed_map_resolutions", {}) # Default to empty dict
merged_resolutions = metadata.get("merged_map_resolutions", {}) # Get merged maps too
map_details = metadata.get("map_details", {}) # Default to empty dict
image_stats_1k = metadata.get("image_stats_1k") # Dict: {map_type: {stats}}
aspect_string = metadata.get("aspect_ratio_change_string")
# Combine processed and merged maps for iteration
all_map_resolutions = {**processed_resolutions, **merged_resolutions}
# Validate essential data
if not asset_name:
print(f" !!! ERROR: Metadata file is missing 'asset_name'. Skipping.")
errors_encountered += 1
continue
if not all_map_resolutions:
print(f" !!! ERROR: Metadata file has no 'processed_map_resolutions' or 'merged_map_resolutions'. Skipping asset '{asset_name}'.")
errors_encountered += 1
continue
# map_details check remains a warning as merged maps won't be in it
print(f" DEBUG: Valid metadata loaded for asset: {asset_name}")
print(f" Asset Name: {asset_name}")
# --- Determine Highest Resolution ---
highest_resolution_value = 0.0
highest_resolution_str = "Unknown"
all_resolutions_present = set()
if all_map_resolutions: # Check combined dict
for res_list in all_map_resolutions.values():
if isinstance(res_list, list):
all_resolutions_present.update(res_list)
if all_resolutions_present:
for res_str in RESOLUTION_ORDER_DESC:
if res_str in all_resolutions_present:
highest_resolution_value = RESOLUTION_VALUE_MAP.get(res_str, 0.0)
highest_resolution_str = res_str
if highest_resolution_value > 0.0:
break # Found the highest valid resolution
print(f" Highest resolution found: {highest_resolution_str} (Value: {highest_resolution_value})")
# --- Load Reference Image for Aspect Ratio ---
ref_image_path = None
ref_image_width = 0
ref_image_height = 0
ref_image_loaded = False
# Use combined resolutions dict to find reference map
for ref_map_type in REFERENCE_MAP_TYPES:
if ref_map_type in all_map_resolutions:
available_resolutions = all_map_resolutions[ref_map_type]
lowest_res = None
for res_pref in REFERENCE_RESOLUTION_ORDER:
if res_pref in available_resolutions:
lowest_res = res_pref
break
if lowest_res:
# Get format from map_details if available, otherwise None
ref_map_details = map_details.get(ref_map_type, {})
ref_format = ref_map_details.get("output_format")
ref_image_path = reconstruct_image_path_with_fallback(
asset_dir_path=asset_dir_path,
asset_name=asset_name,
map_type=ref_map_type,
resolution=lowest_res,
primary_format=ref_format # Pass None if not in map_details
)
if ref_image_path:
break # Found a suitable reference image path
if ref_image_path:
print(f" Loading reference image for aspect ratio: {Path(ref_image_path).name}")
try:
# Load image temporarily
ref_img = bpy.data.images.load(ref_image_path, check_existing=True)
if ref_img:
ref_image_width = ref_img.size[0]
ref_image_height = ref_img.size[1]
ref_image_loaded = True
print(f" Reference image dimensions: {ref_image_width}x{ref_image_height}")
# Remove the temporary image datablock to save memory
bpy.data.images.remove(ref_img)
else:
print(f" !!! ERROR: Failed loading reference image via bpy.data.images.load: {ref_image_path}")
except Exception as e_ref_load:
print(f" !!! ERROR loading reference image '{ref_image_path}': {e_ref_load}")
else:
print(f" !!! WARNING: Could not find suitable reference image ({REFERENCE_MAP_TYPES} at {REFERENCE_RESOLUTION_ORDER}) for aspect ratio calculation.")
# --- Manifest Check (Asset Level - Basic) ---
if ENABLE_MANIFEST and is_asset_processed(manifest_data, asset_name):
# Perform a quick check if *any* map needs processing for this asset
needs_processing = False
for map_type, resolutions in all_map_resolutions.items(): # Check combined maps
for resolution in resolutions:
if not is_map_processed(manifest_data, asset_name, map_type, resolution):
needs_processing = True
break
if needs_processing:
break
if not needs_processing:
print(f" Skipping asset '{asset_name}' (already fully processed according to manifest).")
assets_skipped_manifest += 1
continue # Skip to next metadata file
# Conditional skip based on asset_category
if asset_category not in CATEGORIES_FOR_NODEGROUP_GENERATION:
print(f" Skipping nodegroup content generation for asset '{asset_name}' (Category: '{asset_category}'). Tag added.")
assets_processed += 1 # Still count as processed for summary, even if skipped
continue # Skip the rest of the processing for this asset
# --- Parent Group Handling ---
target_parent_name = f"PBRSET_{asset_name}"
parent_group = bpy.data.node_groups.get(target_parent_name)
is_new_parent = False
if parent_group is None:
print(f" Creating new parent group: '{target_parent_name}'")
print(f" DEBUG: Copying parent template '{PARENT_TEMPLATE_NAME}'")
parent_group = template_parent.copy()
if not parent_group:
print(f" !!! ERROR: Failed to copy parent template '{PARENT_TEMPLATE_NAME}'. Skipping asset '{asset_name}'.")
errors_encountered += 1
continue
parent_group.name = target_parent_name
parent_groups_created += 1
is_new_parent = True
else:
print(f" Updating existing parent group: '{target_parent_name}'")
print(f" DEBUG: Found existing parent group.")
parent_groups_updated += 1
# Ensure marked as asset
if not parent_group.asset_data:
try:
parent_group.asset_mark()
print(f" Marked '{parent_group.name}' as asset.")
except Exception as e_mark:
print(f" !!! ERROR: Failed to mark group '{parent_group.name}' as asset: {e_mark}")
# Continue processing other parts if possible
# Apply Asset Tags
if parent_group.asset_data:
if supplier_name:
add_tag_if_new(parent_group.asset_data, supplier_name)
if archetype:
add_tag_if_new(parent_group.asset_data, archetype)
if asset_category:
add_tag_if_new(parent_group.asset_data, asset_category)
# Add other tags if needed
# Apply Aspect Ratio Correction
aspect_nodes = find_nodes_by_label(parent_group, ASPECT_RATIO_NODE_LABEL, 'ShaderNodeValue')
if aspect_nodes:
aspect_node = aspect_nodes[0]
correction_factor = 1.0 # Default if ref image fails
if ref_image_loaded:
correction_factor = calculate_aspect_correction_factor(ref_image_width, ref_image_height, aspect_string)
print(f" Calculated aspect correction factor: {correction_factor:.4f}")
else:
print(f" !!! WARNING: Using default aspect ratio correction (1.0) due to missing reference image.")
# Check if update is needed
current_val = aspect_node.outputs[0].default_value
if abs(current_val - correction_factor) > 0.0001:
aspect_node.outputs[0].default_value = correction_factor
print(f" Set '{ASPECT_RATIO_NODE_LABEL}' value to {correction_factor:.4f} (was {current_val:.4f})")
aspect_ratio_set += 1
# Apply Highest Resolution Value
hr_nodes = find_nodes_by_label(parent_group, HIGHEST_RESOLUTION_NODE_LABEL, 'ShaderNodeValue')
if hr_nodes:
hr_node = hr_nodes[0]
current_hr_val = hr_node.outputs[0].default_value
if highest_resolution_value > 0.0 and abs(current_hr_val - highest_resolution_value) > 0.001:
hr_node.outputs[0].default_value = highest_resolution_value
print(f" Set '{HIGHEST_RESOLUTION_NODE_LABEL}' value to {highest_resolution_value} ({highest_resolution_str}) (was {current_hr_val:.1f})")
highest_res_set += 1 # Count successful sets
# Apply Stats (using image_stats_1k)
if image_stats_1k and isinstance(image_stats_1k, dict):
for map_type_to_stat in APPLY_STATS_FOR_MAP_TYPES:
if map_type_to_stat in image_stats_1k:
# Find the stats node in the parent group
stats_node_label = f"{STATS_NODE_PREFIX}{map_type_to_stat}"
stats_nodes = find_nodes_by_label(parent_group, stats_node_label, 'ShaderNodeCombineXYZ')
if stats_nodes:
stats_node = stats_nodes[0]
stats = image_stats_1k[map_type_to_stat]
if stats and isinstance(stats, dict):
# Handle potential list format for RGB stats (use first value) or direct float
def get_stat_value(stat_val):
if isinstance(stat_val, list):
return stat_val[0] if stat_val else None
return stat_val
min_val = get_stat_value(stats.get("min"))
max_val = get_stat_value(stats.get("max"))
mean_val = get_stat_value(stats.get("mean")) # Often stored as 'mean' or 'avg'
updated_stat = False
# Check inputs exist before assigning
input_x = stats_node.inputs.get("X")
input_y = stats_node.inputs.get("Y")
input_z = stats_node.inputs.get("Z")
if input_x and min_val is not None and abs(input_x.default_value - min_val) > 0.0001:
input_x.default_value = min_val
updated_stat = True
if input_y and max_val is not None and abs(input_y.default_value - max_val) > 0.0001:
input_y.default_value = max_val
updated_stat = True
if input_z and mean_val is not None and abs(input_z.default_value - mean_val) > 0.0001:
input_z.default_value = mean_val
updated_stat = True
if updated_stat:
print(f" Set stats in '{stats_node_label}': Min={min_val:.4f}, Max={max_val:.4f}, Mean={mean_val:.4f}")
# --- Set Asset Preview (only for new parent groups) ---
# Use the reference image path found earlier if available
if is_new_parent and parent_group.asset_data:
if ref_image_loaded and ref_image_path: # Check if ref image was successfully loaded earlier
print(f" Attempting to set preview from reference image: {Path(ref_image_path).name}")
try:
# Ensure the ID (node group) is the active one for the operator context
with context.temp_override(id=parent_group):
bpy.ops.ed.lib_id_load_custom_preview(filepath=ref_image_path)
print(f" Successfully set custom preview.")
previews_set += 1
except Exception as e_preview:
print(f" !!! ERROR setting custom preview: {e_preview}")
errors_encountered += 1
else:
print(f" Info: Could not set preview for '{asset_name}' as reference image was not found or loaded.")
# --- Child Group Handling ---
# Iterate through the COMBINED map types
print(f" DEBUG: Starting child group loop for asset '{asset_name}'. Map types: {list(all_map_resolutions.keys())}")
for map_type, resolutions in all_map_resolutions.items():
print(f" Processing Map Type: {map_type}")
# Determine if this is a merged map (not in map_details)
is_merged_map = map_type not in map_details
current_map_details = map_details.get(map_type, {})
# For merged maps, primary_format will be None
output_format = current_map_details.get("output_format")
if not output_format and not is_merged_map:
# This case should ideally not happen if metadata is well-formed
# but handle defensively for processed maps.
print(f" !!! WARNING: Missing 'output_format' in map_details for processed map '{map_type}'. Path reconstruction might fail.")
# We will rely solely on fallback for this map type
# Find placeholder node in parent
holder_nodes = find_nodes_by_label(parent_group, map_type, 'ShaderNodeGroup')
if not holder_nodes:
print(f" !!! WARNING: No placeholder node labeled '{map_type}' found in parent group '{parent_group.name}'. Skipping this map type.")
continue
holder_node = holder_nodes[0] # Assume first is correct
print(f" DEBUG: Found placeholder node '{holder_node.label}' for map type '{map_type}'.")
# Determine child group name (LOGICAL and ENCODED)
logical_child_name = f"{asset_name}_{map_type}"
target_child_name_b64 = encode_name_b64(logical_child_name) # Use Base64 name
child_group = bpy.data.node_groups.get(target_child_name_b64) # Find using encoded name
is_new_child = False
if child_group is None:
print(f" DEBUG: Child group '{target_child_name_b64}' not found. Creating new one.")
child_group = template_child.copy()
if not child_group:
print(f" !!! ERROR: Failed to copy child template '{CHILD_TEMPLATE_NAME}'. Skipping map type '{map_type}'.")
errors_encountered += 1
continue
child_group.name = target_child_name_b64 # Set encoded name
child_groups_created += 1
is_new_child = True
else:
print(f" DEBUG: Found existing child group '{target_child_name_b64}'.")
child_groups_updated += 1
# Assign child group to placeholder if needed
if holder_node.node_tree != child_group:
try:
holder_node.node_tree = child_group
print(f" Assigned child group '{child_group.name}' to placeholder '{holder_node.label}'.")
except TypeError as e_assign: # Catch potential type errors if placeholder isn't a group node
print(f" !!! ERROR: Could not assign child group to placeholder '{holder_node.label}'. Is it a Group Node? Error: {e_assign}")
continue # Skip this map type if assignment fails
# Link placeholder output to parent output socket
try:
# Find parent's output node
group_output_node = next((n for n in parent_group.nodes if n.type == 'GROUP_OUTPUT'), None)
if group_output_node:
# Get the specific output socket on the placeholder (usually index 0 or named 'Color'/'Value')
source_socket = holder_node.outputs.get("Color") or holder_node.outputs.get("Value") or holder_node.outputs[0]
# Get the specific input socket on the parent output node (matching map_type)
target_socket = group_output_node.inputs.get(map_type)
if source_socket and target_socket:
# Check if link already exists
link_exists = any(link.from_socket == source_socket and link.to_socket == target_socket for link in parent_group.links)
if not link_exists:
parent_group.links.new(source_socket, target_socket)
print(f" Linked '{holder_node.label}' output to parent output socket '{map_type}'.")
except Exception as e_link:
print(f" !!! ERROR linking sockets for '{map_type}': {e_link}")
# Ensure parent output socket type is Color (if it exists)
try:
# Use the interface API for modern Blender versions
item = parent_group.interface.items_tree.get(map_type)
if item and item.item_type == 'SOCKET' and item.in_out == 'OUTPUT':
# Common useful types: 'NodeSocketColor', 'NodeSocketVector', 'NodeSocketFloat'
# Defaulting to Color seems reasonable for most PBR outputs
if item.socket_type != 'NodeSocketColor':
item.socket_type = 'NodeSocketColor'
except Exception as e_sock_type:
print(f" Warn: Could not verify/set socket type for '{map_type}': {e_sock_type}")
# --- Image Node Handling (Inside Child Group) ---
if not isinstance(resolutions, list):
print(f" !!! ERROR: Invalid format for resolutions list for map type '{map_type}'. Skipping.")
continue
for resolution in resolutions:
# --- Manifest Check (Map/Resolution Level) ---
if ENABLE_MANIFEST and is_map_processed(manifest_data, asset_name, map_type, resolution):
maps_skipped_manifest += 1
continue
print(f" DEBUG: Processing map '{map_type}' resolution '{resolution}'. Manifest skip check passed.")
print(f" Processing Resolution: {resolution}")
# Reconstruct the image path using fallback logic
# Pass output_format (which might be None for merged maps)
image_path_str = reconstruct_image_path_with_fallback(
asset_dir_path=asset_dir_path,
asset_name=asset_name,
map_type=map_type,
resolution=resolution,
primary_format=output_format
)
print(f" DEBUG: Reconstructed image path for {map_type}/{resolution}: {image_path_str}")
if not image_path_str:
# Error already printed by reconstruct function
errors_encountered += 1
continue # Skip this resolution if path not found
# Find image texture node within the CHILD group (labeled by resolution, e.g., "4K")
image_nodes = find_nodes_by_label(child_group, resolution, 'ShaderNodeTexImage')
if not image_nodes:
print(f" !!! WARNING: No Image Texture node labeled '{resolution}' found in child group '{child_group.name}'. Cannot assign image.")
continue # Skip this resolution if node not found
print(f" DEBUG: Found {len(image_nodes)} image node(s) labeled '{resolution}' in child group '{child_group.name}'.")
# --- Load Image ---
img = None
image_load_failed = False
try:
image_path = Path(image_path_str) # Path object created from already found path string
# Use check_existing=True to reuse existing datablocks if path matches
img = bpy.data.images.load(str(image_path), check_existing=True)
if not img:
print(f" !!! ERROR: Failed loading image via bpy.data.images.load: {image_path_str}")
image_load_failed = True
else:
# Only count as loaded if bpy.data.images.load succeeded
# Check if it's newly loaded or reused
is_newly_loaded = img.library is None # Newly loaded images don't have a library initially
if is_newly_loaded: images_loaded += 1
except RuntimeError as e_runtime_load:
# Catch specific Blender runtime errors (e.g., unsupported format)
print(f" !!! ERROR loading image '{image_path_str}': {e_runtime_load}")
image_load_failed = True
except Exception as e_gen_load:
print(f" !!! UNEXPECTED ERROR loading image '{image_path_str}': {e_gen_load}")
image_load_failed = True
errors_encountered += 1
# --- Assign Image & Set Color Space ---
if not image_load_failed and img:
assigned_count_this_res = 0
for image_node in image_nodes:
if image_node.image != img:
image_node.image = img
assigned_count_this_res += 1
if assigned_count_this_res > 0:
images_assigned += assigned_count_this_res
print(f" Assigned image '{img.name}' to {assigned_count_this_res} node(s).")
# Set Color Space
correct_color_space = get_color_space(map_type)
try:
if img.colorspace_settings.name != correct_color_space:
img.colorspace_settings.name = correct_color_space
print(f" Set '{img.name}' color space -> {correct_color_space}")
except TypeError as e_cs: # Handle case where colorspace name is invalid
print(f" !!! WARNING: Could not set color space '{correct_color_space}' for image '{img.name}'. Is the color space available in Blender? Error: {e_cs}")
except Exception as e_cs_gen:
print(f" !!! ERROR setting color space for image '{img.name}': {e_cs_gen}")
# --- Update Manifest (Map/Resolution Level) ---
if update_manifest(manifest_data, asset_name, map_type, resolution):
manifest_needs_saving = True
maps_processed += 1
else:
# Increment error count if loading failed
if image_load_failed: errors_encountered += 1
# --- End Resolution Loop ---
# --- End Map Type Loop ---
assets_processed += 1
except FileNotFoundError:
print(f" !!! ERROR: Metadata file not found (should not happen if scan worked): {metadata_path}")
errors_encountered += 1
except json.JSONDecodeError:
print(f" !!! ERROR: Invalid JSON in metadata file: {metadata_path}")
errors_encountered += 1
except Exception as e_main_loop:
print(f" !!! UNEXPECTED ERROR processing asset from {metadata_path}: {e_main_loop}")
import traceback
traceback.print_exc() # Print detailed traceback for debugging
errors_encountered += 1
# Continue to the next asset
# --- End Metadata File Loop ---
# --- Final Manifest Save ---
if ENABLE_MANIFEST and manifest_needs_saving:
print("\nAttempting final manifest save...")
save_manifest(context, manifest_data)
elif ENABLE_MANIFEST:
print("\nManifest is enabled, but no changes require saving.")
# --- End Final Manifest Save ---
# --- Final Summary ---
end_time = time.time()
duration = end_time - start_time
print("\n--- Script Run Finished ---")
print(f"Duration: {duration:.2f} seconds")
print(f"Metadata Files Found: {metadata_files_found}")
print(f"Assets Processed/Attempted: {assets_processed}")
if ENABLE_MANIFEST:
print(f"Assets Skipped (Manifest): {assets_skipped_manifest}")
print(f"Maps Skipped (Manifest): {maps_skipped_manifest}")
print(f"Parent Groups Created: {parent_groups_created}")
print(f"Parent Groups Updated: {parent_groups_updated}")
print(f"Child Groups Created: {child_groups_created}")
print(f"Child Groups Updated: {child_groups_updated}")
print(f"Images Loaded: {images_loaded}")
print(f"Image Nodes Assigned: {images_assigned}")
print(f"Individual Maps Processed: {maps_processed}")
print(f"Asset Previews Set: {previews_set}")
print(f"Highest Resolution Nodes Set: {highest_res_set}")
print(f"Aspect Ratio Nodes Set: {aspect_ratio_set}")
if errors_encountered > 0:
print(f"!!! Errors Encountered: {errors_encountered} !!!")
print("---------------------------")
# --- Explicit Save ---
print(f" DEBUG: Attempting explicit save for file: {bpy.data.filepath}")
try:
bpy.ops.wm.save_as_mainfile(filepath=bpy.data.filepath)
print("\n--- Explicitly saved the .blend file. ---")
except Exception as e_save:
print(f"\n!!! ERROR explicitly saving .blend file: {e_save} !!!")
errors_encountered += 1 # Count save errors
return True
# --- Execution Block ---
if __name__ == "__main__":
# Ensure we are running within Blender
try:
import bpy
import base64 # Ensure base64 is imported here too if needed globally
import sys
except ImportError:
print("!!! ERROR: This script must be run from within Blender. !!!")
else:
# --- Argument Parsing for Asset Library Root ---
asset_root_arg = None
try:
# Blender arguments passed after '--' appear in sys.argv
if "--" in sys.argv:
args_after_dash = sys.argv[sys.argv.index("--") + 1:]
if len(args_after_dash) >= 1:
asset_root_arg = args_after_dash[0]
print(f"Found asset library root argument: {asset_root_arg}")
else:
print("Info: '--' found but no arguments after it.")
except Exception as e:
print(f"Error parsing command line arguments: {e}")
# --- End Argument Parsing ---
process_library(bpy.context, asset_library_root_override=asset_root_arg)