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()