Files
workinf_Blender_Wasm/tools/web/server-operator-job.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

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)