Files
workinf_Blender_Wasm/tools/web/native_resources.py
mes123456 f3e21c6b98
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled
chore: checkpoint web Blender migration source, tools and docs
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>
2026-11-10 11:18:01 +08:00

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)