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>
237 lines
14 KiB
Python
237 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""Bounded SERVER_NATIVE Blender operator runner.
|
|
|
|
The runner owns a temporary workspace, invokes Blender in background mode, and
|
|
binds the result to the request/source/property hashes. UI, window and
|
|
file-browser contexts are intentionally unavailable.
|
|
"""
|
|
import hashlib
|
|
import json
|
|
import math
|
|
import os
|
|
import re
|
|
import resource
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
SCHEMA = 1
|
|
ROUTE = "SERVER_NATIVE"
|
|
SHA256 = re.compile(r"^[a-f0-9]{64}$")
|
|
JOB_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$")
|
|
KEY = re.compile(r"^[A-Za-z][A-Za-z0-9_.-]{0,127}$")
|
|
BLOCKED_OPERATOR_PREFIXES = ("wm.", "window.", "screen.", "file.", "outliner.", "view3d.")
|
|
|
|
|
|
def canonical(value):
|
|
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
|
|
|
|
def digest(value):
|
|
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
|
|
|
|
|
def validate_request(request):
|
|
if not isinstance(request, dict) or request.get("schemaVersion") != SCHEMA or request.get("route") != ROUTE:
|
|
raise ValueError("SERVER_OPERATOR_JOB_INVALID: schema or route mismatch")
|
|
required = {"schemaVersion", "route", "jobId", "sourceBlendSha256", "sourceBlendByteLength", "sourceRevision", "blenderBuild", "operator", "properties", "propertiesSha256", "timeoutMs", "memoryBytes", "resultBinding", "requestSha256"}
|
|
if set(request) - required - {"context"}:
|
|
raise ValueError("SERVER_OPERATOR_JOB_INVALID: undeclared request field")
|
|
if not JOB_ID.fullmatch(str(request.get("jobId", ""))):
|
|
raise ValueError("SERVER_OPERATOR_JOB_INVALID: jobId is invalid")
|
|
if not all(isinstance(request.get(name), str) and SHA256.fullmatch(request[name]) for name in ("sourceBlendSha256", "propertiesSha256", "requestSha256")):
|
|
raise ValueError("SERVER_OPERATOR_JOB_INVALID: hash binding is invalid")
|
|
if not isinstance(request.get("sourceBlendByteLength"), int) or not 1 <= request["sourceBlendByteLength"] <= 512 * 1024 * 1024:
|
|
raise ValueError("SERVER_OPERATOR_JOB_INVALID: source blend exceeds the budget")
|
|
if not isinstance(request.get("sourceRevision"), int) or request["sourceRevision"] < 0:
|
|
raise ValueError("SERVER_OPERATOR_JOB_INVALID: sourceRevision is invalid")
|
|
if not isinstance(request.get("operator"), str) or not 1 <= len(request["operator"].encode()) <= 128 or not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_.-]*", request["operator"]):
|
|
raise ValueError("SERVER_OPERATOR_JOB_INVALID: operator is invalid")
|
|
if request["operator"].lower().startswith(BLOCKED_OPERATOR_PREFIXES):
|
|
raise ValueError("SERVER_OPERATOR_CONTEXT_BLOCKED: UI/window/file-browser operator")
|
|
props = request.get("properties")
|
|
if not isinstance(props, dict) or len(props) > 128:
|
|
raise ValueError("SERVER_OPERATOR_JOB_INVALID: properties exceed the budget")
|
|
for key, value in props.items():
|
|
def scalar(item):
|
|
return (isinstance(item, str) and len(item.encode()) <= 4096) or type(item) is bool or (type(item) in (int, float) and math.isfinite(item))
|
|
if not KEY.fullmatch(key):
|
|
raise ValueError("SERVER_OPERATOR_JOB_INVALID: invalid property key")
|
|
if isinstance(value, list):
|
|
category = lambda item: 'number' if type(item) in (int, float) else type(item).__name__
|
|
if len(value) > 64 or any(not scalar(item) or category(item) != category(value[0]) for item in value):
|
|
raise ValueError("SERVER_OPERATOR_JOB_INVALID: properties must be bounded homogeneous arrays")
|
|
elif not scalar(value):
|
|
raise ValueError("SERVER_OPERATOR_JOB_INVALID: properties must be typed scalar or array values")
|
|
if len(canonical(props).encode()) > 64 * 1024:
|
|
raise ValueError("SERVER_OPERATOR_JOB_INVALID: properties exceed the byte budget")
|
|
if not isinstance(request.get("blenderBuild"), dict) or not SHA256.fullmatch(str(request["blenderBuild"].get("buildSha256", ""))):
|
|
raise ValueError("SERVER_OPERATOR_JOB_INVALID: Blender build binding is invalid")
|
|
binding = request.get("resultBinding")
|
|
if not isinstance(binding, dict) or binding.get("kind") not in ("json", "snapshot", "scene-delta") or not KEY.fullmatch(str(binding.get("key", ""))):
|
|
raise ValueError("SERVER_OPERATOR_JOB_INVALID: result binding is invalid")
|
|
if request.get("context", "BACKGROUND") != "BACKGROUND":
|
|
raise ValueError("SERVER_OPERATOR_CONTEXT_BLOCKED: UI/window/file-browser contexts are unavailable")
|
|
if not isinstance(request.get("timeoutMs"), int) or not 1000 <= request["timeoutMs"] <= 600000:
|
|
raise ValueError("SERVER_OPERATOR_JOB_INVALID: timeout budget is invalid")
|
|
if not isinstance(request.get("memoryBytes"), int) or not 64 * 1024 * 1024 <= request["memoryBytes"] <= 4 * 1024 * 1024 * 1024:
|
|
raise ValueError("SERVER_OPERATOR_JOB_INVALID: memory budget is invalid")
|
|
return request
|
|
|
|
|
|
def _limit_memory(memory_bytes):
|
|
try:
|
|
resource.setrlimit(resource.RLIMIT_AS, (memory_bytes, memory_bytes))
|
|
except (OSError, ValueError):
|
|
pass
|
|
|
|
|
|
def _blender_script(request, source_path, result_path, result_blend_path):
|
|
request_literal = json.dumps(str(request.get("_requestPath", "")))
|
|
source_literal = json.dumps(source_path or "")
|
|
result_literal = json.dumps(str(result_path))
|
|
blend_literal = json.dumps(str(result_blend_path))
|
|
return Path(__file__).with_name("server_operator_properties.py").read_text() + "\n" + Path(__file__).with_name("server_operator_remesh.py").read_text() + "\n" + f"""import bpy, json, traceback, hashlib
|
|
request=json.load(open({request_literal}, encoding='utf-8'))
|
|
result={{'schemaVersion':1,'route':'SERVER_NATIVE','jobId':request['jobId'],'status':'FAILED','sourceBlendSha256':request['sourceBlendSha256'],'sourceRevision':request['sourceRevision'],'blenderBuild':request['blenderBuild'],'propertiesSha256':request['propertiesSha256'],'requestSha256':request['requestSha256']}}
|
|
try:
|
|
if not bpy.app.background or any(getattr(bpy.context, name, None) is not None for name in ('area', 'region')):
|
|
raise RuntimeError('SERVER_OPERATOR_CONTEXT_BLOCKED')
|
|
source={source_literal}
|
|
if source:
|
|
bpy.ops.wm.open_mainfile(filepath=source, load_ui=False)
|
|
target=bpy.ops
|
|
for part in request['operator'].split('.'):
|
|
target=getattr(target, part)
|
|
if not target.poll():
|
|
raise RuntimeError('SERVER_OPERATOR_CONTEXT_BLOCKED: operator poll failed in background context')
|
|
operator_properties = (prepare_voxel_remesh(bpy, request['properties'])
|
|
if request['operator'] == 'object.voxel_remesh' else request['properties'])
|
|
operator_result=target(**validated_operator_properties(target, operator_properties))
|
|
if isinstance(operator_result, set) and 'FINISHED' not in operator_result:
|
|
raise RuntimeError('SERVER_OPERATOR_CANCELLED: native operator did not finish')
|
|
result['result']=sorted(str(item) for item in operator_result) if isinstance(operator_result, set) else operator_result
|
|
bpy.ops.wm.save_as_mainfile(filepath={blend_literal}, copy=True)
|
|
result['status']='SUCCEEDED'
|
|
except Exception as error:
|
|
result['errorMessage']=str(error)
|
|
result['traceback']=traceback.format_exc(limit=3)
|
|
result['resultSha256']=hashlib.sha256(json.dumps(result.get('result'), sort_keys=True, separators=(',',':')).encode()).hexdigest()
|
|
json.dump(result, open({result_literal}, 'w', encoding='utf-8'), sort_keys=True, separators=(',',':'))
|
|
"""
|
|
|
|
|
|
def _blender_describe_script(operator, result_path):
|
|
operator_literal = json.dumps(operator)
|
|
result_literal = json.dumps(str(result_path))
|
|
return Path(__file__).with_name("server_operator_properties.py").read_text() + "\n" + Path(__file__).with_name("server_operator_remesh.py").read_text() + "\n" + f"""import bpy, json, traceback, hashlib
|
|
result={{'schemaVersion':1,'route':'SERVER_NATIVE','status':'FAILED'}}
|
|
try:
|
|
bundled_exporters={{'export_scene.gltf':'io_scene_gltf2','export_scene.fbx':'io_scene_fbx'}}
|
|
if {operator_literal} in bundled_exporters:
|
|
import addon_utils
|
|
addon_utils.enable(bundled_exporters[{operator_literal}], default_set=False, persistent=False)
|
|
target=bpy.ops
|
|
for part in {operator_literal}.split('.'):
|
|
target=getattr(target, part)
|
|
if not bpy.app.background or any(getattr(bpy.context, name, None) is not None for name in ('area', 'region')):
|
|
raise RuntimeError('SERVER_OPERATOR_CONTEXT_BLOCKED')
|
|
rna=target.get_rna_type()
|
|
properties=[]
|
|
for prop in rna.properties:
|
|
spec=operator_property_spec(prop)
|
|
if spec is not None: properties.append(spec)
|
|
if {operator_literal} == 'object.voxel_remesh':
|
|
properties.extend(remesh_property_specs(bpy))
|
|
build_hash=str(getattr(bpy.app, 'build_hash', '') or getattr(bpy.app, 'version_string', 'unknown'))
|
|
result={{'schemaVersion':1,'route':'SERVER_NATIVE','status':'SUCCEEDED','operator':{{'id':{operator_literal},'label':str(getattr(rna, 'name', {operator_literal})),'description':str(getattr(rna, 'description', '') or ''),'family':{operator_literal}.split('.', 1)[0],'context':'main','backgroundSafe':True,'properties':properties}},'blenderBuild':{{'version':str(getattr(bpy.app, 'version_string', 'unknown')),'buildSha256':hashlib.sha256(build_hash.encode()).hexdigest()}}}}
|
|
except Exception as error:
|
|
result['errorMessage']=str(error)
|
|
result['traceback']=traceback.format_exc(limit=3)
|
|
json.dump(result, open({result_literal}, 'w', encoding='utf-8'), sort_keys=True, separators=(',',':'))
|
|
"""
|
|
|
|
|
|
def describe_server_operator(operator, blender_binary="blender", workspace_root=None, allow_context=False):
|
|
if not isinstance(operator, str) or not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_.-]*", operator):
|
|
raise ValueError("SERVER_OPERATOR_SCHEMA_OPERATOR_INVALID")
|
|
blocked = ("wm.", "window.", "screen.", "file.") if allow_context else BLOCKED_OPERATOR_PREFIXES
|
|
exporters = {'wm.obj_export', 'wm.stl_export', 'wm.ply_export', 'wm.usd_export', 'wm.alembic_export'}
|
|
if operator.lower().startswith(blocked) and not (allow_context and operator in exporters):
|
|
raise ValueError("SERVER_OPERATOR_CONTEXT_BLOCKED")
|
|
owned_workspace = workspace_root is None
|
|
temporary_workspace = tempfile.TemporaryDirectory(prefix="server-operator-describe-") if owned_workspace else None
|
|
try:
|
|
root = Path(temporary_workspace.name if temporary_workspace else workspace_root)
|
|
root.mkdir(parents=True, exist_ok=True)
|
|
result_path = root / "describe.json"
|
|
script_path = root / "describe.py"
|
|
script_path.write_text(_blender_describe_script(operator, result_path), encoding="utf-8")
|
|
completed = subprocess.run([blender_binary, "--background", "--factory-startup", "--python", str(script_path)], cwd=str(root), timeout=120, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, check=False)
|
|
if completed.returncode != 0 and not result_path.exists():
|
|
raise ValueError(completed.stderr.decode(errors="replace")[-4096:] or "SERVER_OPERATOR_SCHEMA_FAILED")
|
|
result = json.loads(result_path.read_text(encoding="utf-8"))
|
|
if result.get("status") != "SUCCEEDED":
|
|
raise ValueError(result.get("errorMessage", "SERVER_OPERATOR_SCHEMA_FAILED"))
|
|
result.pop("status", None)
|
|
return result
|
|
finally:
|
|
if temporary_workspace:
|
|
temporary_workspace.cleanup()
|
|
|
|
|
|
def run_server_operator_job(request, blender_binary="blender", source_blend_path=None, workspace_root=None):
|
|
validate_request(request)
|
|
owned_workspace = workspace_root is None
|
|
temporary_workspace = tempfile.TemporaryDirectory(prefix=f"server-operator-{request['jobId']}-") if owned_workspace else None
|
|
try:
|
|
root = Path(temporary_workspace.name if temporary_workspace else workspace_root)
|
|
root.mkdir(parents=True, exist_ok=True)
|
|
request_path = root / "request.json"
|
|
result_path = root / "result.json"
|
|
result_blend_path = root / "result.blend"
|
|
request_with_path = dict(request, _requestPath=str(request_path))
|
|
request_path.write_text(canonical(request_with_path), encoding="utf-8")
|
|
script_path = root / "job.py"
|
|
script_path.write_text(_blender_script(request_with_path, source_blend_path, result_path, result_blend_path), encoding="utf-8")
|
|
completed = subprocess.run([blender_binary, "--background", "--factory-startup", "--python", str(script_path)], cwd=str(root), timeout=request["timeoutMs"] / 1000, preexec_fn=lambda: _limit_memory(request["memoryBytes"]), stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, check=False)
|
|
if completed.returncode != 0 and not result_path.exists():
|
|
result = dict(request, status="FAILED", resultSha256=digest(""), errorMessage=completed.stderr.decode(errors="replace")[-4096:])
|
|
else:
|
|
result = json.loads(result_path.read_text(encoding="utf-8"))
|
|
result["resultSha256"] = digest(canonical(result.get("result")))
|
|
if result.get("status") == "SUCCEEDED" and result_blend_path.exists():
|
|
result["resultBlendByteLength"] = result_blend_path.stat().st_size
|
|
result["resultBlendSha256"] = hashlib.sha256(result_blend_path.read_bytes()).hexdigest()
|
|
(root / "result.json").write_text(canonical(result), encoding="utf-8")
|
|
return result
|
|
finally:
|
|
if temporary_workspace:
|
|
temporary_workspace.cleanup()
|
|
|
|
|
|
def main():
|
|
envelope = json.load(sys.stdin)
|
|
action = envelope.get("action", "submit")
|
|
if action in ("describe", "describe-context"):
|
|
result = describe_server_operator(envelope["operator"], envelope.get("blenderBinary", "blender"), envelope.get("workspaceRoot"), allow_context=action == "describe-context")
|
|
elif action == "submit":
|
|
result = run_server_operator_job(envelope["request"], envelope.get("blenderBinary", "blender"), envelope.get("sourceBlendPath"), envelope.get("workspaceRoot"))
|
|
elif action == "cancel":
|
|
result = {"schemaVersion": SCHEMA, "route": ROUTE, "jobId": envelope["jobId"], "status": "CANCELLED", "requestSha256": envelope["requestSha256"], "resultSha256": digest("CANCELLED")}
|
|
elif action == "read":
|
|
result = json.loads(Path(envelope["resultPath"]).read_text(encoding="utf-8"))
|
|
else:
|
|
raise ValueError("SERVER_OPERATOR_JOB_INVALID: unknown action")
|
|
print(canonical(result))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
main()
|
|
except Exception as error:
|
|
print(json.dumps({"status": "FAILED", "errorMessage": str(error)}, separators=(",", ":")))
|
|
sys.exit(1)
|