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>
257 lines
14 KiB
Python
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)
|