424 lines
15 KiB
Python
424 lines
15 KiB
Python
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
# <pep8 compliant>
|
|
|
|
import datetime
|
|
import logging
|
|
from pathlib import Path, PurePosixPath
|
|
from typing import Optional, TYPE_CHECKING
|
|
from urllib3.exceptions import HTTPError, MaxRetryError
|
|
|
|
import bpy
|
|
|
|
from . import job_types, job_submission, preferences
|
|
from .job_types_propgroup import JobTypePropertyGroup
|
|
|
|
if TYPE_CHECKING:
|
|
from .bat.interface import (
|
|
PackThread as _PackThread,
|
|
Message as _Message,
|
|
)
|
|
from .manager.models import SubmittedJob as _SubmittedJob
|
|
else:
|
|
_PackThread = object
|
|
_Message = object
|
|
_SubmittedJob = object
|
|
|
|
_log = logging.getLogger(__name__)
|
|
|
|
|
|
class FlamencoOpMixin:
|
|
@staticmethod
|
|
def get_api_client(context):
|
|
"""Get a Flamenco API client to talk to the Manager.
|
|
|
|
Getting the client also loads the dependencies, so only import things
|
|
from `flamenco.manager` after calling this function.
|
|
"""
|
|
from . import comms, preferences
|
|
|
|
manager_url = preferences.manager_url(context)
|
|
api_client = comms.flamenco_api_client(manager_url)
|
|
return api_client
|
|
|
|
|
|
class FLAMENCO_OT_fetch_job_types(FlamencoOpMixin, bpy.types.Operator):
|
|
bl_idname = "flamenco.fetch_job_types"
|
|
bl_label = "Fetch Job Types"
|
|
bl_description = "Query Flamenco Manager to obtain the available job types"
|
|
|
|
def execute(self, context: bpy.types.Context) -> set[str]:
|
|
api_client = self.get_api_client(context)
|
|
|
|
from flamenco.manager import ApiException
|
|
|
|
scene = context.scene
|
|
old_job_type_name = getattr(scene, "flamenco_job_type", "")
|
|
|
|
try:
|
|
job_types.fetch_available_job_types(api_client, scene)
|
|
except ApiException as ex:
|
|
self.report({"ERROR"}, "Error getting job types: %s" % ex)
|
|
return {"CANCELLED"}
|
|
except MaxRetryError as ex:
|
|
# This is the common error, when for example the port number is
|
|
# incorrect and nothing is listening.
|
|
self.report({"ERROR"}, "Unable to reach Manager")
|
|
return {"CANCELLED"}
|
|
|
|
if old_job_type_name:
|
|
# TODO: handle cases where the old job type no longer exists.
|
|
scene.flamenco_job_type = old_job_type_name
|
|
|
|
job_types.update_job_type_properties(scene)
|
|
return {"FINISHED"}
|
|
|
|
|
|
class FLAMENCO_OT_ping_manager(FlamencoOpMixin, bpy.types.Operator):
|
|
bl_idname = "flamenco.ping_manager"
|
|
bl_label = "Flamenco: Ping Manager"
|
|
bl_description = "Attempt to connect to the Manager"
|
|
bl_options = {"REGISTER"} # No UNDO.
|
|
|
|
def execute(self, context: bpy.types.Context) -> set[str]:
|
|
api_client = self.get_api_client(context)
|
|
|
|
from flamenco.manager import ApiException
|
|
from flamenco.manager.apis import MetaApi
|
|
from flamenco.manager.models import FlamencoVersion, ManagerConfiguration
|
|
|
|
context.window_manager.flamenco_status_ping = "..."
|
|
|
|
meta_api = MetaApi(api_client)
|
|
try:
|
|
version: FlamencoVersion = meta_api.get_version()
|
|
config: ManagerConfiguration = meta_api.get_configuration()
|
|
except ApiException as ex:
|
|
report = "Manager cannot be reached: %s" % ex
|
|
level = "ERROR"
|
|
except MaxRetryError as ex:
|
|
# This is the common error, when for example the port number is
|
|
# incorrect and nothing is listening.
|
|
report = "Manager cannot be reached"
|
|
level = "WARNING"
|
|
except HTTPError as ex:
|
|
report = "Manager cannot be reached: %s" % ex
|
|
level = "ERROR"
|
|
else:
|
|
report = "%s version %s found" % (version.name, version.version)
|
|
level = "INFO"
|
|
|
|
# Store whether this Manager supports the Shaman API.
|
|
prefs = preferences.get(context)
|
|
prefs.is_shaman_enabled = config.shaman_enabled
|
|
prefs.job_storage = config.storage_location
|
|
|
|
self.report({level}, report)
|
|
context.window_manager.flamenco_status_ping = report
|
|
return {"FINISHED"}
|
|
|
|
|
|
class FLAMENCO_OT_eval_setting(FlamencoOpMixin, bpy.types.Operator):
|
|
bl_idname = "flamenco.eval_setting"
|
|
bl_label = "Flamenco: Evalutate Setting Value"
|
|
bl_description = "Automatically determine a suitable value"
|
|
bl_options = {"REGISTER", "INTERNAL", "UNDO"}
|
|
|
|
setting_key: bpy.props.StringProperty(name="Setting Key") # type: ignore
|
|
setting_eval: bpy.props.StringProperty(name="Python Expression") # type: ignore
|
|
|
|
def execute(self, context: bpy.types.Context) -> set[str]:
|
|
job = job_submission.job_for_scene(context.scene)
|
|
if job is None:
|
|
self.report({"ERROR"}, "This Scene has no Flamenco job")
|
|
return {"CANCELLED"}
|
|
|
|
propgroup: JobTypePropertyGroup = context.scene.flamenco_job_settings
|
|
propgroup.eval_and_assign(context, job, self.setting_key, self.setting_eval)
|
|
return {"FINISHED"}
|
|
|
|
|
|
class FLAMENCO_OT_submit_job(FlamencoOpMixin, bpy.types.Operator):
|
|
bl_idname = "flamenco.submit_job"
|
|
bl_label = "Flamenco: Submit Job"
|
|
bl_description = "Pack the current blend file and send it to Flamenco"
|
|
bl_options = {"REGISTER"} # No UNDO.
|
|
|
|
blendfile_on_farm: Optional[PurePosixPath] = None
|
|
job_name: bpy.props.StringProperty(name="Job Name") # type: ignore
|
|
job: Optional[_SubmittedJob] = None
|
|
|
|
timer: Optional[bpy.types.Timer] = None
|
|
packthread: Optional[_PackThread] = None
|
|
|
|
log = _log.getChild(bl_idname)
|
|
|
|
def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set[str]:
|
|
filepath = self._save_blendfile(context)
|
|
|
|
# Construct the Job locally before trying to pack. If any validations fail, better fail early.
|
|
self.job = job_submission.job_for_scene(context.scene)
|
|
if self.job is None:
|
|
self.report({"ERROR"}, "Unable to create job")
|
|
return {"CANCELLED"}
|
|
|
|
return self._bat_pack(context, filepath)
|
|
|
|
def modal(self, context: bpy.types.Context, event: bpy.types.Event) -> set[str]:
|
|
# This function is called for TIMER events to poll the BAT pack thread.
|
|
if event.type != "TIMER":
|
|
return {"PASS_THROUGH"}
|
|
|
|
if self.packthread is None:
|
|
# If there is no pack thread running, there isn't much we can do.
|
|
return self._quit(context)
|
|
|
|
msg = self.packthread.poll()
|
|
if not msg:
|
|
return {"RUNNING_MODAL"}
|
|
|
|
return self._on_bat_pack_msg(context, msg)
|
|
|
|
def _save_blendfile(self, context):
|
|
"""Save to a different file, specifically for Flamenco.
|
|
|
|
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
|
|
|
|
# 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
|
|
|
|
# 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 setttings in the output panel.
|
|
render.use_file_extension = True
|
|
|
|
# Rescheduling should not overwrite existing frames.
|
|
render.use_overwrite = False
|
|
render.use_placeholder = False
|
|
|
|
filepath = Path(context.blend_data.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
|
|
)
|
|
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
|
|
|
|
return filepath
|
|
|
|
def _bat_pack(self, context: bpy.types.Context, blendfile: Path) -> set[str]:
|
|
from .bat import interface as bat_interface
|
|
|
|
if bat_interface.is_packing():
|
|
self.report({"ERROR"}, "Another packing operation is running")
|
|
self._quit(context)
|
|
return {"CANCELLED"}
|
|
|
|
prefs = preferences.get(context)
|
|
if prefs.is_shaman_enabled:
|
|
blendfile_on_farm = self._bat_pack_shaman(context, blendfile)
|
|
else:
|
|
blendfile_on_farm = self._bat_pack_filesystem(context, blendfile)
|
|
|
|
self.blendfile_on_farm = blendfile_on_farm
|
|
|
|
context.window_manager.modal_handler_add(self)
|
|
wm = context.window_manager
|
|
self.timer = wm.event_timer_add(0.25, window=context.window)
|
|
|
|
return {"RUNNING_MODAL"}
|
|
|
|
def _bat_pack_filesystem(
|
|
self, context: bpy.types.Context, blendfile: Path
|
|
) -> PurePosixPath:
|
|
"""Use BAT to store the pack on the filesystem.
|
|
|
|
:return: the path of the blend file, for use in the job definition.
|
|
"""
|
|
from .bat import interface as bat_interface
|
|
|
|
# TODO: get project path from addon preferences / project definition on Manager.
|
|
project_path = blendfile.parent
|
|
try:
|
|
project_path = Path(bpy.path.abspath(str(project_path))).resolve()
|
|
except FileNotFoundError:
|
|
# Path.resolve() will raise a FileNotFoundError if the project path doesn't exist.
|
|
self.report({"ERROR"}, "Project path %s does not exist" % project_path)
|
|
raise # TODO: handle this properly.
|
|
|
|
# Determine where the blend file will be stored.
|
|
unique_dir = "%s-%s" % (
|
|
datetime.datetime.now().isoformat("-").replace(":", ""),
|
|
self.job_name,
|
|
)
|
|
prefs = preferences.get(context)
|
|
pack_target_dir = Path(prefs.job_storage) / unique_dir
|
|
|
|
# TODO: this should take the blendfile location relative to the project path into account.
|
|
pack_target_file = pack_target_dir / blendfile.name
|
|
self.log.info("Will store blend file at %s", pack_target_file)
|
|
|
|
self.packthread = bat_interface.copy(
|
|
base_blendfile=blendfile,
|
|
project=project_path,
|
|
target=str(pack_target_dir),
|
|
exclusion_filter="", # TODO: get from GUI.
|
|
relative_only=True, # TODO: get from GUI.
|
|
)
|
|
|
|
return PurePosixPath(pack_target_file.as_posix())
|
|
|
|
def _bat_pack_shaman(
|
|
self, context: bpy.types.Context, blendfile: Path
|
|
) -> PurePosixPath:
|
|
"""Use the Manager's Shaman API to submit the BAT pack.
|
|
|
|
:return: the filesystem path of the blend file, for in the render job definition.
|
|
"""
|
|
from .bat import (
|
|
interface as bat_interface,
|
|
shaman as bat_shaman,
|
|
)
|
|
|
|
assert self.job is not None
|
|
self.log.info("Sending BAT pack to Shaman")
|
|
|
|
# TODO: get project name from preferences/GUI and insert that here too.
|
|
checkout_root = PurePosixPath(f"{self.job.name}")
|
|
|
|
self.packthread = bat_interface.copy(
|
|
base_blendfile=blendfile,
|
|
project=blendfile.parent, # TODO: get from preferences/GUI.
|
|
target="/", # Target directory irrelevant for Shaman transfers.
|
|
exclusion_filter="", # TODO: get from GUI.
|
|
relative_only=True, # TODO: get from GUI.
|
|
packer_class=bat_shaman.Packer,
|
|
packer_kwargs=dict(
|
|
api_client=self.get_api_client(context),
|
|
checkout_path=checkout_root,
|
|
),
|
|
)
|
|
|
|
# Having Shaman enabled on the Manager automatically creates a variable
|
|
# "jobs" that will resolve to the checkout directory.
|
|
return PurePosixPath("{jobs}") / checkout_root / blendfile.name
|
|
|
|
def _on_bat_pack_msg(self, context: bpy.types.Context, msg: _Message) -> set[str]:
|
|
from .bat import interface as bat_interface
|
|
|
|
if isinstance(msg, bat_interface.MsgDone):
|
|
self._submit_job(context)
|
|
return self._quit(context)
|
|
|
|
if isinstance(msg, bat_interface.MsgException):
|
|
self.log.error("Error performing BAT pack: %s", msg.ex)
|
|
self.report({"ERROR"}, "Error performing BAT pack")
|
|
|
|
# This was an exception caught at the top level of the thread, so
|
|
# the packing thread itself has stopped.
|
|
return self._quit(context)
|
|
|
|
if isinstance(msg, bat_interface.MsgSetWMAttribute):
|
|
wm = context.window_manager
|
|
setattr(wm, msg.attribute_name, msg.value)
|
|
|
|
return {"RUNNING_MODAL"}
|
|
|
|
def _submit_job(self, context: bpy.types.Context) -> None:
|
|
"""Use the Flamenco API to submit the new Job."""
|
|
assert self.job is not None
|
|
assert self.blendfile_on_farm is not None
|
|
|
|
api_client = self.get_api_client(context)
|
|
|
|
propgroup = getattr(context.scene, "flamenco_job_settings", None)
|
|
assert isinstance(propgroup, JobTypePropertyGroup), "did not expect %s" % (
|
|
type(propgroup)
|
|
)
|
|
propgroup.eval_hidden_settings_of_job(context, self.job)
|
|
|
|
job_submission.set_blend_file(
|
|
propgroup.job_type, self.job, self.blendfile_on_farm
|
|
)
|
|
|
|
submitted_job = job_submission.submit_job(self.job, api_client)
|
|
self.report({"INFO"}, "Job %s submitted" % submitted_job.name)
|
|
|
|
def _quit(self, context: bpy.types.Context) -> set[str]:
|
|
"""Stop any timer and return a 'FINISHED' status.
|
|
|
|
Does neither check nor abort the BAT pack thread.
|
|
"""
|
|
if self.timer is not None:
|
|
context.window_manager.event_timer_remove(self.timer)
|
|
self.timer = None
|
|
return {"FINISHED"}
|
|
|
|
|
|
class FLAMENCO3_OT_explore_file_path(bpy.types.Operator):
|
|
"""Opens the given path in a file explorer.
|
|
|
|
If the path cannot be found, this operator tries to open its parent.
|
|
"""
|
|
|
|
bl_idname = "flamenco3.explore_file_path"
|
|
bl_label = "Open in file explorer"
|
|
bl_description = __doc__.rstrip(".")
|
|
|
|
path: bpy.props.StringProperty( # type: ignore
|
|
name="Path", description="Path to explore", subtype="DIR_PATH"
|
|
)
|
|
|
|
def execute(self, context):
|
|
import platform
|
|
import pathlib
|
|
|
|
# Possibly open a parent of the path
|
|
to_open = pathlib.Path(self.path)
|
|
while to_open.parent != to_open: # while we're not at the root
|
|
if to_open.exists():
|
|
break
|
|
to_open = to_open.parent
|
|
else:
|
|
self.report(
|
|
{"ERROR"}, "Unable to open %s or any of its parents." % self.path
|
|
)
|
|
return {"CANCELLED"}
|
|
|
|
if platform.system() == "Windows":
|
|
import os
|
|
|
|
# Ignore the mypy error here, as os.startfile() only exists on Windows.
|
|
os.startfile(str(to_open)) # type: ignore
|
|
|
|
elif platform.system() == "Darwin":
|
|
import subprocess
|
|
|
|
subprocess.Popen(["open", str(to_open)])
|
|
|
|
else:
|
|
import subprocess
|
|
|
|
subprocess.Popen(["xdg-open", str(to_open)])
|
|
|
|
return {"FINISHED"}
|
|
|
|
|
|
classes = (
|
|
FLAMENCO_OT_fetch_job_types,
|
|
FLAMENCO_OT_ping_manager,
|
|
FLAMENCO_OT_eval_setting,
|
|
FLAMENCO_OT_submit_job,
|
|
FLAMENCO3_OT_explore_file_path,
|
|
)
|
|
register, unregister = bpy.utils.register_classes_factory(classes)
|