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>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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())
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user