"""Bind uploaded project-relative resources to real Blender data-block paths.""" import hashlib import json from pathlib import Path import bpy def install_resources(root,reload_data=True): manifest=root/'resources.json' if not manifest.exists():return {'mapped':[], 'missing':[]} files=json.loads(manifest.read_text())['files'] lookup={};prefixes={} for file in files: dest=(root/'resources'/file['path']).resolve() if not dest.is_relative_to((root/'resources').resolve()) or not dest.is_file() or (reload_data and hashlib.sha256(dest.read_bytes()).hexdigest()!=file['sha256']): raise RuntimeError('RESOURCE_FILE_BINDING_FAILED: '+file['path']) for key in (file['path'],'//'+file['path'],file.get('sourcePath')): if key:lookup[key]=str(dest) alias=file.get('sourcePath') if alias and '/' in alias: parent=alias.rsplit('/',1)[0]+'/' prefixes.setdefault(parent,set()).add(str(dest.parent)+'/') # Map prefixes too: sequences, UDIM tiles and directory-based simulation caches. def resolve(value): if value in lookup:return lookup[value] for prefix,targets in sorted(prefixes.items(),key=lambda entry:-len(entry[0])): if len(targets)==1 and (value.startswith(prefix) or value.rstrip('/')==prefix.rstrip('/')): tail=value[len(prefix):] if value.startswith(prefix) else '' candidate=(Path(next(iter(targets)))/tail).resolve() if candidate.is_relative_to((root/'resources').resolve()) and (candidate.exists() or '' in candidate.name):return str(candidate) relative=value[2:] if value.startswith('//') else value candidate=(root/'resources'/relative).resolve() if not candidate.is_relative_to((root/'resources').resolve()):return None if candidate.exists():return str(candidate) if '' in candidate.name and next(candidate.parent.glob(candidate.name.replace('','[0-9][0-9][0-9][0-9]')),None):return str(candidate) return None mapped=[];missing=[] def assign(owner,key): value=getattr(owner,key,'') if not isinstance(value,str) or not value:return library=getattr(owner,'library',None) if library: resolved=bpy.path.abspath(value,library=library) if not Path(resolved).exists() and not getattr(owner,'packed_file',None):missing.append(resolved) return replacement=resolve(value) if replacement: setattr(owner,key,replacement);mapped.append({'source':value,'target':replacement}) elif value.startswith('//') and not getattr(owner,'packed_file',None):missing.append(value) for library in bpy.data.libraries:assign(library,'filepath') # Linked IDs must be reloaded before references can be consumed by native operators. for library in list(bpy.data.libraries): if reload_data and library.filepath.startswith(str(root/'resources')): library.reload() for collection in ('images','sounds','movieclips','fonts','volumes','cache_files'): for item in getattr(bpy.data,collection,[]):assign(item,'filepath') for image in bpy.data.images: if reload_data and image.filepath.startswith(str(root/'resources')) and not image.packed_file:image.reload() def strips(items): for strip in items: assign(strip,'filepath');assign(strip,'directory') if strip.type=='META':strips(getattr(strip,'strips',getattr(strip,'sequences',[]))) for scene in bpy.data.scenes: if scene.sequence_editor:strips(getattr(scene.sequence_editor,'strips',getattr(scene.sequence_editor,'sequences',[]))) for obj in bpy.data.objects: for modifier in obj.modifiers: for key in ('filepath','cache_directory','bake_directory'): assign(modifier,key) if modifier.type=='FLUID' and modifier.domain_settings:assign(modifier.domain_settings,'cache_directory') cache=getattr(modifier,'point_cache',None) if cache:assign(cache,'filepath') for system in obj.particle_systems: if system.point_cache:assign(system.point_cache,'filepath') for value in bpy.utils.blend_paths(absolute=False,packed=False): if value and not value.startswith('<'): actual=bpy.path.abspath(value) if '' not in actual and not Path(actual).exists():missing.append(value) return {'mapped':mapped,'missing':sorted(set(missing))} def portable_resource_paths(root,destination=None,changes=None): """Replace job-directory prefixes before publishing a blend that outlives the job.""" prefix=str(root/'resources') def fix(owner,key): value=getattr(owner,key,'') absolute=bpy.path.abspath(value,library=getattr(owner,'library',None)) if isinstance(value,str) and value else '' if absolute.startswith(prefix+'/'): if destination is not None and getattr(owner,'library',None):return relative=absolute[len(prefix)+1:] if changes is not None:changes.append((owner,key,value)) setattr(owner,key,str(destination/relative) if destination is not None else '//'+relative) for collection in ('libraries','images','sounds','movieclips','fonts','volumes','cache_files'): for item in getattr(bpy.data,collection,[]):fix(item,'filepath') def strips(items): for strip in items: fix(strip,'filepath');fix(strip,'directory') if strip.type=='META':strips(getattr(strip,'strips',getattr(strip,'sequences',[]))) for scene in bpy.data.scenes: if scene.sequence_editor:strips(getattr(scene.sequence_editor,'strips',getattr(scene.sequence_editor,'sequences',[]))) for obj in bpy.data.objects: for modifier in obj.modifiers: for key in ('filepath','cache_directory','bake_directory'): fix(modifier,key) if modifier.type=='FLUID' and modifier.domain_settings:fix(modifier.domain_settings,'cache_directory') if getattr(modifier,'point_cache',None):fix(modifier.point_cache,'filepath') for system in obj.particle_systems: if system.point_cache:fix(system.point_cache,'filepath') def save_portable(root,filename): changes=[] try: portable_resource_paths(root,changes=changes) state=bpy.ops.wm.save_as_mainfile(filepath=str(filename),copy=True,relative_remap=False) if 'FINISHED' not in state:raise RuntimeError('NATIVE_BLEND_SAVE_FAILED') finally: for owner,key,value in reversed(changes):setattr(owner,key,value)