Ben Traje
← Back to cinema4d

Auditing Redshift Textures in Cinema 4D with Python

30 Aug 26 (2mo ago)

Finding every texture used in a Cinema 4D project can be challenging when using Redshift. Standard C4D asset tools often fail to see inside Redshift's Node Materials, leaving textures hidden within deeply nested shader graphs.

This becomes especially problematic when trying to clean up project directories, locate missing assets, or consolidate files for rendering.

The Python Script Solution

To resolve this, you can use a custom Python script designed to deeply scan Redshift Node Materials. The script below traverses the node graphs, locates the specific Redshift Texture Sampler nodes, and recursively digs through port inputs to extract the active file paths.

Key Features

  • Deep Scanning: Recursively searches through nested node ports to locate texture strings.
  • Directory Auditing: Compares the textures used in the active scene against a specified local directory to identify orphaned (unused) files and missing assets.
  • UDIM Resolution: Automatically detects abstract <UDIM> tags in the scene and matches them to the physical files (e.g., 1001, 1002) stored on your drive.
  • Condensed Output: Groups discovered UDIM sequences in the console output to keep the diagnostic report clean and readable, supporting both dot (.1001.) and underscore (_1001.) naming conventions.

The Code

To use this tool, open the Script Manager (Extensions > Script Manager), create a new script, paste the code below, and execute it.

Important: Update the TARGET_DIRECTORY variable at the top of the script to match your project's texture folder path before running.

import c4d
import maxon
import os
import re

# --- CONFIGURATION ---
TARGET_DIRECTORY = r"D:\sciepro_girl\c4d\tex"
VALID_EXTS = ['.jpg', '.jpeg', '.png', '.tif', '.tiff', '.exr', '.bmp', '.psd', '<udim>']

def get_files_in_directory(directory):
    if not os.path.exists(directory):
        print(f"[ERROR] Directory not found: {directory}")
        return []
    
    files_on_disk = []
    try:
        for f in os.listdir(directory):
            if os.path.isfile(os.path.join(directory, f)):
                files_on_disk.append(f)
    except Exception as e:
        print(f"Error reading directory: {e}")

    return files_on_disk

def find_texture_paths_in_node(port_root, collected_paths):
    children = port_root.GetChildren()
    
    for port in children:
        is_path_id = (port.GetId().ToString() == "path")
        
        try:
            data = port.GetDefaultValue()
        except:
            data = None
            
        path_str = ""
        if isinstance(data, maxon.Url):
            path_str = data.GetUrl()
        elif isinstance(data, str):
            path_str = data
            
        if path_str and len(path_str) > 1:
            is_valid_file = any(ext in path_str.lower() for ext in VALID_EXTS)
            
            if is_path_id or is_valid_file:
                clean_path = path_str.replace("file:///", "").replace("relative://", "")
                clean_path = clean_path.lstrip('/') 
                if clean_path not in collected_paths:
                    collected_paths.append(clean_path)
        
        find_texture_paths_in_node(port, collected_paths)

def get_scene_textures():
    doc = c4d.documents.GetActiveDocument()
    if not doc: return []

    materials = doc.GetMaterials()
    print(f"--- Scanning {len(materials)} Scene Materials ---")

    redshift_nodespace_id = maxon.Id("com.redshift3d.redshift4c4d.class.nodespace")
    rs_texture_asset_id = maxon.Id("com.redshift3d.redshift4c4d.nodes.core.texturesampler")

    scene_textures = []

    for mat in materials:
        node_material = mat.GetNodeMaterialReference()
        try:
            graph = node_material.GetGraph(redshift_nodespace_id)
        except:
            continue   
        if graph.IsNullValue(): continue

        tex_nodes = []
        maxon.GraphModelHelper.FindNodesByAssetId(graph, rs_texture_asset_id, True, tex_nodes)

        for node in tex_nodes:
            find_texture_paths_in_node(node.GetInputs(), scene_textures)
            
    return scene_textures

def condense_unused_list(file_list):
    """
    Groups UDIM sequences like:
    - name.1001.jpg
    - name_1001.jpg
    """
    pattern = re.compile(r"(.+?)([._])(\d{3,4})\.(.+)$")
    
    singles = []
    udim_groups = {} 

    for f in file_list:
        match = pattern.match(f)
        if match:
            name_part = match.group(1)
            sep_part = match.group(2) 
            number_part = match.group(3)
            ext_part = match.group(4)
            
            key = (name_part, sep_part, ext_part)
            
            if key not in udim_groups:
                udim_groups[key] = []
            udim_groups[key].append(number_part)
        else:
            singles.append(f)
            
    final_list = list(singles)
    
    for (name, sep, ext), numbers in udim_groups.items():
        numbers.sort()
        lowest = numbers[0]
        rep_name = f"{name}{sep}{lowest}.{ext}"
        final_list.append(rep_name)
            
    return final_list

def main():
    scene_texture_list = get_scene_textures()
    disk_file_list = get_files_in_directory(TARGET_DIRECTORY)

    # Normalize
    scene_filenames = set()
    for path in scene_texture_list:
        scene_filenames.add(os.path.basename(path))

    disk_filenames = set(disk_file_list)

    unused_files = disk_filenames - scene_filenames
    missing_files = scene_filenames - disk_filenames
    
    resolved_udims = {} 

    # --- UDIM RESOLVER ---
    for missing_name in list(missing_files):
        tag = ""
        if "<UDIM>" in missing_name: tag = "<UDIM>"
        elif "<udim>" in missing_name: tag = "<udim>"
        
        if tag:
            parts = missing_name.split(tag)
            if len(parts) == 2:
                prefix = parts[0]
                suffix = parts[1]
                matches = []
                for candidate in list(unused_files):
                    if candidate.lower().startswith(prefix.lower()) and candidate.lower().endswith(suffix.lower()):
                        expected_len = len(candidate) - len(prefix) - len(suffix)
                        if expected_len >= 3:
                             matches.append(candidate)

                if matches:
                    resolved_udims[missing_name] = len(matches)
                    missing_files.remove(missing_name)
                    for m in matches:
                        if m in unused_files:
                            unused_files.remove(m)

    # --- OUTPUT ---
    print("\n" + "="*50)
    print(f"DIAGNOSTIC REPORT")
    print("="*50)
    print(f"Total Textures Paths in Scene:  {len(scene_filenames)}")
    print(f"Total Files in Folder:          {len(disk_filenames)}")
    print("-" * 50)

    # --- CONDENSE THE UNUSED LIST ---
    if unused_files:
        condensed_list = condense_unused_list(list(unused_files))
        
        print(f"\n[UNUSED / TO DELETE] Found {len(unused_files)} actual files (Condensed view):")
        print("-" * 50)
        for f in sorted(condensed_list):
            print(f" [x] {f}")
    else:
        print("\n[CLEAN] No unused files found.")

    if resolved_udims:
        print("-" * 50)
        print(f"\n[UDIMs DETECTED & VERIFIED]")
        for name, count in resolved_udims.items():
            print(f" [OK] {name}  >>> Found {count} tiles on disk")

    if missing_files:
        print("-" * 50)
        print(f"\n[MISSING / EXTERNAL] Used in scene but NOT in folder:")
        for f in sorted(list(missing_files)):
            print(f" [?] {f}")
            
    print("="*50)
    c4d.CallCommand(12305) 

if __name__ == '__main__':
    main()