Accumulated uncommitted work: GHOST Web backend, WebGL/GPU web layer, web workflow engine and protocol, tooling, docs and task records. Large generated evidence, user-testing archives, build snapshots and imported test assets remain untracked. Co-Authored-By: Claude Code <noreply@anthropic.com>
118 lines
6.5 KiB
Python
118 lines
6.5 KiB
Python
"""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 '<UDIM>' 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 '<UDIM>' in candidate.name and next(candidate.parent.glob(candidate.name.replace('<UDIM>','[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 '<UDIM>' 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)
|