[Automated] Merged develop into main

This commit is contained in:
ynbot 2024-01-06 04:24:32 +01:00 committed by GitHub
commit 85d526cf34
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 972 additions and 459 deletions

View file

@ -35,6 +35,7 @@ body:
label: Version
description: What version are you running? Look to OpenPype Tray
options:
- 3.18.2
- 3.18.2-nightly.6
- 3.18.2-nightly.5
- 3.18.2-nightly.4
@ -134,7 +135,6 @@ body:
- 3.15.6-nightly.2
- 3.15.6-nightly.1
- 3.15.5
- 3.15.5-nightly.2
validations:
required: true
- type: dropdown

View file

@ -294,6 +294,37 @@ def reset_frame_range(fps: bool = True):
frame_range["frameStartHandle"], frame_range["frameEndHandle"])
def reset_unit_scale():
"""Apply the unit scale setting to 3dsMax
"""
project_name = get_current_project_name()
settings = get_project_settings(project_name).get("max")
scene_scale = settings.get("unit_scale_settings",
{}).get("scene_unit_scale")
if scene_scale:
rt.units.DisplayType = rt.Name("Metric")
rt.units.MetricType = rt.Name(scene_scale)
else:
rt.units.DisplayType = rt.Name("Generic")
def convert_unit_scale():
"""Convert system unit scale in 3dsMax
for fbx export
Returns:
str: unit scale
"""
unit_scale_dict = {
"millimeters": "mm",
"centimeters": "cm",
"meters": "m",
"kilometers": "km"
}
current_unit_scale = rt.Execute("units.MetricType as string")
return unit_scale_dict[current_unit_scale]
def set_context_setting():
"""Apply the project settings from the project definition
@ -310,6 +341,7 @@ def set_context_setting():
reset_scene_resolution()
reset_frame_range()
reset_colorspace()
reset_unit_scale()
def get_max_version():

View file

@ -124,6 +124,10 @@ class OpenPypeMenu(object):
colorspace_action.triggered.connect(self.colorspace_callback)
openpype_menu.addAction(colorspace_action)
unit_scale_action = QtWidgets.QAction("Set Unit Scale", openpype_menu)
unit_scale_action.triggered.connect(self.unit_scale_callback)
openpype_menu.addAction(unit_scale_action)
return openpype_menu
def load_callback(self):
@ -157,3 +161,7 @@ class OpenPypeMenu(object):
def colorspace_callback(self):
"""Callback to reset colorspace"""
return lib.reset_colorspace()
def unit_scale_callback(self):
"""Callback to reset unit scale"""
return lib.reset_unit_scale()

View file

@ -1,55 +0,0 @@
import os
import pyblish.api
from pymxs import runtime as rt
from openpype.hosts.max.api import maintained_selection
from openpype.pipeline import OptionalPyblishPluginMixin, publish
class ExtractCameraFbx(publish.Extractor, OptionalPyblishPluginMixin):
"""Extract Camera with FbxExporter."""
order = pyblish.api.ExtractorOrder - 0.2
label = "Extract Fbx Camera"
hosts = ["max"]
families = ["camera"]
optional = True
def process(self, instance):
if not self.is_active(instance.data):
return
stagingdir = self.staging_dir(instance)
filename = "{name}.fbx".format(**instance.data)
filepath = os.path.join(stagingdir, filename)
rt.FBXExporterSetParam("Animation", True)
rt.FBXExporterSetParam("Cameras", True)
rt.FBXExporterSetParam("AxisConversionMethod", "Animation")
rt.FBXExporterSetParam("UpAxis", "Y")
rt.FBXExporterSetParam("Preserveinstances", True)
with maintained_selection():
# select and export
node_list = instance.data["members"]
rt.Select(node_list)
rt.ExportFile(
filepath,
rt.Name("noPrompt"),
selectedOnly=True,
using=rt.FBXEXP,
)
self.log.info("Performing Extraction ...")
if "representations" not in instance.data:
instance.data["representations"] = []
representation = {
"name": "fbx",
"ext": "fbx",
"files": filename,
"stagingDir": stagingdir,
}
instance.data["representations"].append(representation)
self.log.info(f"Extracted instance '{instance.name}' to: {filepath}")

View file

@ -3,6 +3,7 @@ import pyblish.api
from openpype.pipeline import publish, OptionalPyblishPluginMixin
from pymxs import runtime as rt
from openpype.hosts.max.api import maintained_selection
from openpype.hosts.max.api.lib import convert_unit_scale
class ExtractModelFbx(publish.Extractor, OptionalPyblishPluginMixin):
@ -23,14 +24,7 @@ class ExtractModelFbx(publish.Extractor, OptionalPyblishPluginMixin):
stagingdir = self.staging_dir(instance)
filename = "{name}.fbx".format(**instance.data)
filepath = os.path.join(stagingdir, filename)
rt.FBXExporterSetParam("Animation", False)
rt.FBXExporterSetParam("Cameras", False)
rt.FBXExporterSetParam("Lights", False)
rt.FBXExporterSetParam("PointCache", False)
rt.FBXExporterSetParam("AxisConversionMethod", "Animation")
rt.FBXExporterSetParam("UpAxis", "Y")
rt.FBXExporterSetParam("Preserveinstances", True)
self._set_fbx_attributes()
with maintained_selection():
# select and export
@ -56,3 +50,34 @@ class ExtractModelFbx(publish.Extractor, OptionalPyblishPluginMixin):
self.log.info(
"Extracted instance '%s' to: %s" % (instance.name, filepath)
)
def _set_fbx_attributes(self):
unit_scale = convert_unit_scale()
rt.FBXExporterSetParam("Animation", False)
rt.FBXExporterSetParam("Cameras", False)
rt.FBXExporterSetParam("Lights", False)
rt.FBXExporterSetParam("PointCache", False)
rt.FBXExporterSetParam("AxisConversionMethod", "Animation")
rt.FBXExporterSetParam("UpAxis", "Y")
rt.FBXExporterSetParam("Preserveinstances", True)
if unit_scale:
rt.FBXExporterSetParam("ConvertUnit", unit_scale)
class ExtractCameraFbx(ExtractModelFbx):
"""Extract Camera with FbxExporter."""
order = pyblish.api.ExtractorOrder - 0.2
label = "Extract Fbx Camera"
families = ["camera"]
optional = True
def _set_fbx_attributes(self):
unit_scale = convert_unit_scale()
rt.FBXExporterSetParam("Animation", True)
rt.FBXExporterSetParam("Cameras", True)
rt.FBXExporterSetParam("AxisConversionMethod", "Animation")
rt.FBXExporterSetParam("UpAxis", "Y")
rt.FBXExporterSetParam("Preserveinstances", True)
if unit_scale:
rt.FBXExporterSetParam("ConvertUnit", unit_scale)

View file

@ -0,0 +1,139 @@
"""Backwards compatible implementation of ExitStack for Python 2.
ExitStack contextmanager was implemented with Python 3.3.
As long as we supportPython 2 hosts we can use this backwards
compatible implementation to support bothPython 2 and Python 3.
Instead of using ExitStack from contextlib, use it from this module:
>>> from openpype.hosts.maya.api.exitstack import ExitStack
It will provide the appropriate ExitStack implementation for the current
running Python version.
"""
# TODO: Remove the entire script once dropping Python 2 support.
import contextlib
if getattr(contextlib, "nested", None):
from contextlib import ExitStack # noqa
else:
import sys
from collections import deque
class ExitStack(object):
"""Context manager for dynamic management of a stack of exit callbacks
For example:
with ExitStack() as stack:
files = [stack.enter_context(open(fname))
for fname in filenames]
# All opened files will automatically be closed at the end of
# the with statement, even if attempts to open files later
# in the list raise an exception
"""
def __init__(self):
self._exit_callbacks = deque()
def pop_all(self):
"""Preserve the context stack by transferring
it to a new instance"""
new_stack = type(self)()
new_stack._exit_callbacks = self._exit_callbacks
self._exit_callbacks = deque()
return new_stack
def _push_cm_exit(self, cm, cm_exit):
"""Helper to correctly register callbacks
to __exit__ methods"""
def _exit_wrapper(*exc_details):
return cm_exit(cm, *exc_details)
_exit_wrapper.__self__ = cm
self.push(_exit_wrapper)
def push(self, exit):
"""Registers a callback with the standard __exit__ method signature
Can suppress exceptions the same way __exit__ methods can.
Also accepts any object with an __exit__ method (registering a call
to the method instead of the object itself)
"""
# We use an unbound method rather than a bound method to follow
# the standard lookup behaviour for special methods
_cb_type = type(exit)
try:
exit_method = _cb_type.__exit__
except AttributeError:
# Not a context manager, so assume its a callable
self._exit_callbacks.append(exit)
else:
self._push_cm_exit(exit, exit_method)
return exit # Allow use as a decorator
def callback(self, callback, *args, **kwds):
"""Registers an arbitrary callback and arguments.
Cannot suppress exceptions.
"""
def _exit_wrapper(exc_type, exc, tb):
callback(*args, **kwds)
# We changed the signature, so using @wraps is not appropriate, but
# setting __wrapped__ may still help with introspection
_exit_wrapper.__wrapped__ = callback
self.push(_exit_wrapper)
return callback # Allow use as a decorator
def enter_context(self, cm):
"""Enters the supplied context manager
If successful, also pushes its __exit__ method as a callback and
returns the result of the __enter__ method.
"""
# We look up the special methods on the type to
# match the with statement
_cm_type = type(cm)
_exit = _cm_type.__exit__
result = _cm_type.__enter__(cm)
self._push_cm_exit(cm, _exit)
return result
def close(self):
"""Immediately unwind the context stack"""
self.__exit__(None, None, None)
def __enter__(self):
return self
def __exit__(self, *exc_details):
# We manipulate the exception state so it behaves as though
# we were actually nesting multiple with statements
frame_exc = sys.exc_info()[1]
def _fix_exception_context(new_exc, old_exc):
while 1:
exc_context = new_exc.__context__
if exc_context in (None, frame_exc):
break
new_exc = exc_context
new_exc.__context__ = old_exc
# Callbacks are invoked in LIFO order to match the behaviour of
# nested context managers
suppressed_exc = False
while self._exit_callbacks:
cb = self._exit_callbacks.pop()
try:
if cb(*exc_details):
suppressed_exc = True
exc_details = (None, None, None)
except Exception:
new_exc_details = sys.exc_info()
# simulate the stack of exceptions by setting the context
_fix_exception_context(new_exc_details[1], exc_details[1])
if not self._exit_callbacks:
raise
exc_details = new_exc_details
return suppressed_exc

View file

@ -1,6 +1,7 @@
"""Standalone helper functions"""
import os
import copy
from pprint import pformat
import sys
import uuid
@ -9,6 +10,8 @@ import re
import json
import logging
import contextlib
import capture
from .exitstack import ExitStack
from collections import OrderedDict, defaultdict
from math import ceil
from six import string_types
@ -172,6 +175,216 @@ def maintained_selection():
cmds.select(clear=True)
def reload_all_udim_tile_previews():
"""Regenerate all UDIM tile preview in texture file"""
for texture_file in cmds.ls(type="file"):
if cmds.getAttr("{}.uvTilingMode".format(texture_file)) > 0:
cmds.ogs(regenerateUVTilePreview=texture_file)
@contextlib.contextmanager
def panel_camera(panel, camera):
"""Set modelPanel's camera during the context.
Arguments:
panel (str): modelPanel name.
camera (str): camera name.
"""
original_camera = cmds.modelPanel(panel, query=True, camera=True)
try:
cmds.modelPanel(panel, edit=True, camera=camera)
yield
finally:
cmds.modelPanel(panel, edit=True, camera=original_camera)
def render_capture_preset(preset):
"""Capture playblast with a preset.
To generate the preset use `generate_capture_preset`.
Args:
preset (dict): preset options
Returns:
str: Output path of `capture.capture`
"""
# Force a refresh at the start of the timeline
# TODO (Question): Why do we need to do this? What bug does it solve?
# Is this for simulations?
cmds.refresh(force=True)
refresh_frame_int = int(cmds.playbackOptions(query=True, minTime=True))
cmds.currentTime(refresh_frame_int - 1, edit=True)
cmds.currentTime(refresh_frame_int, edit=True)
log.debug(
"Using preset: {}".format(
json.dumps(preset, indent=4, sort_keys=True)
)
)
preset = copy.deepcopy(preset)
# not supported by `capture` so we pop it off of the preset
reload_textures = preset["viewport_options"].pop("loadTextures", False)
panel = preset.pop("panel")
with ExitStack() as stack:
stack.enter_context(maintained_time())
stack.enter_context(panel_camera(panel, preset["camera"]))
stack.enter_context(viewport_default_options(panel, preset))
if reload_textures:
# Force immediate texture loading when to ensure
# all textures have loaded before the playblast starts
stack.enter_context(material_loading_mode(mode="immediate"))
# Regenerate all UDIM tiles previews
reload_all_udim_tile_previews()
path = capture.capture(log=self.log, **preset)
return path
def generate_capture_preset(instance, camera, path,
start=None, end=None, capture_preset=None):
"""Function for getting all the data of preset options for
playblast capturing
Args:
instance (pyblish.api.Instance): instance
camera (str): review camera
path (str): filepath
start (int): frameStart
end (int): frameEnd
capture_preset (dict): capture preset
Returns:
dict: Resulting preset
"""
preset = load_capture_preset(data=capture_preset)
preset["camera"] = camera
preset["start_frame"] = start
preset["end_frame"] = end
preset["filename"] = path
preset["overwrite"] = True
preset["panel"] = instance.data["panel"]
# Disable viewer since we use the rendering logic for publishing
# We don't want to open the generated playblast in a viewer directly.
preset["viewer"] = False
# "isolate_view" will already have been applied at creation, so we'll
# ignore it here.
preset.pop("isolate_view")
# Set resolution variables from capture presets
width_preset = capture_preset["Resolution"]["width"]
height_preset = capture_preset["Resolution"]["height"]
# Set resolution variables from asset values
asset_data = instance.data["assetEntity"]["data"]
asset_width = asset_data.get("resolutionWidth")
asset_height = asset_data.get("resolutionHeight")
review_instance_width = instance.data.get("review_width")
review_instance_height = instance.data.get("review_height")
# Use resolution from instance if review width/height is set
# Otherwise use the resolution from preset if it has non-zero values
# Otherwise fall back to asset width x height
# Else define no width, then `capture.capture` will use render resolution
if review_instance_width and review_instance_height:
preset["width"] = review_instance_width
preset["height"] = review_instance_height
elif width_preset and height_preset:
preset["width"] = width_preset
preset["height"] = height_preset
elif asset_width and asset_height:
preset["width"] = asset_width
preset["height"] = asset_height
# Isolate view is requested by having objects in the set besides a
# camera. If there is only 1 member it'll be the camera because we
# validate to have 1 camera only.
if instance.data["isolate"] and len(instance.data["setMembers"]) > 1:
preset["isolate"] = instance.data["setMembers"]
# Override camera options
# Enforce persisting camera depth of field
camera_options = preset.setdefault("camera_options", {})
camera_options["depthOfField"] = cmds.getAttr(
"{0}.depthOfField".format(camera)
)
# Use Pan/Zoom from instance data instead of from preset
preset.pop("pan_zoom", None)
camera_options["panZoomEnabled"] = instance.data["panZoom"]
# Override viewport options by instance data
viewport_options = preset.setdefault("viewport_options", {})
viewport_options["displayLights"] = instance.data["displayLights"]
viewport_options["imagePlane"] = instance.data.get("imagePlane", True)
# Override transparency if requested.
transparency = instance.data.get("transparency", 0)
if transparency != 0:
preset["viewport2_options"]["transparencyAlgorithm"] = transparency
# Update preset with current panel setting
# if override_viewport_options is turned off
if not capture_preset["Viewport Options"]["override_viewport_options"]:
panel_preset = capture.parse_view(preset["panel"])
panel_preset.pop("camera")
preset.update(panel_preset)
return preset
@contextlib.contextmanager
def viewport_default_options(panel, preset):
"""Context manager used by `render_capture_preset`.
We need to explicitly enable some viewport changes so the viewport is
refreshed ahead of playblasting.
"""
# TODO: Clarify in the docstring WHY we need to set it ahead of
# playblasting. What issues does it solve?
viewport_defaults = {}
try:
keys = [
"useDefaultMaterial",
"wireframeOnShaded",
"xray",
"jointXray",
"backfaceCulling",
"textures"
]
for key in keys:
viewport_defaults[key] = cmds.modelEditor(
panel, query=True, **{key: True}
)
if preset["viewport_options"].get(key):
cmds.modelEditor(
panel, edit=True, **{key: True}
)
yield
finally:
# Restoring viewport options.
if viewport_defaults:
cmds.modelEditor(
panel, edit=True, **viewport_defaults
)
@contextlib.contextmanager
def material_loading_mode(mode="immediate"):
"""Set material loading mode during context"""
original = cmds.displayPref(query=True, materialLoadingMode=True)
cmds.displayPref(materialLoadingMode=mode)
try:
yield
finally:
cmds.displayPref(materialLoadingMode=original)
def get_namespace(node):
"""Return namespace of given node"""
node_name = node.rsplit("|", 1)[-1]
@ -2677,7 +2890,7 @@ def bake_to_world_space(nodes,
return world_space_nodes
def load_capture_preset(data=None):
def load_capture_preset(data):
"""Convert OpenPype Extract Playblast settings to `capture` arguments
Input data is the settings from:
@ -2691,8 +2904,6 @@ def load_capture_preset(data=None):
"""
import capture
options = dict()
viewport_options = dict()
viewport2_options = dict()

View file

@ -1,9 +1,6 @@
import os
import json
import contextlib
import clique
import capture
from openpype.pipeline import publish
from openpype.hosts.maya.api import lib
@ -11,16 +8,6 @@ from openpype.hosts.maya.api import lib
from maya import cmds
@contextlib.contextmanager
def panel_camera(panel, camera):
original_camera = cmds.modelPanel(panel, query=True, camera=True)
try:
cmds.modelPanel(panel, edit=True, camera=camera)
yield
finally:
cmds.modelPanel(panel, edit=True, camera=original_camera)
class ExtractPlayblast(publish.Extractor):
"""Extract viewport playblast.
@ -36,19 +23,8 @@ class ExtractPlayblast(publish.Extractor):
capture_preset = {}
profiles = None
def _capture(self, preset):
if os.environ.get("OPENPYPE_DEBUG") == "1":
self.log.debug(
"Using preset: {}".format(
json.dumps(preset, indent=4, sort_keys=True)
)
)
path = capture.capture(log=self.log, **preset)
self.log.debug("playblast path {}".format(path))
def process(self, instance):
self.log.debug("Extracting capture..")
self.log.debug("Extracting playblast..")
# get scene fps
fps = instance.data.get("fps") or instance.context.data.get("fps")
@ -63,10 +39,6 @@ class ExtractPlayblast(publish.Extractor):
end = cmds.playbackOptions(query=True, animationEndTime=True)
self.log.debug("start: {}, end: {}".format(start, end))
# get cameras
camera = instance.data["review_camera"]
task_data = instance.data["anatomyData"].get("task", {})
capture_preset = lib.get_capture_preset(
task_data.get("name"),
@ -75,174 +47,35 @@ class ExtractPlayblast(publish.Extractor):
instance.context.data["project_settings"],
self.log
)
preset = lib.load_capture_preset(data=capture_preset)
# "isolate_view" will already have been applied at creation, so we'll
# ignore it here.
preset.pop("isolate_view")
# Set resolution variables from capture presets
width_preset = capture_preset["Resolution"]["width"]
height_preset = capture_preset["Resolution"]["height"]
# Set resolution variables from asset values
asset_data = instance.data["assetEntity"]["data"]
asset_width = asset_data.get("resolutionWidth")
asset_height = asset_data.get("resolutionHeight")
review_instance_width = instance.data.get("review_width")
review_instance_height = instance.data.get("review_height")
preset["camera"] = camera
# Tests if project resolution is set,
# if it is a value other than zero, that value is
# used, if not then the asset resolution is
# used
if review_instance_width and review_instance_height:
preset["width"] = review_instance_width
preset["height"] = review_instance_height
elif width_preset and height_preset:
preset["width"] = width_preset
preset["height"] = height_preset
elif asset_width and asset_height:
preset["width"] = asset_width
preset["height"] = asset_height
preset["start_frame"] = start
preset["end_frame"] = end
# Enforce persisting camera depth of field
camera_options = preset.setdefault("camera_options", {})
camera_options["depthOfField"] = cmds.getAttr(
"{0}.depthOfField".format(camera))
stagingdir = self.staging_dir(instance)
filename = "{0}".format(instance.name)
filename = instance.name
path = os.path.join(stagingdir, filename)
self.log.debug("Outputting images to %s" % path)
# get cameras
camera = instance.data["review_camera"]
preset = lib.generate_capture_preset(
instance, camera, path,
start=start, end=end,
capture_preset=capture_preset)
lib.render_capture_preset(preset)
preset["filename"] = path
preset["overwrite"] = True
cmds.refresh(force=True)
refreshFrameInt = int(cmds.playbackOptions(q=True, minTime=True))
cmds.currentTime(refreshFrameInt - 1, edit=True)
cmds.currentTime(refreshFrameInt, edit=True)
# Use displayLights setting from instance
key = "displayLights"
preset["viewport_options"][key] = instance.data[key]
# Override transparency if requested.
transparency = instance.data.get("transparency", 0)
if transparency != 0:
preset["viewport2_options"]["transparencyAlgorithm"] = transparency
# Isolate view is requested by having objects in the set besides a
# camera. If there is only 1 member it'll be the camera because we
# validate to have 1 camera only.
if instance.data["isolate"] and len(instance.data["setMembers"]) > 1:
preset["isolate"] = instance.data["setMembers"]
# Show/Hide image planes on request.
image_plane = instance.data.get("imagePlane", True)
if "viewport_options" in preset:
preset["viewport_options"]["imagePlane"] = image_plane
else:
preset["viewport_options"] = {"imagePlane": image_plane}
# Disable Pan/Zoom.
pan_zoom = cmds.getAttr("{}.panZoomEnabled".format(preset["camera"]))
preset.pop("pan_zoom", None)
preset["camera_options"]["panZoomEnabled"] = instance.data["panZoom"]
# Need to explicitly enable some viewport changes so the viewport is
# refreshed ahead of playblasting.
keys = [
"useDefaultMaterial",
"wireframeOnShaded",
"xray",
"jointXray",
"backfaceCulling"
]
viewport_defaults = {}
for key in keys:
viewport_defaults[key] = cmds.modelEditor(
instance.data["panel"], query=True, **{key: True}
)
if preset["viewport_options"][key]:
cmds.modelEditor(
instance.data["panel"], edit=True, **{key: True}
)
override_viewport_options = (
capture_preset["Viewport Options"]["override_viewport_options"]
)
# Force viewer to False in call to capture because we have our own
# viewer opening call to allow a signal to trigger between
# playblast and viewer
preset["viewer"] = False
# Update preset with current panel setting
# if override_viewport_options is turned off
if not override_viewport_options:
panel_preset = capture.parse_view(instance.data["panel"])
panel_preset.pop("camera")
preset.update(panel_preset)
# Need to ensure Python 2 compatibility.
# TODO: Remove once dropping Python 2.
if getattr(contextlib, "nested", None):
# Python 3 compatibility.
with contextlib.nested(
lib.maintained_time(),
panel_camera(instance.data["panel"], preset["camera"])
):
self._capture(preset)
else:
# Python 2 compatibility.
with contextlib.ExitStack() as stack:
stack.enter_context(lib.maintained_time())
stack.enter_context(
panel_camera(instance.data["panel"], preset["camera"])
)
self._capture(preset)
# Restoring viewport options.
if viewport_defaults:
cmds.modelEditor(
instance.data["panel"], edit=True, **viewport_defaults
)
try:
cmds.setAttr(
"{}.panZoomEnabled".format(preset["camera"]), pan_zoom)
except RuntimeError:
self.log.warning("Cannot restore Pan/Zoom settings.")
# Find playblast sequence
collected_files = os.listdir(stagingdir)
patterns = [clique.PATTERNS["frames"]]
collections, remainder = clique.assemble(collected_files,
minimum_items=1,
patterns=patterns)
filename = preset.get("filename", "%TEMP%")
self.log.debug("filename {}".format(filename))
self.log.debug("Searching playblast collection for: %s", path)
frame_collection = None
for collection in collections:
filebase = collection.format("{head}").rstrip(".")
self.log.debug("collection head {}".format(filebase))
if filebase in filename:
self.log.debug("Checking collection head: %s", filebase)
if filebase in path:
frame_collection = collection
self.log.debug(
"we found collection of interest {}".format(
str(frame_collection)))
if "representations" not in instance.data:
instance.data["representations"] = []
"Found playblast collection: %s", frame_collection
)
tags = ["review"]
if not instance.data.get("keepImages"):
@ -256,6 +89,9 @@ class ExtractPlayblast(publish.Extractor):
if len(collected_files) == 1:
collected_files = collected_files[0]
if "representations" not in instance.data:
instance.data["representations"] = []
representation = {
"name": capture_preset["Codec"]["compression"],
"ext": capture_preset["Codec"]["compression"],

View file

@ -1,15 +1,10 @@
import os
import glob
import tempfile
import json
import capture
from openpype.pipeline import publish
from openpype.hosts.maya.api import lib
from maya import cmds
class ExtractThumbnail(publish.Extractor):
"""Extract viewport thumbnail.
@ -24,7 +19,7 @@ class ExtractThumbnail(publish.Extractor):
families = ["review"]
def process(self, instance):
self.log.debug("Extracting capture..")
self.log.debug("Extracting thumbnail..")
camera = instance.data["review_camera"]
@ -37,20 +32,24 @@ class ExtractThumbnail(publish.Extractor):
self.log
)
preset = lib.load_capture_preset(data=capture_preset)
# "isolate_view" will already have been applied at creation, so we'll
# ignore it here.
preset.pop("isolate_view")
override_viewport_options = (
capture_preset["Viewport Options"]["override_viewport_options"]
# Create temp directory for thumbnail
# - this is to avoid "override" of source file
dst_staging = tempfile.mkdtemp(prefix="pyblish_tmp_thumbnail")
self.log.debug(
"Create temp directory {} for thumbnail".format(dst_staging)
)
# Store new staging to cleanup paths
filename = instance.name
path = os.path.join(dst_staging, filename)
preset["camera"] = camera
preset["start_frame"] = instance.data["frameStart"]
preset["end_frame"] = instance.data["frameStart"]
preset["camera_options"] = {
self.log.debug("Outputting images to %s" % path)
preset = lib.generate_capture_preset(
instance, camera, path,
start=1, end=1,
capture_preset=capture_preset)
preset["camera_options"].update({
"displayGateMask": False,
"displayResolution": False,
"displayFilmGate": False,
@ -60,101 +59,10 @@ class ExtractThumbnail(publish.Extractor):
"displayFilmPivot": False,
"displayFilmOrigin": False,
"overscan": 1.0,
"depthOfField": cmds.getAttr("{0}.depthOfField".format(camera)),
}
# Set resolution variables from capture presets
width_preset = capture_preset["Resolution"]["width"]
height_preset = capture_preset["Resolution"]["height"]
# Set resolution variables from asset values
asset_data = instance.data["assetEntity"]["data"]
asset_width = asset_data.get("resolutionWidth")
asset_height = asset_data.get("resolutionHeight")
review_instance_width = instance.data.get("review_width")
review_instance_height = instance.data.get("review_height")
# Tests if project resolution is set,
# if it is a value other than zero, that value is
# used, if not then the asset resolution is
# used
if review_instance_width and review_instance_height:
preset["width"] = review_instance_width
preset["height"] = review_instance_height
elif width_preset and height_preset:
preset["width"] = width_preset
preset["height"] = height_preset
elif asset_width and asset_height:
preset["width"] = asset_width
preset["height"] = asset_height
})
path = lib.render_capture_preset(preset)
# Create temp directory for thumbnail
# - this is to avoid "override" of source file
dst_staging = tempfile.mkdtemp(prefix="pyblish_tmp_")
self.log.debug(
"Create temp directory {} for thumbnail".format(dst_staging)
)
# Store new staging to cleanup paths
filename = "{0}".format(instance.name)
path = os.path.join(dst_staging, filename)
self.log.debug("Outputting images to %s" % path)
preset["filename"] = path
preset["overwrite"] = True
cmds.refresh(force=True)
refreshFrameInt = int(cmds.playbackOptions(q=True, minTime=True))
cmds.currentTime(refreshFrameInt - 1, edit=True)
cmds.currentTime(refreshFrameInt, edit=True)
# Use displayLights setting from instance
key = "displayLights"
preset["viewport_options"][key] = instance.data[key]
# Override transparency if requested.
transparency = instance.data.get("transparency", 0)
if transparency != 0:
preset["viewport2_options"]["transparencyAlgorithm"] = transparency
# Isolate view is requested by having objects in the set besides a
# camera. If there is only 1 member it'll be the camera because we
# validate to have 1 camera only.
if instance.data["isolate"] and len(instance.data["setMembers"]) > 1:
preset["isolate"] = instance.data["setMembers"]
# Show or Hide Image Plane
image_plane = instance.data.get("imagePlane", True)
if "viewport_options" in preset:
preset["viewport_options"]["imagePlane"] = image_plane
else:
preset["viewport_options"] = {"imagePlane": image_plane}
# Disable Pan/Zoom.
preset.pop("pan_zoom", None)
preset["camera_options"]["panZoomEnabled"] = instance.data["panZoom"]
with lib.maintained_time():
# Force viewer to False in call to capture because we have our own
# viewer opening call to allow a signal to trigger between
# playblast and viewer
preset["viewer"] = False
# Update preset with current panel setting
# if override_viewport_options is turned off
panel = cmds.getPanel(withFocus=True) or ""
if not override_viewport_options and "modelPanel" in panel:
panel_preset = capture.parse_active_view()
preset.update(panel_preset)
cmds.setFocus(panel)
if os.environ.get("OPENPYPE_DEBUG") == "1":
self.log.debug(
"Using preset: {}".format(
json.dumps(preset, indent=4, sort_keys=True)
)
)
path = capture.capture(**preset)
playblast = self._fix_playblast_output_path(path)
playblast = self._fix_playblast_output_path(path)
_, thumbnail = os.path.split(playblast)

View file

@ -16,6 +16,113 @@ class MissingEventSystem(Exception):
pass
def _get_func_ref(func):
if inspect.ismethod(func):
return WeakMethod(func)
return weakref.ref(func)
def _get_func_info(func):
path = "<unknown path>"
if func is None:
return "<unknown>", path
if hasattr(func, "__name__"):
name = func.__name__
else:
name = str(func)
# Get path to file and fallback to '<unknown path>' if fails
# NOTE This was added because of 'partial' functions which is handled,
# but who knows what else can cause this to fail?
try:
path = os.path.abspath(inspect.getfile(func))
except TypeError:
pass
return name, path
class weakref_partial:
"""Partial function with weak reference to the wrapped function.
Can be used as 'functools.partial' but it will store weak reference to
function. That means that the function must be reference counted
to avoid garbage collecting the function itself.
When the referenced functions is garbage collected then calling the
weakref partial (no matter the args/kwargs passed) will do nothing.
It will fail silently, returning `None`. The `is_valid()` method can
be used to detect whether the reference is still valid.
Is useful for object methods. In that case the callback is
deregistered when object is destroyed.
Warnings:
Values passed as *args and **kwargs are stored strongly in memory.
That may "keep alive" objects that should be already destroyed.
It is recommended to pass only immutable objects like 'str',
'bool', 'int' etc.
Args:
func (Callable): Function to wrap.
*args: Arguments passed to the wrapped function.
**kwargs: Keyword arguments passed to the wrapped function.
"""
def __init__(self, func, *args, **kwargs):
self._func_ref = _get_func_ref(func)
self._args = args
self._kwargs = kwargs
def __call__(self, *args, **kwargs):
func = self._func_ref()
if func is None:
return
new_args = tuple(list(self._args) + list(args))
new_kwargs = dict(self._kwargs)
new_kwargs.update(kwargs)
return func(*new_args, **new_kwargs)
def get_func(self):
"""Get wrapped function.
Returns:
Union[Callable, None]: Wrapped function or None if it was
destroyed.
"""
return self._func_ref()
def is_valid(self):
"""Check if wrapped function is still valid.
Returns:
bool: Is wrapped function still valid.
"""
return self._func_ref() is not None
def validate_signature(self, *args, **kwargs):
"""Validate if passed arguments are supported by wrapped function.
Returns:
bool: Are passed arguments supported by wrapped function.
"""
func = self._func_ref()
if func is None:
return False
new_args = tuple(list(self._args) + list(args))
new_kwargs = dict(self._kwargs)
new_kwargs.update(kwargs)
return is_func_signature_supported(
func, *new_args, **new_kwargs
)
class EventCallback(object):
"""Callback registered to a topic.
@ -34,20 +141,37 @@ class EventCallback(object):
or none arguments. When 1 argument is expected then the processed 'Event'
object is passed in.
The registered callbacks don't keep function in memory so it is not
possible to store lambda function as callback.
The callbacks are validated against their reference counter, that is
achieved using 'weakref' module. That means that the callback must
be stored in memory somewhere. e.g. lambda functions are not
supported as valid callback.
You can use 'weakref_partial' functions. In that case is partial object
stored in the callback object and reference counter is checked for
the wrapped function.
Args:
topic(str): Topic which will be listened.
func(func): Callback to a topic.
topic (str): Topic which will be listened.
func (Callable): Callback to a topic.
order (Union[int, None]): Order of callback. Lower number means higher
priority.
Raises:
TypeError: When passed function is not a callable object.
"""
def __init__(self, topic, func):
def __init__(self, topic, func, order):
if not callable(func):
raise TypeError((
"Registered callback is not callable. \"{}\""
).format(str(func)))
self._validate_order(order)
self._log = None
self._topic = topic
self._order = order
self._enabled = True
# Replace '*' with any character regex and escape rest of text
# - when callback is registered for '*' topic it will receive all
# events
@ -63,37 +187,38 @@ class EventCallback(object):
topic_regex = re.compile(topic_regex_str)
self._topic_regex = topic_regex
# Convert callback into references
# - deleted functions won't cause crashes
if inspect.ismethod(func):
func_ref = WeakMethod(func)
elif callable(func):
func_ref = weakref.ref(func)
# Callback function prep
if isinstance(func, weakref_partial):
partial_func = func
(name, path) = _get_func_info(func.get_func())
func_ref = None
expect_args = partial_func.validate_signature("fake")
expect_kwargs = partial_func.validate_signature(event="fake")
else:
raise TypeError((
"Registered callback is not callable. \"{}\""
).format(str(func)))
partial_func = None
(name, path) = _get_func_info(func)
# Convert callback into references
# - deleted functions won't cause crashes
func_ref = _get_func_ref(func)
# Collect function name and path to file for logging
func_name = func.__name__
func_path = os.path.abspath(inspect.getfile(func))
# Get expected arguments from function spec
# - positional arguments are always preferred
expect_args = is_func_signature_supported(func, "fake")
expect_kwargs = is_func_signature_supported(func, event="fake")
# Get expected arguments from function spec
# - positional arguments are always preferred
expect_args = is_func_signature_supported(func, "fake")
expect_kwargs = is_func_signature_supported(func, event="fake")
self._func_ref = func_ref
self._func_name = func_name
self._func_path = func_path
self._partial_func = partial_func
self._ref_is_valid = True
self._expect_args = expect_args
self._expect_kwargs = expect_kwargs
self._ref_valid = func_ref is not None
self._enabled = True
self._name = name
self._path = path
def __repr__(self):
return "< {} - {} > {}".format(
self.__class__.__name__, self._func_name, self._func_path
self.__class__.__name__, self._name, self._path
)
@property
@ -104,32 +229,83 @@ class EventCallback(object):
@property
def is_ref_valid(self):
return self._ref_valid
"""
Returns:
bool: Is reference to callback valid.
"""
self._validate_ref()
return self._ref_is_valid
def validate_ref(self):
if not self._ref_valid:
return
"""Validate if reference to callback is valid.
callback = self._func_ref()
if not callback:
self._ref_valid = False
Deprecated:
Reference is always live checkd with 'is_ref_valid'.
"""
# Trigger validate by getting 'is_valid'
_ = self.is_ref_valid
@property
def enabled(self):
"""Is callback enabled."""
"""Is callback enabled.
Returns:
bool: Is callback enabled.
"""
return self._enabled
def set_enabled(self, enabled):
"""Change if callback is enabled."""
"""Change if callback is enabled.
Args:
enabled (bool): Change enabled state of the callback.
"""
self._enabled = enabled
def deregister(self):
"""Calling this function will cause that callback will be removed."""
# Fake reference
self._ref_valid = False
self._ref_is_valid = False
self._partial_func = None
self._func_ref = None
def get_order(self):
"""Get callback order.
Returns:
Union[int, None]: Callback order.
"""
return self._order
def set_order(self, order):
"""Change callback order.
Args:
order (Union[int, None]): Order of callback. Lower number means
higher priority.
"""
self._validate_order(order)
self._order = order
order = property(get_order, set_order)
def topic_matches(self, topic):
"""Check if event topic matches callback's topic."""
"""Check if event topic matches callback's topic.
Args:
topic (str): Topic name.
Returns:
bool: Topic matches callback's topic.
"""
return self._topic_regex.match(topic)
def process_event(self, event):
@ -139,36 +315,69 @@ class EventCallback(object):
event(Event): Event that was triggered.
"""
# Skip if callback is not enabled or has invalid reference
if not self._ref_valid or not self._enabled:
# Skip if callback is not enabled
if not self._enabled:
return
# Get reference
callback = self._func_ref()
# Check if reference is valid or callback's topic matches the event
if not callback:
# Change state if is invalid so the callback is removed
self._ref_valid = False
# Get reference and skip if is not available
callback = self._get_callback()
if callback is None:
return
elif self.topic_matches(event.topic):
# Try execute callback
try:
if self._expect_args:
callback(event)
if not self.topic_matches(event.topic):
return
elif self._expect_kwargs:
callback(event=event)
# Try to execute callback
try:
if self._expect_args:
callback(event)
else:
callback()
elif self._expect_kwargs:
callback(event=event)
except Exception:
self.log.warning(
"Failed to execute event callback {}".format(
str(repr(self))
),
exc_info=True
)
else:
callback()
except Exception:
self.log.warning(
"Failed to execute event callback {}".format(
str(repr(self))
),
exc_info=True
)
def _validate_order(self, order):
if isinstance(order, int):
return
raise TypeError(
"Expected type 'int' got '{}'.".format(str(type(order)))
)
def _get_callback(self):
if self._partial_func is not None:
return self._partial_func
if self._func_ref is not None:
return self._func_ref()
return None
def _validate_ref(self):
if self._ref_is_valid is False:
return
if self._func_ref is not None:
self._ref_is_valid = self._func_ref() is not None
elif self._partial_func is not None:
self._ref_is_valid = self._partial_func.is_valid()
else:
self._ref_is_valid = False
if not self._ref_is_valid:
self._func_ref = None
self._partial_func = None
# Inherit from 'object' for Python 2 hosts
@ -282,30 +491,39 @@ class Event(object):
class EventSystem(object):
"""Encapsulate event handling into an object.
System wraps registered callbacks and triggered events into single object
so it is possible to create mutltiple independent systems that have their
System wraps registered callbacks and triggered events into single object,
so it is possible to create multiple independent systems that have their
topics and callbacks.
Callbacks are stored by order of their registration, but it is possible to
manually define order of callbacks using 'order' argument within
'add_callback'.
"""
default_order = 100
def __init__(self):
self._registered_callbacks = []
def add_callback(self, topic, callback):
def add_callback(self, topic, callback, order=None):
"""Register callback in event system.
Args:
topic (str): Topic for EventCallback.
callback (Callable): Function or method that will be called
when topic is triggered.
callback (Union[Callable, weakref_partial]): Function or method
that will be called when topic is triggered.
order (Optional[int]): Order of callback. Lower number means
higher priority.
Returns:
EventCallback: Created callback object which can be used to
stop listening.
"""
callback = EventCallback(topic, callback)
if order is None:
order = self.default_order
callback = EventCallback(topic, callback, order)
self._registered_callbacks.append(callback)
return callback
@ -341,22 +559,6 @@ class EventSystem(object):
event.emit()
return event
def _process_event(self, event):
"""Process event topic and trigger callbacks.
Args:
event (Event): Prepared event with topic and data.
"""
invalid_callbacks = []
for callback in self._registered_callbacks:
callback.process_event(event)
if not callback.is_ref_valid:
invalid_callbacks.append(callback)
for callback in invalid_callbacks:
self._registered_callbacks.remove(callback)
def emit_event(self, event):
"""Emit event object.
@ -366,6 +568,21 @@ class EventSystem(object):
self._process_event(event)
def _process_event(self, event):
"""Process event topic and trigger callbacks.
Args:
event (Event): Prepared event with topic and data.
"""
callbacks = tuple(sorted(
self._registered_callbacks, key=lambda x: x.order
))
for callback in callbacks:
callback.process_event(event)
if not callback.is_ref_valid:
self._registered_callbacks.remove(callback)
class QueuedEventSystem(EventSystem):
"""Events are automatically processed in queue.

View file

@ -269,7 +269,7 @@ def is_func_signature_supported(func, *args, **kwargs):
True
Args:
func (function): A function where the signature should be tested.
func (Callable): A function where the signature should be tested.
*args (Any): Positional arguments for function signature.
**kwargs (Any): Keyword arguments for function signature.

View file

@ -1,4 +1,8 @@
{
"unit_scale_settings": {
"enabled": true,
"scene_unit_scale": "Meters"
},
"imageio": {
"activate_host_color_management": true,
"ocio_config": {

View file

@ -1289,6 +1289,7 @@
"twoSidedLighting": true,
"lineAAEnable": true,
"multiSample": 8,
"loadTextures": false,
"useDefaultMaterial": false,
"wireframeOnShaded": false,
"xray": false,

View file

@ -5,6 +5,34 @@
"label": "Max",
"is_file": true,
"children": [
{
"key": "unit_scale_settings",
"type": "dict",
"label": "Set Unit Scale",
"collapsible": true,
"is_group": true,
"checkbox_key": "enabled",
"children": [
{
"type": "boolean",
"key": "enabled",
"label": "Enabled"
},
{
"key": "scene_unit_scale",
"label": "Scene Unit Scale",
"type": "enum",
"multiselection": false,
"defaults": "exr",
"enum_items": [
{"Millimeters": "mm"},
{"Centimeters": "cm"},
{"Meters": "m"},
{"Kilometers": "km"}
]
}
]
},
{
"key": "imageio",
"type": "dict",

View file

@ -236,6 +236,11 @@
{
"type": "splitter"
},
{
"type": "boolean",
"key": "loadTextures",
"label": "Load Textures"
},
{
"type": "boolean",
"key": "useDefaultMaterial",
@ -908,6 +913,12 @@
{
"type": "splitter"
},
{
"type": "boolean",
"key": "loadTextures",
"label": "Load Textures",
"default": false
},
{
"type": "boolean",
"key": "useDefaultMaterial",

View file

@ -1230,12 +1230,12 @@ class SwitchAssetDialog(QtWidgets.QDialog):
version_ids = list()
version_docs_by_parent_id = {}
version_docs_by_parent_id_and_name = collections.defaultdict(dict)
for version_doc in version_docs:
parent_id = version_doc["parent"]
if parent_id not in version_docs_by_parent_id:
version_ids.append(version_doc["_id"])
version_docs_by_parent_id[parent_id] = version_doc
version_ids.append(version_doc["_id"])
name = version_doc["name"]
version_docs_by_parent_id_and_name[parent_id][name] = version_doc
hero_version_docs_by_parent_id = {}
for hero_version_doc in hero_version_docs:
@ -1293,13 +1293,32 @@ class SwitchAssetDialog(QtWidgets.QDialog):
repre_doc = _repres.get(container_repre_name)
if not repre_doc:
version_doc = version_docs_by_parent_id[subset_id]
version_id = version_doc["_id"]
repres_by_name = repre_docs_by_parent_id_by_name[version_id]
if selected_representation:
repre_doc = repres_by_name[selected_representation]
version_docs_by_name = version_docs_by_parent_id_and_name[
subset_id
]
# If asset or subset are selected for switching, we use latest
# version else we try to keep the current container version.
if (
selected_asset not in (None, container_asset_name)
or selected_subset not in (None, container_subset_name)
):
version_name = max(version_docs_by_name)
else:
repre_doc = repres_by_name[container_repre_name]
version_name = container_version["name"]
version_doc = version_docs_by_name[version_name]
version_id = version_doc["_id"]
repres_docs_by_name = repre_docs_by_parent_id_by_name[
version_id
]
if selected_representation:
repres_name = selected_representation
else:
repres_name = container_repre_name
repre_doc = repres_docs_by_name[repres_name]
error = None
try:

View file

@ -12,6 +12,25 @@ from .publishers import (
)
def unit_scale_enum():
"""Return enumerator for scene unit scale."""
return [
{"label": "mm", "value": "Millimeters"},
{"label": "cm", "value": "Centimeters"},
{"label": "m", "value": "Meters"},
{"label": "km", "value": "Kilometers"}
]
class UnitScaleSettings(BaseSettingsModel):
enabled: bool = Field(True, title="Enabled")
scene_unit_scale: str = Field(
"Centimeters",
title="Scene Unit Scale",
enum_resolver=unit_scale_enum
)
class PRTAttributesModel(BaseSettingsModel):
_layout = "compact"
name: str = Field(title="Name")
@ -24,6 +43,10 @@ class PointCloudSettings(BaseSettingsModel):
class MaxSettings(BaseSettingsModel):
unit_scale_settings: UnitScaleSettings = Field(
default_factory=UnitScaleSettings,
title="Set Unit Scale"
)
imageio: ImageIOSettings = Field(
default_factory=ImageIOSettings,
title="Color Management (ImageIO)"
@ -46,6 +69,10 @@ class MaxSettings(BaseSettingsModel):
DEFAULT_VALUES = {
"unit_scale_settings": {
"enabled": True,
"scene_unit_scale": "Centimeters"
},
"RenderSettings": DEFAULT_RENDER_SETTINGS,
"CreateReview": DEFAULT_CREATE_REVIEW_SETTINGS,
"PointCloud": {

View file

@ -1 +1 @@
__version__ = "0.1.3"
__version__ = "0.1.4"

View file

@ -108,6 +108,7 @@ class ViewportOptionsSetting(BaseSettingsModel):
True, title="Enable Anti-Aliasing", section="Anti-Aliasing"
)
multiSample: int = Field(8, title="Anti Aliasing Samples")
loadTextures: bool = Field(False, title="Load Textures")
useDefaultMaterial: bool = Field(False, title="Use Default Material")
wireframeOnShaded: bool = Field(False, title="Wireframe On Shaded")
xray: bool = Field(False, title="X-Ray")
@ -302,6 +303,7 @@ DEFAULT_PLAYBLAST_SETTING = {
"twoSidedLighting": True,
"lineAAEnable": True,
"multiSample": 8,
"loadTextures": False,
"useDefaultMaterial": False,
"wireframeOnShaded": False,
"xray": False,

View file

@ -16,3 +16,8 @@ pynput = "^1.7.2" # Timers manager - TODO remove
"Qt.py" = "^1.3.3"
qtawesome = "0.7.3"
speedcopy = "^2.1"
[ayon.runtimeDependencies]
OpenTimelineIO = "0.14.1"
opencolorio = "2.2.1"
Pillow = "9.5.0"

View file

@ -1,4 +1,9 @@
from openpype.lib.events import EventSystem, QueuedEventSystem
from functools import partial
from openpype.lib.events import (
EventSystem,
QueuedEventSystem,
weakref_partial,
)
def test_default_event_system():
@ -81,3 +86,93 @@ def test_manual_event_system_queue():
assert output == expected_output, (
"Callbacks were not called in correct order")
def test_unordered_events():
"""
Validate if callbacks are triggered in order of their register.
"""
result = []
def function_a():
result.append("A")
def function_b():
result.append("B")
def function_c():
result.append("C")
# Without order
event_system = QueuedEventSystem()
event_system.add_callback("test", function_a)
event_system.add_callback("test", function_b)
event_system.add_callback("test", function_c)
event_system.emit("test", {}, "test")
assert result == ["A", "B", "C"]
def test_ordered_events():
"""
Validate if callbacks are triggered by their order and order
of their register.
"""
result = []
def function_a():
result.append("A")
def function_b():
result.append("B")
def function_c():
result.append("C")
def function_d():
result.append("D")
def function_e():
result.append("E")
def function_f():
result.append("F")
# Without order
event_system = QueuedEventSystem()
event_system.add_callback("test", function_a)
event_system.add_callback("test", function_b, order=-10)
event_system.add_callback("test", function_c, order=200)
event_system.add_callback("test", function_d, order=150)
event_system.add_callback("test", function_e)
event_system.add_callback("test", function_f, order=200)
event_system.emit("test", {}, "test")
assert result == ["B", "A", "E", "D", "C", "F"]
def test_events_partial_callbacks():
"""
Validate if partial callbacks are triggered.
"""
result = []
def function(name):
result.append(name)
def function_regular():
result.append("regular")
event_system = QueuedEventSystem()
event_system.add_callback("test", function_regular)
event_system.add_callback("test", partial(function, "foo"))
event_system.add_callback("test", weakref_partial(function, "bar"))
event_system.emit("test", {}, "test")
# Delete function should also make partial callbacks invalid
del function
event_system.emit("test", {}, "test")
assert result == ["regular", "bar", "regular"]