work: restore shift+spacebar for media play/pause

maybe put in maya config? idk what funiman's preference is
This commit is contained in:
Nathan
2026-05-29 14:58:59 -06:00
parent 2f8e5f472f
commit 6c3b78075b
130 changed files with 10461 additions and 19696 deletions
+12 -5
View File
@@ -5,14 +5,14 @@
bl_info = {
"name": "Flamenco",
"author": "Sybren A. Stüvel",
"version": (3, 8, 5),
"version": (3, 10),
"blender": (3, 1, 0),
"description": "Flamenco client for Blender.",
"location": "Output Properties > Flamenco",
"doc_url": "https://flamenco.blender.org/",
"category": "System",
"support": "COMMUNITY",
"warning": "",
"warning": "This is version 3.10-alpha0 of the add-on, which is not a stable release",
}
from pathlib import Path
@@ -20,14 +20,14 @@ from pathlib import Path
__is_first_load = "operators" not in locals()
if __is_first_load:
from . import (
operators,
comms,
gui,
job_types,
comms,
manager_info,
operators,
preferences,
projects,
worker_tags,
manager_info,
)
else:
import importlib
@@ -99,11 +99,18 @@ def register() -> None:
bpy.app.handlers.save_pre.append(_unset_flamenco_job_name)
bpy.app.handlers.save_post.append(_set_flamenco_job_name)
bpy.types.WindowManager.flamenco_can_abort = bpy.props.BoolProperty(
name="Flamenco Can Abort",
default=False,
description="Whether the Flamenco submission can be aborted",
)
bpy.types.WindowManager.flamenco_bat_status = bpy.props.EnumProperty(
items=[
("IDLE", "IDLE", "Not doing anything."),
("SAVING", "SAVING", "Saving your file."),
("INVESTIGATING", "INVESTIGATING", "Finding all dependencies."),
("REWRITING", "REWRITING", "Rewriting blend files."),
("TRANSFERRING", "TRANSFERRING", "Transferring all dependencies."),
("COMMUNICATING", "COMMUNICATING", "Communicating with Flamenco Server."),
("DONE", "DONE", "Not doing anything, but doing something earlier."),
+11
View File
@@ -0,0 +1,11 @@
# SPDX-FileCopyrightText: 2026 Blender Authors
# SPDX-License-Identifier: GPL-3.0-or-later
# This is the interface to BAT v1.x
def bat_version() -> str:
from .submodules import bat_toplevel
bat_version: str = bat_toplevel.__version__
return bat_version
+9 -2
View File
@@ -539,6 +539,13 @@ def _encode_original_filename_header(filename: str) -> str:
"""
# This is a no-op when the filename is already in ASCII.
fake_header = email.header.Header()
fake_header = email.header.Header(maxlinelen=0)
fake_header.append(filename, charset="utf-8")
return fake_header.encode()
encoded_header = fake_header.encode()
# Make sure that there are no newlines in the returned value.
# HTTP Header line folding is obsolete, see RFC9112 section 5.2 in
# https://www.rfc-editor.org/rfc/rfc9112#name-obsolete-line-folding
assert "\n" not in encoded_header
return encoded_header
@@ -4,5 +4,6 @@ from .. import wheels
_bat_modules = wheels.load_wheel(
"blender_asset_tracer",
("blendfile", "pack", "pack.progress", "pack.transfer", "pack.shaman", "bpathlib"),
filename_prefix="blender_asset_tracer-1.",
)
bat_toplevel, blendfile, pack, progress, transfer, shaman, bpathlib = _bat_modules
@@ -0,0 +1,15 @@
# SPDX-FileCopyrightText: 2026 Blender Authors
# SPDX-License-Identifier: GPL-3.0-or-later
"""BAT v2 support for Flamenco.
NOTE: This module uses late imports to avoid importing BAT v2 until it's really
necessary. Functions should _only_ be called from Blender 5.1+ (Python 3.13+),
as BAT uses language features that were not available in 5.0 (Python 3.11).
"""
def bat_version() -> str:
from .submodules import bat_toplevel
return bat_toplevel.__version__
+39
View File
@@ -0,0 +1,39 @@
# SPDX-FileCopyrightText: 2026 Blender Authors
# SPDX-License-Identifier: GPL-3.0-or-later
"""BAT v2 packing to the filesystem."""
from __future__ import annotations
__all__ = ("pack_start",)
from pathlib import Path
from typing import Any, TypeAlias
# Alias some types from blender_asset_tracer so that we can use type annotations
# without having to import from BATv2.
BATPackReporter: TypeAlias = Any
BATPacker: TypeAlias = Any
def pack_start(
project_root: Path,
reporter: BATPackReporter,
*,
use_relative_only: bool,
pack_target_dir: Path,
ignore_globs: set[str] = set(),
) -> BATPacker:
"""Investigate what's needed to create a BAT pack."""
from .submodules import file_usage, pack
batpacker = pack.BATPacker(
project_root,
file_usage.Options(
use_relative_only=use_relative_only,
ignore_globs=ignore_globs,
),
reporter,
pack_target_dir=pack_target_dir,
)
return batpacker
@@ -0,0 +1,594 @@
# SPDX-FileCopyrightText: 2026 Blender Authors
# SPDX-License-Identifier: GPL-3.0-or-later
"""BAT v2 packing to a Shaman server."""
from __future__ import annotations
__all__ = ("pack_start",)
import dataclasses
import email.header
import logging
import random
from functools import partial
from pathlib import Path, PurePosixPath
from typing import TYPE_CHECKING, Any, TypeAlias
import bpy
if TYPE_CHECKING:
# _BATPacker: TypeAlias = pack.BATPacker
from ..manager import ApiClient as _ApiClient
from ..manager.apis import ShamanApi as _ShamanApi
from ..manager.models import (
ShamanCheckoutResult as _ShamanCheckoutResult,
)
from ..manager.models import (
ShamanFileSpec as _ShamanFileSpec,
)
from ..manager.models import (
ShamanRequirementsRequest as _ShamanRequirementsRequest,
)
from .submodules.file_usage import FileInfo as _FileInfo
from .submodules.pack import BATPacker as _BATPacker
from .submodules.pack import QueueingExecutor as _QueueingExecutor
else:
_ApiClient = object
_ShamanApi = object
_ShamanCheckoutResult = object
_ShamanRequirementsRequest = object
_ShamanFileSpec = object
_BATPacker = object
_FileInfo = object
_QueueingExecutor = object
log = logging.getLogger(__name__)
MAX_DEFERRED_PATHS = 8
MAX_FAILED_PATHS = 8
HASH_STORAGE_PATH = Path(bpy.app.cachedir) / "flamenco/shaman"
HASH_METHOD = "sha256"
SHAMAN_JOBS_VARIABLE = "{jobs}"
HashableShamanFileSpec = tuple[str, int, str]
"""Tuple of the 'sha', 'size', and 'path' fields of a ShamanFileSpec."""
# Alias some types from blender_asset_tracer so that we can use type annotations
# without having to import from BATv2.
BATPackReporter: TypeAlias = Any
BATPacker: TypeAlias = Any
def pack_start(
project_root: Path,
reporter: BATPackReporter,
*,
use_relative_only: bool,
api_client: _ApiClient,
checkout_path: PurePosixPath,
ignore_globs: set[str] = set(),
) -> BATPacker:
"""Investigate what's needed to create a BAT pack."""
from ..manager.apis import ShamanApi
from .submodules import file_usage, pack
shaman_api = ShamanApi(api_client)
executor = pack.QueueingExecutor()
shaman_transferer = ShamanPacker(shaman_api, checkout_path, executor, reporter)
batpacker = pack.BATPacker(
project_root,
file_usage.Options(
use_relative_only=use_relative_only,
ignore_globs=ignore_globs,
),
reporter,
file_transfer=shaman_transferer,
)
return batpacker
@dataclasses.dataclass
class ShamanUploadProgress:
# When another client is already uploading a file that we also want to
# upload, we defer the file. That way, when we do get around to uploading
# it, the other person may already have finished their upload, saving us
# time.
#
# Mapping from 'path in pack' to the BAT FileInfo and Shaman FileSpec.
deferred: dict[PurePosixPath, tuple[_FileInfo, _ShamanFileSpec]] = (
dataclasses.field(default_factory=dict)
)
# When a file doesn't want to get uploaded, it's stored here to retry. If
# too many files fail, or the retry counter reaches its max, it'll get
# reported as an actual error.
# The string value is the error message.
failures: dict[PurePosixPath, tuple[_FileInfo, _ShamanFileSpec, str]] = (
dataclasses.field(default_factory=dict)
)
retry_counter: int = 0
max_retries: int = 50
def is_deferred(self, relpath_in_pack: PurePosixPath) -> bool:
return relpath_in_pack in self.deferred
@property
def num_deferred(self) -> int:
return len(self.deferred)
@property
def num_failed(self) -> int:
return len(self.failures)
def defer(
self,
bat_file_info: _FileInfo,
shaman_file_spec: _ShamanFileSpec,
) -> None:
# A file should only be deferred once. Once an upload has been deferred,
# the next attempt shouldn't be deferred again.
assert bat_file_info.relpath_in_pack not in self.deferred
self.deferred[bat_file_info.relpath_in_pack] = (bat_file_info, shaman_file_spec)
def failed(
self,
bat_file_info: _FileInfo,
shaman_file_spec: _ShamanFileSpec,
errormsg: str,
) -> None:
# A file should only be added to the 'failures' dict once. When its
# upload is retried, it should be removed from the 'failures' dict first.
assert bat_file_info.relpath_in_pack not in self.failures
self.failures[bat_file_info.relpath_in_pack] = (
bat_file_info,
shaman_file_spec,
errormsg,
)
@dataclasses.dataclass
class ShamanPacker:
shaman_api: _ShamanApi
checkout_path: PurePosixPath
executor: _QueueingExecutor
reporter: BATPackReporter
# Shaman may decide to create the checkout at another path than requested.
# This will be set to the actually-used path on the farm, relative to the
# Shaman's "jobs" directory.
#
# NOTE: It is the checkout path of the job, NOT the path to the blend file.
_checkout_path_final: PurePosixPath | None = None
_source_file_relpath_in_pack: PurePosixPath | None = None
_num_files_to_transfer_total: int = -1
_num_files_to_transfer_done: int = 0
@property
def is_succes(self) -> bool:
"""Return whether the Shaman operation was completed succesfully."""
return bool(self._checkout_path_final)
def start(self, batpacker: _BATPacker) -> None:
files_to_copy = batpacker.all_files_to_copy()
# Initial value is the total number of files to copy. Once the Shaman
# server has told us how many files to submit, this will be adjusted.
self._num_files_to_transfer_total = len(files_to_copy)
self._num_files_to_transfer_done = 0
# Remember where the main blend file sits in the BAT pack.
source_file_info = batpacker.deps_repo.source_file_info()
assert source_file_info.relpath_in_pack is not None
self._source_file_relpath_in_pack = PurePosixPath(
source_file_info.relpath_in_pack.as_posix()
)
self.executor.queue(partial(self._step_queue_hashing, files_to_copy))
def step(self) -> bool:
"""Perform a single step in the Shaman file transfer.
Returns whether there are more steps to do (True) or the process is done (False).
"""
if self.executor.is_done:
return False
self.executor.run_step()
return not self.executor.is_done
def blendfile_location_in_pack(self) -> PurePosixPath:
assert self._checkout_path_final is not None
assert self._source_file_relpath_in_pack is not None
return (
PurePosixPath(SHAMAN_JOBS_VARIABLE)
/ self._checkout_path_final
/ self._source_file_relpath_in_pack
)
def num_files_to_transfer(self) -> tuple[int, int]:
"""Return the number of files that need to be transferred.
This is a tuple [total, done] with the total number of files to
transfer, and the number of transferred files so far.
The number may change during the packing process, as it takes time
for the Shaman protocol to get this information. Or some paths may
turn out to be multiple paths (UDIMs for example).
"""
return self._num_files_to_transfer_total, self._num_files_to_transfer_done
def _step_queue_hashing(self, files_to_copy: dict[Path, _FileInfo]) -> None:
from ..manager.models import ShamanRequirementsRequest
# Shaman Spec that's shared between the queued function calls. They
# can all just append to the same list.
shaman_spec = ShamanRequirementsRequest(files=[])
assert isinstance(shaman_spec, ShamanRequirementsRequest)
# Tracks deferred files and failed uploads.
upload_progress = ShamanUploadProgress()
# Queue up all the hash computations.
for file_info in files_to_copy.values():
self.executor.queue(
partial(
self._step_hash_file,
file_info,
shaman_spec,
)
)
# After the hashes are gathered in 'filespecs', send the spec to Shaman.
self.executor.queue(
partial(
self._step_queue_uploads_of_files,
files_to_copy,
shaman_spec,
upload_progress,
)
)
def _step_hash_file(
self,
file_info: _FileInfo,
shaman_spec: _ShamanRequirementsRequest,
) -> None:
from _bpy_internal import disk_file_hash_service
from ..manager.models import ShamanFileSpec
path_to_pack = file_info.path_to_pack
if not path_to_pack.exists():
# If the file is missing, there's little else to do than reporting
# it as such and continue with the next file.
if path_to_pack == file_info.source_path:
log.info("File missing: %s", path_to_pack)
else:
log.info(
"File missing after rewriting %s to %s",
file_info.source_path,
path_to_pack,
)
self.reporter.on_missing_file(
file_info.source_path, file_info.relpath_in_pack
)
return
# It might be tempting to use the same Disk File Hash Service as BAT's
# path rewriting system is using. However, that only hashes the files
# that need rewriting, and the code below only deals with paths after
# rewriting (or where rewriting was not necessary). That means that
# there is no benefit in sharing the same database.
dfhs = disk_file_hash_service.get_service(HASH_STORAGE_PATH)
checksum = dfhs.get_hash(path_to_pack, HASH_METHOD)
filesize = path_to_pack.stat().st_size
filespec = ShamanFileSpec(
sha=checksum,
size=filesize,
path=str(file_info.relpath_in_pack),
)
assert isinstance(filespec, ShamanFileSpec)
shaman_spec.files.append(filespec)
def _step_queue_uploads_of_files(
self,
files_to_copy: dict[Path, _FileInfo],
shaman_spec: _ShamanRequirementsRequest,
upload_progress: ShamanUploadProgress,
) -> None:
"""Send the spec file to Shaman, and queue file uploads."""
# Query Shaman to figure out which files still need uploading.
to_upload = self._send_spec_to_shaman(shaman_spec)
if to_upload is None:
# Errors have been reported already, so just stop.
return
log.info(
"Feeding %d/%d files to the Shaman", len(to_upload), len(shaman_spec.files)
)
self._num_files_to_transfer_total = len(to_upload)
# Create a mapping from the path in the pack (which is used in
# `filespecs`) to the FileInfo.
path_in_pack_to_abs: dict[str, _FileInfo] = {
str(file_info.relpath_in_pack): file_info
for file_info in files_to_copy.values()
}
# Queue the file uploads.
for index, file_spec in enumerate(to_upload):
file_info = path_in_pack_to_abs[file_spec.path]
is_last_file = index == len(to_upload)
self.executor.queue(
partial(
self._step_queue_upload_file,
file_info,
file_spec,
is_last_file,
upload_progress,
)
)
self.executor.queue(
partial(
self._step_check_upload_success,
files_to_copy,
shaman_spec,
upload_progress,
)
)
def _step_queue_upload_file(
self,
file_info: _FileInfo,
file_spec: _ShamanFileSpec,
is_last_file: bool,
upload_progress: ShamanUploadProgress,
) -> None:
# Pre-flight check. The generated API code will load the entire file
# into memory before sending it to the Shaman. It's faster to do a check
# at Shaman first, to see if we need uploading at all.
check_resp = self.shaman_api.shaman_file_store_check(
checksum=file_spec.sha,
filesize=file_spec.size,
)
if check_resp.status.value == "stored":
log.info(" %s: skipping, already on server", file_spec.path)
return
# Do the 'start' reporting in a separate step, so that the Blender UI
# can be updated for it. The 'done'/'error' reports are done at the end
# of the file upload step, and so these don't need a separate step.
self.executor.queue(partial(self._step_report_upload_file_start, file_info))
self.executor.queue(
partial(
self._step_upload_file,
file_info,
file_spec,
is_last_file,
upload_progress,
)
)
def _step_report_upload_file_start(self, file_info: _FileInfo) -> None:
self.reporter.on_copy_start(file_info.source_path, file_info.relpath_in_pack)
def _step_upload_file(
self,
file_info: _FileInfo,
file_spec: _ShamanFileSpec,
is_last_file: bool,
upload_progress: ShamanUploadProgress,
) -> None:
from ..manager.exceptions import ApiException
# See whether we may be able to defer uploading this file or not.
can_defer = bool(
not is_last_file
and upload_progress.num_deferred < MAX_DEFERRED_PATHS
and not upload_progress.is_deferred(file_info.relpath_in_pack)
)
filename_header = _encode_original_filename_header(file_spec.path)
try:
with file_info.path_to_pack.open("rb") as file_reader:
self.shaman_api.shaman_file_store(
checksum=file_spec.sha,
filesize=file_spec.size,
body=file_reader,
x_shaman_can_defer_upload=can_defer,
x_shaman_original_filename=filename_header,
)
except ApiException as ex:
if ex.status == 425:
# Too Early, i.e. defer uploading this file.
log.info(
" %s: someone else is uploading this file, deferring",
file_spec.path,
)
upload_progress.defer(file_info, file_spec)
return
elif ex.status == 417:
# Expectation Failed; mismatch of checksum or file size.
msg = "Error from Shaman uploading %s, code %d: %s" % (
file_spec.path,
ex.status,
ex.body,
)
else: # Unknown error
msg = "API exception\nHeaders: %s\nBody: %s\n" % (
ex.headers,
ex.body,
)
log.error(msg)
upload_progress.failed(file_info, file_spec, msg)
return
self._num_files_to_transfer_done += 1
self.reporter.on_copy_done(file_info.source_path, file_info.relpath_in_pack)
def _step_check_upload_success(
self,
files_to_copy: dict[Path, _FileInfo],
shaman_spec: _ShamanRequirementsRequest,
upload_progress: ShamanUploadProgress,
) -> None:
"""See if there were any deferred or failed files.
If there were, re-queue the uploading of the remaining files.
Unless the number of retries has been exceeded, in which case the
failures are final.
"""
if upload_progress.num_deferred == 0 and upload_progress.num_failed == 0:
# Nothing left to do, so move on to the next stage.
self.executor.queue(partial(self._step_request_checkout, shaman_spec))
return
upload_progress.retry_counter += 1
if upload_progress.retry_counter >= upload_progress.max_retries:
# Failed uploads have really failed now.
#
# Deferred uploads shouldn't be mentioned, because they only get
# deferred on the first upload attempt. After that, if they fail,
# they get into the failures.
for fileinfo, _, errormsg in upload_progress.failures.values():
self.reporter.on_copy_error(
fileinfo.source_path, fileinfo.relpath_in_pack, errormsg
)
return
# Retry uploading.
self.executor.queue(
partial(
self._step_queue_uploads_of_files,
files_to_copy,
shaman_spec,
upload_progress,
)
)
def _step_request_checkout(self, shaman_spec: _ShamanRequirementsRequest) -> None:
"""Ask the Shaman to create a checkout of this BAT pack."""
assert self.checkout_path
from ..manager.exceptions import ApiException
from ..manager.models import ShamanCheckout, ShamanCheckoutResult
log.info(
"Requesting checkout at Shaman for checkout_path=%s", self.checkout_path
)
checkoutRequest = ShamanCheckout(
files=shaman_spec.files,
checkout_path=str(self.checkout_path),
)
try:
result: ShamanCheckoutResult = self.shaman_api.shaman_checkout(
checkoutRequest
)
except ApiException as ex:
if ex.status == 424: # Files were missing
msg = "We did not upload some files, checkout aborted"
elif ex.status == 409: # Checkout already exists
msg = "There is already an existing checkout at %s" % self.checkout_path
else: # Unknown error
msg = "API exception\nHeaders: %s\nBody: %s\n" % (
ex.headers,
ex.body,
)
log.error(msg)
self.reporter.on_error(msg)
return
log.info("Shaman created checkout at %s", result.checkout_path)
self._checkout_path_final = result.checkout_path
def _send_spec_to_shaman(
self,
requirements: _ShamanRequirementsRequest,
) -> list[_ShamanFileSpec] | None:
"""Send the checkout definition file to the Shaman.
:return: A list of file specs that still need to be uploaded, or
None if there was an error.
"""
from ..manager.exceptions import ApiException
from ..manager.models import ShamanRequirementsResponse
requested_relpaths = {file.path for file in requirements.files}
try:
resp = self.shaman_api.shaman_checkout_requirements(requirements)
except ApiException as ex:
# TODO: the body should be JSON of a predefined type, parse it to get the actual message.
msg = "Error from Shaman, code %d: %s" % (ex.status, ex.body)
log.error(msg)
self.reporter.on_error(msg)
return None
assert isinstance(resp, ShamanRequirementsResponse)
# Go over the response, and create two queues for uploading. Any file
# that's already being uploaded by somebody else will be put in the
# low-priority queue.
to_upload_normal_prio: list[_ShamanFileSpec] = []
to_upload_low_prio: list[_ShamanFileSpec] = []
for file_spec in resp.files:
if file_spec.path not in requested_relpaths:
msg = (
"Shaman requested path we did not intend to upload: %r" % file_spec
)
log.error(msg)
self.reporter.on_error(msg)
return None
log.debug(" %s: %s", file_spec.status, file_spec.path)
status = file_spec.status.value
if status == "unknown":
to_upload_normal_prio.append(file_spec)
elif status == "uploading":
to_upload_low_prio.append(file_spec)
else:
msg = "Unknown status in response from Shaman: %r" % file_spec
log.error(msg)
self.reporter.on_error(msg)
return None
# Randomize the two lists, so that when two clients upload similar sets
# of files, collissions are minimized.
random.shuffle(to_upload_normal_prio)
random.shuffle(to_upload_low_prio)
return to_upload_normal_prio + to_upload_low_prio
def _encode_original_filename_header(filename: str) -> str:
"""Encode the 'original filename' as valid HTTP Header.
See the specs for the X-Shaman-Original-Filename header in the OpenAPI
operation `shamanFileStore`, defined in flamenco-openapi.yaml.
"""
# This is a no-op when the filename is already in ASCII.
fake_header = email.header.Header(maxlinelen=0)
fake_header.append(filename, charset="utf-8")
encoded_header = fake_header.encode()
# Make sure that there are no newlines in the returned value.
# HTTP Header line folding is obsolete, see RFC9112 section 5.2 in
# https://www.rfc-editor.org/rfc/rfc9112#name-obsolete-line-folding
assert "\n" not in encoded_header
return encoded_header
@@ -0,0 +1,72 @@
import os
from pathlib import Path
from typing import TYPE_CHECKING
# SORRY FOR THE COMPLEXITY!
#
# Flamenco needs to be able to deal with BAT versions 1 and 2 at the same time,
# by loading them from different wheel files, depending on which version of
# Blender is running.
#
# At the same time, there's developers who will be really happy when mypy can do
# its thing, and when Blender can load mypy and BAT from virtual environments.
WHEEL_MODULE = "blender_asset_tracer"
WHEEL_FILENAME_PREFIX = "blender_asset_tracer-2."
if TYPE_CHECKING:
# When type-checking, BAT should be importable.
import blender_asset_tracer as bat_toplevel
from blender_asset_tracer import (
file_usage,
pack,
path_rewriting,
path_rewriting_process,
)
else:
# For development only: if we can import BAT directly, just assume it's the
# right version and go with it.
if "VIRTUAL_ENV" in os.environ:
import site
from pathlib import Path
venv_path = Path(os.environ["VIRTUAL_ENV"])
print(f"Reactivating virtualenv: {venv_path}")
# Add the virtual environments libraries.
lib_dirs_posix = list(venv_path.rglob("lib/*/site-packages"))
lib_dirs_windows = list(venv_path.rglob("Lib/site-packages"))
for lib_dir in lib_dirs_posix + lib_dirs_windows:
site.addsitedir(str(lib_dir))
try:
import blender_asset_tracer as bat_toplevel
from blender_asset_tracer import (
file_usage,
pack,
path_rewriting,
path_rewriting_process,
)
except ImportError:
# At runtime, some trickery is necessary to load BAT from the bundled wheel file, without making
# it available in `sys.modules` (to prevent interaction with other add-ons).
from .. import wheels
# Load all the submodules we need from BAT in one go.
_bat_modules = wheels.load_wheel(
WHEEL_MODULE,
("file_usage", "pack", "path_rewriting", "path_rewriting_process"),
filename_prefix=WHEEL_FILENAME_PREFIX,
)
bat_toplevel, file_usage, pack, path_rewriting, path_rewriting_process = (
_bat_modules
)
# Expose the location of the wheel file to BAT by setting an environment
# variable. This is necessary for BAT's path rewriting sub-process, in
# order to know where to load its own sources from.
wheel_filename: Path = wheels.filename(
WHEEL_MODULE,
filename_prefix=WHEEL_FILENAME_PREFIX,
)
os.environ["BAT_WHEEL"] = str(wheel_filename)
+14 -7
View File
@@ -1,16 +1,18 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# <pep8 compliant>
from typing import Optional, TYPE_CHECKING
from . import preferences, job_types
from .job_types_propgroup import JobTypePropertyGroup
from typing import TYPE_CHECKING, Optional
import bpy
from . import job_types, preferences
from .job_types_propgroup import JobTypePropertyGroup
if TYPE_CHECKING:
from flamenco.manager.models import (
AvailableJobSetting as _AvailableJobSetting,
)
from flamenco.manager.models import (
SubmittedJob as _SubmittedJob,
)
else:
@@ -178,13 +180,18 @@ class FLAMENCO_PT_job_submission(bpy.types.Panel):
elif flamenco_status == "INVESTIGATING":
row = layout.row(align=True)
row.label(text="Investigating your files")
# row.operator(FLAMENCO_OT_abort.bl_idname, text="", icon="CANCEL")
row.operator("flamenco.abort", text="", icon="CANCEL")
elif flamenco_status == "REWRITING":
row = layout.row(align=True)
row.label(text="Rewriting files")
row.operator("flamenco.abort", text="", icon="CANCEL")
elif flamenco_status == "COMMUNICATING":
layout.label(text="Communicating with Flamenco Server")
elif flamenco_status == "ABORTING":
row = layout.row(align=True)
row.label(text="Aborting, please wait.")
# row.operator(FLAMENCO_OT_abort.bl_idname, text="", icon="CANCEL")
row.operator("flamenco.abort", text="", icon="CANCEL")
if flamenco_status == "TRANSFERRING":
row = layout.row(align=True)
row.prop(
@@ -192,7 +199,7 @@ class FLAMENCO_PT_job_submission(bpy.types.Panel):
"flamenco_bat_progress",
text=context.window_manager.flamenco_bat_status_txt,
)
# row.operator(FLAMENCO_OT_abort.bl_idname, text="", icon="CANCEL")
row.operator("flamenco.abort", text="", icon="CANCEL")
elif (
flamenco_status != "IDLE" and context.window_manager.flamenco_bat_status_txt
):
+13 -7
View File
@@ -1,20 +1,23 @@
# SPDX-License-Identifier: GPL-3.0-or-later
import logging
import platform
from pathlib import Path, PurePosixPath
from typing import TYPE_CHECKING, Optional, Union
import platform
import logging
import bpy
from .job_types_propgroup import JobTypePropertyGroup
from .bat.submodules import bpathlib
from . import manager_info
from .bat.submodules import bpathlib
if TYPE_CHECKING:
from .manager import ApiClient as _ApiClient
from .manager.models import (
AvailableJobType as _AvailableJobType,
)
from .manager.models import (
Job as _Job,
)
from .manager.models import (
SubmittedJob as _SubmittedJob,
)
else:
@@ -32,8 +35,11 @@ log = logging.getLogger(__name__)
def job_for_scene(scene: bpy.types.Scene) -> Optional[_SubmittedJob]:
from flamenco.manager.models import SubmittedJob, JobMetadata
from flamenco.manager.model.job_status import JobStatus
from flamenco.manager.models import JobMetadata, SubmittedJob
# Do a late import, to make the order in which this file is imported less sensitive.
from .job_types_propgroup import JobTypePropertyGroup
propgroup = getattr(scene, "flamenco_job_settings", None)
assert isinstance(propgroup, JobTypePropertyGroup), "did not expect %s" % (
@@ -106,7 +112,7 @@ def submit_job(job: _SubmittedJob, api_client: _ApiClient) -> _Job:
"""Send the given job to Flamenco Manager."""
from flamenco.manager import ApiClient
from flamenco.manager.api import jobs_api
from flamenco.manager.models import SubmittedJob, Job
from flamenco.manager.models import Job, SubmittedJob
assert isinstance(job, SubmittedJob), "got %s" % type(job)
assert isinstance(api_client, ApiClient), "got %s" % type(api_client)
@@ -122,7 +128,7 @@ def submit_job_check(job: _SubmittedJob, api_client: _ApiClient) -> None:
"""Check the given job at Flamenco Manager to see if it is acceptable."""
from flamenco.manager import ApiClient
from flamenco.manager.api import jobs_api
from flamenco.manager.models import SubmittedJob, Job
from flamenco.manager.models import Job, SubmittedJob
assert isinstance(job, SubmittedJob), "got %s" % type(job)
assert isinstance(api_client, ApiClient), "got %s" % type(api_client)
+1 -1
View File
@@ -10,7 +10,7 @@
"""
__version__ = "3.8.5"
__version__ = "3.10-alpha0"
# import ApiClient
from flamenco.manager.api_client import ApiClient
@@ -76,7 +76,7 @@ class ApiClient(object):
self.default_headers[header_name] = header_value
self.cookie = cookie
# Set default User-Agent.
self.user_agent = 'Flamenco/3.8.5 (Blender add-on)'
self.user_agent = 'Flamenco/3.10-alpha0 (Blender add-on)'
def __enter__(self):
return self
@@ -404,7 +404,7 @@ conf = flamenco.manager.Configuration(
"OS: {env}\n"\
"Python Version: {pyversion}\n"\
"Version of the API: 1.0.0\n"\
"SDK Package Version: 3.8.5".\
"SDK Package Version: 3.10-alpha0".\
format(env=sys.platform, pyversion=sys.version)
def get_host_settings(self):
+1 -1
View File
@@ -4,7 +4,7 @@ Render Farm manager API
The `flamenco.manager` package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 1.0.0
- Package version: 3.8.5
- Package version: 3.10-alpha0
- Build package: org.openapitools.codegen.languages.PythonClientCodegen
For more information, please visit [https://flamenco.blender.org/](https://flamenco.blender.org/)
+346 -382
View File
@@ -3,29 +3,33 @@
import datetime
import logging
import os
import time
from pathlib import Path, PurePosixPath
from typing import Optional, TYPE_CHECKING
from urllib3.exceptions import HTTPError, MaxRetryError
from pathlib import Path, PurePath, PurePosixPath
from types import ModuleType
from typing import TYPE_CHECKING, Optional
import bpy
from urllib3.exceptions import HTTPError, MaxRetryError
from . import job_types, job_submission, preferences, manager_info
from .job_types_propgroup import JobTypePropertyGroup
from . import job_submission, job_types, manager_info, preferences
from .bat.submodules import bpathlib
from .job_types_propgroup import JobTypePropertyGroup
if TYPE_CHECKING:
from .bat.interface import (
PackThread as _PackThread,
Message as _Message,
)
from .manager.models import (
Error as _Error,
SubmittedJob as _SubmittedJob,
from .bat.interface import (
PackThread as _PackThread,
)
from .manager.api_client import ApiClient as _ApiClient
from .manager.exceptions import ApiException as _ApiException
from .manager.models import (
Error as _Error,
)
from .manager.models import (
SubmittedJob as _SubmittedJob,
)
else:
_PackThread = object
_Message = object
@@ -34,6 +38,21 @@ else:
_ApiException = object
_Error = object
# Conditionally import BAT v2, as that version requires Blender 5.1+.
#
# Blender 5.1.0 still had some limitations, most importantly missing support for geometry nodes
# simulation caches (https://projects.blender.org/blender/blender/issues/155953). That should be
# fixed in 5.1.1 though.
bat_v2: ModuleType | None
if bpy.app.version >= (5, 1, 1) or TYPE_CHECKING:
from . import bat_v2
from .bat_v2.pack_fs import BATPacker
_BATPacker = BATPacker
else:
bat_v2 = None
_BATPacker = object
_log = logging.getLogger(__name__)
@@ -111,15 +130,21 @@ class FLAMENCO_OT_submit_job(FlamencoOpMixin, bpy.types.Operator):
job_name: bpy.props.StringProperty(name="Job Name") # type: ignore
job: Optional[_SubmittedJob] = None
temp_blendfile: Optional[Path] = None
ignore_version_mismatch: bpy.props.BoolProperty( # type: ignore
name="Ignore Version Mismatch",
default=False,
)
TIMER_PERIOD = 0.25 # seconds
TIMER_PERIOD_BAT_V2 = 0.01 # seconds
timer: Optional[bpy.types.Timer] = None
# For BAT v1:
packthread: Optional[_PackThread] = None
# For BAT v2:
bat_v2_packer: _BATPacker | None = None
bat_v2_packer_reported_error: bool = False
bat_v2_packer_missing_files: list[Path]
log = _log.getChild(bl_idname)
@@ -131,55 +156,96 @@ class FLAMENCO_OT_submit_job(FlamencoOpMixin, bpy.types.Operator):
return job_type is not None
def execute(self, context: bpy.types.Context) -> set[str]:
"""Submit the job files in a blocking way.
This allows scripted submission, which blocks the main thread until
the process is done.
"""
filepath, ok = self._presubmit_check(context)
if not ok:
return {"CANCELLED"}
is_running = self._submit_files(context, filepath)
# Use BAT v2 if available.
if bat_v2 is not None:
is_running = self._submit_files_bat_v2(context, filepath)
if not is_running:
return {"CANCELLED"}
# self.bat_v2_packer is None when no packing is necessary, that is, when the file is
# already on the shared storage.
while self.bat_v2_packer and self.bat_v2_packer.step():
pass
return self.bat_v2_packer_finalize_and_quit(context)
is_running = self._submit_files_bat_v1(context, filepath)
if not is_running:
return {"CANCELLED"}
if self.packthread is None:
# If there is no pack thread running, there isn't much we can do.
self.report({"ERROR"}, "No pack thread running, please report a bug")
self._quit(context)
return {"CANCELLED"}
# Keep handling messages from the background thread. That's only necessary if there is a
# background thread.
if self.packthread:
while True:
# Block for 5 seconds at a time. The exact duration doesn't matter,
# as this while-loop is blocking the main thread anyway.
msg = self.packthread.poll(timeout=5)
if not msg:
# No message received, is fine, just wait for another one.
continue
# Keep handling messages from the background thread.
while True:
# Block for 5 seconds at a time. The exact duration doesn't matter,
# as this while-loop is blocking the main thread anyway.
msg = self.packthread.poll(timeout=5)
if not msg:
# No message received, is fine, just wait for another one.
continue
result = self._on_bat_pack_msg(context, msg)
if "RUNNING_MODAL" not in result:
break
result = self._on_bat_pack_msg(context, msg)
if "RUNNING_MODAL" not in result:
break
self.packthread.join(timeout=5)
self._quit(context)
self.packthread.join(timeout=5)
return {"FINISHED"}
def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set[str]:
filepath, ok = self._presubmit_check(context)
if not ok:
return {"CANCELLED"}
is_running = self._submit_files(context, filepath)
if bat_v2 is None:
is_running = self._submit_files_bat_v1(context, filepath)
else:
is_running = self._submit_files_bat_v2(context, filepath)
if not is_running:
return {"CANCELLED"}
context.window_manager.modal_handler_add(self)
if bat_v2:
# Only BATv2 supports aborting the packing.
self.report({"INFO"}, "Flamenco: Submitting files, press ESC to abort")
wm = context.window_manager
self.timer = wm.event_timer_add(self.TIMER_PERIOD_BAT_V2, window=context.window)
wm.modal_handler_add(self)
return {"RUNNING_MODAL"}
def modal(self, context: bpy.types.Context, event: bpy.types.Event) -> set[str]:
wm = bpy.context.window_manager
# Only BAT v2 has support for aborting the packing operation.
should_abort = event.type == "ESC" or wm.flamenco_bat_status == "ABORTING"
if self.bat_v2_packer is not None and should_abort:
self.report({"WARNING"}, "Flamenco: Job submission aborted")
wm.flamenco_bat_status = "ABORTED"
wm.flamenco_bat_status_txt = ""
return self._quit(context)
# This function is called for TIMER events to poll the BAT pack thread.
if event.type != "TIMER":
return {"PASS_THROUGH"}
if self.bat_v2_packer is not None:
# BAT v2 pack is underway.
keep_going = self.bat_v2_packer.step()
if keep_going:
return {"RUNNING_MODAL"}
return self.bat_v2_packer_finalize_and_quit(context)
if self.packthread is None:
# If there is no pack thread running, there isn't much we can do.
return self._quit(context)
@@ -304,13 +370,7 @@ class FLAMENCO_OT_submit_job(FlamencoOpMixin, bpy.types.Operator):
We shouldn't overwrite the artist's file.
We can compress, since this file won't be managed by SVN and doesn't need diffability.
"""
render = context.scene.render
prefs = context.preferences
# Remember settings we need to restore after saving.
old_use_file_extension = render.use_file_extension
old_use_overwrite = render.use_overwrite
old_use_placeholder = render.use_placeholder
old_use_all_linked_data_direct = getattr(
prefs.experimental, "use_all_linked_data_direct", None
)
@@ -318,44 +378,23 @@ class FLAMENCO_OT_submit_job(FlamencoOpMixin, bpy.types.Operator):
# TODO: see about disabling the denoiser (like the old Blender Cloud addon did).
try:
# The file extension should be determined by the render settings, not necessarily
# by the settings in the output panel.
render.use_file_extension = True
# Rescheduling should not overwrite existing frames.
render.use_overwrite = False
render.use_placeholder = False
# To work around a shortcoming of BAT, ensure that all
# To work around a shortcoming of BAT v1, ensure that all
# indirectly-linked data is still saved as directly-linked.
#
# See `133dde41bb5b: Improve handling of (in)directly linked status
# for linked IDs` in Blender's Git repository.
if old_use_all_linked_data_direct is not None:
if bat_v2 is None and old_use_all_linked_data_direct is not None:
self.log.info(
"Overriding prefs.experimental.use_all_linked_data_direct = True"
)
prefs.experimental.use_all_linked_data_direct = True
filepath = Path(context.blend_data.filepath)
if job_submission.is_file_inside_job_storage(context, filepath):
self.log.info(
"Saving blendfile, already in shared storage: %s", filepath
)
if bpy.data.is_dirty:
self.log.info("Saving blendfile: %s", filepath)
bpy.ops.wm.save_as_mainfile()
else:
filepath = filepath.with_suffix(".flamenco.blend")
self.log.info("Saving copy to temporary file %s", filepath)
bpy.ops.wm.save_as_mainfile(
filepath=str(filepath), compress=True, copy=True
)
self.temp_blendfile = filepath
finally:
# Restore the settings we changed, even after an exception.
render.use_file_extension = old_use_file_extension
render.use_overwrite = old_use_overwrite
render.use_placeholder = old_use_placeholder
# Only restore if the property exists to begin with:
if old_use_all_linked_data_direct is not None:
prefs.experimental.use_all_linked_data_direct = (
@@ -364,135 +403,183 @@ class FLAMENCO_OT_submit_job(FlamencoOpMixin, bpy.types.Operator):
return filepath
def _bat_project_path(
self, context: bpy.types.Context, blendfile: Path
) -> Path:
"""BAT 'project' directory: preference root, or wider if links lie outside it.
Linked blends outside the configured project force BAT to use KEEP_PATH;
Shaman packing can then fail path rewriting. Using a common ancestor of
the submission blend and all linked library paths keeps assets inside
the project tree for BAT.
"""
prefs = preferences.get(context)
configured = bpathlib.make_absolute(
Path(bpy.path.abspath(str(prefs.project_root())))
)
blend_abs = bpathlib.make_absolute(blendfile)
lib_paths: list[Path] = []
for lib in bpy.data.libraries:
if not lib.filepath:
continue
lib_paths.append(
bpathlib.make_absolute(Path(bpy.path.abspath(lib.filepath)))
)
def is_under(root: Path, path: Path) -> bool:
try:
path.resolve().relative_to(root.resolve())
return True
except ValueError:
return False
need_widen = (not is_under(configured, blend_abs)) or any(
not is_under(configured, lp) for lp in lib_paths
)
if not need_widen:
return configured
all_paths = [blend_abs] + lib_paths
try:
common = Path(os.path.commonpath([str(p) for p in all_paths])).resolve()
except ValueError:
self.log.warning(
"Could not compute common path for BAT project root, using preferences"
)
return configured
self.log.info(
"BAT project root widened from %s to %s (assets outside preference project)",
configured,
common,
)
return common
def _convert_relpaths_to_absolute(self, context: bpy.types.Context) -> None:
"""Convert all relative paths in the blend file to absolute paths.
Covers libraries, images, clips, sounds, fonts, volumes, and cache_files
(point caches / GN caches). Allows the blend to be sent as-is without BAT.
"""
# Convert library paths to absolute
for library in bpy.data.libraries:
if library.filepath:
old_path = library.filepath
abs_path = bpy.path.abspath(library.filepath)
library.filepath = abs_path
self.log.debug("Converted library path: %s -> %s", old_path, abs_path)
# Convert image paths to absolute
for image in bpy.data.images:
if image.filepath and not image.packed_file:
old_path = image.filepath
abs_path = bpy.path.abspath(image.filepath)
image.filepath = abs_path
self.log.debug("Converted image path: %s -> %s", old_path, abs_path)
# Convert movie paths to absolute
for movie in bpy.data.movieclips:
if movie.filepath:
old_path = movie.filepath
abs_path = bpy.path.abspath(movie.filepath)
movie.filepath = abs_path
self.log.debug("Converted movie path: %s -> %s", old_path, abs_path)
# Convert sound paths to absolute
for sound in bpy.data.sounds:
if sound.filepath:
old_path = sound.filepath
abs_path = bpy.path.abspath(sound.filepath)
sound.filepath = abs_path
self.log.debug("Converted sound path: %s -> %s", old_path, abs_path)
# Convert font paths to absolute (skip VectorFont - its filepath is read-only)
for font in bpy.data.fonts:
if font.filepath:
try:
old_path = font.filepath
abs_path = bpy.path.abspath(font.filepath)
font.filepath = abs_path
self.log.debug("Converted font path: %s -> %s", old_path, abs_path)
except (TypeError, AttributeError):
self.log.debug("Skipping font %s (filepath is read-only)", font.name)
# Convert volume paths to absolute
for volume in bpy.data.volumes:
if volume.filepath:
old_path = volume.filepath
abs_path = bpy.path.abspath(volume.filepath)
volume.filepath = abs_path
self.log.debug("Converted volume path: %s -> %s", old_path, abs_path)
# Point / mesh cache files (e.g. .pc2), geometry-nodes caches, etc.
for cache_file in bpy.data.cache_files:
if cache_file.filepath:
old_path = cache_file.filepath
abs_path = bpy.path.abspath(cache_file.filepath)
cache_file.filepath = abs_path
self.log.debug("Converted cache_file path: %s -> %s", old_path, abs_path)
def _submit_files(self, context: bpy.types.Context, blendfile: Path) -> bool:
def _submit_files_bat_v2(self, context: bpy.types.Context, blendfile: Path) -> bool:
"""Ensure that the files are somewhere in the shared storage.
Returns True if a packing thread has been started, and False otherwise.
"""
prefs = preferences.get(context)
if prefs.bat_bypass:
return self._submit_files_bat_bypass(context, blendfile)
from .bat_v2 import bat_version, pack_fs, pack_shaman
self.log.info("Submitting files via BAT %s", bat_version())
# Reset state from any previous run.
self.bat_v2_packer = None
self.bat_v2_packer_reported_error = False
self.bat_v2_packer_missing_files = []
manager = self._manager_info(context)
if not manager:
return False
# Get the project root and double-check its existence.
prefs = preferences.get(context)
project_path: Path = prefs.project_root()
assert project_path.is_absolute(), (
"Expecting project path {!s} to be an absolute path".format(project_path)
)
if not project_path.exists():
self.report(
{"ERROR"}, "Project path {!s} does not exist".format(project_path)
)
raise FileNotFoundError()
if job_submission.is_file_inside_job_storage(context, blendfile):
self.log.info(
"File is already in job storage location, submitting it as-is"
)
self._use_blendfile_directly(context, blendfile)
return True
if manager.shared_storage.shaman_enabled:
# Pack to the Shaman server.
self.log.info("Copying BAT pack to Shaman storage")
batpacker = pack_shaman.pack_start(
project_root=project_path,
reporter=self,
use_relative_only=prefs.use_relative_only,
api_client=self.get_api_client(context),
checkout_path=PurePosixPath(self.job_name),
ignore_globs=prefs.ignore_globs(),
)
batpacker.start()
# When packing via Shaman, the Shaman server determines the final
# location of the blend file, and so it's not known yet.
self.blendfile_on_farm = None
else:
# Pack to the filesystem.
unique_dir = "%s-%s" % (
datetime.datetime.now().isoformat("-").replace(":", ""),
self.job_name,
)
pack_target_dir = Path(manager.shared_storage.location) / unique_dir
self.log.info("Copying BAT pack to shared storage: %s", pack_target_dir)
batpacker = pack_fs.pack_start(
project_root=project_path,
reporter=self,
use_relative_only=prefs.use_relative_only,
pack_target_dir=pack_target_dir,
ignore_globs=prefs.ignore_globs(),
)
batpacker.start()
# When packing to the filesystem, the final path of the file on the
# farm is known immediately.
source_file_info = batpacker.source_file_info()
abspath_on_farm = pack_target_dir / source_file_info.relpath_in_pack
self.blendfile_on_farm = PurePosixPath(abspath_on_farm.as_posix())
self.log.info(" %s", abspath_on_farm)
self.bat_v2_packer = batpacker
# Start the timer for periodic updates of the packing process. This
# needs a relatively fast update cycle, as each file to be copied needs
# its own update.
#
# TODO: if blocking the UI for each file copy gets too annoying, move
# the process to a separate thread.
wm = context.window_manager
wm.flamenco_bat_status = "INVESTIGATING"
wm.flamenco_can_abort = True # Only BAT v2 can abort.
return True
def bat_v2_packer_finalize_and_quit(self, context: bpy.types.Context) -> set[str]:
if self.bat_v2_packer_reported_error:
# The errors themselves should have been reported already.
context.window_manager.flamenco_bat_status = "ABORTED"
return self._quit(context)
if self.bat_v2_packer is not None:
# BAT v2 pack is done.
self.blendfile_on_farm = self.bat_v2_packer.blendfile_location_in_pack()
self._submit_job(context)
return self._quit(context)
# Reporter Protocol for our BAT v2 interface.
# See `BATPackReporter` in BAT's `blender_asset_tracer/pack.py`.
def on_error_on_error(self, errormsg: str, ex: Exception) -> None:
import traceback
self.bat_v2_packer_reported_error = True
# This callback is only called on serious errors that likely indicate
# bugs, namely when either `on_copy_error()` or `on_rewrite_error()`
# caused an exception themselves.
print(60 * "-")
print("Flamenco ran into an error while sending files to the farm:")
print()
print(errormsg)
print()
traceback.print_exception(ex)
bug_report_url = "https://flamenco.blender.org/get-involved"
print("Please copy-paste the above into a bug report at", bug_report_url)
print()
print(60 * "-")
self.report({"ERROR"}, "Flamenco: Error sending files, check the terminal")
def on_copy_start(self, src: Path, dest: PurePath) -> None:
bpy.context.window_manager.flamenco_bat_status = "TRANSFERRING"
self.log.info("Uploading %s", dest)
bpy.context.window_manager.flamenco_bat_status_txt = "Uploading {!s}".format(
dest.name
)
def on_copy_done(self, src: Path, dest: PurePath) -> None:
assert self.bat_v2_packer is not None
num_total, num_done = self.bat_v2_packer.num_files_to_transfer()
if num_total < 0:
progress = 0
else:
progress = int(100 * num_done / num_total)
bpy.context.window_manager.flamenco_bat_progress = progress
def on_copy_error(self, src: Path, dest: PurePath, errormsg: str) -> None:
self.bat_v2_packer_reported_error = True
self.report({"ERROR"}, "Copying {!s} to {!s}: {!s}".format(src, dest, errormsg))
def on_rewrite_error(self, blendfile: Path, save_as: Path, errormsg: str) -> None:
self.bat_v2_packer_reported_error = True
self.report({"ERROR"}, "Rewriting {!s}: {!s}".format(blendfile, errormsg))
def on_rewrite_start(self, blendfile: Path, save_as: Path) -> None:
self.report({"INFO"}, "Rewriting {!s}".format(blendfile.name))
wm = bpy.context.window_manager
wm.flamenco_bat_status = "REWRITING"
wm.flamenco_bat_status_txt = blendfile.name
def on_rewrite_done(self, blendfile: Path, save_as: Path) -> None:
pass
def on_missing_file(self, blendfile: Path, relpath_in_pack: PurePath) -> None:
self.bat_v2_packer_missing_files.append(blendfile)
self.report({"WARNING"}, "Missing file: {!s}".format(blendfile))
# End of Reporter Protocol.
def _submit_files_bat_v1(self, context: bpy.types.Context, blendfile: Path) -> bool:
"""Ensure that the files are somewhere in the shared storage.
Returns True if a packing thread has been started, and False otherwise.
"""
from .bat import bat_version
from .bat import interface as bat_interface
self.log.info("Submitting files via BAT %s", bat_version())
if bat_interface.is_packing():
self.report({"ERROR"}, "Another packing operation is running")
self._quit(context)
@@ -517,190 +604,19 @@ class FLAMENCO_OT_submit_job(FlamencoOpMixin, bpy.types.Operator):
"File is not already in job storage location, copying it there"
)
try:
self.blendfile_on_farm = self._bat_pack_filesystem(context, blendfile)
self.blendfile_on_farm = self._bat_v1_pack_filesystem(
context, blendfile
)
except FileNotFoundError:
self._quit(context)
return False
wm = context.window_manager
self.timer = wm.event_timer_add(self.TIMER_PERIOD, window=context.window)
wm.flamenco_can_abort = False # Only BAT v2 can abort.
return True
def _submit_files_bat_bypass(self, context: bpy.types.Context, blendfile: Path) -> bool:
"""Bypass BAT: absolute paths, then upload or copy the blend only."""
manager = self._manager_info(context)
if not manager:
return False
self.log.info("Converting all relative paths to absolute")
self._convert_relpaths_to_absolute(context)
self.log.info("Saving blend file with absolute paths")
blendfile = self._save_blendfile(context)
blendfile = bpathlib.make_absolute(blendfile)
if manager.shared_storage.shaman_enabled:
self.log.info("Uploading blend file directly to Shaman (bypassing BAT)")
self._upload_blendfile_to_shaman(context, blendfile)
self._quit(context)
return False
if job_submission.is_file_inside_job_storage(context, blendfile):
self.log.info(
"File is already in job storage location, submitting it as-is"
)
self._use_blendfile_directly(context, blendfile)
return False
self.log.info(
"File is not already in job storage location, copying it there"
)
try:
self._copy_blendfile_to_storage(context, blendfile)
except FileNotFoundError:
self._quit(context)
return False
return False
def _upload_blendfile_to_shaman(
self, context: bpy.types.Context, blendfile: Path
) -> None:
"""Upload blend file directly to Shaman without BAT.
Creates a Shaman checkout with just the blend file, maintaining its
relative path from the project root.
"""
from .bat import cache
from .manager.apis import ShamanApi
from .manager.models import (
ShamanFileSpec,
ShamanCheckout,
)
from .manager.exceptions import ApiException
from . import preferences
api_client = self.get_api_client(context)
shaman_api = ShamanApi(api_client)
# Get project root to calculate relative path
prefs = preferences.get(context)
project_path: Path = prefs.project_root()
project_path = bpathlib.make_absolute(Path(bpy.path.abspath(str(project_path))))
# Calculate relative path from project root
try:
blendfile_rel_path = blendfile.relative_to(project_path)
# Convert to POSIX path for Shaman
blendfile_path_in_checkout = PurePosixPath(blendfile_rel_path.as_posix())
except ValueError:
# Blend file is not under project root, use just the filename
self.log.warning(
"Blend file %s is not under project root %s, using filename only",
blendfile,
project_path,
)
blendfile_path_in_checkout = PurePosixPath(blendfile.name)
# Compute checksum and file size
self.log.info("Computing checksum for %s", blendfile.name)
checksum = cache.compute_cached_checksum(blendfile)
filesize = blendfile.stat().st_size
# Upload the blend file to Shaman
self.log.info("Uploading blend file to Shaman: %s", blendfile.name)
try:
with blendfile.open("rb") as file_reader:
shaman_api.shaman_file_store(
checksum=checksum,
filesize=filesize,
body=file_reader,
x_shaman_can_defer_upload=True,
x_shaman_original_filename=blendfile.name,
)
except ApiException as ex:
if ex.status == 208:
# File already known to Shaman
self.log.info("Blend file already known to Shaman")
elif ex.status == 425:
# Defer upload - someone else is uploading
self.log.info("Blend file is being uploaded by another client, deferring")
# Retry after a short delay
import time
time.sleep(1)
with blendfile.open("rb") as file_reader:
shaman_api.shaman_file_store(
checksum=checksum,
filesize=filesize,
body=file_reader,
x_shaman_can_defer_upload=False,
x_shaman_original_filename=blendfile.name,
)
else:
self.log.error("Error uploading to Shaman: %s", ex)
self.report({"ERROR"}, f"Error uploading to Shaman: {ex}")
return
# Create checkout definition with just the blend file
checkout_path = self._shaman_checkout_path()
filespec = ShamanFileSpec(
sha=checksum,
size=filesize,
path=str(blendfile_path_in_checkout), # Relative path from project root
)
# Create the checkout
self.log.info("Creating Shaman checkout: %s", checkout_path)
self.log.info("Blend file path in checkout: %s", blendfile_path_in_checkout)
checkout = ShamanCheckout(
files=[filespec],
checkout_path=str(checkout_path),
)
try:
result = shaman_api.shaman_checkout(checkout)
self.actual_shaman_checkout_path = PurePosixPath(result.checkout_path)
# The checkout itself is created in a unique subdirectory. The job's
# blendfile must include that checkout path.
self.blendfile_on_farm = (
PurePosixPath("{jobs}")
/ self.actual_shaman_checkout_path
/ blendfile_path_in_checkout
)
self.log.info("Shaman checkout created: %s", self.actual_shaman_checkout_path)
self._submit_job(context)
except ApiException as ex:
self.log.error("Error creating Shaman checkout: %s", ex)
self.report({"ERROR"}, f"Error creating Shaman checkout: {ex}")
return
def _copy_blendfile_to_storage(
self, context: bpy.types.Context, blendfile: Path
) -> None:
"""Copy blend file to job storage without BAT."""
import shutil
manager = self._manager_info(context)
if not manager:
raise FileNotFoundError("Manager info not known")
unique_dir = "%s-%s" % (
datetime.datetime.now().isoformat("-").replace(":", ""),
self.job_name,
)
pack_target_dir = Path(manager.shared_storage.location) / unique_dir
pack_target_dir.mkdir(parents=True, exist_ok=True)
pack_target_file = pack_target_dir / blendfile.name
self.log.info("Copying blend file to %s", pack_target_file)
shutil.copy2(blendfile, pack_target_file)
self.blendfile_on_farm = PurePosixPath(pack_target_file.as_posix())
self.actual_shaman_checkout_path = None
self._submit_job(context)
def _bat_pack_filesystem(
def _bat_v1_pack_filesystem(
self, context: bpy.types.Context, blendfile: Path
) -> PurePosixPath:
"""Use BAT to store the pack on the filesystem.
@@ -709,7 +625,9 @@ class FLAMENCO_OT_submit_job(FlamencoOpMixin, bpy.types.Operator):
"""
from .bat import interface as bat_interface
project_path = self._bat_project_path(context, blendfile)
# Get project path from addon preferences.
prefs = preferences.get(context)
project_path: Path = prefs.project_root()
project_path = bpathlib.make_absolute(Path(bpy.path.abspath(str(project_path))))
if not project_path.exists():
@@ -734,10 +652,8 @@ class FLAMENCO_OT_submit_job(FlamencoOpMixin, bpy.types.Operator):
base_blendfile=blendfile,
project=project_path,
target=str(pack_target_dir),
exclusion_filter="", # TODO: get from GUI.
# False: relative_only=True can leave linked blends on KEEP_PATH and hit BAT
# _rewrite_paths assertions with Shaman (same as stock 3.8.x + BAT 1.x).
relative_only=False,
exclusion_filter=prefs.exclusion_filter,
relative_only=prefs.use_relative_only,
)
return PurePosixPath(pack_target_file.as_posix())
@@ -762,21 +678,23 @@ class FLAMENCO_OT_submit_job(FlamencoOpMixin, bpy.types.Operator):
"""
from .bat import (
interface as bat_interface,
)
from .bat import (
shaman as bat_shaman,
)
assert self.job is not None
self.log.info("Sending BAT pack to Shaman")
project_path = self._bat_project_path(context, blendfile)
prefs = preferences.get(context)
project_path: Path = prefs.project_root()
self.packthread = bat_interface.copy(
base_blendfile=blendfile,
project=project_path,
target="/", # Target directory irrelevant for Shaman transfers.
exclusion_filter="", # TODO: get from GUI.
# See _bat_pack_filesystem: avoid BAT+Shaman KEEP_PATH / _rewrite_paths failure.
relative_only=False,
exclusion_filter=prefs.exclusion_filter,
relative_only=prefs.use_relative_only,
packer_class=bat_shaman.Packer,
packer_kwargs=dict(
api_client=self.get_api_client(context),
@@ -821,10 +739,6 @@ class FLAMENCO_OT_submit_job(FlamencoOpMixin, bpy.types.Operator):
def _use_blendfile_directly(
self, context: bpy.types.Context, blendfile: Path
) -> None:
# The temporary '.flamenco.blend' file should not be deleted, as it
# will be used directly by the render job.
self.temp_blendfile = None
# The blend file is contained in the job storage path, no need to
# copy anything.
self.blendfile_on_farm = bpathlib.make_absolute(blendfile)
@@ -872,10 +786,13 @@ class FLAMENCO_OT_submit_job(FlamencoOpMixin, bpy.types.Operator):
if not self._prepare_job_for_submission(context):
return
context.window_manager.flamenco_bat_status = "COMMUNICATING"
context.window_manager.flamenco_bat_status_txt = ""
api_client = self.get_api_client(context)
try:
submitted_job = job_submission.submit_job(self.job, api_client)
except MaxRetryError as ex:
except MaxRetryError:
self.report({"ERROR"}, "Unable to reach Flamenco Manager")
return
except HTTPError as ex:
@@ -895,7 +812,27 @@ class FLAMENCO_OT_submit_job(FlamencoOpMixin, bpy.types.Operator):
self.report({"ERROR"}, f"Could not submit job: {ex.reason}")
return
self.report({"INFO"}, "Job %s submitted" % submitted_job.name)
# Show a final report.
if self.bat_v2_packer:
num_missing_files = len(self.bat_v2_packer_missing_files)
else:
# Only BATv2 tracks missing files like this.
num_missing_files = 0
if num_missing_files:
self.report(
{"WARNING"},
"Job {!s} submitted (with {:d} missing files)".format(
submitted_job.name, num_missing_files
),
)
context.window_manager.flamenco_bat_status_txt = (
"Submitted with {:d} missing files".format(num_missing_files)
)
else:
self.report({"INFO"}, "Job {!s} submitted".format(submitted_job.name))
context.window_manager.flamenco_bat_status_txt = ""
context.window_manager.flamenco_bat_status = "DONE"
def _check_job(self, context: bpy.types.Context) -> bool:
"""Use the Flamenco API to check the Job before submitting files.
@@ -911,7 +848,7 @@ class FLAMENCO_OT_submit_job(FlamencoOpMixin, bpy.types.Operator):
api_client = self.get_api_client(context)
try:
job_submission.submit_job_check(self.job, api_client)
except MaxRetryError as ex:
except MaxRetryError:
self.report({"ERROR"}, "Unable to reach Flamenco Manager")
return False
except HTTPError as ex:
@@ -938,9 +875,10 @@ class FLAMENCO_OT_submit_job(FlamencoOpMixin, bpy.types.Operator):
Does neither check nor abort the BAT pack thread.
"""
if self.temp_blendfile is not None:
self.log.info("Removing temporary file %s", self.temp_blendfile)
self.temp_blendfile.unlink(missing_ok=True)
if self.bat_v2_packer is not None and not self.bat_v2_packer.is_done:
self.log.info("Aborting BAT packer")
self.bat_v2_packer.abort()
self.bat_v2_packer = None
if self.timer is not None:
context.window_manager.event_timer_remove(self.timer)
@@ -948,6 +886,31 @@ class FLAMENCO_OT_submit_job(FlamencoOpMixin, bpy.types.Operator):
return {"FINISHED"}
class FLAMENCO_OT_abort(bpy.types.Operator):
bl_idname = "flamenco.abort"
bl_label = "Abort"
bl_description = (
"Abort a running job submission.\nBlender make take a while to respond to this"
)
ABORTABLE_STATES = {
"INVESTIGATING",
"REWRITING",
"TRANSFERRING",
"COMMUNICATING",
}
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
wm = context.window_manager
return wm.flamenco_can_abort and wm.flamenco_bat_status in cls.ABORTABLE_STATES
def execute(self, context: bpy.types.Context) -> set[str]:
wm = context.window_manager
wm.flamenco_bat_status = "ABORTING"
return {"FINISHED"}
class FLAMENCO3_OT_explore_file_path(bpy.types.Operator):
"""Opens the given path in a file explorer.
@@ -963,8 +926,8 @@ class FLAMENCO3_OT_explore_file_path(bpy.types.Operator):
)
def execute(self, context):
import platform
import pathlib
import platform
# Possibly open a parent of the path
to_open = pathlib.Path(self.path)
@@ -1001,6 +964,7 @@ classes = (
FLAMENCO_OT_ping_manager,
FLAMENCO_OT_eval_setting,
FLAMENCO_OT_submit_job,
FLAMENCO_OT_abort,
FLAMENCO3_OT_explore_file_path,
)
register, unregister = bpy.utils.register_classes_factory(classes)
+26 -17
View File
@@ -5,7 +5,7 @@ from pathlib import Path
import bpy
from . import projects, manager_info
from . import manager_info, projects
def discard_flamenco_client(context):
@@ -65,16 +65,6 @@ class FlamencoPreferences(bpy.types.AddonPreferences):
items=_project_finder_enum_items,
)
bat_bypass: bpy.props.BoolProperty( # type: ignore
name="Bypass BAT",
description=(
"When enabled, submission skips Blender Asset Tracer: paths in the blend "
"are written as absolute, then the blend is uploaded or copied without a BAT pack. "
"When disabled, Flamenco uses the normal BAT pack (Shaman or job storage)"
),
default=True,
)
# Property that gets its value from the above _job_storage, and cannot be
# set. This makes it read-only in the GUI.
job_storage_for_gui: bpy.props.StringProperty( # type: ignore
@@ -86,6 +76,21 @@ class FlamencoPreferences(bpy.types.AddonPreferences):
get=lambda prefs: prefs._job_storage(),
)
use_relative_only: bpy.props.BoolProperty( # type: ignore
name="Relative Paths Only",
default=True,
description="When sending files to Flamenco, only include assets that are referenced by "
"relative path. Absolute paths are then assumed to be valid on all Workers. When turned "
"off, all files are sent, regardless of how they are referenced",
)
exclusion_filter: bpy.props.StringProperty( # type: ignore
name="Exclusion Filter",
default="",
description="Space-separated list of file glob patterns. When sending files to Flamenco, "
"exclude any file that matches a pattern in this list. For example: '*.abc *.vdb' to skip "
"copying all Alembic and OpenVDB files",
)
def draw(self, context: bpy.types.Context) -> None:
layout = self.layout
layout.use_property_decorate = False
@@ -128,9 +133,9 @@ class FlamencoPreferences(bpy.types.AddonPreferences):
else:
text_row(col, str(project_root))
col = layout.column(align=True)
col.label(text="Submission")
col.prop(self, "bat_bypass")
col = layout.column(heading="File Submission")
col.prop(self, "use_relative_only")
col.prop(self, "exclusion_filter")
def project_root(self) -> Path:
"""Use the configured project finder to find the project root directory."""
@@ -150,13 +155,17 @@ class FlamencoPreferences(bpy.types.AddonPreferences):
return "Unknown, refresh first."
return str(info.shared_storage.location)
def ignore_globs(self) -> set[str]:
"""Return exclusion filter as set of strings."""
return set(self.exclusion_filter.strip().split())
def get(context: bpy.types.Context) -> FlamencoPreferences:
"""Return the add-on preferences."""
prefs = context.preferences.addons["flamenco"].preferences
assert isinstance(
prefs, FlamencoPreferences
), "Expected FlamencoPreferences, got %s instead" % (type(prefs))
assert isinstance(prefs, FlamencoPreferences), (
"Expected FlamencoPreferences, got %s instead" % (type(prefs))
)
return prefs
+25 -9
View File
@@ -4,17 +4,34 @@
import contextlib
import importlib
from pathlib import Path
import sys
import logging
import sys
from pathlib import Path
from types import ModuleType
from typing import Iterator, Iterable
from typing import Iterable, Iterator
_my_dir = Path(__file__).parent
_log = logging.getLogger(__name__)
def load_wheel(module_name: str, submodules: Iterable[str]) -> list[ModuleType]:
def filename(
module_name: str,
*,
filename_prefix: str = "",
) -> Path:
"""Returns the filename of the wheel file for this module."""
if not filename_prefix:
filename_prefix = _fname_prefix_from_module_name(module_name)
return _wheel_filename(filename_prefix)
def load_wheel(
module_name: str,
submodules: Iterable[str],
*,
filename_prefix: str = "",
) -> list[ModuleType]:
"""Loads modules from a wheel file 'module_name*.whl'.
Loads `module_name`, and if submodules are given, loads
@@ -25,8 +42,7 @@ def load_wheel(module_name: str, submodules: Iterable[str]) -> list[ModuleType]:
Returns the loaded modules, so [module, submodule, submodule, ...].
"""
fname_prefix = _fname_prefix_from_module_name(module_name)
wheel = _wheel_filename(fname_prefix)
wheel = filename(module_name, filename_prefix=filename_prefix)
loaded_modules: list[ModuleType] = []
to_load = [module_name] + [f"{module_name}.{submodule}" for submodule in submodules]
@@ -47,9 +63,9 @@ def load_wheel(module_name: str, submodules: Iterable[str]) -> list[ModuleType]:
loaded_modules.append(module)
_log.info("Loaded %s from %s", modname, module.__file__)
assert len(loaded_modules) == len(
to_load
), f"expecting to load {len(to_load)} modules, but only have {len(loaded_modules)}: {loaded_modules}"
assert len(loaded_modules) == len(to_load), (
f"expecting to load {len(to_load)} modules, but only have {len(loaded_modules)}: {loaded_modules}"
)
return loaded_modules