Ben Traje
← Back to cinema4d

Identify Missing and Unused Texture Assets Redshift Cinema4D

06 Feb 26 (14d ago)

Normally, when you just want to keep the textures being used in the scene. You can just ran the command File > Save Project With Assets. And it works. You keep what you need. But in this case, I want to know what textures I didn't used. Context: I'm working from textures to objects on this project rather than the usual other way around.

Also I needed to know if there are missing textures being used by. You can just see this actually when you hit render on Redshift. It complains. But in this case, I also wanted a list of it.

This script is "Read-Only." It will not actually delete any files from your hard drive; it only generates a report so you can manually clean your project.

You need to fill in the target directory path. Normally, it is just the tex folder right beside the C4D file but in this case, I just hard coded it.

import c4d
import maxon
import os
import re

# --- CONFIGURATION ---
TARGET_DIRECTORY = r"D:\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
    """
    # Regex Breakdown:
    # (.+?)   -> Group 1: Name Prefix (Non-greedy, grabs everything up to the separator)
    # ([._])  -> Group 2: Separator (Matches either '.' or '_')
    # (\d{3,4}) -> Group 3: The UDIM Number (3 or 4 digits)
    # \.      -> Literal dot before extension
    # (.+)$   -> Group 4: Extension
    pattern = re.compile(r"(.+?)([._])(\d{3,4})\.(.+)$")
    
    singles = []
    # Key = (Name, Separator, Extension) -> Value = List of numbers
    udim_groups = {} 

    for f in file_list:
        match = pattern.match(f)
        if match:
            name_part = match.group(1)
            sep_part = match.group(2) # Stores if it was '.' or '_'
            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]
        
        # Always output the representative name using the ORIGINAL separator
        # e.g., "color" + "_" + "1001" + "." + "jpg"
        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)
    

if __name__ == '__main__':
    main()