Files
workinf_Blender_Wasm/tools/web/native-workflow-blender.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

257 lines
14 KiB
Python

"""Runs inside a source-backed Blender process; produces actual files, never mock renders."""
import ast
import contextlib
import hashlib
import io
import json
import os
from pathlib import Path
import sys
import traceback
import zipfile
import bpy
sys.path.insert(0,str(Path(__file__).resolve().parent))
from native_resources import install_resources, save_portable
ROOT = Path(sys.argv[sys.argv.index('--') + 1]).resolve()
REQUEST = json.loads((ROOT / 'request.json').read_text())
MAX_OUTPUT = 256 * 1024 * 1024
class BoundedOutput(io.TextIOBase):
def __init__(self):
self.parts = []
self.bytes = 0
def write(self, text):
size = len(text.encode('utf-8'))
if self.bytes + size > 1024 * 1024:
raise RuntimeError('PYTHON_OUTPUT_BUDGET_EXCEEDED')
self.parts.append(text)
self.bytes += size
return len(text)
def flush(self):
pass
def publish(path, value):
temp = path.with_suffix('.tmp')
temp.write_text(json.dumps(value, ensure_ascii=False))
temp.replace(path)
def apply_context(context):
scene = bpy.data.scenes.get(context.get('sceneName', '')) if context.get('sceneName') else bpy.context.scene
if scene is None:
raise RuntimeError('NATIVE_SCENE_NOT_FOUND')
window = bpy.context.window or next(iter(bpy.context.window_manager.windows), None)
if window:
window.scene = scene
layer = window.view_layer
else:
layer = scene.view_layers[0]
selected = context.get('selectedObjects', [])
active_name = context.get('activeObject')
if active_name and active_name not in selected:
selected = selected + [active_name]
if selected:
for obj in layer.objects:
obj.select_set(obj.name in selected, view_layer=layer)
for name in selected:
if layer.objects.get(name) is None:
raise RuntimeError('NATIVE_SELECTED_OBJECT_NOT_FOUND: ' + name)
if active_name:
active = layer.objects.get(active_name)
if active is None:
raise RuntimeError('NATIVE_ACTIVE_OBJECT_NOT_FOUND')
layer.objects.active = active
override = {'scene': scene, 'view_layer': layer}
if REQUEST['kind'] in {'DESKTOP_OPERATOR', 'EXPORT'}:
if window is None or bpy.app.background:
raise RuntimeError('NATIVE_WINDOW_REQUIRED')
area_type = context.get('areaType', 'VIEW_3D')
area = next((area for area in window.screen.areas if area.type == area_type), None)
if area is None:
area = max(window.screen.areas, key=lambda a: a.width * a.height)
area.type = area_type
if area_type == 'NODE_EDITOR':
area.ui_type = context.get('nodeTreeType','GeometryNodeTree')
tree = bpy.data.node_groups.get(context.get('nodeTreeName',''))
if tree is not None:
area.spaces.active.pin = True
area.spaces.active.node_tree = tree
region = next((r for r in area.regions if r.type == 'WINDOW'), None)
if region is None:
raise RuntimeError('NATIVE_REGION_REQUIRED')
override.update(window=window, area=area, region=region)
return override
def execute_python(spec, output):
source = spec['source']
name = spec['textName']
text = bpy.data.texts.get(name) or bpy.data.texts.new(name)
text.clear(); text.write(source)
namespace = {'__name__': '__main__', '__file__': name, 'bpy': bpy}
tree = ast.parse(source, filename=name, mode='exec')
with contextlib.redirect_stdout(output), contextlib.redirect_stderr(output):
# Console expressions print their native Python value; scripts execute as written.
if spec.get('console') and tree.body and isinstance(tree.body[-1], ast.Expr):
last = tree.body.pop()
exec(compile(tree, name, 'exec'), namespace, namespace)
value = eval(compile(ast.Expression(last.value), name, 'eval'), namespace, namespace)
if value is not None:
print(repr(value))
else:
exec(compile(tree, name, 'exec'), namespace, namespace)
def main():
result = {key: REQUEST[key] for key in ('schemaVersion','jobId','requestSha256','sourceBlendSha256','sourceRevision','kind')}
result.update(status='FAILED', blenderVersion=bpy.app.version_string, artifacts=[])
output = BoundedOutput()
total_bytes = 0
def artifact(file, mime, frame=None):
nonlocal total_bytes
size = file.stat().st_size
total_bytes += size
if size < 1 or total_bytes > MAX_OUTPUT:
raise RuntimeError('NATIVE_ARTIFACT_BUDGET_EXCEEDED')
item = {'id': file.name, 'name': file.name, 'mime': mime, 'bytes': size,
'sha256': hashlib.sha256(file.read_bytes()).hexdigest()}
if frame is not None:
item['frame'] = frame
result['artifacts'].append(item)
try:
from native_preferences import load_preferences
load_preferences(ROOT)
bpy.ops.wm.open_mainfile(filepath=str(ROOT/'source.blend'), load_ui=False, use_scripts=False)
result['resources']=install_resources(ROOT)
if result['resources']['missing']:raise RuntimeError('NATIVE_RESOURCES_MISSING: '+', '.join(result['resources']['missing'][:32]))
override = apply_context(REQUEST['context'])
with bpy.context.temp_override(**override):
selection = REQUEST['context'].get('meshSelection')
if selection is not None:
obj=bpy.context.view_layer.objects.active
if obj is None or obj.type != 'MESH': raise RuntimeError('NATIVE_MESH_CONTEXT_REQUIRED')
if obj.mode != 'OBJECT': bpy.ops.object.mode_set(mode='OBJECT')
mesh=obj.data
groups={'VERT':mesh.vertices,'EDGE':mesh.edges,'FACE':mesh.polygons}
target=groups[selection['domain']]
indices=set(selection['indices'])
if any(index<0 or index>=len(target) for index in indices): raise RuntimeError('NATIVE_SELECTION_RANGE')
for group in groups.values():
for item in group: item.select=False
for index in indices: target[index].select=True
bpy.context.tool_settings.mesh_select_mode=tuple(selection['domain']==domain for domain in ('VERT','EDGE','FACE'))
mode = REQUEST['context'].get('mode', 'OBJECT')
active_object=bpy.context.view_layer.objects.active
if active_object and active_object.mode != mode:
if not bpy.ops.object.mode_set.poll():
raise RuntimeError('NATIVE_MODE_CONTEXT_UNAVAILABLE')
bpy.ops.object.mode_set(mode=mode)
if REQUEST['kind'] == 'RENDER':
spec = REQUEST['render']; scene = override['scene']
missing=[]
for image in bpy.data.images:
if image.source=='FILE' and image.filepath and not image.packed_file and not Path(bpy.path.abspath(image.filepath,library=image.library)).is_file():
missing.append(image.filepath)
for sound in bpy.data.sounds:
if sound.filepath and not sound.packed_file and not Path(bpy.path.abspath(sound.filepath,library=sound.library)).is_file():
missing.append(sound.filepath)
if missing: raise RuntimeError('NATIVE_RENDER_MISSING_RESOURCES: '+', '.join(missing[:16]))
engine = spec.get('engine','SCENE')
if engine != 'SCENE': scene.render.engine = engine
if scene.render.engine == 'CYCLES': scene.cycles.device = 'CPU'
if 'resolutionPercentage' in spec: scene.render.resolution_percentage = spec['resolutionPercentage']
pixels = scene.render.resolution_x * scene.render.resolution_y * (scene.render.resolution_percentage/100)**2
if pixels > 64 * 1024 * 1024: raise RuntimeError('NATIVE_RENDER_PIXEL_BUDGET')
scene.render.image_settings.media_type = 'VIDEO' if spec['format']=='FFMPEG' else 'IMAGE'
scene.render.image_settings.file_format = spec['format']
scene.render.use_file_extension = True
# Route compositor File Output nodes into this job's writable directory.
for tree in bpy.data.node_groups:
for node in tree.nodes:
if node.bl_idname == 'CompositorNodeOutputFile': node.base_path = str(ROOT/'compositor')
frames = list(range(spec['frameStart'],spec['frameEnd']+1,spec['step']))
rendered = []
if spec['format']=='FFMPEG':
scene.frame_start=spec['frameStart']; scene.frame_end=spec['frameEnd']; scene.frame_step=spec['step']
scene.render.ffmpeg.format='MPEG4';scene.render.ffmpeg.codec='H264';scene.render.ffmpeg.audio_codec='AAC'
filename=ROOT/'animation.mp4';scene.render.filepath=str(filename)
def render_progress(current_scene,*_):
completed=min(len(frames),max(0,(current_scene.frame_current-spec['frameStart'])//spec['step']+1))
publish(ROOT/'progress.json',{'completed':completed,'total':len(frames)})
bpy.app.handlers.render_write.append(render_progress)
try:
state=bpy.ops.render.render('EXEC_DEFAULT',animation=True,scene=scene.name)
finally:
bpy.app.handlers.render_write.remove(render_progress)
if 'FINISHED' not in state: raise RuntimeError('NATIVE_ANIMATION_NOT_FINISHED')
movies=list(ROOT.glob('animation*.mp4'))
if len(movies)!=1: raise RuntimeError('NATIVE_ANIMATION_OUTPUT_MISSING')
artifact(movies[0],'video/mp4')
for index, frame in enumerate(frames if spec['format']!='FFMPEG' else []):
scene.frame_set(frame)
suffix = 'png' if spec['format']=='PNG' else 'exr'
filename = ROOT/f'frame-{frame:07d}.{suffix}'
scene.render.filepath = str(filename)
state = bpy.ops.render.render('EXEC_DEFAULT', write_still=True, scene=scene.name)
if 'FINISHED' not in state or not filename.is_file():
raise RuntimeError('NATIVE_RENDER_DID_NOT_WRITE_IMAGE')
artifact(filename,'image/png' if suffix=='png' else 'image/x-exr',frame)
rendered.append(filename)
publish(ROOT/'progress.json',{'completed':index+1,'total':len(frames)})
if len(rendered)>1:
archive=ROOT/'animation-frames.zip'
with zipfile.ZipFile(archive,'w',compression=zipfile.ZIP_STORED) as bundle:
for file in rendered: bundle.write(file,file.name)
artifact(archive,'application/zip')
elif REQUEST['kind'] == 'PYTHON':
execute_python(REQUEST['python'],output)
elif REQUEST['kind'] == 'EXPORT':
from native_export import export_scene
export_scene(ROOT, REQUEST['export'], artifact, output)
else:
from server_operator_properties import validated_operator_properties
spec=REQUEST['operator'];family,name=spec['id'].split('.',1)
target=getattr(getattr(bpy.ops,family),name)
if not target.poll(): raise RuntimeError('NATIVE_OPERATOR_POLL_FAILED')
values=validated_operator_properties(target,spec.get('properties',{}))
state=target('EXEC_DEFAULT',**values)
if 'FINISHED' not in state:
raise RuntimeError('NATIVE_OPERATOR_NOT_FINISHED: '+','.join(sorted(state)))
output.write('Operator: '+','.join(sorted(state))+'\n')
if REQUEST['kind'] in {'PYTHON', 'DESKTOP_OPERATOR'}:
result['context']={'scene':bpy.context.scene.name,
'activeObject':bpy.context.view_layer.objects.active.name if bpy.context.view_layer.objects.active else None,
'selectedObjects':[obj.name for obj in bpy.context.view_layer.objects if obj.select_get()],
'mode':getattr(bpy.context,'mode','OBJECT'),'area':bpy.context.area.type if bpy.context.area else None}
active_object=bpy.context.view_layer.objects.active
if active_object and active_object.mode != 'OBJECT': bpy.ops.object.mode_set(mode='OBJECT')
save_portable(ROOT,ROOT/'result.blend')
artifact(ROOT/'result.blend','application/x-blender')
if REQUEST['kind'] != 'EXPORT' and (ROOT/'resources').exists():
archive=ROOT/'project-resources.zip'
with zipfile.ZipFile(archive,'w',zipfile.ZIP_STORED) as bundle:
for file in sorted((ROOT/'resources').rglob('*')):
if file.is_symlink():raise RuntimeError('NATIVE_RESOURCE_SYMLINK')
if file.is_file():bundle.write(file,str(file.relative_to(ROOT/'resources')))
if any((ROOT/'resources').iterdir()):artifact(archive,'application/zip')
result.update(status='SUCCEEDED',output=''.join(output.parts))
except BaseException as error:
result.update(status='FAILED',error=str(error),output=''.join(output.parts)+'\n'+traceback.format_exc(limit=6),artifacts=[])
publish(ROOT/'result.json',result)
if not bpy.app.background:
bpy.ops.wm.quit_blender()
return None
if bpy.app.background:
main()
else:
bpy.app.timers.register(main,first_interval=0.5)