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

@@ -31,9 +31,11 @@ import dataclasses
import enum
import hashlib
import logging
import multiprocessing
import multiprocessing.connection
import multiprocessing.process
import sys
if sys.platform != 'emscripten':
import multiprocessing
import multiprocessing.connection
import multiprocessing.process
import os
import sys
import time
@@ -46,13 +48,15 @@ from typing import Protocol, TypeAlias, Any, override
# mypy : Variable "multiprocessing.Event" is not valid as a type
# note: See https://mypy.readthedocs.io/en/stable/common_issues.html#variables-vs-type-aliases
# Pylance: Variable not allowed in type expression
from multiprocessing.synchronize import Event as EventClass
if sys.platform != 'emscripten':
from multiprocessing.synchronize import Event as EventClass
import cattrs
import cattrs.preconf.json
import requests
import requests.adapters
import urllib3.util.retry
if sys.platform != 'emscripten':
import requests
import requests.adapters
import urllib3.util.retry
logger = logging.getLogger(__name__)
@@ -414,7 +418,7 @@ class ConditionalDownloader:
#
# So I (Sybren) figure it's better to test with the 'spawn' method,
# which is also the current default on Windows and macOS.
_mp_context = multiprocessing.get_context(method='spawn')
_mp_context = multiprocessing.get_context(method='spawn') if sys.platform != 'emscripten' else None
@dataclasses.dataclass
@@ -1681,3 +1685,6 @@ def _create_temp_file(dirpath: Path, prefix: str, suffix: str) -> Path:
fd, path_as_str = tempfile.mkstemp(prefix=prefix, suffix=suffix, dir=dirpath)
os.close(fd)
return Path(path_as_str)
if sys.platform == 'emscripten':
from .web_downloader import BackgroundDownloader

View File

@@ -0,0 +1,104 @@
# SPDX-License-Identifier: GPL-2.0-or-later
"""Browser transport for Blender's original listing and asset download managers."""
import collections
import json
import time
from pathlib import Path
from types import SimpleNamespace
from . import downloader as native
import _webengine
_retry_after = {}
def bridge(**request):
return json.loads(_webengine.preview_download(json.dumps(request)))
class BackgroundDownloader(native.BackgroundDownloader):
def __init__(self, options, on_callback_error):
self._options, self._on_callback_error = options, on_callback_error
self._jobs, self._reporters = {}, [self]
self._on_download_done_callbacks = collections.defaultdict(list)
self.num_downloads_ok = self.num_downloads_error = self._num_pending_downloads = 0
self._alive = self._stopping = False
self._shutdown_event = SimpleNamespace(is_set=lambda: self._stopping)
@property
def is_subprocess_alive(self): return self._alive
@property
def is_shutdown_requested(self): return self._stopping
@property
def is_shutdown_complete(self): return self._stopping and not self._jobs
def start(self): self._alive, self._stopping = True, False
def shutdown(self):
self._stopping = True
for job in self._jobs.values():
if job['id'] is not None: bridge(op='cancel', id=job['id'])
self._jobs.clear(); self._on_download_done_callbacks.clear()
self._num_pending_downloads = 0
self._alive = False
def queue_download(self, remote_url, local_path, on_download_done=None, *, http_method='GET'):
if not self._alive or self._stopping: raise RuntimeError('Asset downloader is not running')
request = native.RequestDescription(http_method=http_method, url=remote_url)
self._on_download_done_callbacks[request].append(on_download_done)
if request not in self._jobs:
self._jobs[request] = {'path': Path(local_path), 'id': None, 'started': False}
self._num_pending_downloads += 1
return request
def _start(self, request, job):
import bpy
if not bpy.app.online_access: raise RuntimeError('Internet access is disabled')
path = job['path']; path.parent.mkdir(parents=True, exist_ok=True)
headers = dict(self._options.http_headers)
meta = self._options.metadata_provider.load(request)
if meta and self._options.metadata_provider.is_valid(meta, request, path):
if meta.etag: headers['If-None-Match'] = meta.etag
if meta.last_modified: headers['If-Modified-Since'] = meta.last_modified
timeout = self._options.timeout
if isinstance(timeout, tuple): timeout = sum(timeout)
maximum = self._options.max_disk_size_bytes or 1073741824
result = bridge(op='start', url=request.url, path=str(path), method=request.http_method,
headers=headers, timeoutMs=int(timeout * 1000), maxBytes=maximum)
job['id'] = result['id']; job['started'] = True
self._notify('download_starts', request)
def _notify(self, method, *args):
for reporter in tuple(self._reporters):
try: getattr(reporter, method)(*args)
except Exception: self._logger.exception('Asset download reporter failed: %s', method)
def update(self):
import bpy
if not self._alive: raise native.BackgroundProcessNotRunningError()
for request, job in tuple(self._jobs.items()):
if self._jobs.get(request) is not job: continue
if job['id'] is None and time.monotonic() < _retry_after.get(request.url, 0): continue
try:
if not bpy.app.online_access: raise RuntimeError('Internet access is disabled')
if job['id'] is None: self._start(request, job)
if self._jobs.get(request) is not job: continue
result = bridge(op='poll', id=job['id'])
request.response_headers.update(result.get('headers') or {})
if result['state'] == 'pending':
size = result.get('size', 0)
self._notify('download_progress', request, native.DownloadProgress(size, None, size))
continue
self._finish(request, job, result)
except Exception as error: self._fail(request, job, error)
def _finish(self, request, job, result):
path = job['path']
if result['state'] not in ('done', 'unchanged'):
_retry_after[request.url] = time.monotonic() + max(60, result.get('retryAfter') or 60)
raise RuntimeError(result.get('error', 'Asset transport ended: ' + result['state']))
if result['state'] == 'done' and not path.is_file(): raise RuntimeError('Downloaded asset file is missing')
if result['state'] == 'unchanged' and request.http_method != 'HEAD':
meta = self._options.metadata_provider.load(request)
if not meta or not self._options.metadata_provider.is_valid(meta, request, path): raise RuntimeError('Conditional asset cache is no longer valid')
_retry_after.pop(request.url, None)
if result['state'] == 'done':
self._options.metadata_provider.save(request, native.HTTPMetadata(request, request.response_headers.get('etag', ''), request.response_headers.get('last-modified', ''), path.stat().st_size))
self._jobs.pop(request, None)
self._notify('already_downloaded' if result['state'] == 'unchanged' else 'download_finished', request, path)
def _fail(self, request, job, error):
if self._jobs.pop(request, None) is None: return
if job['id'] is not None: bridge(op='cancel', id=job['id'])
self._on_download_done_callbacks.pop(request, None)
if not isinstance(error, native.DownloadCancelled):
_retry_after[request.url] = max(_retry_after.get(request.url, 0), time.monotonic() + 60)
self._notify('download_error', request, job['path'], error)
def cancel_download(self, http_req_descr):
job = self._jobs.get(http_req_descr)
if job is not None: self._fail(http_req_descr, job, native.DownloadCancelled())

View File

@@ -1173,6 +1173,11 @@ class _GenericUI:
@classmethod
def _dyn_ui_initialize(cls):
# Journal the actual draw wrapper before append/prepend/remove mutates
# it. Native startup rollback restores existing Console-owned hooks.
from bpy.utils import _web_startup_state
if _web_startup_state is not None:
_web_startup_state.track_draw(cls)
draw_funcs = getattr(cls.draw, "_draw_funcs", None)
if draw_funcs is None:

View File

@@ -371,6 +371,16 @@ def enable(module_name, *, default_set=False, persistent=False, refresh_handled=
import traceback
traceback.print_exc()
# Keep desktop diagnostics, but also report consumed import/register errors
# to the embedded startup transaction before it can publish native UI.
if sys.platform == "emscripten":
import _webengine
handle_error_original = handle_error
def handle_error(ex):
_webengine.startup_registration_failed()
handle_error_original(ex)
if (is_extension := module_name.startswith(_ext_base_pkg_idname_with_dot)):
if not refresh_handled:
extensions_refresh(

View File

@@ -42,6 +42,17 @@ def _enable(template_id, *, handle_error=None, ignore_not_found=False):
import traceback
traceback.print_exc()
# Desktop diagnostics consume the exception. The native first-attach
# transaction must still reject publication of a partially registered UI.
import sys
if sys.platform == "emscripten":
import _webengine
handle_error_original = handle_error
def handle_error(ex):
_webengine.startup_registration_failed()
handle_error_original(ex)
# Split registering up into 2 steps so we can undo
# if it fails par way through.

View File

@@ -141,6 +141,10 @@ def _test_import(module_name, loaded_modules):
try:
mod = __import__(module_name)
except Exception:
# Embedded WM cannot proceed to region drawing with missing startup
# classes. Preserve the real error for its native startup boundary.
if _sys.platform == "emscripten":
raise
import traceback
traceback.print_exc()
return None
@@ -209,6 +213,8 @@ def _register_module_call(mod):
try:
register()
except Exception:
if _sys.platform == "emscripten":
raise
import traceback
traceback.print_exc()
else:
@@ -376,6 +382,186 @@ def _on_exit():
_unregister_module_call(mod)
# The native WebEngine owns this transaction. These are the real Blender Python
# registries, not replacement RNA objects. Keep strong references until native
# callbacks/types have been removed, and never invoke add-on unregister hooks
# while recovering a failed first desktop attach.
_web_startup_state = None
class _WebStartupState:
def __init__(self):
self.modules = _sys.modules.copy()
self.path = _sys.path[:]
self.registered = _registered_module_names[:]
self.draw = {}
self.tool_containers = {}
self.tool_helpers = {}
self.tool_classes = {}
self.closing = False
self.missing = object()
self.module_flags = [
(mod, {key: vars(mod).get(key, self.missing) for key in (
"__addon_enabled__", "__addon_persistent__", "__template_enabled__",
)})
for mod in self.modules.values() if type(mod) is type(_sys)
]
self.module_bindings = []
for name in self.modules:
parent_name, _, child = name.rpartition(".")
parent = self.modules.get(parent_name)
if type(parent) is type(_sys):
self.module_bindings.append((parent, child, vars(parent).get(child, self.missing)))
self.addon_once = vars(_addon_utils).get("_initialize_once", self.missing)
template = self.modules.get("bl_app_template_utils")
self.template = None if template is None else (
template, template._app_template.copy(), template._modules.copy(),
)
# Capture Blender's already registered toolbar consumers before an
# extension can mutate a shared ToolDef.keymap list or group dictionary.
# Do not expand dynamic tool generators or invoke extension callbacks.
tool_module = self.modules.get("bl_ui.space_toolsystem_common")
self.tool_def_type = None if tool_module is None else tool_module.ToolDef
if tool_module is not None:
for cls in tool_module.ToolSelectPanelHelper.__subclasses__():
self.track_tool_helper(cls)
def ensure_open(self):
if self.closing:
raise RuntimeError("BLENDER_WEB_STARTUP_CLEANUP_ACTIVE")
def track_draw(self, cls):
self.ensure_open()
if cls not in self.draw:
draw = cls.draw
funcs = getattr(draw, "_draw_funcs", None)
self.draw[cls] = (
vars(cls).get("draw", self.missing), draw, funcs,
None if funcs is None else funcs[:],
)
def track_tool(self, tool_cls, tools):
self.ensure_open()
self.track_tool_container(tools)
if tool_cls not in self.tool_classes:
tool = vars(tool_cls).get("_bl_tool", self.missing)
self.tool_classes[tool_cls] = tool
self.track_tool_container(tool)
def track_tool_container(self, value):
self.ensure_open()
if type(value) is self.tool_def_type:
self.track_tool_container(value.keymap)
elif type(value) in {list, dict}:
if id(value) in self.tool_containers:
return
self.tool_containers[id(value)] = (value, value.copy())
for item in value.values() if type(value) is dict else value:
self.track_tool_container(item)
elif type(value) is tuple:
for item in value:
self.track_tool_container(item)
def track_tool_helper(self, cls):
self.ensure_open()
if cls in self.tool_helpers:
return
names = ("_tools", "_tool_group_active", "_has_keymap_data")
self.tool_helpers[cls] = {name: vars(cls).get(name, self.missing) for name in names}
for name in names:
self.track_tool_container(getattr(cls, name, self.missing))
def restore_attribute(self, obj, name, value):
if value is self.missing:
if name in vars(obj):
delattr(obj, name)
else:
setattr(obj, name, value)
def rollback_ui(self):
self.closing = True
# Restore the original list objects as callers may have cached them.
for cls, (draw, resolved_draw, funcs, before) in self.draw.items():
current_funcs = getattr(cls.draw, "_draw_funcs", None)
if current_funcs is not None and current_funcs is not funcs:
# A retained newly-created wrapper must not keep dispatching the
# failed add-on even after the class's original draw is restored.
current_funcs[:] = [resolved_draw]
if funcs is not None:
funcs[:] = before
self.restore_attribute(cls, "draw", draw)
for container, before in self.tool_containers.values():
if type(container) is dict:
container.clear()
container.update(before)
else:
container[:] = before
for cls, attributes in self.tool_helpers.items():
for name, value in attributes.items():
self.restore_attribute(cls, name, value)
for cls, tool in self.tool_classes.items():
self.restore_attribute(cls, "_bl_tool", tool)
def finish(self, commit):
global _web_startup_state
self.closing = True
try:
if not commit:
for mod, flags in self.module_flags:
for name, value in flags.items():
self.restore_attribute(mod, name, value)
self.restore_attribute(_addon_utils, "_initialize_once", self.addon_once)
if self.template is not None:
template, state, modules = self.template
template._app_template.clear()
template._app_template.update(state)
template._modules.clear()
template._modules.update(modules)
elif (template := _sys.modules.get("bl_app_template_utils")) is not None:
template._app_template.clear()
template._app_template["id"] = ""
template._modules.clear()
_registered_module_names[:] = self.registered
_sys.path[:] = self.path
# Remove imports and parent-package bindings introduced by the
# failed pass, without calling extension code or unloading bpy.
for name, mod in tuple(_sys.modules.items()):
if name not in self.modules:
if type(mod) is type(_sys):
for flag in ("__addon_enabled__", "__template_enabled__"):
if flag in vars(mod):
setattr(mod, flag, False)
parent_name, _, child = name.rpartition(".")
parent = _sys.modules.get(parent_name)
if type(parent) is type(_sys) and vars(parent).get(child) is mod:
delattr(parent, child)
del _sys.modules[name]
_sys.modules.update(self.modules)
for parent, child, value in self.module_bindings:
self.restore_attribute(parent, child, value)
finally:
# Release snapshots while the native reset/context guards still
# apply. Reentrant menu/tool registration remains rejected here.
self.draw.clear()
self.tool_containers.clear()
self.tool_helpers.clear()
self.tool_classes.clear()
self.module_flags.clear()
self.module_bindings.clear()
self.modules.clear()
self.template = None
_web_startup_state = None
def _web_startup_begin():
global _web_startup_state
if _sys.platform != "emscripten" or _web_startup_state is not None:
raise RuntimeError("BLENDER_WEB_STARTUP_CONTEXT_INVALID")
state = _WebStartupState()
_web_startup_state = state
return state
def load_scripts_extensions(*, reload_scripts=False):
"""
Load extensions scripts (add-ons and app-templates)
@@ -859,13 +1045,17 @@ def keyconfig_init():
# Load the default key configuration.
filepath = preset_find(default_config, "keyconfig")
keyconfig_set(filepath)
loaded = keyconfig_set(filepath)
if not loaded and _sys.platform == "emscripten":
raise RuntimeError("Unable to load default key configuration: " + str(filepath))
# Set the active key configuration if different.
if default_config != active_config:
filepath = preset_find(active_config, "keyconfig")
if filepath:
keyconfig_set(filepath)
loaded = keyconfig_set(filepath)
if not loaded and _sys.platform == "emscripten":
raise RuntimeError("Unable to load active key configuration: " + str(filepath))
def keyconfig_set(filepath, *, report=None):
@@ -1088,6 +1278,8 @@ def register_tool(tool_cls, *, after=None, separator=False, group=False):
if cls is None:
raise Exception("Space type {!r} has no toolbar".format(space_type))
tools = cls._tools[context_mode]
if _web_startup_state is not None:
_web_startup_state.track_tool(tool_cls, tools)
# First sanity check
from bpy.types import WorkSpaceTool
@@ -1206,6 +1398,8 @@ def unregister_tool(tool_cls):
if cls is None:
raise Exception("Space type {!r} has no toolbar".format(space_type))
tools = cls._tools[context_mode]
if _web_startup_state is not None:
_web_startup_state.track_tool(tool_cls, tools)
tool_def = tool_cls._bl_tool
try:

View File

@@ -284,6 +284,8 @@ def bake_action_iter(
*,
action,
bake_options,
pose_bone_names=None,
depsgraph=None,
):
"""
A coroutine that bakes action for a single object.
@@ -295,6 +297,8 @@ def bake_action_iter(
:type action: :class:`bpy.types.Action` | None
:param bake_options: Options for baking.
:type bake_options: :class:`anim_utils.BakeOptions`
:param pose_bone_names: Optional explicit pose channel subset, independent of selection.
:param depsgraph: Optional caller's evaluated dependency graph for visual sampling.
:return: an action or None
:rtype: :class:`bpy.types.Action` | None
"""
@@ -384,11 +388,15 @@ def bake_action_iter(
# itself cannot be animated.
continue
bone_names = None if pose_bone_names is None else frozenset(pose_bone_names)
def pose_frame_info(obj):
matrix = {}
bbones = {}
custom_props = {}
for name, pbone in obj.pose.bones.items():
if bone_names is not None and name not in bone_names:
continue
if bake_options.do_visual_keying:
# Get the final transform of the bone in its own local space...
matrix[name] = obj.convert_space(
@@ -461,7 +469,8 @@ def bake_action_iter(
if frame is None:
break
if bake_options.do_pose:
pose_info.append((frame, *pose_frame_info(obj)))
sample_obj = obj.evaluated_get(depsgraph) if depsgraph is not None else obj
pose_info.append((frame, *pose_frame_info(sample_obj)))
armature_info.append((frame, armature_frame_info(obj)))
if bake_options.do_object:
obj_info.append((frame, *obj_frame_info(obj)))
@@ -521,6 +530,8 @@ def bake_action_iter(
)
for name, pbone in obj.pose.bones.items():
if bone_names is not None and name not in bone_names:
continue
if bake_options.only_selected and not pbone.select:
continue