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>
This commit is contained in:
117
tools/web/native_resources.py
Normal file
117
tools/web/native_resources.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user