Skip to content

Instantly share code, notes, and snippets.

@bandinopla
Created February 2, 2025 00:14
Show Gist options
  • Save bandinopla/86995dc9112e6c657e89c611b1c7a417 to your computer and use it in GitHub Desktop.
Save bandinopla/86995dc9112e6c657e89c611b1c7a417 to your computer and use it in GitHub Desktop.
Blender Addon to pull images from directories outside of the .blend file's directory.
#
# will create a menu item in the File menu, when clicked it will track all image files pointing to files outside of the .blend file directory and pull them in in a folder.
#
bl_info = {
"name": "Pull External Images",
"blender": (2, 93, 0), # Adjust to the version you're using
"category": "File",
"author": "Bandinopla + ChatGPT",
"description": "Copy external images used by the blend file to a new directory and update material image paths.",
"version": (1, 0, 0),
"location": "File > Pull External Images",
}
import bpy
import shutil
import os
from pathlib import Path
# If the file is relative to us...
def is_near_us( us_path, target_path):
# Convert to Path objects and resolve to get absolute paths
base_path = Path(us_path).resolve()
target_path = Path(target_path).resolve()
# Check if the resolved target path is within the resolved base path
return base_path in target_path.parents
# For files pointing outside of the blendfile, the goal here is to put them in a folder called the same as the blend file + --textures
# If the file is close to us just leave it there. Since the goal is to move the images near us anyway...
def get_target_directory( blend_file_path, report, desiredFolderName=None ):
#blend_file_path = bpy.data.filepath
if not blend_file_path:
raise RuntimeError("No .blend file is open!")
# Current path of the blend file
blend_directory = os.path.dirname(blend_file_path)
blend_filename = os.path.splitext(os.path.basename(blend_file_path))[0]
goal_folder_name = desiredFolderName or f"{blend_filename}--textures"
target_dir = os.path.join(blend_directory, goal_folder_name)
# Ensure the target directory exists
if not os.path.exists(target_dir):
os.makedirs(target_dir)
report(f"Created folder {target_dir}")
return (blend_directory, target_dir, goal_folder_name)
# This function updates the material's image texture paths
def update_material_image_paths( ok_dir, target_dir, report):
report("Updating material nodes...")
for material in bpy.data.materials:
# Ensure the material has nodes enabled
if material.use_nodes:
for node in material.node_tree.nodes:
# Check if the node is an image texture
if node.type == 'TEX_IMAGE':
image = node.image
if image:
# Skip if the image is embedded (internal to .blend)
if image.source == 'FILE':
continue # Skip embedded images
image_path = bpy.path.abspath( image.filepath )
if not is_near_us( ok_dir, image_path ): # Image is outside the blend file directory
# Get the image file name and make a new path in the target directory
filename = os.path.basename(image_path)
new_image_path = os.path.join(target_dir, filename)
new_relative_file = os.path.join("//", os.path.basename(target_dir), filename)
node.image.filepath = new_relative_file
report(f"Update material '{material.name}' to use: {new_relative_file}")
def copy_outside_images(ok_dir, target_dir, report):
report("Scanning for images used in the blend file...")
# Iterate through all images used in the .blend file and copy them
for image in bpy.data.images:
if image.is_dirty: # Skip unsaved images
continue
image_path = bpy.path.abspath(image.filepath)
if not is_near_us( ok_dir, image_path ): # Image is outside the blend file directory
if os.path.exists(image_path):
filename = os.path.basename(image_path)
new_image_path = os.path.join(target_dir, filename)
if not os.path.exists(new_image_path):
# Copy the image to the new directory
report(f"COPY {image_path} ---TO---> {new_image_path}")
shutil.copy(image_path, new_image_path)
# Operator to pull and copy external images and update materials
class OBJECT_OT_pull_external_images(bpy.types.Operator):
bl_idname = "file.pull_external_images"
bl_label = "Pull External Images"
bl_options = {'REGISTER', 'UNDO'}
goalFolderName: bpy.props.StringProperty(name="Folder name (to put external images)")
def invoke(self, context, event):
report = lambda msg, level='INFO': self.report({level}, msg)
dirs = get_target_directory( bpy.data.filepath, report )
self.goalFolderName = dirs[2]
# Display the dialog for input
return context.window_manager.invoke_props_dialog(self)
def execute(self, context):
self.report({'INFO'}, f"*****************")
# Get the target directory based on the Blender file name
report = lambda msg, level='INFO': self.report({level}, msg)
try:
dirs = get_target_directory( bpy.data.filepath, report, self.goalFolderName )
report(f"Pulling images outside of '{dirs[0]}' to be inside of our goal: '{dirs[1]}'")
copy_outside_images( dirs[0], dirs[1], report)
update_material_image_paths( dirs[0], dirs[1], report )
report("̣\nAll done! All images are now relative to the .blend file\n")
except Exception as e:
self.report({'ERROR'}, f"Oops! {str(e)}")
return {'FINISHED'}
# Menu to add the option in the File menu
def menu_func(self, context):
layout = self.layout
# Adding a separator to place the operator visually below other menu items
layout.separator() # This adds a visual break before the operator appears
# Adding the operator in the File menu after the separator
layout.operator(OBJECT_OT_pull_external_images.bl_idname)
# Register the addon
def register():
bpy.utils.register_class(OBJECT_OT_pull_external_images)
bpy.types.TOPBAR_MT_file.append(menu_func)
# Unregister the addon
def unregister():
bpy.utils.unregister_class(OBJECT_OT_pull_external_images)
bpy.types.TOPBAR_MT_file.remove(menu_func)
if __name__ == "__main__":
register()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment