feat(web): complete Blender route runtime execution
This commit is contained in:
@@ -21,6 +21,7 @@ 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):
|
||||
@@ -47,6 +48,8 @@ def validate_request(request):
|
||||
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")
|
||||
@@ -76,32 +79,165 @@ def _limit_memory(memory_bytes):
|
||||
pass
|
||||
|
||||
|
||||
def _blender_script(request, source_path, result_path):
|
||||
return """import bpy, json, os, traceback\nrequest=json.load(open(%r, encoding='utf-8'))\nresult={'schemaVersion':1,'route':'SERVER_NATIVE','jobId':request['jobId'],'status':'FAILED','sourceBlendSha256':request['sourceBlendSha256'],'sourceRevision':request['sourceRevision'],'propertiesSha256':request['propertiesSha256'],'requestSha256':request['requestSha256']}\ntry:\n if bpy.context.window is not None or bpy.context.area is not None or bpy.context.region is not None:\n raise RuntimeError('SERVER_OPERATOR_CONTEXT_BLOCKED')\n source=%r\n if source:\n bpy.ops.wm.open_mainfile(filepath=source, load_ui=False)\n target=bpy.ops\n for part in request['operator'].split('.'):\n target=getattr(target, part)\n if not target.poll():\n raise RuntimeError('SERVER_OPERATOR_CONTEXT_BLOCKED: operator poll failed in background context')\n result['result']=target(**request['properties'])\n result['status']='SUCCEEDED'\nexcept Exception as error:\n result['errorMessage']=str(error)\n result['traceback']=traceback.format_exc(limit=3)\nresult['resultSha256']='%s' % __import__('hashlib').sha256(json.dumps(result.get('result'), sort_keys=True, separators=(',',':')).encode()).hexdigest()\njson.dump(result, open(%r, 'w', encoding='utf-8'), sort_keys=True, separators=(',',':'))\n""" % (str(request.get("_requestPath", "")), source_path or "", digest("result"), str(result_path))
|
||||
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 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')
|
||||
allowed={{}}
|
||||
for prop in target.get_rna_type().properties:
|
||||
kind=str(getattr(prop, 'type', '')).upper()
|
||||
if prop.identifier == 'rna_type' or getattr(prop, 'array_length', 0) not in (0, 1) or kind not in ('BOOLEAN', 'INT', 'FLOAT', 'DOUBLE', 'ENUM', 'STRING'):
|
||||
continue
|
||||
allowed[prop.identifier]=kind
|
||||
for key, value in request['properties'].items():
|
||||
kind=allowed.get(key)
|
||||
if kind is None:
|
||||
raise RuntimeError('SERVER_OPERATOR_PROPERTY_BLOCKED: ' + key)
|
||||
if kind == 'BOOLEAN' and not isinstance(value, bool):
|
||||
raise RuntimeError('SERVER_OPERATOR_PROPERTY_TYPE: ' + key)
|
||||
if kind in ('INT', 'FLOAT', 'DOUBLE') and (isinstance(value, bool) or not isinstance(value, (int, float))):
|
||||
raise RuntimeError('SERVER_OPERATOR_PROPERTY_TYPE: ' + key)
|
||||
if kind in ('ENUM', 'STRING') and not isinstance(value, str):
|
||||
raise RuntimeError('SERVER_OPERATOR_PROPERTY_TYPE: ' + key)
|
||||
operator_result=target(**request['properties'])
|
||||
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 f"""import bpy, json, traceback, hashlib
|
||||
result={{'schemaVersion':1,'route':'SERVER_NATIVE','status':'FAILED'}}
|
||||
try:
|
||||
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')) or not target.poll():
|
||||
raise RuntimeError('SERVER_OPERATOR_CONTEXT_BLOCKED')
|
||||
rna=target.get_rna_type()
|
||||
properties=[]
|
||||
for prop in rna.properties:
|
||||
kind=str(getattr(prop, 'type', '')).upper()
|
||||
if prop.identifier == 'rna_type' or getattr(prop, 'array_length', 0) not in (0, 1) or kind not in ('BOOLEAN', 'INT', 'FLOAT', 'DOUBLE', 'ENUM', 'STRING'):
|
||||
continue
|
||||
type_name='boolean' if kind == 'BOOLEAN' else 'number' if kind in ('INT', 'FLOAT', 'DOUBLE') else 'enum' if kind == 'ENUM' else 'string'
|
||||
spec={{'name':prop.identifier,'type':type_name,'required':bool(getattr(prop, 'is_required', False))}}
|
||||
label=getattr(prop, 'name', None)
|
||||
description=getattr(prop, 'description', None)
|
||||
if label: spec['label']=str(label)
|
||||
if description: spec['description']=str(description)
|
||||
try:
|
||||
default=getattr(prop, 'default', None)
|
||||
if isinstance(default, (bool, int, float, str)):
|
||||
spec['defaultValue']=default
|
||||
except Exception: pass
|
||||
for key, source in (('min','hard_min'),('max','hard_max')):
|
||||
try:
|
||||
value=getattr(prop, source, None)
|
||||
if isinstance(value, (int, float)) and not isinstance(value, bool): spec[key]=value
|
||||
except Exception: pass
|
||||
if kind == 'ENUM':
|
||||
items=[]
|
||||
try:
|
||||
for item in prop.enum_items:
|
||||
items.append({{'value':str(item.identifier),'label':str(getattr(item, 'name', item.identifier))}})
|
||||
except Exception: pass
|
||||
if items: spec['enumItems']=items
|
||||
properties.append(spec)
|
||||
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):
|
||||
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")
|
||||
if operator.lower().startswith(BLOCKED_OPERATOR_PREFIXES):
|
||||
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)
|
||||
with tempfile.TemporaryDirectory(prefix=f"server-operator-{request['jobId']}-", dir=workspace_root) as workspace:
|
||||
root = Path(workspace)
|
||||
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), encoding="utf-8")
|
||||
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():
|
||||
return dict(request, status="FAILED", resultSha256=digest(""), errorMessage=completed.stderr.decode(errors="replace")[-4096:])
|
||||
result = json.loads(result_path.read_text(encoding="utf-8"))
|
||||
result["resultSha256"] = digest(canonical(result.get("result")))
|
||||
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 == "submit":
|
||||
if action == "describe":
|
||||
result = describe_server_operator(envelope["operator"], envelope.get("blenderBinary", "blender"), envelope.get("workspaceRoot"))
|
||||
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")}
|
||||
|
||||
Reference in New Issue
Block a user