Asset-Frameworker/Deprecated/POC/Blender-NodegroupsFromPBRSETS.py
2025-04-29 18:26:13 +02:00

988 lines
50 KiB
Python

# Full script - PBR Texture Importer V4 (Manifest, Auto-Save/Reload, Aspect Ratio, Asset Tags)
import bpy
import os # For auto-save rename/remove
from pathlib import Path
import time
import base64
import numpy as np # For stats calculation
import json # For manifest handling
import re # For parsing scaling string
# --- USER CONFIGURATION ---
# File Paths & Templates
texture_root_directory = r"G:\02 Content\10-19 Content\13 Textures Power of Two\13.00" # <<< CHANGE THIS PATH!
PARENT_TEMPLATE_NAME = "Template_PBRSET" # Name of the parent node group template
CHILD_TEMPLATE_NAME = "Template_PBRTYPE" # Name of the child node group template
# Processing Limits & Intervals
MAX_NEW_GROUPS_PER_RUN = 1000 # Max NEW parent groups created per run before stopping
SAVE_INTERVAL = 25 # Auto-save interval during NEW group creation (every N groups)
# Features & Behavior
AUTO_SAVE_ENABLED = True # Enable periodic auto-saving (main file + manifest) during processing?
AUTO_RELOAD_ON_FINISH = True # Save and reload the blend file upon successful script completion?
# Naming & Structure Conventions
VALID_EXTENSIONS = {".jpg", ".jpeg", ".png", ".tif", ".tiff"} # Allowed texture file types
RESOLUTION_LABELS = ["1k", "2k", "4k", "8k"] # Expected resolution labels (LOWEST FIRST for aspect/tag calc)
SG_VALUE_NODE_LABEL = "SpecularGlossy" # Label for the Specular/Glossy value node in parent template
HISTOGRAM_NODE_PREFIX = "Histogram-" # Prefix for Combine XYZ nodes storing stats (e.g., "Histogram-ROUGH")
ASPECT_RATIO_NODE_LABEL = "AspectRatioCorrection" # Label for the Value node storing the aspect ratio correction factor
# Texture Map Properties
PBR_COLOR_SPACE_MAP = { # Map PBR type (from filename) to Blender color space
"AO": "sRGB", "COL-1": "sRGB", "COL-2": "sRGB", "COL-3": "sRGB",
"DISP": "Non-Color", "NRM": "Non-Color", "REFL": "Non-Color", "ROUGH": "Non-Color",
"METAL": "Non-Color", "FUZZ": "Non-Color", "MASK": "Non-Color", "SSS": "sRGB",
}
DEFAULT_COLOR_SPACE = "sRGB" # Fallback color space if PBR type not in map
# --- END USER CONFIGURATION ---
# --- Helper Functions ---
def parse_texture_filename(filename_stem):
"""Parses texture filename stem based on expected convention."""
parts = filename_stem.split('_');
# Expecting Tag_Groupname_Resolution_Scaling_PBRType
if len(parts) == 5:
return {"Tag": parts[0], "Groupname": parts[1], "Resolution": parts[2], "Scaling": parts[3], "PBRType": parts[4]}
else:
print(f" Warn: Skip '{filename_stem}' - Expected 5 parts, found {len(parts)}.");
return None
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:
if node.label and node.label == label:
if node_type is None or node.type == node_type:
matching_nodes.append(node)
return matching_nodes
def encode_name_b64(name_str):
"""Encodes a string using URL-safe Base64 for node group names."""
try:
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 calculate_image_stats(image):
"""Calculates Min, Max, Median of the first channel of a Blender image."""
if not image: return None
pixels_arr, value_channel_arr, result = None, None, None
try:
width = image.size[0]; height = image.size[1]; channels = image.channels
if width == 0 or height == 0 or channels == 0:
print(f" Warn: Invalid dims for '{image.name}'. Skip stats."); return None
actual_len = len(image.pixels); expected_len = width * height * channels
if expected_len != actual_len:
print(f" Warn: Pixel buffer mismatch for '{image.name}'. Skip stats."); return None
if actual_len == 0: return None
pixels_arr = np.fromiter(image.pixels, dtype=np.float32, count=actual_len)
if channels == 1: value_channel_arr = pixels_arr
elif channels >= 2: value_channel_arr = pixels_arr[0::channels]
else: return None
if value_channel_arr is None or value_channel_arr.size == 0:
print(f" Warn: No value channel for '{image.name}'. Skip stats."); return None
min_val = float(np.min(value_channel_arr))
max_val = float(np.max(value_channel_arr))
median_val = float(np.median(value_channel_arr))
result = (min_val, max_val, median_val)
except MemoryError:
print(f" Error: Not enough memory for stats calc on '{image.name}'.")
except Exception as e:
print(f" Error during stats calc for '{image.name}': {e}");
import traceback; traceback.print_exc()
finally:
# Explicitly delete potentially large numpy arrays
if 'value_channel_arr' in locals() and value_channel_arr is not None:
try:
del value_channel_arr
except NameError:
pass # Ignore if already gone
if 'pixels_arr' in locals() and pixels_arr is not None:
try:
del pixels_arr
except NameError:
pass # Ignore if already gone
return result
def calculate_aspect_ratio_factor(image_width, image_height, scaling_string):
"""Calculates the X-axis UV scaling factor based on image dims and scaling string."""
if image_height <= 0:
print(" Warn: Image height is zero, cannot calculate aspect ratio. Returning 1.0.")
return 1.0 # Return 1.0 if height is invalid
# Calculate the actual aspect ratio of the image file
current_aspect_ratio = image_width / image_height
# Check the scaling string
if scaling_string.upper() == "EVEN":
# 'EVEN' means uniform scaling was applied (or none needed).
# The correction factor is the image's own aspect ratio.
return current_aspect_ratio
else:
# Handle non-uniform scaling cases ("Xnnn", "Ynnn")
match = re.match(r"([XY])(\d+)", scaling_string, re.IGNORECASE)
if not match:
print(f" Warn: Invalid Scaling string format '{scaling_string}'. Returning current ratio {current_aspect_ratio:.4f} as fallback.")
# Fallback to the image's own ratio if scaling string is invalid
return current_aspect_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 '{scaling_string}'. Returning current ratio {current_aspect_ratio:.4f}.")
return current_aspect_ratio
except ValueError:
print(f" Warn: Invalid Amount in Scaling string '{scaling_string}'. Returning current ratio {current_aspect_ratio:.4f}.")
return current_aspect_ratio
# Apply the non-uniform correction formula
factor = current_aspect_ratio # Default to current ratio in case of issues below
scaling_factor_percent = amount / 100.0
try:
if axis == 'X':
if scaling_factor_percent == 0: raise ZeroDivisionError
factor = current_aspect_ratio / scaling_factor_percent
elif axis == 'Y':
factor = current_aspect_ratio * scaling_factor_percent
# No 'else' needed due to regex structure
except ZeroDivisionError:
print(f" Warn: Division by zero during factor calculation. Returning current ratio {current_aspect_ratio:.4f}.")
return current_aspect_ratio
return factor
# --- Manifest Helper Functions ---
def get_manifest_path(context_filepath):
"""Gets the expected path for the manifest JSON file based on blend filepath."""
if not context_filepath:
return None
blend_path = Path(context_filepath)
manifest_filename = f"{blend_path.stem}_manifest.json"
return blend_path.parent / manifest_filename
def load_manifest(manifest_path):
"""Loads the manifest data from the JSON file."""
if not manifest_path or not manifest_path.exists():
return {}
try:
with open(manifest_path, 'r', encoding='utf-8') as f:
data = json.load(f)
print(f" Loaded manifest from: {manifest_path.name}")
return data
except json.JSONDecodeError:
print(f"!!! ERROR: 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 {}
def save_manifest(manifest_path, data):
"""Saves the manifest data to the JSON file."""
if not manifest_path:
return False
try:
with open(manifest_path, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2)
return True
except Exception as e:
print(f"!!!!!!!!!!!!!!!!!!!\n!!! Manifest save FAILED: {e} !!!\n!!!!!!!!!!!!!!!!!!!")
return False
# --- Auto-Save Helper Function ---
def perform_safe_autosave(manifest_path, manifest_data):
"""Performs a safe auto-save of the main blend file and manifest."""
blend_filepath = bpy.data.filepath
if not blend_filepath or not manifest_path:
print(" Skipping auto-save: Blend file is not saved.")
return False
print(f"\n--- Attempting Auto-Save ({time.strftime('%H:%M:%S')}) ---")
blend_path = Path(blend_filepath)
manifest_path_obj = Path(manifest_path) # Ensure it's a Path object
blend_bak_path = blend_path.with_suffix('.blend.bak')
manifest_bak_path = manifest_path_obj.with_suffix('.json.bak')
# 1. Delete old backups if they exist
try:
if blend_bak_path.exists():
blend_bak_path.unlink()
if manifest_bak_path.exists():
manifest_bak_path.unlink()
except OSError as e:
print(f" Warn: Could not delete old backup file: {e}")
# Continue anyway, renaming might still work
# 2. Rename current files to backup
renamed_blend = False
renamed_manifest = False
try:
if blend_path.exists():
os.rename(blend_path, blend_bak_path)
renamed_blend = True
# print(f" Renamed '{blend_path.name}' to '{blend_bak_path.name}'") # Optional verbose log
if manifest_path_obj.exists():
os.rename(manifest_path_obj, manifest_bak_path)
renamed_manifest = True
# print(f" Renamed '{manifest_path_obj.name}' to '{manifest_bak_path.name}'") # Optional verbose log
except OSError as e:
print(f"!!! ERROR: Failed to rename files for backup: {e} !!!")
# Attempt to roll back renames if only one succeeded
if renamed_blend and not renamed_manifest and blend_bak_path.exists():
print(f" Attempting rollback: Renaming {blend_bak_path.name} back...")
try:
os.rename(blend_bak_path, blend_path)
except OSError as rb_e:
print(f" Rollback rename of blend file FAILED: {rb_e}")
if renamed_manifest and not renamed_blend and manifest_bak_path.exists():
print(f" Attempting rollback: Renaming {manifest_bak_path.name} back...")
try:
os.rename(manifest_bak_path, manifest_path_obj)
except OSError as rb_e:
print(f" Rollback rename of manifest file FAILED: {rb_e}")
print("--- Auto-Save ABORTED ---")
return False
# 3. Save new main blend file
save_blend_success = False
try:
bpy.ops.wm.save_mainfile()
print(f" Saved main blend file: {blend_path.name}")
save_blend_success = True
except Exception as e:
print(f"!!!!!!!!!!!!!!!!!!!!!!!!!\n!!! Auto-Save FAILED (Blend File Save): {e} !!!\n!!!!!!!!!!!!!!!!!!!!!!!!!")
# Attempt to restore from backup
print(" Attempting to restore from backup...")
try:
if blend_bak_path.exists():
os.rename(blend_bak_path, blend_path)
if manifest_bak_path.exists():
os.rename(manifest_bak_path, manifest_path_obj)
print(" Restored from backup.")
except OSError as re:
print(f"!!! CRITICAL: Failed to restore from backup after save failure: {re} !!!")
print(f"!!! Please check for '.bak' files manually in: {blend_path.parent} !!!")
print("--- Auto-Save ABORTED ---")
return False
# 4. Save new manifest file (only if blend save succeeded)
if save_blend_success:
if save_manifest(manifest_path, manifest_data):
print(f" Saved manifest file: {manifest_path_obj.name}")
print("--- Auto-Save Successful ---")
return True
else:
# Manifest save failed, but blend file is okay. Warn user.
print("!!! WARNING: Auto-save completed for blend file, but manifest save FAILED. Manifest may be out of sync. !!!")
return True # Still counts as 'completed' in terms of blend file safety
return False # Should not be reached
# --- Asset Tagging Helper Functions ---
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) or tag_name.strip() == "":
return False # Invalid input
cleaned_tag_name = tag_name.strip() # Remove leading/trailing whitespace
if not cleaned_tag_name:
return False # Don't add empty tags
# Check if tag already exists
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
else:
# print(f" Tag '{cleaned_tag_name}' already exists.") # Optional info
return False # Not added because it existed
def get_supplier_tag_from_path(file_path_str, groupname):
"""
Determines supplier tag based on directory structure.
Assumes structure is .../Supplier/Groupname/file.ext or .../Supplier/file.ext
"""
try:
file_path = Path(file_path_str).resolve()
groupname_lower = groupname.lower()
if not file_path.is_file():
print(f" Warn (get_supplier_tag): Input path is not a file: {file_path_str}")
return None
current_dir = file_path.parent # Directory containing the file
if not current_dir:
print(f" Warn (get_supplier_tag): Cannot get parent directory for {file_path_str}")
return None # Cannot determine without parent
parent_dir = current_dir.parent # Directory potentially containing the 'supplier' name
# Check if we are at the root or have no parent
if not parent_dir or parent_dir == current_dir:
# If the file is in the root scan directory or similar shallow path,
# maybe the directory it's in IS the supplier tag? Or return None?
# Returning current_dir.name might be unexpected, let's return None for safety.
print(f" Warn (get_supplier_tag): File path too shallow to determine supplier reliably: {file_path_str}")
return None
# Compare the file's directory name with the groupname
if current_dir.name.lower() == groupname_lower:
# Structure is likely .../Supplier/Groupname/file.ext
# Return the name of the directory ABOVE the groupname directory
return parent_dir.name
else:
# Structure is likely .../Supplier/file.ext
# Return the name of the directory CONTAINING the file
return current_dir.name
except Exception as e:
print(f" Error getting supplier tag for {groupname} from path {file_path_str}: {e}")
return None
def apply_asset_tags(parent_group, groupname, group_info):
"""Applies various asset tags to the parent node group."""
if not parent_group:
return
# 1. Ensure group is marked as an asset
try:
if not parent_group.asset_data:
print(f" Marking '{parent_group.name}' as asset for tagging.")
parent_group.asset_mark()
# Ensure asset_data is available after marking
if not parent_group.asset_data:
print(f" Error: Could not access asset_data for '{parent_group.name}' after marking.")
return
asset_data = parent_group.asset_data
except Exception as e_mark:
print(f" Error marking group '{parent_group.name}' as asset: {e_mark}")
return # Cannot proceed without asset_data
# 2. Apply Supplier Tag (Current Requirement)
try:
# Find lowest resolution path (reuse logic from aspect ratio)
lowest_res_path = None; found_res = False
pbr_types_dict = group_info.get("pbr_types", {})
# Check RESOLUTION_LABELS in order (assuming lowest is first)
for res_label in RESOLUTION_LABELS:
for res_data in pbr_types_dict.values(): # Check all PBR types for this res
if res_label in res_data:
lowest_res_path = res_data[res_label]
found_res = True
break # Found path for this resolution label
if found_res:
break # Found lowest available resolution path
if lowest_res_path:
supplier_tag = get_supplier_tag_from_path(lowest_res_path, groupname)
if supplier_tag:
add_tag_if_new(asset_data, supplier_tag) # Use helper to add if new
else:
print(f" Warn (apply_asset_tags): No image path found for group '{groupname}' to determine supplier tag.")
except Exception as e_supp:
print(f" Error during supplier tag processing for '{groupname}': {e_supp}")
# 3. --- Future Tagging Logic Placeholder ---
# Example: Tag based on PBR Types present
# try:
# present_pbr_types = list(group_info.get("pbr_types", {}).keys())
# for pbr_tag in present_pbr_types:
# # Maybe add prefix or modify tag name
# add_tag_if_new(asset_data, f"PBR_{pbr_tag}")
# except Exception as e_pbr:
# print(f" Error during PBR type tagging for '{groupname}': {e_pbr}")
# Example: Tag based on filename Tag (if not default like 'T-MR')
# filename_tag = group_info.get("tag") # Need to store 'Tag' in group_info during scan
# if filename_tag and filename_tag not in ["T-MR", "T-SG"]:
# add_tag_if_new(asset_data, f"Tag_{filename_tag}")
# --- End Future Tagging Logic ---
# --- Main Processing Function ---
def process_textures_to_groups(root_directory):
"""Scans textures, creates/updates node groups based on templates and manifest."""
start_time = time.time()
print(f"--- Starting Texture Processing ---")
print(f"Scanning directory: {root_directory}")
root_path = Path(root_directory)
if not root_path.is_dir():
print(f"Error: Directory not found: {root_directory}")
return False # Indicate failure
# --- Manifest Setup ---
current_blend_filepath = bpy.data.filepath
manifest_path = get_manifest_path(current_blend_filepath)
manifest_data = {}
manifest_enabled = False
if manifest_path:
manifest_data = load_manifest(manifest_path)
manifest_enabled = True
# Flag will be True if any change requires saving the manifest
manifest_needs_saving = False
# --- End Manifest Setup ---
# --- Load 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 '{PARENT_TEMPLATE_NAME}' not found.")
return False
if not template_child:
print(f"Error: Child template '{CHILD_TEMPLATE_NAME}' not found.")
return False
print(f"Found templates: '{PARENT_TEMPLATE_NAME}', '{CHILD_TEMPLATE_NAME}'")
# --- End Load Templates ---
# --- Initialize Data Structures ---
# Stores {"GroupName": {"pbr_types": {...}, "scaling": "...", "sg": False, "thumb": "..."}}
texture_data = {}
file_count, processed_files = 0, 0
groups_created, groups_updated, child_groups_created, child_groups_updated = 0, 0, 0, 0
nodes_updated, links_created = 0, 0
# Cache for image datablocks loaded in THIS RUN only
loaded_images_this_run = {}
# --- End Initialize Data Structures ---
print("Scanning files...")
# --- File Scanning ---
for dirpath, _, filenames in os.walk(root_directory):
for filename in filenames:
file_path = Path(dirpath) / filename
# Check extension
if file_path.suffix.lower() not in VALID_EXTENSIONS:
continue
file_count += 1
filename_stem = file_path.stem
parsed = parse_texture_filename(filename_stem)
if not parsed:
continue # Skip if filename doesn't match format
# Extract parts
groupname = parsed["Groupname"]
pbr_type = parsed["PBRType"]
resolution_label = parsed["Resolution"].lower()
scaling_str = parsed["Scaling"]
tag_str = parsed["Tag"].upper()
file_path_str = str(file_path)
# Validate resolution label
if resolution_label not in RESOLUTION_LABELS:
print(f"Warn: Skip '{filename}' - Invalid Res '{resolution_label}'. Expected one of {RESOLUTION_LABELS}")
continue
# Ensure base structure for group exists in texture_data
group_entry = texture_data.setdefault(groupname, {
"pbr_types": {}, "scaling": None, "sg": False, "thumb": None
})
# Store texture path under the specific PBR type and resolution
group_entry["pbr_types"].setdefault(pbr_type, {})[resolution_label] = file_path_str
# Store scaling string ONCE per groupname (first encountered wins)
if group_entry["scaling"] is None:
group_entry["scaling"] = scaling_str
elif group_entry["scaling"] != scaling_str:
# Warn only once per group if inconsistency found
if not group_entry.get("scaling_warning_printed", False):
print(f" Warn: Inconsistent 'Scaling' string found for group '{groupname}'. "
f"Using first encountered: '{group_entry['scaling']}'.")
group_entry["scaling_warning_printed"] = True
# Track SG status and thumbnail path
if tag_str == "T-SG":
group_entry["sg"] = True
# Use 1k COL-1 as the potential thumbnail source
if resolution_label == "1k" and pbr_type == "COL-1":
group_entry["thumb"] = file_path_str
processed_files += 1
# --- End File Scanning ---
print(f"\nFile Scan Complete. Found {file_count} files, parsed {processed_files} valid textures.")
total_groups_found = len(texture_data)
print(f"Total unique Groupnames found: {total_groups_found}")
if not texture_data:
print("No valid textures found. Exiting.")
return True # No work needed is considered success
print("\n--- Processing Node Groups ---")
all_groupnames = sorted(list(texture_data.keys()))
processing_stopped_early = False
# --- Main Processing Loop ---
for groupname in all_groupnames:
group_info = texture_data[groupname] # Get pre-scanned info
pbr_types_data = group_info.get("pbr_types", {})
scaling_string_for_group = group_info.get("scaling")
sg_status_for_group = group_info.get("sg", False)
thumbnail_path_for_group = group_info.get("thumb")
target_parent_name = f"PBRSET_{groupname}"
print(f"\nProcessing Group: '{target_parent_name}'")
parent_group = bpy.data.node_groups.get(target_parent_name)
is_new_parent = False
# --- Find or Create Parent Group ---
if parent_group is None:
# Check batch limit BEFORE creating
if groups_created >= MAX_NEW_GROUPS_PER_RUN:
print(f"\n--- Reached NEW parent group limit ({MAX_NEW_GROUPS_PER_RUN}). Stopping. ---")
processing_stopped_early = True
break # Exit the main groupname loop
print(f" Creating new parent group: '{target_parent_name}'")
parent_group = template_parent.copy()
if not parent_group:
print(f" Error: Failed copy parent template. Skip group '{groupname}'.")
continue # Skip to next groupname
parent_group.name = target_parent_name
groups_created += 1
is_new_parent = True
# --- Auto-Save Trigger ---
# Trigger AFTER creating the group and incrementing counter
if AUTO_SAVE_ENABLED and groups_created > 0 and groups_created % SAVE_INTERVAL == 0:
if perform_safe_autosave(manifest_path, manifest_data):
# If auto-save succeeded, manifest is up-to-date on disk
manifest_needs_saving = False
else:
# Auto-save failed, continue but warn
print("!!! WARNING: Auto-save failed. Continuing processing... !!!")
# --- End Auto-Save Trigger ---
else: # Update Existing Parent Group
print(f" Updating existing parent group: '{target_parent_name}'")
groups_updated += 1
# --- End Find or Create Parent Group ---
# --- Process Parent Group Internals ---
# This block processes both newly created and existing parent groups
try:
# --- Calculate and Store Aspect Ratio Correction (Once per group) ---
# Find the designated Value node in the parent template
aspect_node_list = find_nodes_by_label(parent_group, ASPECT_RATIO_NODE_LABEL, 'VALUE')
if aspect_node_list:
aspect_node = aspect_node_list[0] # Assume first found is correct
if scaling_string_for_group:
# Find the path to the lowest resolution image available
lowest_res_path = None; found_res = False
# Check resolution labels in configured order (e.g., "1k", "2k"...)
for res_label in RESOLUTION_LABELS:
# Check all PBR types for this resolution
for res_data in pbr_types_data.values():
if res_label in res_data:
lowest_res_path = res_data[res_label]
found_res = True
break # Found path for this resolution label
if found_res:
break # Found lowest available resolution path
if lowest_res_path:
# Load the image (use cache if possible)
img = None; img_load_error = False
if lowest_res_path in loaded_images_this_run:
img = loaded_images_this_run[lowest_res_path]
img_load_error = (img is None) # Check if cached result was failure
else:
# Attempt to load if not cached
try:
img_path_obj = Path(lowest_res_path)
if img_path_obj.is_file():
img = bpy.data.images.load(lowest_res_path, check_existing=True)
else:
img_load_error = True
print(f" Error: Aspect source image not found: {lowest_res_path}")
if img is None and not img_load_error: # Check if load function returned None
img_load_error = True
print(f" Error: Failed loading aspect source image: {lowest_res_path}")
except Exception as e_load_aspect:
print(f" Error loading aspect source image: {e_load_aspect}")
img_load_error = True
# Cache the result (image object or None)
loaded_images_this_run[lowest_res_path] = img if not img_load_error else None
if not img_load_error and img:
# Get dimensions and calculate factor
img_width, img_height = img.size[0], img.size[1]
factor = calculate_aspect_ratio_factor(img_width, img_height, scaling_string_for_group)
print(f" Calculated Aspect Ratio Factor: {factor:.4f} (from {img_width}x{img_height}, Scaling='{scaling_string_for_group}')")
# Store factor in node if value changed significantly
if abs(aspect_node.outputs[0].default_value - factor) > 0.0001:
aspect_node.outputs[0].default_value = factor
print(f" Set '{ASPECT_RATIO_NODE_LABEL}' node value to {factor:.4f}")
else:
print(f" Warn: Could not load image '{lowest_res_path}' for aspect ratio calc.")
else:
print(f" Warn: No suitable image found (e.g., 1k) to calculate aspect ratio for '{groupname}'.")
else:
print(f" Warn: No Scaling string found for group '{groupname}'. Cannot calculate aspect ratio.")
# else: # Optional Warning if node is missing from template
# print(f" Warn: Value node '{ASPECT_RATIO_NODE_LABEL}' not found in parent group '{parent_group.name}'. Cannot store aspect ratio.")
# --- End Aspect Ratio Correction ---
# Set SG Value
sg_nodes = find_nodes_by_label(parent_group, SG_VALUE_NODE_LABEL, 'VALUE')
if sg_nodes:
sg_node = sg_nodes[0]
target_val = 1.0 if sg_status_for_group else 0.0
if abs(sg_node.outputs[0].default_value - target_val) > 0.001:
sg_node.outputs[0].default_value = target_val
print(f" Set '{SG_VALUE_NODE_LABEL}' to: {target_val}")
# Set Asset Info (Thumbnail Path Prop, Initial Preview & Tagging)
# This block runs for both new and existing groups
try:
# 1. Set/Update Thumbnail Path Property & Mark Asset
if not parent_group.asset_data:
parent_group.asset_mark()
print(f" Marked '{parent_group.name}' as asset.")
# Update thumbnail property logic
if thumbnail_path_for_group:
thumb_path_obj = Path(thumbnail_path_for_group)
if thumb_path_obj.is_file():
if parent_group.get("thumbnail_filepath") != thumbnail_path_for_group:
parent_group["thumbnail_filepath"] = thumbnail_path_for_group
if not is_new_parent: print(f" Updated thumbnail path property.") # Log update only if not new
elif "thumbnail_filepath" in parent_group:
del parent_group["thumbnail_filepath"]
if not is_new_parent: print(f" Removed thumbnail path property (file not found).")
elif "thumbnail_filepath" in parent_group:
del parent_group["thumbnail_filepath"]
if not is_new_parent: print(f" Removed old thumbnail path property.")
# 2. Set Initial Preview (Only if NEW parent)
if is_new_parent and thumbnail_path_for_group and Path(thumbnail_path_for_group).is_file():
print(f" Attempting initial preview from '{Path(thumbnail_path_for_group).name}'...")
try:
with bpy.context.temp_override(id=parent_group):
bpy.ops.ed.lib_id_load_custom_preview(filepath=thumbnail_path_for_group)
print(f" Set initial custom preview.")
except Exception as e_prev:
print(f" Preview Error: {e_prev}")
# 3. Apply Asset Tags (Supplier, etc.)
apply_asset_tags(parent_group, groupname, group_info)
except Exception as e_asset_info:
print(f" Error setting asset info/tags: {e_asset_info}")
# --- End Asset Info ---
# --- Process Child Groups (PBR Types) ---
for pbr_type, resolutions_data in pbr_types_data.items():
# print(f" Processing PBR Type: {pbr_type}") # Can be verbose
# Find placeholder node in parent
holder_nodes = find_nodes_by_label(parent_group, pbr_type, 'GROUP')
if not holder_nodes:
print(f" Warn: No placeholder node labeled '{pbr_type}' in parent group '{parent_group.name}'. Skipping PBR Type.")
continue
holder_node = holder_nodes[0] # Assume first is correct
# Determine child group name (Base64 encoded)
logical_child_name = f"{groupname}_{pbr_type}"
target_child_name_b64 = encode_name_b64(logical_child_name)
# Find or Create Child Group
child_group = bpy.data.node_groups.get(target_child_name_b64)
if child_group is None:
# print(f" Creating new child group for '{pbr_type}'") # Verbose
child_group = template_child.copy()
if not child_group:
print(f" Error: Failed copy child template. Skip PBR Type.")
continue
child_group.name = target_child_name_b64
child_groups_created += 1
else:
# print(f" Updating existing child group for '{pbr_type}'") # Verbose
child_groups_updated += 1
# Assign child group to placeholder if needed
if holder_node.node_tree != child_group:
holder_node.node_tree = child_group
print(f" Assigned child group '{child_group.name}' to placeholder '{holder_node.label}'.")
# Connect placeholder output to parent output socket if needed
try:
source_socket = holder_node.outputs[0] if holder_node.outputs else None
group_output_node = next((n for n in parent_group.nodes if n.type == 'GROUP_OUTPUT'), None)
target_socket = None
if group_output_node:
target_socket = group_output_node.inputs.get(pbr_type) # Get socket by name/label
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)
links_created += 1
print(f" Connected '{holder_node.label}' output to parent output socket '{pbr_type}'.")
# else: # Optional warning if sockets aren't found
# if not source_socket: print(f" Warn: No output socket found on placeholder '{holder_node.label}'.")
# if not target_socket: print(f" Warn: No input socket '{pbr_type}' found on parent output node.")
except Exception as e_link:
print(f" Error linking sockets for '{pbr_type}': {e_link}")
# Ensure parent output socket type is Color
try:
item = parent_group.interface.items_tree.get(pbr_type)
if item and item.in_out == 'OUTPUT' and item.socket_type != 'NodeSocketColor':
item.socket_type = 'NodeSocketColor'
# print(f" Set parent output socket '{pbr_type}' type to Color.") # Optional info
except Exception as e_sock:
print(f" Error updating socket type for '{pbr_type}': {e_sock}")
# --- Process Resolutions within Child Group ---
for resolution_label, image_path_str in resolutions_data.items():
# Find image texture nodes within the CHILD group
image_nodes = find_nodes_by_label(child_group, resolution_label, 'TEX_IMAGE')
if not image_nodes:
# print(f" Warn: No node labeled '{resolution_label}' found in child group for '{pbr_type}'.") # Optional
continue
# --- >>> Manifest Check <<< ---
is_processed = False
if manifest_enabled: # Only check if manifest is enabled
# Check if this specific group/pbr/res combo is done
processed_resolutions = manifest_data.get(groupname, {}).get(pbr_type, [])
if resolution_label in processed_resolutions:
is_processed = True
# print(f" Skipping {groupname}/{pbr_type}/{resolution_label} (Manifest)") # Verbose skip log
if is_processed:
continue # Skip to the next resolution
# --- >>> End Manifest Check <<< ---
# --- Load Image & Assign (if not skipped) ---
# print(f" Processing Resolution: {resolution_label} for {pbr_type}") # Verbose
img = None
image_load_failed = False
# Check intra-run cache first
if image_path_str in loaded_images_this_run:
img = loaded_images_this_run[image_path_str]
image_load_failed = (img is None) # Respect cached failure
else:
# Not cached in this run, attempt to load
try:
image_path = Path(image_path_str)
if not image_path.is_file():
print(f" Error: Image file not found: {image_path_str}")
image_load_failed = True
else:
# Use check_existing=True to potentially reuse existing datablocks
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: # Success block is handled below
# pass
except RuntimeError as e_runtime_load:
print(f" Runtime 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
# Cache result (image object or None for failure)
loaded_images_this_run[image_path_str] = img if not image_load_failed else None
# --- Process image if loaded/cached successfully ---
if not image_load_failed and img:
try:
# Set Color Space
correct_color_space = PBR_COLOR_SPACE_MAP.get(pbr_type, DEFAULT_COLOR_SPACE)
if img.colorspace_settings.name != correct_color_space:
print(f" Setting '{Path(img.filepath).name}' color space -> {correct_color_space}")
img.colorspace_settings.name = correct_color_space
# Histogram Stats Calculation
if resolution_label == "1k" and pbr_type in ["ROUGH", "DISP"]:
target_node_label = f"{HISTOGRAM_NODE_PREFIX}{pbr_type}"
target_nodes = find_nodes_by_label(parent_group, target_node_label, 'COMBXYZ')
if target_nodes:
target_node = target_nodes[0]
try:
socket_x = target_node.inputs.get("X")
socket_y = target_node.inputs.get("Y")
socket_z = target_node.inputs.get("Z")
if socket_x and socket_y and socket_z:
print(f" Calculating histogram stats for {pbr_type} 1K...")
stats = calculate_image_stats(img)
if stats:
min_val, max_val, median_val = stats
print(f" Stats: Min={min_val:.4f}, Max={max_val:.4f}, Median={median_val:.4f}")
# Store stats in the Combine XYZ node
socket_x.default_value = min_val
socket_y.default_value = max_val
socket_z.default_value = median_val
print(f" Stored stats in '{target_node_label}'.")
else:
print(f" Warn: Failed calc stats for '{Path(img.filepath).name}'.")
# else: print(f" Warn: Node '{target_node_label}' missing X/Y/Z sockets.")
except Exception as e_combxyz_store:
print(f" Error processing stats in '{target_node_label}': {e_combxyz_store}")
# else: print(f" Warn: No stats node '{target_node_label}' found.")
# Assign Image to nodes in child group
nodes_updated_this_res = 0
for image_node in image_nodes:
if image_node.image != img:
image_node.image = img
nodes_updated_this_res += 1
nodes_updated += nodes_updated_this_res
if nodes_updated_this_res > 0:
print(f" Assigned image '{Path(img.filepath).name}' to {nodes_updated_this_res} node(s).")
# --- >>> Update Manifest <<< ---
if manifest_enabled:
# Ensure nested structure exists
manifest_data.setdefault(groupname, {}).setdefault(pbr_type, [])
# Add resolution if not already present
if resolution_label not in manifest_data[groupname][pbr_type]:
manifest_data[groupname][pbr_type].append(resolution_label)
# Keep the list sorted for consistency in the JSON file
manifest_data[groupname][pbr_type].sort()
manifest_needs_saving = True # Mark that we need to save later
# print(f" Marked {groupname}/{pbr_type}/{resolution_label} processed in manifest.") # Verbose
# --- >>> End Update Manifest <<< ---
except Exception as e_proc_img:
print(f" Error during post-load processing for image '{image_path_str}': {e_proc_img}")
# Continue to next resolution even if post-load fails
# --- End Process image ---
# --- End Resolution Loop ---
# --- End PBR Type Loop ---
except Exception as e_group:
print(f" !!! ERROR processing group '{groupname}': {e_group} !!!")
import traceback; traceback.print_exc()
continue # Continue to next groupname
# --- End Main Processing Loop ---
# --- Final Manifest Save ---
# Save if manifest is enabled AND changes were made since the last save/start.
# This happens even if the script stopped early due to MAX_NEW_GROUPS_PER_RUN.
if manifest_enabled and manifest_needs_saving:
print("\n--- Attempting Final Manifest Save (End of Run) ---")
if save_manifest(manifest_path, manifest_data):
print(" Manifest saved successfully.")
# Error message handled within save_manifest
# --- End Final Manifest Save ---
# --- Final Summary ---
end_time = time.time(); duration = end_time - start_time
print("\n--- Script Run Finished ---")
if processing_stopped_early:
print(f"--- NOTE: Reached NEW parent group processing limit ({MAX_NEW_GROUPS_PER_RUN}). ---")
print(f"--- You may need to SAVE manually, REVERT/RELOAD file, and RUN SCRIPT AGAIN. ---")
print(f"Duration: {duration:.2f} seconds this run.")
print(f"Summary: New Parents={groups_created}, Updated Parents={groups_updated}, New Children={child_groups_created}, Updated Children={child_groups_updated}.")
print(f" Images assigned={nodes_updated} times. Links created={links_created}.")
# Add other stats if needed, e.g., number of tags added
# --- End Final Summary ---
return True # Indicate successful completion (or reaching limit)
# --- How to Run ---
# 1. Ensure 'numpy' is available in Blender's Python environment.
# 2. Create Node Group "Template_PBRSET": Configure placeholders, Value nodes (SG, Aspect Ratio), Stats nodes, outputs.
# 3. Create Node Group "Template_PBRTYPE": Configure Image Texture nodes labeled by resolution.
# 4. !! SAVE YOUR BLEND FILE AT LEAST ONCE !! for manifest, auto-saving, and auto-reloading to work.
# 5. Adjust variables in the '--- USER CONFIGURATION ---' section at the top as needed.
# 6. Paste into Blender's Text Editor and run (Alt+P or Run Script button). Check Window -> Toggle System Console.
# 7. If script stops due to limit: SAVE manually, REVERT/REOPEN file, RUN SCRIPT AGAIN. Manifest prevents reprocessing.
if __name__ == "__main__":
print(f"Script execution started at: {time.strftime('%Y-%m-%d %H:%M:%S')}")
# Pre-run Checks using variables from CONFIG section
valid_run_setup = True
try:
tex_dir_path = Path(texture_root_directory)
# Basic check if path looks like a placeholder or doesn't exist
if texture_root_directory == r"C:\path\to\your\texture\library" or not tex_dir_path.is_dir() :
print(f"\nERROR: 'texture_root_directory' is invalid or a placeholder.")
print(f" Current value: '{texture_root_directory}'")
valid_run_setup = False
except Exception as e_path:
print(f"\nERROR checking texture_root_directory: {e_path}")
valid_run_setup = False
# Check templates
if not bpy.data.node_groups.get(PARENT_TEMPLATE_NAME):
print(f"\nERROR: Parent template node group '{PARENT_TEMPLATE_NAME}' not found.")
valid_run_setup = False
if not bpy.data.node_groups.get(CHILD_TEMPLATE_NAME):
print(f"\nERROR: Child template node group '{CHILD_TEMPLATE_NAME}' not found.")
valid_run_setup = False
# Check numpy (needed for stats)
try:
import numpy
except ImportError:
print("\nCRITICAL ERROR: Python library 'numpy' not found (required for image stats).")
print(" Please install numpy into Blender's Python environment.")
valid_run_setup = False
# Execute main function if setup checks pass
script_completed_successfully = False
if valid_run_setup:
# Check if file is saved before running features that depend on it
if not bpy.data.filepath:
print("\nWARNING: Blend file not saved. Manifest, Auto-Save, and Auto-Reload features disabled.")
script_completed_successfully = process_textures_to_groups(texture_root_directory)
else:
print("\nScript aborted due to configuration errors.")
# --- Final Save & Reload ---
# Use config variables directly as they are in module scope
if script_completed_successfully and AUTO_RELOAD_ON_FINISH:
if bpy.data.filepath: # Only if file is saved
print("\n--- Auto-saving and reloading blend file ---")
try:
bpy.ops.wm.save_mainfile()
print(" Blend file saved.")
print(" Reloading...")
# Ensure script execution stops cleanly before reload starts
bpy.ops.wm.open_mainfile(filepath=bpy.data.filepath)
# Script execution effectively stops here upon reload
except Exception as e:
print(f"!!! ERROR during final save/reload: {e} !!!")
else:
print("\nSkipping final save & reload because the blend file is not saved.")
# --- End Final Save & Reload ---
# This print might not be reached if reload occurs
print(f"Script execution finished processing at: {time.strftime('%Y-%m-%d %H:%M:%S')}")