work: restore shift+spacebar for media play/pause
maybe put in maya config? idk what funiman's preference is
This commit is contained in:
@@ -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."),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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__
|
||||
@@ -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)
|
||||
@@ -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
|
||||
):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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/)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user