chore: checkpoint web Blender migration source, tools and docs
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

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>
This commit is contained in:
mes123456
2026-11-10 11:18:01 +08:00
parent 122142c26c
commit f3e21c6b98
6887 changed files with 257154 additions and 12294 deletions

View File

@@ -7,6 +7,7 @@ file-browser contexts are intentionally unavailable.
"""
import hashlib
import json
import math
import os
import re
import resource
@@ -54,8 +55,16 @@ def validate_request(request):
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():
if not KEY.fullmatch(key) or not ((isinstance(value, str) and len(value.encode()) <= 4096) or isinstance(value, (bool, int, float)) and not isinstance(value, bool) or isinstance(value, bool)):
raise ValueError("SERVER_OPERATOR_JOB_INVALID: properties must be typed scalar values")
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", ""))):
@@ -84,7 +93,7 @@ def _blender_script(request, source_path, result_path, result_blend_path):
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
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:
@@ -98,23 +107,11 @@ try:
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'])
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'
@@ -129,44 +126,25 @@ json.dump(result, open({result_literal}, 'w', encoding='utf-8'), sort_keys=True,
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
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')) or not target.poll():
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:
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)
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:
@@ -176,10 +154,12 @@ json.dump(result, open({result_literal}, 'w', encoding='utf-8'), sort_keys=True,
"""
def describe_server_operator(operator, blender_binary="blender", workspace_root=None):
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")
if operator.lower().startswith(BLOCKED_OPERATOR_PREFIXES):
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
@@ -235,8 +215,8 @@ def run_server_operator_job(request, blender_binary="blender", source_blend_path
def main():
envelope = json.load(sys.stdin)
action = envelope.get("action", "submit")
if action == "describe":
result = describe_server_operator(envelope["operator"], envelope.get("blenderBinary", "blender"), envelope.get("workspaceRoot"))
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":