"""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)