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())