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())
|
||||
Reference in New Issue
Block a user