From 10fa0ee5c463de5676a5e209e447c2a845f6b3f6 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Wed, 15 Dec 2021 16:34:21 +0000 Subject: [PATCH 001/583] Implemented creator for render --- .../unreal/plugins/create/create_render.py | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 openpype/hosts/unreal/plugins/create/create_render.py diff --git a/openpype/hosts/unreal/plugins/create/create_render.py b/openpype/hosts/unreal/plugins/create/create_render.py new file mode 100644 index 0000000000..a0bf320225 --- /dev/null +++ b/openpype/hosts/unreal/plugins/create/create_render.py @@ -0,0 +1,52 @@ +import unreal +from openpype.hosts.unreal.api.plugin import Creator +from avalon.unreal import pipeline + + +class CreateRender(Creator): + """Create instance for sequence for rendering""" + + name = "unrealRender" + label = "Unreal - Render" + family = "render" + icon = "cube" + asset_types = ["LevelSequence"] + + root = "/Game/AvalonInstances" + suffix = "_INS" + + def __init__(self, *args, **kwargs): + super(CreateRender, self).__init__(*args, **kwargs) + + def process(self): + name = self.data["subset"] + + print(self.data) + + selection = [] + if (self.options or {}).get("useSelection"): + sel_objects = unreal.EditorUtilityLibrary.get_selected_assets() + selection = [ + a.get_path_name() for a in sel_objects + if a.get_class().get_name() in self.asset_types] + + unreal.log("selection: {}".format(selection)) + # instantiate(self.root, name, self.data, selection, self.suffix) + # container_name = "{}{}".format(name, self.suffix) + + # if we specify assets, create new folder and move them there. If not, + # just create empty folder + # new_name = pipeline.create_folder(self.root, name) + path = "{}/{}".format(self.root, name) + unreal.EditorAssetLibrary.make_directory(path) + + ar = unreal.AssetRegistryHelpers.get_asset_registry() + + for a in selection: + d = self.data.copy() + d["sequence"] = a + asset = ar.get_asset_by_object_path(a).get_asset() + container_name = asset.get_name() + pipeline.create_publish_instance(instance=container_name, path=path) + pipeline.imprint("{}/{}".format(path, container_name), d) + From 998154b5a905c9764c49abfdc6cb54dc9d5bd1f3 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Sun, 30 Jan 2022 23:53:36 +0100 Subject: [PATCH 002/583] Implement Hardware Renderer 2.0 support for Render Products --- openpype/hosts/maya/api/lib_renderproducts.py | 65 ++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/maya/api/lib_renderproducts.py b/openpype/hosts/maya/api/lib_renderproducts.py index e8e4b9aaef..e879682700 100644 --- a/openpype/hosts/maya/api/lib_renderproducts.py +++ b/openpype/hosts/maya/api/lib_renderproducts.py @@ -77,6 +77,7 @@ IMAGE_PREFIXES = { "arnold": "defaultRenderGlobals.imageFilePrefix", "renderman": "rmanGlobals.imageFileFormat", "redshift": "defaultRenderGlobals.imageFilePrefix", + "mayahardware2": "defaultRenderGlobals.imageFilePrefix" } @@ -154,7 +155,8 @@ def get(layer, render_instance=None): "arnold": RenderProductsArnold, "vray": RenderProductsVray, "redshift": RenderProductsRedshift, - "renderman": RenderProductsRenderman + "renderman": RenderProductsRenderman, + "mayahardware2": RenderProductsMayaHardware }.get(renderer_name.lower(), None) if renderer is None: raise UnsupportedRendererException( @@ -1107,6 +1109,67 @@ class RenderProductsRenderman(ARenderProducts): return new_files +class RenderProductsMayaHardware(ARenderProducts): + """Expected files for MayaHardware renderer.""" + + renderer = "mayahardware2" + + extensions = [ + {"label": "JPEG", "index": 8, "extension": "jpg"}, + {"label": "PNG", "index": 32, "extension": "png"}, + {"label": "EXR(exr)", "index": 40, "extension": "exr"} + ] + + def _get_extension(self, value): + result = None + if isinstance(value, int): + extensions = { + extension["index"]: extension["extension"] + for extension in self.extensions + } + try: + result = extensions[value] + except KeyError: + raise NotImplementedError( + "Could not find extension for {}".format(value) + ) + + if isinstance(value, six.string_types): + extensions = { + extension["label"]: extension["extension"] + for extension in self.extensions + } + try: + result = extensions[value] + except KeyError: + raise NotImplementedError( + "Could not find extension for {}".format(value) + ) + + if not result: + raise NotImplementedError( + "Could not find extension for {}".format(value) + ) + + return result + + def get_render_products(self): + """Get all AOVs. + See Also: + :func:`ARenderProducts.get_render_products()` + """ + ext = self._get_extension( + self._get_attr("defaultRenderGlobals.imageFormat") + ) + + products = [] + for cam in self.get_renderable_cameras(): + product = RenderProduct(productName="beauty", ext=ext, camera=cam) + products.append(product) + + return products + + class AOVError(Exception): """Custom exception for determining AOVs.""" From 4ff7cf67ab7fe852216c7f408161ca0f9d0d5ecf Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Mon, 31 Jan 2022 11:20:13 +0000 Subject: [PATCH 003/583] Loading layouts and cameras now create level sequences for hierarchy --- .../hosts/unreal/plugins/load/load_camera.py | 143 ++++++++++++++--- .../hosts/unreal/plugins/load/load_layout.py | 149 ++++++++++++++++-- 2 files changed, 256 insertions(+), 36 deletions(-) diff --git a/openpype/hosts/unreal/plugins/load/load_camera.py b/openpype/hosts/unreal/plugins/load/load_camera.py index b2b25eec73..00d17407f9 100644 --- a/openpype/hosts/unreal/plugins/load/load_camera.py +++ b/openpype/hosts/unreal/plugins/load/load_camera.py @@ -15,6 +15,20 @@ class CameraLoader(api.Loader): icon = "cube" color = "orange" + def _add_sub_sequence(self, master, sub): + track = master.add_master_track(unreal.MovieSceneCinematicShotTrack) + section = track.add_section() + section.set_editor_property('sub_sequence', sub) + return section + + def _get_data(self, asset_name): + asset_doc = io.find_one({ + "type": "asset", + "name": asset_name + }) + + return asset_doc.get("data") + def load(self, context, name, namespace, data): """ Load and containerise representation into Content Browser. @@ -39,7 +53,13 @@ class CameraLoader(api.Loader): """ # Create directory for asset and avalon container - root = "/Game/Avalon/Assets" + hierarchy = context.get('asset').get('data').get('parents') + root = "/Game/Avalon" + hierarchy_dir = root + hierarchy_list = [] + for h in hierarchy: + hierarchy_dir = f"{hierarchy_dir}/{h}" + hierarchy_list.append(hierarchy_dir) asset = context.get('asset').get('name') suffix = "_CON" if asset: @@ -49,9 +69,9 @@ class CameraLoader(api.Loader): tools = unreal.AssetToolsHelpers().get_asset_tools() + # Create a unique name for the camera directory unique_number = 1 - - if unreal.EditorAssetLibrary.does_directory_exist(f"{root}/{asset}"): + if unreal.EditorAssetLibrary.does_directory_exist(f"{hierarchy_dir}/{asset}"): asset_content = unreal.EditorAssetLibrary.list_assets( f"{root}/{asset}", recursive=False, include_folder=True ) @@ -71,42 +91,121 @@ class CameraLoader(api.Loader): unique_number = f_numbers[-1] + 1 asset_dir, container_name = tools.create_unique_asset_name( - f"{root}/{asset}/{name}_{unique_number:02d}", suffix="") + f"{hierarchy_dir}/{asset}/{name}_{unique_number:02d}", suffix="") container_name += suffix + # sequence = None + + # ar = unreal.AssetRegistryHelpers.get_asset_registry() + + # if not unreal.EditorAssetLibrary.does_directory_exist(asset_dir): + # unreal.EditorAssetLibrary.make_directory(asset_dir) + + # sequence = tools.create_asset( + # asset_name=asset_name, + # package_path=asset_dir, + # asset_class=unreal.LevelSequence, + # factory=unreal.LevelSequenceFactoryNew() + # ) + # else: + # asset_content = unreal.EditorAssetLibrary.list_assets( + # asset_dir, recursive=False) + # for a in asset_content: + # obj = ar.get_asset_by_object_path(a) + # if obj.get_asset().get_class().get_name() == 'LevelSequence': + # sequence = obj.get_asset() + # break + + # assert sequence, "Sequence not found" + + # Get all the sequences in the hierarchy. It will create them, if + # they don't exist. + sequences = [] + i = 0 + for h in hierarchy_list: + root_content = unreal.EditorAssetLibrary.list_assets( + h, recursive=False, include_folder=False) + + existing_sequences = [ + unreal.EditorAssetLibrary.find_asset_data(asset) + for asset in root_content + if unreal.EditorAssetLibrary.find_asset_data( + asset).get_class().get_name() == 'LevelSequence' + ] + + # for asset in root_content: + # asset_data = EditorAssetLibrary.find_asset_data(asset) + # # imported_asset = unreal.AssetRegistryHelpers.get_asset( + # # imported_asset_data) + # if asset_data.get_class().get_name() == 'LevelSequence': + # break + + if not existing_sequences: + scene = tools.create_asset( + asset_name=hierarchy[i], + package_path=h, + asset_class=unreal.LevelSequence, + factory=unreal.LevelSequenceFactoryNew() + ) + sequences.append(scene) + else: + for e in existing_sequences: + sequences.append(e.get_asset()) + + i += 1 + unreal.EditorAssetLibrary.make_directory(asset_dir) - sequence = tools.create_asset( - asset_name=asset_name, + cam_seq = tools.create_asset( + asset_name=asset, package_path=asset_dir, asset_class=unreal.LevelSequence, factory=unreal.LevelSequenceFactoryNew() ) - io_asset = io.Session["AVALON_ASSET"] - asset_doc = io.find_one({ - "type": "asset", - "name": io_asset - }) + sequences.append(cam_seq) - data = asset_doc.get("data") + # Add sequences data to hierarchy + data_i = self._get_data(sequences[0].get_name()) + + for i in range(0, len(sequences) - 1): + section = self._add_sub_sequence(sequences[i], sequences[i + 1]) + + print(sequences[i]) + print(sequences[i + 1]) + + data_j = self._get_data(sequences[i + 1].get_name()) + + if data_i: + sequences[i].set_display_rate(unreal.FrameRate(data_i.get("fps"), 1.0)) + sequences[i].set_playback_start(data_i.get("frameStart")) + sequences[i].set_playback_end(data_i.get("frameEnd")) + if data_j: + section.set_range( + data_j.get("frameStart"), + data_j.get("frameEnd")) + + data_i = data_j + + data = self._get_data(asset) if data: - sequence.set_display_rate(unreal.FrameRate(data.get("fps"), 1.0)) - sequence.set_playback_start(data.get("frameStart")) - sequence.set_playback_end(data.get("frameEnd")) + cam_seq.set_display_rate(unreal.FrameRate(data.get("fps"), 1.0)) + cam_seq.set_playback_start(data.get("frameStart")) + cam_seq.set_playback_end(data.get("frameEnd")) settings = unreal.MovieSceneUserImportFBXSettings() settings.set_editor_property('reduce_keys', False) - unreal.SequencerTools.import_fbx( - unreal.EditorLevelLibrary.get_editor_world(), - sequence, - sequence.get_bindings(), - settings, - self.fname - ) + if cam_seq: + unreal.SequencerTools.import_fbx( + unreal.EditorLevelLibrary.get_editor_world(), + cam_seq, + cam_seq.get_bindings(), + settings, + self.fname + ) # Create Asset Container lib.create_avalon_container(container=container_name, path=asset_dir) diff --git a/openpype/hosts/unreal/plugins/load/load_layout.py b/openpype/hosts/unreal/plugins/load/load_layout.py index 19d0b74e3e..7554a4658b 100644 --- a/openpype/hosts/unreal/plugins/load/load_layout.py +++ b/openpype/hosts/unreal/plugins/load/load_layout.py @@ -9,7 +9,7 @@ from unreal import AssetToolsHelpers from unreal import FBXImportType from unreal import MathLibrary as umath -from avalon import api, pipeline +from avalon import api, io, pipeline from avalon.unreal import lib from avalon.unreal import pipeline as unreal_pipeline @@ -74,10 +74,26 @@ class LayoutLoader(api.Loader): return None - def _process_family(self, assets, classname, transform, inst_name=None): + def _add_sub_sequence(self, master, sub): + track = master.add_master_track(unreal.MovieSceneCinematicShotTrack) + section = track.add_section() + section.set_editor_property('sub_sequence', sub) + return section + + def _get_data(self, asset_name): + asset_doc = io.find_one({ + "type": "asset", + "name": asset_name + }) + + return asset_doc.get("data") + + def _process_family( + self, assets, classname, transform, sequence, inst_name=None): ar = unreal.AssetRegistryHelpers.get_asset_registry() actors = [] + bindings = [] for asset in assets: obj = ar.get_asset_by_object_path(asset).get_asset() @@ -109,11 +125,17 @@ class LayoutLoader(api.Loader): actors.append(actor) - return actors + binding = sequence.add_possessable(actor) + # root_component_binding = sequence.add_possessable(actor.root_component) + # root_component_binding.set_parent(binding) + + bindings.append(binding) + + return actors, bindings def _import_animation( self, asset_dir, path, instance_name, skeleton, actors_dict, - animation_file): + animation_file, bindings_dict, sequence): anim_file = Path(animation_file) anim_file_name = anim_file.with_suffix('') @@ -192,7 +214,20 @@ class LayoutLoader(api.Loader): actor.skeletal_mesh_component.animation_data.set_editor_property( 'anim_to_play', animation) - def _process(self, libpath, asset_dir, loaded=None): + # Add animation to the sequencer + bindings = bindings_dict.get(instance_name) + + for binding in bindings: + binding.add_track(unreal.MovieSceneSkeletalAnimationTrack) + for track in binding.get_tracks(): + section = track.add_section() + section.set_range( + sequence.get_playback_start(), + sequence.get_playback_end()) + sec_params = section.get_editor_property('params') + sec_params.set_editor_property('animation', animation) + + def _process(self, libpath, asset_dir, sequence, loaded=None): ar = unreal.AssetRegistryHelpers.get_asset_registry() with open(libpath, "r") as fp: @@ -207,6 +242,7 @@ class LayoutLoader(api.Loader): skeleton_dict = {} actors_dict = {} + bindings_dict = {} for element in data: reference = None @@ -264,12 +300,13 @@ class LayoutLoader(api.Loader): actors = [] if family == 'model': - actors = self._process_family( - assets, 'StaticMesh', transform, inst) + actors, _ = self._process_family( + assets, 'StaticMesh', transform, sequence, inst) elif family == 'rig': - actors = self._process_family( - assets, 'SkeletalMesh', transform, inst) + actors, bindings = self._process_family( + assets, 'SkeletalMesh', transform, sequence, inst) actors_dict[inst] = actors + bindings_dict[inst] = bindings if family == 'rig': # Finds skeleton among the imported assets @@ -289,8 +326,13 @@ class LayoutLoader(api.Loader): if animation_file and skeleton: self._import_animation( - asset_dir, path, instance_name, skeleton, - actors_dict, animation_file) + asset_dir, path, instance_name, skeleton, actors_dict, + animation_file, bindings_dict, sequence) + + # track = sequence.add_master_track( + # unreal.MovieSceneActorReferenceTrack) + # section = track.add_section() + # section.set_editor_property('sub_sequence', sequence) def _remove_family(self, assets, components, classname, propname): ar = unreal.AssetRegistryHelpers.get_asset_registry() @@ -356,7 +398,13 @@ class LayoutLoader(api.Loader): list(str): list of container content """ # Create directory for asset and avalon container - root = "/Game/Avalon/Assets" + hierarchy = context.get('asset').get('data').get('parents') + root = "/Game/Avalon" + hierarchy_dir = root + hierarchy_list = [] + for h in hierarchy: + hierarchy_dir = f"{hierarchy_dir}/{h}" + hierarchy_list.append(hierarchy_dir) asset = context.get('asset').get('name') suffix = "_CON" if asset: @@ -366,13 +414,86 @@ class LayoutLoader(api.Loader): tools = unreal.AssetToolsHelpers().get_asset_tools() asset_dir, container_name = tools.create_unique_asset_name( - "{}/{}/{}".format(root, asset, name), suffix="") + "{}/{}/{}".format(hierarchy_dir, asset, name), suffix="") container_name += suffix EditorAssetLibrary.make_directory(asset_dir) - self._process(self.fname, asset_dir) + # Get all the sequences in the hierarchy. It will create them, if + # they don't exist. + sequences = [] + i = 0 + for h in hierarchy_list: + root_content = EditorAssetLibrary.list_assets( + h, recursive=False, include_folder=False) + + existing_sequences = [ + EditorAssetLibrary.find_asset_data(asset) + for asset in root_content + if EditorAssetLibrary.find_asset_data( + asset).get_class().get_name() == 'LevelSequence' + ] + + # for asset in root_content: + # asset_data = EditorAssetLibrary.find_asset_data(asset) + # # imported_asset = unreal.AssetRegistryHelpers.get_asset( + # # imported_asset_data) + # if asset_data.get_class().get_name() == 'LevelSequence': + # break + + if not existing_sequences: + scene = tools.create_asset( + asset_name=hierarchy[i], + package_path=h, + asset_class=unreal.LevelSequence, + factory=unreal.LevelSequenceFactoryNew() + ) + sequences.append(scene) + else: + for e in existing_sequences: + sequences.append(e.get_asset()) + + i += 1 + + # TODO: check if shot already exists + + shot = tools.create_asset( + asset_name=asset, + package_path=asset_dir, + asset_class=unreal.LevelSequence, + factory=unreal.LevelSequenceFactoryNew() + ) + + sequences.append(shot) + + # Add sequences data to hierarchy + data_i = self._get_data(sequences[0].get_name()) + + for i in range(0, len(sequences) - 1): + section = self._add_sub_sequence(sequences[i], sequences[i + 1]) + + data_j = self._get_data(sequences[i + 1].get_name()) + + if data_i: + sequences[i].set_display_rate(unreal.FrameRate(data_i.get("fps"), 1.0)) + sequences[i].set_playback_start(data_i.get("frameStart")) + sequences[i].set_playback_end(data_i.get("frameEnd")) + if data_j: + section.set_range( + data_j.get("frameStart"), + data_j.get("frameEnd")) + + data_i = data_j + + data = self._get_data(asset) + + if data: + shot.set_display_rate(unreal.FrameRate(data.get("fps"), 1.0)) + shot.set_playback_start(data.get("frameStart")) + shot.set_playback_end(data.get("frameEnd")) + + self._process(self.fname, asset_dir, shot) # Create Asset Container lib.create_avalon_container( From 5efc23c7433c08da7b42c182e7bcd1a4ad7b16ac Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Mon, 31 Jan 2022 11:22:47 +0000 Subject: [PATCH 004/583] Added button for starting the rendering of the selected instance --- openpype/hosts/unreal/api/rendering.py | 85 +++++++++++++++++++ openpype/hosts/unreal/api/tools_ui.py | 7 ++ .../unreal/plugins/create/create_render.py | 5 +- 3 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 openpype/hosts/unreal/api/rendering.py diff --git a/openpype/hosts/unreal/api/rendering.py b/openpype/hosts/unreal/api/rendering.py new file mode 100644 index 0000000000..7c58987c0d --- /dev/null +++ b/openpype/hosts/unreal/api/rendering.py @@ -0,0 +1,85 @@ +import avalon.unreal.pipeline as pipeline +import avalon.unreal.lib as lib +import unreal + + +queue = None +executor = None + +def _queue_finish_callback(exec, success): + unreal.log("Render completed. Success: " + str(success)) + + # Delete our reference so we don't keep it alive. + global executor + global queue + del executor + del queue + + +def _job_finish_callback(job, success): + # You can make any edits you want to the editor world here, and the world + # will be duplicated when the next render happens. Make sure you undo your + # edits in OnQueueFinishedCallback if you don't want to leak state changes + # into the editor world. + unreal.log("Individual job completed.") + + +def start_rendering(): + """ + Start the rendering process. + """ + print("Starting rendering...") + + # Get selected sequences + assets = unreal.EditorUtilityLibrary.get_selected_assets() + + # instances = pipeline.ls_inst() + instances = [ + a for a in assets + if a.get_class().get_name() == "AvalonPublishInstance"] + + inst_data = [] + + for i in instances: + data = pipeline.parse_container(i.get_path_name()) + if data["family"] == "render": + inst_data.append(data) + + # subsystem = unreal.get_editor_subsystem(unreal.MoviePipelineQueueSubsystem) + # queue = subsystem.get_queue() + global queue + queue = unreal.MoviePipelineQueue() + + for i in inst_data: + job = queue.allocate_new_job(unreal.MoviePipelineExecutorJob) + job.sequence = unreal.SoftObjectPath(i["sequence"]) + job.map = unreal.SoftObjectPath(i["map"]) + job.author = "OpenPype" + + # User data could be used to pass data to the job, that can be read + # in the job's OnJobFinished callback. We could, for instance, + # pass the AvalonPublishInstance's path to the job. + # job.user_data = "" + + output_setting = job.get_configuration().find_or_add_setting_by_class( + unreal.MoviePipelineOutputSetting) + output_setting.output_resolution = unreal.IntPoint(1280, 720) + output_setting.file_name_format = "{sequence_name}.{frame_number}" + output_setting.output_directory.path += f"{i['subset']}/" + + renderPass = job.get_configuration().find_or_add_setting_by_class( + unreal.MoviePipelineDeferredPassBase) + renderPass.disable_multisample_effects = True + + job.get_configuration().find_or_add_setting_by_class( + unreal.MoviePipelineImageSequenceOutput_PNG) + + # TODO: check if queue is empty + + global executor + executor = unreal.MoviePipelinePIEExecutor() + executor.on_executor_finished_delegate.add_callable_unique( + _queue_finish_callback) + executor.on_individual_job_finished_delegate.add_callable_unique( + _job_finish_callback) # Only available on PIE Executor + executor.execute(queue) diff --git a/openpype/hosts/unreal/api/tools_ui.py b/openpype/hosts/unreal/api/tools_ui.py index 93361c3574..2500f8495f 100644 --- a/openpype/hosts/unreal/api/tools_ui.py +++ b/openpype/hosts/unreal/api/tools_ui.py @@ -7,6 +7,7 @@ from openpype import ( ) from openpype.tools.utils import host_tools from openpype.tools.utils.lib import qt_app_context +from openpype.hosts.unreal.api import rendering class ToolsBtnsWidget(QtWidgets.QWidget): @@ -20,6 +21,7 @@ class ToolsBtnsWidget(QtWidgets.QWidget): load_btn = QtWidgets.QPushButton("Load...", self) publish_btn = QtWidgets.QPushButton("Publish...", self) manage_btn = QtWidgets.QPushButton("Manage...", self) + render_btn = QtWidgets.QPushButton("Render...", self) experimental_tools_btn = QtWidgets.QPushButton( "Experimental tools...", self ) @@ -30,6 +32,7 @@ class ToolsBtnsWidget(QtWidgets.QWidget): layout.addWidget(load_btn, 0) layout.addWidget(publish_btn, 0) layout.addWidget(manage_btn, 0) + layout.addWidget(render_btn, 0) layout.addWidget(experimental_tools_btn, 0) layout.addStretch(1) @@ -37,6 +40,7 @@ class ToolsBtnsWidget(QtWidgets.QWidget): load_btn.clicked.connect(self._on_load) publish_btn.clicked.connect(self._on_publish) manage_btn.clicked.connect(self._on_manage) + render_btn.clicked.connect(self._on_render) experimental_tools_btn.clicked.connect(self._on_experimental) def _on_create(self): @@ -51,6 +55,9 @@ class ToolsBtnsWidget(QtWidgets.QWidget): def _on_manage(self): self.tool_required.emit("sceneinventory") + def _on_render(self): + rendering.start_rendering() + def _on_experimental(self): self.tool_required.emit("experimental_tools") diff --git a/openpype/hosts/unreal/plugins/create/create_render.py b/openpype/hosts/unreal/plugins/create/create_render.py index a0bf320225..0128808a70 100644 --- a/openpype/hosts/unreal/plugins/create/create_render.py +++ b/openpype/hosts/unreal/plugins/create/create_render.py @@ -44,9 +44,10 @@ class CreateRender(Creator): for a in selection: d = self.data.copy() + d["members"] = [a] d["sequence"] = a + d["map"] = unreal.EditorLevelLibrary.get_editor_world().get_path_name() asset = ar.get_asset_by_object_path(a).get_asset() - container_name = asset.get_name() + container_name = f"{asset.get_name()}{self.suffix}" pipeline.create_publish_instance(instance=container_name, path=path) pipeline.imprint("{}/{}".format(path, container_name), d) - From 67339b488be8727d864f4f7804dacc9d9f267e6a Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Mon, 31 Jan 2022 11:23:26 +0000 Subject: [PATCH 005/583] Implemented extraction of renders --- .../unreal/plugins/publish/extract_render.py | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 openpype/hosts/unreal/plugins/publish/extract_render.py diff --git a/openpype/hosts/unreal/plugins/publish/extract_render.py b/openpype/hosts/unreal/plugins/publish/extract_render.py new file mode 100644 index 0000000000..7ba53c9155 --- /dev/null +++ b/openpype/hosts/unreal/plugins/publish/extract_render.py @@ -0,0 +1,46 @@ +from pathlib import Path +import openpype.api +from avalon import io +import unreal + + +class ExtractRender(openpype.api.Extractor): + """Extract render.""" + + label = "Extract Render" + hosts = ["unreal"] + families = ["render"] + optional = True + + def process(self, instance): + # Define extract output file path + stagingdir = self.staging_dir(instance) + + # Perform extraction + self.log.info("Performing extraction..") + + # Get the render output directory + project_dir = unreal.Paths.project_dir() + render_dir = f"{project_dir}/Saved/MovieRenders/{instance.data['subset']}" + + assert unreal.Paths.directory_exists(render_dir), \ + "Render directory does not exist" + + render_path = Path(render_dir) + + frames = [] + + for x in render_path.iterdir(): + if x.is_file() and x.suffix == '.png': + frames.append(str(x)) + + if "representations" not in instance.data: + instance.data["representations"] = [] + + render_representation = { + 'name': 'png', + 'ext': 'png', + 'files': frames, + "stagingDir": stagingdir, + } + instance.data["representations"].append(render_representation) From 2966068aa58d7e42f5a86cd289355242a51e779b Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Tue, 15 Feb 2022 12:27:54 +0000 Subject: [PATCH 006/583] Layout and Cameras create the level and sequence hierarchy structure --- .../hosts/unreal/plugins/load/load_camera.py | 93 +++++------ .../hosts/unreal/plugins/load/load_layout.py | 147 +++++++++++++++--- 2 files changed, 159 insertions(+), 81 deletions(-) diff --git a/openpype/hosts/unreal/plugins/load/load_camera.py b/openpype/hosts/unreal/plugins/load/load_camera.py index 00d17407f9..feab531aaa 100644 --- a/openpype/hosts/unreal/plugins/load/load_camera.py +++ b/openpype/hosts/unreal/plugins/load/load_camera.py @@ -15,12 +15,6 @@ class CameraLoader(api.Loader): icon = "cube" color = "orange" - def _add_sub_sequence(self, master, sub): - track = master.add_master_track(unreal.MovieSceneCinematicShotTrack) - section = track.add_section() - section.set_editor_property('sub_sequence', sub) - return section - def _get_data(self, asset_name): asset_doc = io.find_one({ "type": "asset", @@ -29,6 +23,35 @@ class CameraLoader(api.Loader): return asset_doc.get("data") + def _set_sequence_hierarchy(self, seq_i, seq_j, data_i, data_j): + if data_i: + seq_i.set_display_rate(unreal.FrameRate(data_i.get("fps"), 1.0)) + seq_i.set_playback_start(data_i.get("frameStart")) + seq_i.set_playback_end(data_i.get("frameEnd") + 1) + + tracks = seq_i.get_master_tracks() + track = None + for t in tracks: + if t.get_class() == unreal.MovieSceneSubTrack.static_class(): + track = t + break + if not track: + track = seq_i.add_master_track(unreal.MovieSceneSubTrack) + + subscenes = track.get_sections() + subscene = None + for s in subscenes: + if s.get_editor_property('sub_sequence') == seq_j: + subscene = s + break + if not subscene: + subscene = track.add_section() + subscene.set_row_index(len(track.get_sections())) + subscene.set_editor_property('sub_sequence', seq_j) + subscene.set_range( + data_j.get("frameStart"), + data_j.get("frameEnd") + 1) + def load(self, context, name, namespace, data): """ Load and containerise representation into Content Browser. @@ -95,30 +118,6 @@ class CameraLoader(api.Loader): container_name += suffix - # sequence = None - - # ar = unreal.AssetRegistryHelpers.get_asset_registry() - - # if not unreal.EditorAssetLibrary.does_directory_exist(asset_dir): - # unreal.EditorAssetLibrary.make_directory(asset_dir) - - # sequence = tools.create_asset( - # asset_name=asset_name, - # package_path=asset_dir, - # asset_class=unreal.LevelSequence, - # factory=unreal.LevelSequenceFactoryNew() - # ) - # else: - # asset_content = unreal.EditorAssetLibrary.list_assets( - # asset_dir, recursive=False) - # for a in asset_content: - # obj = ar.get_asset_by_object_path(a) - # if obj.get_asset().get_class().get_name() == 'LevelSequence': - # sequence = obj.get_asset() - # break - - # assert sequence, "Sequence not found" - # Get all the sequences in the hierarchy. It will create them, if # they don't exist. sequences = [] @@ -134,13 +133,6 @@ class CameraLoader(api.Loader): asset).get_class().get_name() == 'LevelSequence' ] - # for asset in root_content: - # asset_data = EditorAssetLibrary.find_asset_data(asset) - # # imported_asset = unreal.AssetRegistryHelpers.get_asset( - # # imported_asset_data) - # if asset_data.get_class().get_name() == 'LevelSequence': - # break - if not existing_sequences: scene = tools.create_asset( asset_name=hierarchy[i], @@ -158,42 +150,27 @@ class CameraLoader(api.Loader): unreal.EditorAssetLibrary.make_directory(asset_dir) cam_seq = tools.create_asset( - asset_name=asset, + asset_name=f"{asset}_camera", package_path=asset_dir, asset_class=unreal.LevelSequence, factory=unreal.LevelSequenceFactoryNew() ) - sequences.append(cam_seq) - # Add sequences data to hierarchy data_i = self._get_data(sequences[0].get_name()) for i in range(0, len(sequences) - 1): - section = self._add_sub_sequence(sequences[i], sequences[i + 1]) - - print(sequences[i]) - print(sequences[i + 1]) - data_j = self._get_data(sequences[i + 1].get_name()) - if data_i: - sequences[i].set_display_rate(unreal.FrameRate(data_i.get("fps"), 1.0)) - sequences[i].set_playback_start(data_i.get("frameStart")) - sequences[i].set_playback_end(data_i.get("frameEnd")) - if data_j: - section.set_range( - data_j.get("frameStart"), - data_j.get("frameEnd")) + self._set_sequence_hierarchy( + sequences[i], sequences[i + 1], data_i, data_j) data_i = data_j + parent_data = self._get_data(sequences[-1].get_name()) data = self._get_data(asset) - - if data: - cam_seq.set_display_rate(unreal.FrameRate(data.get("fps"), 1.0)) - cam_seq.set_playback_start(data.get("frameStart")) - cam_seq.set_playback_end(data.get("frameEnd")) + self._set_sequence_hierarchy( + sequences[-1], cam_seq, parent_data, data) settings = unreal.MovieSceneUserImportFBXSettings() settings.set_editor_property('reduce_keys', False) diff --git a/openpype/hosts/unreal/plugins/load/load_layout.py b/openpype/hosts/unreal/plugins/load/load_layout.py index 7554a4658b..a7d5a5841f 100644 --- a/openpype/hosts/unreal/plugins/load/load_layout.py +++ b/openpype/hosts/unreal/plugins/load/load_layout.py @@ -5,6 +5,7 @@ from pathlib import Path import unreal from unreal import EditorAssetLibrary from unreal import EditorLevelLibrary +from unreal import EditorLevelUtils from unreal import AssetToolsHelpers from unreal import FBXImportType from unreal import MathLibrary as umath @@ -74,12 +75,6 @@ class LayoutLoader(api.Loader): return None - def _add_sub_sequence(self, master, sub): - track = master.add_master_track(unreal.MovieSceneCinematicShotTrack) - section = track.add_section() - section.set_editor_property('sub_sequence', sub) - return section - def _get_data(self, asset_name): asset_doc = io.find_one({ "type": "asset", @@ -88,6 +83,78 @@ class LayoutLoader(api.Loader): return asset_doc.get("data") + def _set_sequence_hierarchy(self, seq_i, seq_j, data_i, data_j, map_paths): + # Set data for the parent sequence + if data_i: + seq_i.set_display_rate(unreal.FrameRate(data_i.get("fps"), 1.0)) + seq_i.set_playback_start(data_i.get("frameStart")) + seq_i.set_playback_end(data_i.get("frameEnd") + 1) + + # Get existing sequencer tracks or create them if they don't exist + tracks = seq_i.get_master_tracks() + subscene_track = None + visibility_track = None + for t in tracks: + if t.get_class() == unreal.MovieSceneSubTrack.static_class(): + subscene_track = t + if t.get_class() == unreal.MovieSceneLevelVisibilityTrack.static_class(): + visibility_track = t + if not subscene_track: + subscene_track = seq_i.add_master_track(unreal.MovieSceneSubTrack) + if not visibility_track: + visibility_track = seq_i.add_master_track(unreal.MovieSceneLevelVisibilityTrack) + + # Create the sub-scene section + subscenes = subscene_track.get_sections() + subscene = None + for s in subscenes: + if s.get_editor_property('sub_sequence') == seq_j: + subscene = s + break + if not subscene: + subscene = subscene_track.add_section() + subscene.set_row_index(len(subscene_track.get_sections())) + subscene.set_editor_property('sub_sequence', seq_j) + subscene.set_range( + data_j.get("frameStart"), + data_j.get("frameEnd") + 1) + + # Create the visibility section + ar = unreal.AssetRegistryHelpers.get_asset_registry() + maps = [] + for m in map_paths: + # Unreal requires to load the level to get the map name + EditorLevelLibrary.save_all_dirty_levels() + EditorLevelLibrary.load_level(m) + maps.append(str(ar.get_asset_by_object_path(m).asset_name)) + + vis_section = visibility_track.add_section() + index = len(visibility_track.get_sections()) + + vis_section.set_range( + data_j.get("frameStart"), + data_j.get("frameEnd") + 1) + vis_section.set_visibility(unreal.LevelVisibility.VISIBLE) + vis_section.set_row_index(index) + vis_section.set_level_names(maps) + + if data_j.get("frameStart") > 1: + hid_section = visibility_track.add_section() + hid_section.set_range( + 1, + data_j.get("frameStart")) + hid_section.set_visibility(unreal.LevelVisibility.HIDDEN) + hid_section.set_row_index(index) + hid_section.set_level_names(maps) + if data_j.get("frameEnd") < data_i.get("frameEnd"): + hid_section = visibility_track.add_section() + hid_section.set_range( + data_j.get("frameEnd") + 1, + data_i.get("frameEnd") + 1) + hid_section.set_visibility(unreal.LevelVisibility.HIDDEN) + hid_section.set_row_index(index) + hid_section.set_level_names(maps) + def _process_family( self, assets, classname, transform, sequence, inst_name=None): ar = unreal.AssetRegistryHelpers.get_asset_registry() @@ -420,6 +487,37 @@ class LayoutLoader(api.Loader): EditorAssetLibrary.make_directory(asset_dir) + # Create map for the shot, and create hierarchy of map. If the maps + # already exist, we will use them. + maps = [] + for h in hierarchy_list: + a = h.split('/')[-1] + map = f"{h}/{a}_map.{a}_map" + new = False + + if not EditorAssetLibrary.does_asset_exist(map): + EditorLevelLibrary.new_level(f"{h}/{a}_map") + new = True + + maps.append({"map": map, "new": new}) + + EditorLevelLibrary.new_level(f"{asset_dir}/{asset}_map") + maps.append( + {"map":f"{asset_dir}/{asset}_map.{asset}_map", "new": True}) + + for i in range(0, len(maps) - 1): + for j in range(i + 1, len(maps)): + if maps[j].get('new'): + EditorLevelLibrary.load_level(maps[i].get('map')) + EditorLevelUtils.add_level_to_world( + EditorLevelLibrary.get_editor_world(), + maps[j].get('map'), + unreal.LevelStreamingDynamic + ) + EditorLevelLibrary.save_all_dirty_levels() + + EditorLevelLibrary.load_level(maps[-1].get('map')) + # Get all the sequences in the hierarchy. It will create them, if # they don't exist. sequences = [] @@ -456,8 +554,6 @@ class LayoutLoader(api.Loader): i += 1 - # TODO: check if shot already exists - shot = tools.create_asset( asset_name=asset, package_path=asset_dir, @@ -465,36 +561,39 @@ class LayoutLoader(api.Loader): factory=unreal.LevelSequenceFactoryNew() ) - sequences.append(shot) - # Add sequences data to hierarchy data_i = self._get_data(sequences[0].get_name()) for i in range(0, len(sequences) - 1): - section = self._add_sub_sequence(sequences[i], sequences[i + 1]) + maps_to_add = [] + for j in range(i + 1, len(maps)): + maps_to_add.append(maps[j].get('map')) data_j = self._get_data(sequences[i + 1].get_name()) - if data_i: - sequences[i].set_display_rate(unreal.FrameRate(data_i.get("fps"), 1.0)) - sequences[i].set_playback_start(data_i.get("frameStart")) - sequences[i].set_playback_end(data_i.get("frameEnd")) - if data_j: - section.set_range( - data_j.get("frameStart"), - data_j.get("frameEnd")) + self._set_sequence_hierarchy( + sequences[i], sequences[i + 1], + data_i, data_j, + maps_to_add) data_i = data_j + parent_data = self._get_data(sequences[-1].get_name()) data = self._get_data(asset) + self._set_sequence_hierarchy( + sequences[-1], shot, + parent_data, data, + [maps[-1].get('map')]) - if data: - shot.set_display_rate(unreal.FrameRate(data.get("fps"), 1.0)) - shot.set_playback_start(data.get("frameStart")) - shot.set_playback_end(data.get("frameEnd")) + EditorLevelLibrary.load_level(maps[-1].get('map')) self._process(self.fname, asset_dir, shot) + for s in sequences: + EditorAssetLibrary.save_asset(s.get_full_name()) + + EditorLevelLibrary.save_current_level() + # Create Asset Container lib.create_avalon_container( container=container_name, path=asset_dir) @@ -520,6 +619,8 @@ class LayoutLoader(api.Loader): for a in asset_content: EditorAssetLibrary.save_asset(a) + EditorLevelLibrary.load_level(maps[0].get('map')) + return asset_content def update(self, container, representation): From 9e7208187599b8095c9e03a873b750682862e717 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Wed, 16 Feb 2022 09:48:28 +0000 Subject: [PATCH 007/583] Animation are added to sequence when loaded --- .../unreal/plugins/load/load_animation.py | 188 +++++++++++------- 1 file changed, 119 insertions(+), 69 deletions(-) diff --git a/openpype/hosts/unreal/plugins/load/load_animation.py b/openpype/hosts/unreal/plugins/load/load_animation.py index 20baa30847..7d054c4899 100644 --- a/openpype/hosts/unreal/plugins/load/load_animation.py +++ b/openpype/hosts/unreal/plugins/load/load_animation.py @@ -1,10 +1,14 @@ import os import json +import unreal +from unreal import EditorAssetLibrary +from unreal import MovieSceneSkeletalAnimationTrack +from unreal import MovieSceneSkeletalAnimationSection + from avalon import api, pipeline from avalon.unreal import lib from avalon.unreal import pipeline as unreal_pipeline -import unreal class AnimationFBXLoader(api.Loader): @@ -16,59 +20,13 @@ class AnimationFBXLoader(api.Loader): icon = "cube" color = "orange" - def load(self, context, name, namespace, options=None): - """ - Load and containerise representation into Content Browser. - - This is two step process. First, import FBX to temporary path and - then call `containerise()` on it - this moves all content to new - directory and then it will create AssetContainer there and imprint it - with metadata. This will mark this path as container. - - Args: - context (dict): application context - name (str): subset name - namespace (str): in Unreal this is basically path to container. - This is not passed here, so namespace is set - by `containerise()` because only then we know - real path. - data (dict): Those would be data to be imprinted. This is not used - now, data are imprinted by `containerise()`. - - Returns: - list(str): list of container content - """ - - # Create directory for asset and avalon container - root = "/Game/Avalon/Assets" - asset = context.get('asset').get('name') - suffix = "_CON" - if asset: - asset_name = "{}_{}".format(asset, name) - else: - asset_name = "{}".format(name) - - tools = unreal.AssetToolsHelpers().get_asset_tools() - asset_dir, container_name = tools.create_unique_asset_name( - "{}/{}/{}".format(root, asset, name), suffix="") - - container_name += suffix - - unreal.EditorAssetLibrary.make_directory(asset_dir) - + def _process(self, asset_dir, asset_name, instance_name): automated = False actor = None task = unreal.AssetImportTask() task.options = unreal.FbxImportUI() - libpath = self.fname.replace("fbx", "json") - - with open(libpath, "r") as fp: - data = json.load(fp) - - instance_name = data.get("instance_name") - if instance_name: automated = True # Old method to get the actor @@ -126,6 +84,116 @@ class AnimationFBXLoader(api.Loader): unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) + asset_content = unreal.EditorAssetLibrary.list_assets( + asset_dir, recursive=True, include_folder=True + ) + + animation = None + + for a in asset_content: + imported_asset_data = unreal.EditorAssetLibrary.find_asset_data(a) + imported_asset = unreal.AssetRegistryHelpers.get_asset( + imported_asset_data) + if imported_asset.__class__ == unreal.AnimSequence: + animation = imported_asset + break + + if animation: + animation.set_editor_property('enable_root_motion', True) + actor.skeletal_mesh_component.set_editor_property( + 'animation_mode', unreal.AnimationMode.ANIMATION_SINGLE_NODE) + actor.skeletal_mesh_component.animation_data.set_editor_property( + 'anim_to_play', animation) + + return animation + + def load(self, context, name, namespace, options=None): + """ + Load and containerise representation into Content Browser. + + This is two step process. First, import FBX to temporary path and + then call `containerise()` on it - this moves all content to new + directory and then it will create AssetContainer there and imprint it + with metadata. This will mark this path as container. + + Args: + context (dict): application context + name (str): subset name + namespace (str): in Unreal this is basically path to container. + This is not passed here, so namespace is set + by `containerise()` because only then we know + real path. + data (dict): Those would be data to be imprinted. This is not used + now, data are imprinted by `containerise()`. + + Returns: + list(str): list of container content + """ + + # Create directory for asset and avalon container + hierarchy = context.get('asset').get('data').get('parents') + root = "/Game/Avalon" + asset = context.get('asset').get('name') + suffix = "_CON" + if asset: + asset_name = "{}_{}".format(asset, name) + else: + asset_name = "{}".format(name) + + tools = unreal.AssetToolsHelpers().get_asset_tools() + asset_dir, container_name = tools.create_unique_asset_name( + f"{root}/Assets/{asset}/{name}", suffix="") + + hierarchy_dir = root + for h in hierarchy: + hierarchy_dir = f"{hierarchy_dir}/{h}" + hierarchy_dir = f"{hierarchy_dir}/{asset}" + + container_name += suffix + + unreal.EditorAssetLibrary.make_directory(asset_dir) + + libpath = self.fname.replace("fbx", "json") + + with open(libpath, "r") as fp: + data = json.load(fp) + + instance_name = data.get("instance_name") + + animation = self._process(asset_dir, container_name, instance_name) + + asset_content = unreal.EditorAssetLibrary.list_assets( + hierarchy_dir, recursive=True, include_folder=False) + + # Get the sequence for the layout, excluding the camera one. + sequences = [a for a in asset_content + if (EditorAssetLibrary.find_asset_data(a).get_class() == + unreal.LevelSequence.static_class() and + "_camera" not in a.split("/")[-1])] + + ar = unreal.AssetRegistryHelpers.get_asset_registry() + + for s in sequences: + sequence = ar.get_asset_by_object_path(s).get_asset() + possessables = [ + p for p in sequence.get_possessables() + if p.get_display_name() == instance_name] + + for p in possessables: + tracks = [ + t for t in p.get_tracks() + if (t.get_class() == + MovieSceneSkeletalAnimationTrack.static_class())] + + for t in tracks: + sections = [ + s for s in t.get_sections() + if (s.get_class() == + MovieSceneSkeletalAnimationSection.static_class())] + + for s in sections: + s.params.set_editor_property('animation', animation) + # Create Asset Container lib.create_avalon_container( container=container_name, path=asset_dir) @@ -145,29 +213,11 @@ class AnimationFBXLoader(api.Loader): unreal_pipeline.imprint( "{}/{}".format(asset_dir, container_name), data) - asset_content = unreal.EditorAssetLibrary.list_assets( - asset_dir, recursive=True, include_folder=True - ) + imported_content = unreal.EditorAssetLibrary.list_assets( + asset_dir, recursive=True, include_folder=False) - animation = None - - for a in asset_content: + for a in imported_content: unreal.EditorAssetLibrary.save_asset(a) - imported_asset_data = unreal.EditorAssetLibrary.find_asset_data(a) - imported_asset = unreal.AssetRegistryHelpers.get_asset( - imported_asset_data) - if imported_asset.__class__ == unreal.AnimSequence: - animation = imported_asset - break - - if animation: - animation.set_editor_property('enable_root_motion', True) - actor.skeletal_mesh_component.set_editor_property( - 'animation_mode', unreal.AnimationMode.ANIMATION_SINGLE_NODE) - actor.skeletal_mesh_component.animation_data.set_editor_property( - 'anim_to_play', animation) - - return asset_content def update(self, container, representation): name = container["asset_name"] From d11a871bb181d0934efda4579ccafb5c73cb8c17 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Thu, 17 Feb 2022 16:34:18 +0000 Subject: [PATCH 008/583] Changed logic to obtain min and max frame of sequences --- .../hosts/unreal/plugins/load/load_camera.py | 32 ++++++++++++++--- .../hosts/unreal/plugins/load/load_layout.py | 35 +++++++++++++++---- 2 files changed, 55 insertions(+), 12 deletions(-) diff --git a/openpype/hosts/unreal/plugins/load/load_camera.py b/openpype/hosts/unreal/plugins/load/load_camera.py index feab531aaa..2d29319fc7 100644 --- a/openpype/hosts/unreal/plugins/load/load_camera.py +++ b/openpype/hosts/unreal/plugins/load/load_camera.py @@ -24,11 +24,6 @@ class CameraLoader(api.Loader): return asset_doc.get("data") def _set_sequence_hierarchy(self, seq_i, seq_j, data_i, data_j): - if data_i: - seq_i.set_display_rate(unreal.FrameRate(data_i.get("fps"), 1.0)) - seq_i.set_playback_start(data_i.get("frameStart")) - seq_i.set_playback_end(data_i.get("frameEnd") + 1) - tracks = seq_i.get_master_tracks() track = None for t in tracks: @@ -140,6 +135,33 @@ class CameraLoader(api.Loader): asset_class=unreal.LevelSequence, factory=unreal.LevelSequenceFactoryNew() ) + + asset_data = io.find_one({ + "type": "asset", + "name": h.split('/')[-1] + }) + + id = asset_data.get('_id') + + start_frames = [] + end_frames = [] + + elements = list( + io.find({"type": "asset", "data.visualParent": id})) + for e in elements: + start_frames.append(e.get('data').get('clipIn')) + end_frames.append(e.get('data').get('clipOut')) + + elements.extend(io.find({ + "type": "asset", + "data.visualParent": e.get('_id') + })) + + scene.set_display_rate( + unreal.FrameRate(asset_data.get('data').get("fps"), 1.0)) + scene.set_playback_start(min(start_frames)) + scene.set_playback_end(max(end_frames)) + sequences.append(scene) else: for e in existing_sequences: diff --git a/openpype/hosts/unreal/plugins/load/load_layout.py b/openpype/hosts/unreal/plugins/load/load_layout.py index a7d5a5841f..a36bd6663a 100644 --- a/openpype/hosts/unreal/plugins/load/load_layout.py +++ b/openpype/hosts/unreal/plugins/load/load_layout.py @@ -1,4 +1,4 @@ -import os +import os, sys import json from pathlib import Path @@ -84,12 +84,6 @@ class LayoutLoader(api.Loader): return asset_doc.get("data") def _set_sequence_hierarchy(self, seq_i, seq_j, data_i, data_j, map_paths): - # Set data for the parent sequence - if data_i: - seq_i.set_display_rate(unreal.FrameRate(data_i.get("fps"), 1.0)) - seq_i.set_playback_start(data_i.get("frameStart")) - seq_i.set_playback_end(data_i.get("frameEnd") + 1) - # Get existing sequencer tracks or create them if they don't exist tracks = seq_i.get_master_tracks() subscene_track = None @@ -547,6 +541,33 @@ class LayoutLoader(api.Loader): asset_class=unreal.LevelSequence, factory=unreal.LevelSequenceFactoryNew() ) + + asset_data = io.find_one({ + "type": "asset", + "name": h.split('/')[-1] + }) + + id = asset_data.get('_id') + + start_frames = [] + end_frames = [] + + elements = list( + io.find({"type": "asset", "data.visualParent": id})) + for e in elements: + start_frames.append(e.get('data').get('clipIn')) + end_frames.append(e.get('data').get('clipOut')) + + elements.extend(io.find({ + "type": "asset", + "data.visualParent": e.get('_id') + })) + + scene.set_display_rate( + unreal.FrameRate(asset_data.get('data').get("fps"), 1.0)) + scene.set_playback_start(min(start_frames)) + scene.set_playback_end(max(end_frames)) + sequences.append(scene) else: for e in existing_sequences: From ce4984d7e60488b20e08850a87468f488805822c Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Thu, 17 Feb 2022 17:35:14 +0000 Subject: [PATCH 009/583] Camera is now saved in the right level --- .../hosts/unreal/plugins/load/load_camera.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/openpype/hosts/unreal/plugins/load/load_camera.py b/openpype/hosts/unreal/plugins/load/load_camera.py index 2d29319fc7..61d9c04d2f 100644 --- a/openpype/hosts/unreal/plugins/load/load_camera.py +++ b/openpype/hosts/unreal/plugins/load/load_camera.py @@ -78,7 +78,10 @@ class CameraLoader(api.Loader): for h in hierarchy: hierarchy_dir = f"{hierarchy_dir}/{h}" hierarchy_list.append(hierarchy_dir) + print(h) + print(hierarchy_dir) asset = context.get('asset').get('name') + print(asset) suffix = "_CON" if asset: asset_name = "{}_{}".format(asset, name) @@ -113,6 +116,23 @@ class CameraLoader(api.Loader): container_name += suffix + current_level = unreal.EditorLevelLibrary.get_editor_world().get_full_name() + unreal.EditorLevelLibrary.save_all_dirty_levels() + + # asset_content = unreal.EditorAssetLibrary.list_assets( + # f"{hierarchy_dir}/{asset}/", recursive=True, include_folder=False + # ) + + ar = unreal.AssetRegistryHelpers.get_asset_registry() + filter = unreal.ARFilter( + class_names = ["World"], + package_paths = [f"{hierarchy_dir}/{asset}/"], + recursive_paths = True) + maps = ar.get_assets(filter) + + # There should be only one map in the list + unreal.EditorLevelLibrary.load_level(maps[0].get_full_name()) + # Get all the sequences in the hierarchy. It will create them, if # they don't exist. sequences = [] @@ -224,6 +244,9 @@ class CameraLoader(api.Loader): unreal_pipeline.imprint( "{}/{}".format(asset_dir, container_name), data) + unreal.EditorLevelLibrary.save_all_dirty_levels() + unreal.EditorLevelLibrary.load_level(current_level) + asset_content = unreal.EditorAssetLibrary.list_assets( asset_dir, recursive=True, include_folder=True ) From fa64a26a17271522e80af955a48f6238c5c4af2b Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Thu, 17 Feb 2022 17:46:45 +0000 Subject: [PATCH 010/583] Removed debug prints --- openpype/hosts/unreal/plugins/load/load_camera.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/openpype/hosts/unreal/plugins/load/load_camera.py b/openpype/hosts/unreal/plugins/load/load_camera.py index 61d9c04d2f..6ee88f8acc 100644 --- a/openpype/hosts/unreal/plugins/load/load_camera.py +++ b/openpype/hosts/unreal/plugins/load/load_camera.py @@ -78,10 +78,7 @@ class CameraLoader(api.Loader): for h in hierarchy: hierarchy_dir = f"{hierarchy_dir}/{h}" hierarchy_list.append(hierarchy_dir) - print(h) - print(hierarchy_dir) asset = context.get('asset').get('name') - print(asset) suffix = "_CON" if asset: asset_name = "{}_{}".format(asset, name) @@ -119,10 +116,6 @@ class CameraLoader(api.Loader): current_level = unreal.EditorLevelLibrary.get_editor_world().get_full_name() unreal.EditorLevelLibrary.save_all_dirty_levels() - # asset_content = unreal.EditorAssetLibrary.list_assets( - # f"{hierarchy_dir}/{asset}/", recursive=True, include_folder=False - # ) - ar = unreal.AssetRegistryHelpers.get_asset_registry() filter = unreal.ARFilter( class_names = ["World"], From 26d63e6beacf7334eaa4d5c4a5cad789adc75db5 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Fri, 18 Feb 2022 12:15:06 +0000 Subject: [PATCH 011/583] Fix start and end frames of sequences --- .../hosts/unreal/plugins/load/load_layout.py | 48 ++++++++++--------- 1 file changed, 25 insertions(+), 23 deletions(-) diff --git a/openpype/hosts/unreal/plugins/load/load_layout.py b/openpype/hosts/unreal/plugins/load/load_layout.py index a36bd6663a..5d7977b237 100644 --- a/openpype/hosts/unreal/plugins/load/load_layout.py +++ b/openpype/hosts/unreal/plugins/load/load_layout.py @@ -1,4 +1,4 @@ -import os, sys +import os import json from pathlib import Path @@ -83,7 +83,9 @@ class LayoutLoader(api.Loader): return asset_doc.get("data") - def _set_sequence_hierarchy(self, seq_i, seq_j, data_i, data_j, map_paths): + def _set_sequence_hierarchy(self, + seq_i, seq_j, max_frame_i, min_frame_j, max_frame_j, map_paths + ): # Get existing sequencer tracks or create them if they don't exist tracks = seq_i.get_master_tracks() subscene_track = None @@ -110,8 +112,8 @@ class LayoutLoader(api.Loader): subscene.set_row_index(len(subscene_track.get_sections())) subscene.set_editor_property('sub_sequence', seq_j) subscene.set_range( - data_j.get("frameStart"), - data_j.get("frameEnd") + 1) + min_frame_j, + max_frame_j + 1) # Create the visibility section ar = unreal.AssetRegistryHelpers.get_asset_registry() @@ -126,25 +128,25 @@ class LayoutLoader(api.Loader): index = len(visibility_track.get_sections()) vis_section.set_range( - data_j.get("frameStart"), - data_j.get("frameEnd") + 1) + min_frame_j, + max_frame_j + 1) vis_section.set_visibility(unreal.LevelVisibility.VISIBLE) vis_section.set_row_index(index) vis_section.set_level_names(maps) - if data_j.get("frameStart") > 1: + if min_frame_j > 1: hid_section = visibility_track.add_section() hid_section.set_range( 1, - data_j.get("frameStart")) + min_frame_j) hid_section.set_visibility(unreal.LevelVisibility.HIDDEN) hid_section.set_row_index(index) hid_section.set_level_names(maps) - if data_j.get("frameEnd") < data_i.get("frameEnd"): + if max_frame_j < max_frame_i: hid_section = visibility_track.add_section() hid_section.set_range( - data_j.get("frameEnd") + 1, - data_i.get("frameEnd") + 1) + max_frame_j + 1, + max_frame_i + 1) hid_section.set_visibility(unreal.LevelVisibility.HIDDEN) hid_section.set_row_index(index) hid_section.set_level_names(maps) @@ -515,6 +517,7 @@ class LayoutLoader(api.Loader): # Get all the sequences in the hierarchy. It will create them, if # they don't exist. sequences = [] + frame_ranges = [] i = 0 for h in hierarchy_list: root_content = EditorAssetLibrary.list_assets( @@ -563,12 +566,16 @@ class LayoutLoader(api.Loader): "data.visualParent": e.get('_id') })) + min_frame = min(start_frames) + max_frame = max(end_frames) + scene.set_display_rate( unreal.FrameRate(asset_data.get('data').get("fps"), 1.0)) - scene.set_playback_start(min(start_frames)) - scene.set_playback_end(max(end_frames)) + scene.set_playback_start(min_frame) + scene.set_playback_end(max_frame) sequences.append(scene) + frame_ranges.append((min_frame, max_frame)) else: for e in existing_sequences: sequences.append(e.get_asset()) @@ -582,28 +589,23 @@ class LayoutLoader(api.Loader): factory=unreal.LevelSequenceFactoryNew() ) - # Add sequences data to hierarchy - data_i = self._get_data(sequences[0].get_name()) - + # sequences and frame_ranges have the same length for i in range(0, len(sequences) - 1): maps_to_add = [] for j in range(i + 1, len(maps)): maps_to_add.append(maps[j].get('map')) - data_j = self._get_data(sequences[i + 1].get_name()) - self._set_sequence_hierarchy( sequences[i], sequences[i + 1], - data_i, data_j, + frame_ranges[i][1], + frame_ranges[i + 1][0], frame_ranges[i + 1][1], maps_to_add) - data_i = data_j - - parent_data = self._get_data(sequences[-1].get_name()) data = self._get_data(asset) self._set_sequence_hierarchy( sequences[-1], shot, - parent_data, data, + frame_ranges[-1][1], + data.get('clipIn'), data.get('clipOut'), [maps[-1].get('map')]) EditorLevelLibrary.load_level(maps[-1].get('map')) From 55bcd6bba71f0ef191f2597a9aefc61c01639950 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Wed, 23 Feb 2022 10:19:29 +0000 Subject: [PATCH 012/583] Set correct start and end frames for existing sequences --- openpype/hosts/unreal/plugins/load/load_layout.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openpype/hosts/unreal/plugins/load/load_layout.py b/openpype/hosts/unreal/plugins/load/load_layout.py index 5d7977b237..58b6f661b9 100644 --- a/openpype/hosts/unreal/plugins/load/load_layout.py +++ b/openpype/hosts/unreal/plugins/load/load_layout.py @@ -579,6 +579,9 @@ class LayoutLoader(api.Loader): else: for e in existing_sequences: sequences.append(e.get_asset()) + frame_ranges.append(( + e.get_asset().get_playback_start(), + e.get_asset().get_playback_end())) i += 1 From 10c6d7be08aaeccc219dec51361c793ac495fcff Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Wed, 23 Feb 2022 10:20:30 +0000 Subject: [PATCH 013/583] Add an empty Camera Cut Track to the sequences in the hierarchy --- .../hosts/unreal/plugins/load/load_layout.py | 27 ++++++++++--------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/openpype/hosts/unreal/plugins/load/load_layout.py b/openpype/hosts/unreal/plugins/load/load_layout.py index 58b6f661b9..05615ff083 100644 --- a/openpype/hosts/unreal/plugins/load/load_layout.py +++ b/openpype/hosts/unreal/plugins/load/load_layout.py @@ -530,15 +530,8 @@ class LayoutLoader(api.Loader): asset).get_class().get_name() == 'LevelSequence' ] - # for asset in root_content: - # asset_data = EditorAssetLibrary.find_asset_data(asset) - # # imported_asset = unreal.AssetRegistryHelpers.get_asset( - # # imported_asset_data) - # if asset_data.get_class().get_name() == 'LevelSequence': - # break - if not existing_sequences: - scene = tools.create_asset( + sequence = tools.create_asset( asset_name=hierarchy[i], package_path=h, asset_class=unreal.LevelSequence, @@ -569,13 +562,23 @@ class LayoutLoader(api.Loader): min_frame = min(start_frames) max_frame = max(end_frames) - scene.set_display_rate( + sequence.set_display_rate( unreal.FrameRate(asset_data.get('data').get("fps"), 1.0)) - scene.set_playback_start(min_frame) - scene.set_playback_end(max_frame) + sequence.set_playback_start(min_frame) + sequence.set_playback_end(max_frame) - sequences.append(scene) + sequences.append(sequence) frame_ranges.append((min_frame, max_frame)) + + tracks = sequence.get_master_tracks() + track = None + for t in tracks: + if t.get_class() == unreal.MovieSceneCameraCutTrack.static_class(): + track = t + break + if not track: + track = sequence.add_master_track( + unreal.MovieSceneCameraCutTrack) else: for e in existing_sequences: sequences.append(e.get_asset()) From 6bb615e2a04d47068b091290fcbac6481c1fa01e Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Wed, 23 Feb 2022 11:31:19 +0000 Subject: [PATCH 014/583] Set correct start and end frame for camera sequences --- .../hosts/unreal/plugins/load/load_camera.py | 33 +++++++++++-------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/openpype/hosts/unreal/plugins/load/load_camera.py b/openpype/hosts/unreal/plugins/load/load_camera.py index 6ee88f8acc..12aaceb385 100644 --- a/openpype/hosts/unreal/plugins/load/load_camera.py +++ b/openpype/hosts/unreal/plugins/load/load_camera.py @@ -23,7 +23,9 @@ class CameraLoader(api.Loader): return asset_doc.get("data") - def _set_sequence_hierarchy(self, seq_i, seq_j, data_i, data_j): + def _set_sequence_hierarchy(self, + seq_i, seq_j, min_frame_j, max_frame_j + ): tracks = seq_i.get_master_tracks() track = None for t in tracks: @@ -44,8 +46,8 @@ class CameraLoader(api.Loader): subscene.set_row_index(len(track.get_sections())) subscene.set_editor_property('sub_sequence', seq_j) subscene.set_range( - data_j.get("frameStart"), - data_j.get("frameEnd") + 1) + min_frame_j, + max_frame_j + 1) def load(self, context, name, namespace, data): """ @@ -129,6 +131,7 @@ class CameraLoader(api.Loader): # Get all the sequences in the hierarchy. It will create them, if # they don't exist. sequences = [] + frame_ranges = [] i = 0 for h in hierarchy_list: root_content = unreal.EditorAssetLibrary.list_assets( @@ -170,15 +173,22 @@ class CameraLoader(api.Loader): "data.visualParent": e.get('_id') })) + min_frame = min(start_frames) + max_frame = max(end_frames) + scene.set_display_rate( unreal.FrameRate(asset_data.get('data').get("fps"), 1.0)) - scene.set_playback_start(min(start_frames)) - scene.set_playback_end(max(end_frames)) + scene.set_playback_start(min_frame) + scene.set_playback_end(max_frame) sequences.append(scene) + frame_ranges.append((min_frame, max_frame)) else: for e in existing_sequences: sequences.append(e.get_asset()) + frame_ranges.append(( + e.get_asset().get_playback_start(), + e.get_asset().get_playback_end())) i += 1 @@ -192,20 +202,15 @@ class CameraLoader(api.Loader): ) # Add sequences data to hierarchy - data_i = self._get_data(sequences[0].get_name()) - for i in range(0, len(sequences) - 1): - data_j = self._get_data(sequences[i + 1].get_name()) - self._set_sequence_hierarchy( - sequences[i], sequences[i + 1], data_i, data_j) + sequences[i], sequences[i + 1], + frame_ranges[i + 1][0], frame_ranges[i + 1][1]) - data_i = data_j - - parent_data = self._get_data(sequences[-1].get_name()) data = self._get_data(asset) self._set_sequence_hierarchy( - sequences[-1], cam_seq, parent_data, data) + sequences[-1], cam_seq, + data.get('clipIn'), data.get('clipOut')) settings = unreal.MovieSceneUserImportFBXSettings() settings.set_editor_property('reduce_keys', False) From 35f5e4a8408fb52e1c0d1d318416e922a55f32f5 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Wed, 2 Mar 2022 11:17:58 +0000 Subject: [PATCH 015/583] Render from the master sequence and output keeps the hierarchy --- openpype/hosts/unreal/api/rendering.py | 99 +++++++++++++------ .../unreal/plugins/create/create_render.py | 62 +++++++++++- 2 files changed, 129 insertions(+), 32 deletions(-) diff --git a/openpype/hosts/unreal/api/rendering.py b/openpype/hosts/unreal/api/rendering.py index 7c58987c0d..8eb4e1e75a 100644 --- a/openpype/hosts/unreal/api/rendering.py +++ b/openpype/hosts/unreal/api/rendering.py @@ -1,11 +1,11 @@ import avalon.unreal.pipeline as pipeline -import avalon.unreal.lib as lib import unreal queue = None executor = None + def _queue_finish_callback(exec, success): unreal.log("Render completed. Success: " + str(success)) @@ -45,41 +45,84 @@ def start_rendering(): if data["family"] == "render": inst_data.append(data) - # subsystem = unreal.get_editor_subsystem(unreal.MoviePipelineQueueSubsystem) + # subsystem = unreal.get_editor_subsystem( + # unreal.MoviePipelineQueueSubsystem) # queue = subsystem.get_queue() global queue queue = unreal.MoviePipelineQueue() + ar = unreal.AssetRegistryHelpers.get_asset_registry() + for i in inst_data: - job = queue.allocate_new_job(unreal.MoviePipelineExecutorJob) - job.sequence = unreal.SoftObjectPath(i["sequence"]) - job.map = unreal.SoftObjectPath(i["map"]) - job.author = "OpenPype" + sequence = ar.get_asset_by_object_path(i["sequence"]).get_asset() - # User data could be used to pass data to the job, that can be read - # in the job's OnJobFinished callback. We could, for instance, - # pass the AvalonPublishInstance's path to the job. - # job.user_data = "" + sequences = [{ + "sequence": sequence, + "output": f"{i['subset']}/{sequence.get_name()}", + "frame_range": ( + int(float(i["startFrame"])), + int(float(i["endFrame"])) + 1) + }] + render_list = [] - output_setting = job.get_configuration().find_or_add_setting_by_class( - unreal.MoviePipelineOutputSetting) - output_setting.output_resolution = unreal.IntPoint(1280, 720) - output_setting.file_name_format = "{sequence_name}.{frame_number}" - output_setting.output_directory.path += f"{i['subset']}/" + # Get all the sequences to render. If there are subsequences, + # add them and their frame ranges to the render list. We also + # use the names for the output paths. + for s in sequences: + tracks = s.get('sequence').get_master_tracks() + subscene_track = None + for t in tracks: + if t.get_class() == unreal.MovieSceneSubTrack.static_class(): + subscene_track = t + if subscene_track is not None and subscene_track.get_sections(): + subscenes = subscene_track.get_sections() - renderPass = job.get_configuration().find_or_add_setting_by_class( - unreal.MoviePipelineDeferredPassBase) - renderPass.disable_multisample_effects = True + for ss in subscenes: + sequences.append({ + "sequence": ss.get_sequence(), + "output": f"{s.get('output')}/{ss.get_sequence().get_name()}", + "frame_range": ( + ss.get_start_frame(), ss.get_end_frame()) + }) + else: + # Avoid rendering camera sequences + if "_camera" not in s.get('sequence').get_name(): + render_list.append(s) - job.get_configuration().find_or_add_setting_by_class( - unreal.MoviePipelineImageSequenceOutput_PNG) + # Create the rendering jobs and add them to the queue. + for r in render_list: + job = queue.allocate_new_job(unreal.MoviePipelineExecutorJob) + job.sequence = unreal.SoftObjectPath(i["master_sequence"]) + job.map = unreal.SoftObjectPath(i["master_level"]) + job.author = "OpenPype" - # TODO: check if queue is empty + # User data could be used to pass data to the job, that can be + # read in the job's OnJobFinished callback. We could, + # for instance, pass the AvalonPublishInstance's path to the job. + # job.user_data = "" - global executor - executor = unreal.MoviePipelinePIEExecutor() - executor.on_executor_finished_delegate.add_callable_unique( - _queue_finish_callback) - executor.on_individual_job_finished_delegate.add_callable_unique( - _job_finish_callback) # Only available on PIE Executor - executor.execute(queue) + settings = job.get_configuration().find_or_add_setting_by_class( + unreal.MoviePipelineOutputSetting) + settings.output_resolution = unreal.IntPoint(1920, 1080) + settings.custom_start_frame = r.get("frame_range")[0] + settings.custom_end_frame = r.get("frame_range")[1] + settings.use_custom_playback_range = True + settings.file_name_format = "{sequence_name}.{frame_number}" + settings.output_directory.path += r.get('output') + + renderPass = job.get_configuration().find_or_add_setting_by_class( + unreal.MoviePipelineDeferredPassBase) + renderPass.disable_multisample_effects = True + + job.get_configuration().find_or_add_setting_by_class( + unreal.MoviePipelineImageSequenceOutput_PNG) + + # If there are jobs in the queue, start the rendering process. + if queue.get_jobs(): + global executor + executor = unreal.MoviePipelinePIEExecutor() + executor.on_executor_finished_delegate.add_callable_unique( + _queue_finish_callback) + executor.on_individual_job_finished_delegate.add_callable_unique( + _job_finish_callback) # Only available on PIE Executor + executor.execute(queue) diff --git a/openpype/hosts/unreal/plugins/create/create_render.py b/openpype/hosts/unreal/plugins/create/create_render.py index 0128808a70..de092c4dd7 100644 --- a/openpype/hosts/unreal/plugins/create/create_render.py +++ b/openpype/hosts/unreal/plugins/create/create_render.py @@ -1,5 +1,8 @@ import unreal + from openpype.hosts.unreal.api.plugin import Creator + +from avalon import io from avalon.unreal import pipeline @@ -21,7 +24,22 @@ class CreateRender(Creator): def process(self): name = self.data["subset"] - print(self.data) + ar = unreal.AssetRegistryHelpers.get_asset_registry() + + # Get the master sequence and the master level. + # There should be only one sequence and one level in the directory. + filter = unreal.ARFilter( + class_names = ["LevelSequence"], + package_paths = [f"/Game/Avalon/{self.data['asset']}"], + recursive_paths = False) + sequences = ar.get_assets(filter) + ms = sequences[0].object_path + filter = unreal.ARFilter( + class_names = ["World"], + package_paths = [f"/Game/Avalon/{self.data['asset']}"], + recursive_paths = False) + levels = ar.get_assets(filter) + ml = levels[0].object_path selection = [] if (self.options or {}).get("useSelection"): @@ -46,8 +64,44 @@ class CreateRender(Creator): d = self.data.copy() d["members"] = [a] d["sequence"] = a - d["map"] = unreal.EditorLevelLibrary.get_editor_world().get_path_name() + d["master_sequence"] = ms + d["master_level"] = ml asset = ar.get_asset_by_object_path(a).get_asset() - container_name = f"{asset.get_name()}{self.suffix}" - pipeline.create_publish_instance(instance=container_name, path=path) + asset_name = asset.get_name() + + # Get frame range. We need to go through the hierarchy and check + # the frame range for the children. + asset_data = io.find_one({ + "type": "asset", + "name": asset_name + }) + id = asset_data.get('_id') + + elements = list( + io.find({"type": "asset", "data.visualParent": id})) + + if elements: + start_frames = [] + end_frames = [] + for e in elements: + start_frames.append(e.get('data').get('clipIn')) + end_frames.append(e.get('data').get('clipOut')) + + elements.extend(io.find({ + "type": "asset", + "data.visualParent": e.get('_id') + })) + + min_frame = min(start_frames) + max_frame = max(end_frames) + else: + min_frame = asset_data.get('data').get('clipIn') + max_frame = asset_data.get('data').get('clipOut') + + d["startFrame"] = min_frame + d["endFrame"] = max_frame + + container_name = f"{asset_name}{self.suffix}" + pipeline.create_publish_instance( + instance=container_name, path=path) pipeline.imprint("{}/{}".format(path, container_name), d) From 5d8bac337f1631f4653d19b5e59f40ee406247c0 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Thu, 3 Mar 2022 10:37:02 +0000 Subject: [PATCH 016/583] Fixed frame range and frame rate for shot sequences --- openpype/hosts/unreal/plugins/load/load_camera.py | 4 ++++ openpype/hosts/unreal/plugins/load/load_layout.py | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/openpype/hosts/unreal/plugins/load/load_camera.py b/openpype/hosts/unreal/plugins/load/load_camera.py index 12aaceb385..cea59ae93f 100644 --- a/openpype/hosts/unreal/plugins/load/load_camera.py +++ b/openpype/hosts/unreal/plugins/load/load_camera.py @@ -208,6 +208,10 @@ class CameraLoader(api.Loader): frame_ranges[i + 1][0], frame_ranges[i + 1][1]) data = self._get_data(asset) + cam_seq.set_display_rate( + unreal.FrameRate(data.get("fps"), 1.0)) + cam_seq.set_playback_start(0) + cam_seq.set_playback_end(data.get('clipOut') - data.get('clipIn') + 1) self._set_sequence_hierarchy( sequences[-1], cam_seq, data.get('clipIn'), data.get('clipOut')) diff --git a/openpype/hosts/unreal/plugins/load/load_layout.py b/openpype/hosts/unreal/plugins/load/load_layout.py index 05615ff083..5a976a1fb5 100644 --- a/openpype/hosts/unreal/plugins/load/load_layout.py +++ b/openpype/hosts/unreal/plugins/load/load_layout.py @@ -608,6 +608,10 @@ class LayoutLoader(api.Loader): maps_to_add) data = self._get_data(asset) + shot.set_display_rate( + unreal.FrameRate(data.get("fps"), 1.0)) + shot.set_playback_start(0) + shot.set_playback_end(data.get('clipOut') - data.get('clipIn') + 1) self._set_sequence_hierarchy( sequences[-1], shot, frame_ranges[-1][1], From 468a5e145ce138c9470db35a59612e68bcebcb4e Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Thu, 3 Mar 2022 11:05:11 +0000 Subject: [PATCH 017/583] Hound fixes --- openpype/hosts/unreal/api/rendering.py | 3 +- .../hosts/unreal/plugins/load/load_camera.py | 69 ++++++++++--------- .../hosts/unreal/plugins/load/load_layout.py | 42 ++++++----- .../unreal/plugins/publish/extract_render.py | 8 ++- 4 files changed, 63 insertions(+), 59 deletions(-) diff --git a/openpype/hosts/unreal/api/rendering.py b/openpype/hosts/unreal/api/rendering.py index 8eb4e1e75a..3ed77cc651 100644 --- a/openpype/hosts/unreal/api/rendering.py +++ b/openpype/hosts/unreal/api/rendering.py @@ -80,7 +80,8 @@ def start_rendering(): for ss in subscenes: sequences.append({ "sequence": ss.get_sequence(), - "output": f"{s.get('output')}/{ss.get_sequence().get_name()}", + "output": (f"{s.get('output')}/" + f"{ss.get_sequence().get_name()}"), "frame_range": ( ss.get_start_frame(), ss.get_end_frame()) }) diff --git a/openpype/hosts/unreal/plugins/load/load_camera.py b/openpype/hosts/unreal/plugins/load/load_camera.py index cea59ae93f..f93de0a79a 100644 --- a/openpype/hosts/unreal/plugins/load/load_camera.py +++ b/openpype/hosts/unreal/plugins/load/load_camera.py @@ -1,9 +1,12 @@ import os +import unreal +from unreal import EditorAssetLibrary +from unreal import EditorLevelLibrary + from avalon import api, io, pipeline from avalon.unreal import lib from avalon.unreal import pipeline as unreal_pipeline -import unreal class CameraLoader(api.Loader): @@ -23,8 +26,8 @@ class CameraLoader(api.Loader): return asset_doc.get("data") - def _set_sequence_hierarchy(self, - seq_i, seq_j, min_frame_j, max_frame_j + def _set_sequence_hierarchy( + self, seq_i, seq_j, min_frame_j, max_frame_j ): tracks = seq_i.get_master_tracks() track = None @@ -91,8 +94,8 @@ class CameraLoader(api.Loader): # Create a unique name for the camera directory unique_number = 1 - if unreal.EditorAssetLibrary.does_directory_exist(f"{hierarchy_dir}/{asset}"): - asset_content = unreal.EditorAssetLibrary.list_assets( + if EditorAssetLibrary.does_directory_exist(f"{hierarchy_dir}/{asset}"): + asset_content = EditorAssetLibrary.list_assets( f"{root}/{asset}", recursive=False, include_folder=True ) @@ -115,32 +118,32 @@ class CameraLoader(api.Loader): container_name += suffix - current_level = unreal.EditorLevelLibrary.get_editor_world().get_full_name() - unreal.EditorLevelLibrary.save_all_dirty_levels() + current_level = EditorLevelLibrary.get_editor_world().get_full_name() + EditorLevelLibrary.save_all_dirty_levels() ar = unreal.AssetRegistryHelpers.get_asset_registry() filter = unreal.ARFilter( - class_names = ["World"], - package_paths = [f"{hierarchy_dir}/{asset}/"], - recursive_paths = True) + class_names=["World"], + package_paths=[f"{hierarchy_dir}/{asset}/"], + recursive_paths=True) maps = ar.get_assets(filter) # There should be only one map in the list - unreal.EditorLevelLibrary.load_level(maps[0].get_full_name()) + EditorLevelLibrary.load_level(maps[0].get_full_name()) - # Get all the sequences in the hierarchy. It will create them, if + # Get all the sequences in the hierarchy. It will create them, if # they don't exist. sequences = [] frame_ranges = [] i = 0 for h in hierarchy_list: - root_content = unreal.EditorAssetLibrary.list_assets( + root_content = EditorAssetLibrary.list_assets( h, recursive=False, include_folder=False) existing_sequences = [ - unreal.EditorAssetLibrary.find_asset_data(asset) + EditorAssetLibrary.find_asset_data(asset) for asset in root_content - if unreal.EditorAssetLibrary.find_asset_data( + if EditorAssetLibrary.find_asset_data( asset).get_class().get_name() == 'LevelSequence' ] @@ -192,7 +195,7 @@ class CameraLoader(api.Loader): i += 1 - unreal.EditorAssetLibrary.make_directory(asset_dir) + EditorAssetLibrary.make_directory(asset_dir) cam_seq = tools.create_asset( asset_name=f"{asset}_camera", @@ -213,15 +216,15 @@ class CameraLoader(api.Loader): cam_seq.set_playback_start(0) cam_seq.set_playback_end(data.get('clipOut') - data.get('clipIn') + 1) self._set_sequence_hierarchy( - sequences[-1], cam_seq, - data.get('clipIn'), data.get('clipOut')) + sequences[-1], cam_seq, + data.get('clipIn'), data.get('clipOut')) settings = unreal.MovieSceneUserImportFBXSettings() settings.set_editor_property('reduce_keys', False) if cam_seq: unreal.SequencerTools.import_fbx( - unreal.EditorLevelLibrary.get_editor_world(), + EditorLevelLibrary.get_editor_world(), cam_seq, cam_seq.get_bindings(), settings, @@ -246,15 +249,15 @@ class CameraLoader(api.Loader): unreal_pipeline.imprint( "{}/{}".format(asset_dir, container_name), data) - unreal.EditorLevelLibrary.save_all_dirty_levels() - unreal.EditorLevelLibrary.load_level(current_level) + EditorLevelLibrary.save_all_dirty_levels() + EditorLevelLibrary.load_level(current_level) - asset_content = unreal.EditorAssetLibrary.list_assets( + asset_content = EditorAssetLibrary.list_assets( asset_dir, recursive=True, include_folder=True ) for a in asset_content: - unreal.EditorAssetLibrary.save_asset(a) + EditorAssetLibrary.save_asset(a) return asset_content @@ -264,25 +267,25 @@ class CameraLoader(api.Loader): ar = unreal.AssetRegistryHelpers.get_asset_registry() tools = unreal.AssetToolsHelpers().get_asset_tools() - asset_content = unreal.EditorAssetLibrary.list_assets( + asset_content = EditorAssetLibrary.list_assets( path, recursive=False, include_folder=False ) asset_name = "" for a in asset_content: asset = ar.get_asset_by_object_path(a) if a.endswith("_CON"): - loaded_asset = unreal.EditorAssetLibrary.load_asset(a) - unreal.EditorAssetLibrary.set_metadata_tag( + loaded_asset = EditorAssetLibrary.load_asset(a) + EditorAssetLibrary.set_metadata_tag( loaded_asset, "representation", str(representation["_id"]) ) - unreal.EditorAssetLibrary.set_metadata_tag( + EditorAssetLibrary.set_metadata_tag( loaded_asset, "parent", str(representation["parent"]) ) - asset_name = unreal.EditorAssetLibrary.get_metadata_tag( + asset_name = EditorAssetLibrary.get_metadata_tag( loaded_asset, "asset_name" ) elif asset.asset_class == "LevelSequence": - unreal.EditorAssetLibrary.delete_asset(a) + EditorAssetLibrary.delete_asset(a) sequence = tools.create_asset( asset_name=asset_name, @@ -308,7 +311,7 @@ class CameraLoader(api.Loader): settings.set_editor_property('reduce_keys', False) unreal.SequencerTools.import_fbx( - unreal.EditorLevelLibrary.get_editor_world(), + EditorLevelLibrary.get_editor_world(), sequence, sequence.get_bindings(), settings, @@ -319,11 +322,11 @@ class CameraLoader(api.Loader): path = container["namespace"] parent_path = os.path.dirname(path) - unreal.EditorAssetLibrary.delete_directory(path) + EditorAssetLibrary.delete_directory(path) - asset_content = unreal.EditorAssetLibrary.list_assets( + asset_content = EditorAssetLibrary.list_assets( parent_path, recursive=False, include_folder=True ) if len(asset_content) == 0: - unreal.EditorAssetLibrary.delete_directory(parent_path) + EditorAssetLibrary.delete_directory(parent_path) diff --git a/openpype/hosts/unreal/plugins/load/load_layout.py b/openpype/hosts/unreal/plugins/load/load_layout.py index 5a976a1fb5..e25f06ad42 100644 --- a/openpype/hosts/unreal/plugins/load/load_layout.py +++ b/openpype/hosts/unreal/plugins/load/load_layout.py @@ -83,8 +83,8 @@ class LayoutLoader(api.Loader): return asset_doc.get("data") - def _set_sequence_hierarchy(self, - seq_i, seq_j, max_frame_i, min_frame_j, max_frame_j, map_paths + def _set_sequence_hierarchy( + self, seq_i, seq_j, max_frame_i, min_frame_j, max_frame_j, map_paths ): # Get existing sequencer tracks or create them if they don't exist tracks = seq_i.get_master_tracks() @@ -93,12 +93,14 @@ class LayoutLoader(api.Loader): for t in tracks: if t.get_class() == unreal.MovieSceneSubTrack.static_class(): subscene_track = t - if t.get_class() == unreal.MovieSceneLevelVisibilityTrack.static_class(): + if (t.get_class() == + unreal.MovieSceneLevelVisibilityTrack.static_class()): visibility_track = t if not subscene_track: subscene_track = seq_i.add_master_track(unreal.MovieSceneSubTrack) if not visibility_track: - visibility_track = seq_i.add_master_track(unreal.MovieSceneLevelVisibilityTrack) + visibility_track = seq_i.add_master_track( + unreal.MovieSceneLevelVisibilityTrack) # Create the sub-scene section subscenes = subscene_track.get_sections() @@ -152,7 +154,8 @@ class LayoutLoader(api.Loader): hid_section.set_level_names(maps) def _process_family( - self, assets, classname, transform, sequence, inst_name=None): + self, assets, classname, transform, sequence, inst_name=None + ): ar = unreal.AssetRegistryHelpers.get_asset_registry() actors = [] @@ -189,16 +192,15 @@ class LayoutLoader(api.Loader): actors.append(actor) binding = sequence.add_possessable(actor) - # root_component_binding = sequence.add_possessable(actor.root_component) - # root_component_binding.set_parent(binding) bindings.append(binding) return actors, bindings def _import_animation( - self, asset_dir, path, instance_name, skeleton, actors_dict, - animation_file, bindings_dict, sequence): + self, asset_dir, path, instance_name, skeleton, actors_dict, + animation_file, bindings_dict, sequence + ): anim_file = Path(animation_file) anim_file_name = anim_file.with_suffix('') @@ -389,14 +391,9 @@ class LayoutLoader(api.Loader): if animation_file and skeleton: self._import_animation( - asset_dir, path, instance_name, skeleton, actors_dict, + asset_dir, path, instance_name, skeleton, actors_dict, animation_file, bindings_dict, sequence) - # track = sequence.add_master_track( - # unreal.MovieSceneActorReferenceTrack) - # section = track.add_section() - # section.set_editor_property('sub_sequence', sequence) - def _remove_family(self, assets, components, classname, propname): ar = unreal.AssetRegistryHelpers.get_asset_registry() @@ -499,7 +496,7 @@ class LayoutLoader(api.Loader): EditorLevelLibrary.new_level(f"{asset_dir}/{asset}_map") maps.append( - {"map":f"{asset_dir}/{asset}_map.{asset}_map", "new": True}) + {"map": f"{asset_dir}/{asset}_map.{asset}_map", "new": True}) for i in range(0, len(maps) - 1): for j in range(i + 1, len(maps)): @@ -514,7 +511,7 @@ class LayoutLoader(api.Loader): EditorLevelLibrary.load_level(maps[-1].get('map')) - # Get all the sequences in the hierarchy. It will create them, if + # Get all the sequences in the hierarchy. It will create them, if # they don't exist. sequences = [] frame_ranges = [] @@ -573,7 +570,8 @@ class LayoutLoader(api.Loader): tracks = sequence.get_master_tracks() track = None for t in tracks: - if t.get_class() == unreal.MovieSceneCameraCutTrack.static_class(): + if (t.get_class() == + unreal.MovieSceneCameraCutTrack.static_class()): track = t break if not track: @@ -613,10 +611,10 @@ class LayoutLoader(api.Loader): shot.set_playback_start(0) shot.set_playback_end(data.get('clipOut') - data.get('clipIn') + 1) self._set_sequence_hierarchy( - sequences[-1], shot, - frame_ranges[-1][1], - data.get('clipIn'), data.get('clipOut'), - [maps[-1].get('map')]) + sequences[-1], shot, + frame_ranges[-1][1], + data.get('clipIn'), data.get('clipOut'), + [maps[-1].get('map')]) EditorLevelLibrary.load_level(maps[-1].get('map')) diff --git a/openpype/hosts/unreal/plugins/publish/extract_render.py b/openpype/hosts/unreal/plugins/publish/extract_render.py index 7ba53c9155..37fe7e916f 100644 --- a/openpype/hosts/unreal/plugins/publish/extract_render.py +++ b/openpype/hosts/unreal/plugins/publish/extract_render.py @@ -1,8 +1,9 @@ from pathlib import Path -import openpype.api -from avalon import io + import unreal +import openpype.api + class ExtractRender(openpype.api.Extractor): """Extract render.""" @@ -21,7 +22,8 @@ class ExtractRender(openpype.api.Extractor): # Get the render output directory project_dir = unreal.Paths.project_dir() - render_dir = f"{project_dir}/Saved/MovieRenders/{instance.data['subset']}" + render_dir = (f"{project_dir}/Saved/MovieRenders/" + f"{instance.data['subset']}") assert unreal.Paths.directory_exists(render_dir), \ "Render directory does not exist" From df02c64f18ec3b8e3bca1b07b99de93a5629a634 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Thu, 3 Mar 2022 11:07:23 +0000 Subject: [PATCH 018/583] More hound fixes --- .../hosts/unreal/plugins/create/create_render.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/unreal/plugins/create/create_render.py b/openpype/hosts/unreal/plugins/create/create_render.py index de092c4dd7..e6c233a2c5 100644 --- a/openpype/hosts/unreal/plugins/create/create_render.py +++ b/openpype/hosts/unreal/plugins/create/create_render.py @@ -29,15 +29,15 @@ class CreateRender(Creator): # Get the master sequence and the master level. # There should be only one sequence and one level in the directory. filter = unreal.ARFilter( - class_names = ["LevelSequence"], - package_paths = [f"/Game/Avalon/{self.data['asset']}"], - recursive_paths = False) + class_names=["LevelSequence"], + package_paths=[f"/Game/Avalon/{self.data['asset']}"], + recursive_paths=False) sequences = ar.get_assets(filter) ms = sequences[0].object_path filter = unreal.ARFilter( - class_names = ["World"], - package_paths = [f"/Game/Avalon/{self.data['asset']}"], - recursive_paths = False) + class_names=["World"], + package_paths=[f"/Game/Avalon/{self.data['asset']}"], + recursive_paths=False) levels = ar.get_assets(filter) ml = levels[0].object_path From e127e08be4bc7ca1c05c3e5b560cf3e00dd53590 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Tue, 8 Mar 2022 12:08:56 +0000 Subject: [PATCH 019/583] Fix paths for loading animations --- openpype/hosts/unreal/plugins/load/load_animation.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/unreal/plugins/load/load_animation.py b/openpype/hosts/unreal/plugins/load/load_animation.py index bc4a42c84b..c1f7942ef0 100644 --- a/openpype/hosts/unreal/plugins/load/load_animation.py +++ b/openpype/hosts/unreal/plugins/load/load_animation.py @@ -134,7 +134,7 @@ class AnimationFBXLoader(plugin.Loader): # Create directory for asset and avalon container hierarchy = context.get('asset').get('data').get('parents') - root = "/Game/Avalon" + root = "/Game/OpenPype" asset = context.get('asset').get('name') suffix = "_CON" if asset: @@ -144,7 +144,7 @@ class AnimationFBXLoader(plugin.Loader): tools = unreal.AssetToolsHelpers().get_asset_tools() asset_dir, container_name = tools.create_unique_asset_name( - f"{root}/Assets/{asset}/{name}", suffix="") + f"{root}/Animations/{asset}/{name}", suffix="") hierarchy_dir = root for h in hierarchy: From ef726be366bbaaa877262b5843ff118205f24183 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Tue, 8 Mar 2022 12:30:38 +0000 Subject: [PATCH 020/583] Activates MovieRenderPipeline plugin when creating Unreal project --- openpype/hosts/unreal/lib.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/hosts/unreal/lib.py b/openpype/hosts/unreal/lib.py index d4a776e892..805e883c64 100644 --- a/openpype/hosts/unreal/lib.py +++ b/openpype/hosts/unreal/lib.py @@ -254,6 +254,7 @@ def create_unreal_project(project_name: str, {"Name": "PythonScriptPlugin", "Enabled": True}, {"Name": "EditorScriptingUtilities", "Enabled": True}, {"Name": "SequencerScripting", "Enabled": True}, + {"Name": "MovieRenderPipeline", "Enabled": True}, {"Name": "OpenPype", "Enabled": True} ] } From fedc09f83dd304923715803253702a547cfbe9ff Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Tue, 8 Mar 2022 13:05:14 +0000 Subject: [PATCH 021/583] Set bound scale for rig actors loaded with layout This is needed for actors that gets close to the camera, that wouldn't be rendered without this parameter set to the maximum value. --- openpype/hosts/unreal/plugins/load/load_layout.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/openpype/hosts/unreal/plugins/load/load_layout.py b/openpype/hosts/unreal/plugins/load/load_layout.py index 9f30affa3d..d99224042a 100644 --- a/openpype/hosts/unreal/plugins/load/load_layout.py +++ b/openpype/hosts/unreal/plugins/load/load_layout.py @@ -194,6 +194,11 @@ class LayoutLoader(plugin.Loader): ), False) actor.set_actor_scale3d(transform.get('scale')) + if class_name == 'SkeletalMesh': + skm_comp = actor.get_editor_property( + 'skeletal_mesh_component') + skm_comp.set_bounds_scale(10.0) + actors.append(actor) binding = sequence.add_possessable(actor) From 88a59bc0ee4efa61c6765a26094342f7d4d6b106 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Thu, 10 Mar 2022 16:18:49 +0000 Subject: [PATCH 022/583] Fixed class name for Render Publish Instance --- openpype/hosts/unreal/api/rendering.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/unreal/api/rendering.py b/openpype/hosts/unreal/api/rendering.py index d70d621b8a..38bcf21b1c 100644 --- a/openpype/hosts/unreal/api/rendering.py +++ b/openpype/hosts/unreal/api/rendering.py @@ -37,7 +37,7 @@ def start_rendering(): # instances = pipeline.ls_inst() instances = [ a for a in assets - if a.get_class().get_name() == "AvalonPublishInstance"] + if a.get_class().get_name() == "OpenPypePublishInstance"] inst_data = [] From 4ad37ad687324c934612aa12235a4292a70955ac Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 12 Mar 2022 01:10:30 +0000 Subject: [PATCH 023/583] Bump pillow from 9.0.0 to 9.0.1 Bumps [pillow](https://github.com/python-pillow/Pillow) from 9.0.0 to 9.0.1. - [Release notes](https://github.com/python-pillow/Pillow/releases) - [Changelog](https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst) - [Commits](https://github.com/python-pillow/Pillow/compare/9.0.0...9.0.1) --- updated-dependencies: - dependency-name: pillow dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- poetry.lock | 146 ++++++++++++++++++++++++++-------------------------- 1 file changed, 73 insertions(+), 73 deletions(-) diff --git a/poetry.lock b/poetry.lock index ee7b839b8d..b8c7090cc0 100644 --- a/poetry.lock +++ b/poetry.lock @@ -680,15 +680,8 @@ category = "main" optional = false python-versions = "*" -[package.dependencies] -attrs = ">=17.4.0" -importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} -pyrsistent = ">=0.14.0" -six = ">=1.11.0" - [package.extras] -format = ["idna", "jsonpointer (>1.13)", "rfc3987", "strict-rfc3339", "webcolors"] -format_nongpl = ["idna", "jsonpointer (>1.13)", "webcolors", "rfc3986-validator (>0.1.0)", "rfc3339-validator"] +format = ["rfc3987", "strict-rfc3339", "webcolors"] [[package]] name = "keyring" @@ -826,7 +819,7 @@ six = "*" [[package]] name = "pillow" -version = "9.0.0" +version = "9.0.1" description = "Python Imaging Library (Fork)" category = "main" optional = false @@ -1087,14 +1080,6 @@ category = "main" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" -[[package]] -name = "pyrsistent" -version = "0.18.1" -description = "Persistent/Functional/Immutable data structures" -category = "main" -optional = false -python-versions = ">=3.7" - [[package]] name = "pysftp" version = "0.2.9" @@ -1633,7 +1618,7 @@ testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest- [metadata] lock-version = "1.1" python-versions = "3.7.*" -content-hash = "2f78d48a6aad2d8a88b7dd7f31a76d907bec9fb65f0086fba6b6d2e1605f0f88" +content-hash = "b02313c8255a1897b0f0617ad4884a5943696c363512921aab1cb2dd8f4fdbe0" [metadata.files] acre = [] @@ -2171,12 +2156,28 @@ log4mongo = [ {file = "log4mongo-1.7.0.tar.gz", hash = "sha256:dc374617206162a0b14167fbb5feac01dbef587539a235dadba6200362984a68"}, ] markupsafe = [ + {file = "MarkupSafe-2.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d8446c54dc28c01e5a2dbac5a25f071f6653e6e40f3a8818e8b45d790fe6ef53"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:36bc903cbb393720fad60fc28c10de6acf10dc6cc883f3e24ee4012371399a38"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2d7d807855b419fc2ed3e631034685db6079889a1f01d5d9dac950f764da3dad"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:add36cb2dbb8b736611303cd3bfcee00afd96471b09cda130da3581cbdc56a6d"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:168cd0a3642de83558a5153c8bd34f175a9a6e7f6dc6384b9655d2697312a646"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:4dc8f9fb58f7364b63fd9f85013b780ef83c11857ae79f2feda41e270468dd9b"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:20dca64a3ef2d6e4d5d615a3fd418ad3bde77a47ec8a23d984a12b5b4c74491a"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:cdfba22ea2f0029c9261a4bd07e830a8da012291fbe44dc794e488b6c9bb353a"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-win32.whl", hash = "sha256:99df47edb6bda1249d3e80fdabb1dab8c08ef3975f69aed437cb69d0a5de1e28"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:e0f138900af21926a02425cf736db95be9f4af72ba1bb21453432a07f6082134"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f9081981fe268bd86831e5c75f7de206ef275defcb82bc70740ae6dc507aee51"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:0955295dd5eec6cb6cc2fe1698f4c6d84af2e92de33fbcac4111913cd100a6ff"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:0446679737af14f45767963a1a9ef7620189912317d095f2d9ffa183a4d25d2b"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:f826e31d18b516f653fe296d967d700fddad5901ae07c622bb3705955e1faa94"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:fa130dd50c57d53368c9d59395cb5526eda596d3ffe36666cd81a44d56e48872"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:905fec760bd2fa1388bb5b489ee8ee5f7291d692638ea5f67982d968366bef9f"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf5d821ffabf0ef3533c39c518f3357b171a1651c1ff6827325e4489b0e46c3c"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0d4b31cc67ab36e3392bbf3862cfbadac3db12bdd8b02a2731f509ed5b829724"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:baa1a4e8f868845af802979fcdbf0bb11f94f1cb7ced4c4b8a351bb60d108145"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:deb993cacb280823246a026e3b2d81c493c53de6acfd5e6bfe31ab3402bb37dd"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:63f3268ba69ace99cab4e3e3b5840b03340efed0948ab8f78d2fd87ee5442a4f"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:8d206346619592c6200148b01a2142798c989edcb9c896f9ac9722a99d4e77e6"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-win32.whl", hash = "sha256:6c4ca60fa24e85fe25b912b01e62cb969d69a23a5d5867682dd3e80b5b02581d"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b2f4bf27480f5e5e8ce285a8c8fd176c0b03e93dcc6646477d4630e83440c6a9"}, {file = "MarkupSafe-2.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0717a7390a68be14b8c793ba258e075c6f4ca819f15edfc2a3a027c823718567"}, @@ -2185,14 +2186,27 @@ markupsafe = [ {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:d7f9850398e85aba693bb640262d3611788b1f29a79f0c93c565694658f4071f"}, {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:6a7fae0dd14cf60ad5ff42baa2e95727c3d81ded453457771d02b7d2b3f9c0c2"}, {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:b7f2d075102dc8c794cbde1947378051c4e5180d52d276987b8d28a3bd58c17d"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e9936f0b261d4df76ad22f8fee3ae83b60d7c3e871292cd42f40b81b70afae85"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:2a7d351cbd8cfeb19ca00de495e224dea7e7d919659c2841bbb7f420ad03e2d6"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:60bf42e36abfaf9aff1f50f52644b336d4f0a3fd6d8a60ca0d054ac9f713a864"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d6c7ebd4e944c85e2c3421e612a7057a2f48d478d79e61800d81468a8d842207"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:f0567c4dc99f264f49fe27da5f735f414c4e7e7dd850cfd8e69f0862d7c74ea9"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:89c687013cb1cd489a0f0ac24febe8c7a666e6e221b783e53ac50ebf68e45d86"}, {file = "MarkupSafe-2.0.1-cp37-cp37m-win32.whl", hash = "sha256:a30e67a65b53ea0a5e62fe23682cfe22712e01f453b95233b25502f7c61cb415"}, {file = "MarkupSafe-2.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:611d1ad9a4288cf3e3c16014564df047fe08410e628f89805e475368bd304914"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5bb28c636d87e840583ee3adeb78172efc47c8b26127267f54a9c0ec251d41a9"}, {file = "MarkupSafe-2.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:be98f628055368795d818ebf93da628541e10b75b41c559fdf36d104c5787066"}, {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:1d609f577dc6e1aa17d746f8bd3c31aa4d258f4070d61b2aa5c4166c1539de35"}, {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7d91275b0245b1da4d4cfa07e0faedd5b0812efc15b702576d103293e252af1b"}, {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:01a9b8ea66f1658938f65b93a85ebe8bc016e6769611be228d797c9d998dd298"}, {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:47ab1e7b91c098ab893b828deafa1203de86d0bc6ab587b160f78fe6c4011f75"}, {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:97383d78eb34da7e1fa37dd273c20ad4320929af65d156e35a5e2d89566d9dfb"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6fcf051089389abe060c9cd7caa212c707e58153afa2c649f00346ce6d260f1b"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:5855f8438a7d1d458206a2466bf82b0f104a3724bf96a1c781ab731e4201731a"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:3dd007d54ee88b46be476e293f48c85048603f5f516008bee124ddd891398ed6"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:aca6377c0cb8a8253e493c6b451565ac77e98c2951c45f913e0b52facdcff83f"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:04635854b943835a6ea959e948d19dcd311762c5c0c6e1f0e16ee57022669194"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6300b8454aa6930a24b9618fbb54b5a68135092bc666f7b06901f897fa5c2fee"}, {file = "MarkupSafe-2.0.1-cp38-cp38-win32.whl", hash = "sha256:023cb26ec21ece8dc3907c0e8320058b2e0cb3c55cf9564da612bc325bed5e64"}, {file = "MarkupSafe-2.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:984d76483eb32f1bcb536dc27e4ad56bba4baa70be32fa87152832cdd9db0833"}, {file = "MarkupSafe-2.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2ef54abee730b502252bcdf31b10dacb0a416229b72c18b19e24a4509f273d26"}, @@ -2202,6 +2216,12 @@ markupsafe = [ {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:4efca8f86c54b22348a5467704e3fec767b2db12fc39c6d963168ab1d3fc9135"}, {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:ab3ef638ace319fa26553db0624c4699e31a28bb2a835c5faca8f8acf6a5a902"}, {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:f8ba0e8349a38d3001fae7eadded3f6606f0da5d748ee53cc1dab1d6527b9509"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c47adbc92fc1bb2b3274c4b3a43ae0e4573d9fbff4f54cd484555edbf030baf1"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:37205cac2a79194e3750b0af2a5720d95f786a55ce7df90c3af697bfa100eaac"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:1f2ade76b9903f39aa442b4aadd2177decb66525062db244b35d71d0ee8599b6"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:4296f2b1ce8c86a6aea78613c34bb1a672ea0e3de9c6ba08a960efe0b0a09047"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f02365d4e99430a12647f09b6cc8bab61a6564363f313126f775eb4f6ef798e"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5b6d930f030f8ed98e3e6c98ffa0652bdb82601e7a016ec2ab5d7ff23baa78d1"}, {file = "MarkupSafe-2.0.1-cp39-cp39-win32.whl", hash = "sha256:10f82115e21dc0dfec9ab5c0223652f7197feb168c940f3ef61563fc2d6beb74"}, {file = "MarkupSafe-2.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:693ce3f9e70a6cf7d2fb9e6c9d8b204b6b39897a2c4a1aa65728d5ac97dcc1d8"}, {file = "MarkupSafe-2.0.1.tar.gz", hash = "sha256:594c67807fb16238b30c44bdf74f36c02cdf22d1c8cda91ef8a0ed8dabf5620a"}, @@ -2289,38 +2309,41 @@ pathlib2 = [ {file = "pathlib2-2.3.6.tar.gz", hash = "sha256:7d8bcb5555003cdf4a8d2872c538faa3a0f5d20630cb360e518ca3b981795e5f"}, ] pillow = [ - {file = "Pillow-9.0.0-cp310-cp310-macosx_10_10_universal2.whl", hash = "sha256:113723312215b25c22df1fdf0e2da7a3b9c357a7d24a93ebbe80bfda4f37a8d4"}, - {file = "Pillow-9.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bb47a548cea95b86494a26c89d153fd31122ed65255db5dcbc421a2d28eb3379"}, - {file = "Pillow-9.0.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:31b265496e603985fad54d52d11970383e317d11e18e856971bdbb86af7242a4"}, - {file = "Pillow-9.0.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d154ed971a4cc04b93a6d5b47f37948d1f621f25de3e8fa0c26b2d44f24e3e8f"}, - {file = "Pillow-9.0.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80fe92813d208ce8aa7d76da878bdc84b90809f79ccbad2a288e9bcbeac1d9bd"}, - {file = "Pillow-9.0.0-cp310-cp310-win32.whl", hash = "sha256:d5dcea1387331c905405b09cdbfb34611050cc52c865d71f2362f354faee1e9f"}, - {file = "Pillow-9.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:52abae4c96b5da630a8b4247de5428f593465291e5b239f3f843a911a3cf0105"}, - {file = "Pillow-9.0.0-cp37-cp37m-macosx_10_10_x86_64.whl", hash = "sha256:72c3110228944019e5f27232296c5923398496b28be42535e3b2dc7297b6e8b6"}, - {file = "Pillow-9.0.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97b6d21771da41497b81652d44191489296555b761684f82b7b544c49989110f"}, - {file = "Pillow-9.0.0-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:72f649d93d4cc4d8cf79c91ebc25137c358718ad75f99e99e043325ea7d56100"}, - {file = "Pillow-9.0.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7aaf07085c756f6cb1c692ee0d5a86c531703b6e8c9cae581b31b562c16b98ce"}, - {file = "Pillow-9.0.0-cp37-cp37m-win32.whl", hash = "sha256:03b27b197deb4ee400ed57d8d4e572d2d8d80f825b6634daf6e2c18c3c6ccfa6"}, - {file = "Pillow-9.0.0-cp37-cp37m-win_amd64.whl", hash = "sha256:a09a9d4ec2b7887f7a088bbaacfd5c07160e746e3d47ec5e8050ae3b2a229e9f"}, - {file = "Pillow-9.0.0-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:490e52e99224858f154975db61c060686df8a6b3f0212a678e5d2e2ce24675c9"}, - {file = "Pillow-9.0.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:500d397ddf4bbf2ca42e198399ac13e7841956c72645513e8ddf243b31ad2128"}, - {file = "Pillow-9.0.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ebd8b9137630a7bbbff8c4b31e774ff05bbb90f7911d93ea2c9371e41039b52"}, - {file = "Pillow-9.0.0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd0e5062f11cb3e730450a7d9f323f4051b532781026395c4323b8ad055523c4"}, - {file = "Pillow-9.0.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9f3b4522148586d35e78313db4db0df4b759ddd7649ef70002b6c3767d0fdeb7"}, - {file = "Pillow-9.0.0-cp38-cp38-win32.whl", hash = "sha256:0b281fcadbb688607ea6ece7649c5d59d4bbd574e90db6cd030e9e85bde9fecc"}, - {file = "Pillow-9.0.0-cp38-cp38-win_amd64.whl", hash = "sha256:b5050d681bcf5c9f2570b93bee5d3ec8ae4cf23158812f91ed57f7126df91762"}, - {file = "Pillow-9.0.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:c2067b3bb0781f14059b112c9da5a91c80a600a97915b4f48b37f197895dd925"}, - {file = "Pillow-9.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2d16b6196fb7a54aff6b5e3ecd00f7c0bab1b56eee39214b2b223a9d938c50af"}, - {file = "Pillow-9.0.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:98cb63ca63cb61f594511c06218ab4394bf80388b3d66cd61d0b1f63ee0ea69f"}, - {file = "Pillow-9.0.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bc462d24500ba707e9cbdef436c16e5c8cbf29908278af053008d9f689f56dee"}, - {file = "Pillow-9.0.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3586e12d874ce2f1bc875a3ffba98732ebb12e18fb6d97be482bd62b56803281"}, - {file = "Pillow-9.0.0-cp39-cp39-win32.whl", hash = "sha256:68e06f8b2248f6dc8b899c3e7ecf02c9f413aab622f4d6190df53a78b93d97a5"}, - {file = "Pillow-9.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:6579f9ba84a3d4f1807c4aab4be06f373017fc65fff43498885ac50a9b47a553"}, - {file = "Pillow-9.0.0-pp37-pypy37_pp73-macosx_10_10_x86_64.whl", hash = "sha256:47f5cf60bcb9fbc46011f75c9b45a8b5ad077ca352a78185bd3e7f1d294b98bb"}, - {file = "Pillow-9.0.0-pp37-pypy37_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2fd8053e1f8ff1844419842fd474fc359676b2e2a2b66b11cc59f4fa0a301315"}, - {file = "Pillow-9.0.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c5439bfb35a89cac50e81c751317faea647b9a3ec11c039900cd6915831064d"}, - {file = "Pillow-9.0.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:95545137fc56ce8c10de646074d242001a112a92de169986abd8c88c27566a05"}, - {file = "Pillow-9.0.0.tar.gz", hash = "sha256:ee6e2963e92762923956fe5d3479b1fdc3b76c83f290aad131a2f98c3df0593e"}, + {file = "Pillow-9.0.1-1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a5d24e1d674dd9d72c66ad3ea9131322819ff86250b30dc5821cbafcfa0b96b4"}, + {file = "Pillow-9.0.1-1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2632d0f846b7c7600edf53c48f8f9f1e13e62f66a6dbc15191029d950bfed976"}, + {file = "Pillow-9.0.1-1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b9618823bd237c0d2575283f2939655f54d51b4527ec3972907a927acbcc5bfc"}, + {file = "Pillow-9.0.1-cp310-cp310-macosx_10_10_universal2.whl", hash = "sha256:9bfdb82cdfeccec50aad441afc332faf8606dfa5e8efd18a6692b5d6e79f00fd"}, + {file = "Pillow-9.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5100b45a4638e3c00e4d2320d3193bdabb2d75e79793af7c3eb139e4f569f16f"}, + {file = "Pillow-9.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:528a2a692c65dd5cafc130de286030af251d2ee0483a5bf50c9348aefe834e8a"}, + {file = "Pillow-9.0.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f29d831e2151e0b7b39981756d201f7108d3d215896212ffe2e992d06bfe049"}, + {file = "Pillow-9.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:855c583f268edde09474b081e3ddcd5cf3b20c12f26e0d434e1386cc5d318e7a"}, + {file = "Pillow-9.0.1-cp310-cp310-win32.whl", hash = "sha256:d9d7942b624b04b895cb95af03a23407f17646815495ce4547f0e60e0b06f58e"}, + {file = "Pillow-9.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:81c4b81611e3a3cb30e59b0cf05b888c675f97e3adb2c8672c3154047980726b"}, + {file = "Pillow-9.0.1-cp37-cp37m-macosx_10_10_x86_64.whl", hash = "sha256:413ce0bbf9fc6278b2d63309dfeefe452835e1c78398efb431bab0672fe9274e"}, + {file = "Pillow-9.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:80fe64a6deb6fcfdf7b8386f2cf216d329be6f2781f7d90304351811fb591360"}, + {file = "Pillow-9.0.1-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cef9c85ccbe9bee00909758936ea841ef12035296c748aaceee535969e27d31b"}, + {file = "Pillow-9.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1d19397351f73a88904ad1aee421e800fe4bbcd1aeee6435fb62d0a05ccd1030"}, + {file = "Pillow-9.0.1-cp37-cp37m-win32.whl", hash = "sha256:d21237d0cd37acded35154e29aec853e945950321dd2ffd1a7d86fe686814669"}, + {file = "Pillow-9.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:ede5af4a2702444a832a800b8eb7f0a7a1c0eed55b644642e049c98d589e5092"}, + {file = "Pillow-9.0.1-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:b5b3f092fe345c03bca1e0b687dfbb39364b21ebb8ba90e3fa707374b7915204"}, + {file = "Pillow-9.0.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:335ace1a22325395c4ea88e00ba3dc89ca029bd66bd5a3c382d53e44f0ccd77e"}, + {file = "Pillow-9.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:db6d9fac65bd08cea7f3540b899977c6dee9edad959fa4eaf305940d9cbd861c"}, + {file = "Pillow-9.0.1-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f154d173286a5d1863637a7dcd8c3437bb557520b01bddb0be0258dcb72696b5"}, + {file = "Pillow-9.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14d4b1341ac07ae07eb2cc682f459bec932a380c3b122f5540432d8977e64eae"}, + {file = "Pillow-9.0.1-cp38-cp38-win32.whl", hash = "sha256:effb7749713d5317478bb3acb3f81d9d7c7f86726d41c1facca068a04cf5bb4c"}, + {file = "Pillow-9.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:7f7609a718b177bf171ac93cea9fd2ddc0e03e84d8fa4e887bdfc39671d46b00"}, + {file = "Pillow-9.0.1-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:80ca33961ced9c63358056bd08403ff866512038883e74f3a4bf88ad3eb66838"}, + {file = "Pillow-9.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1c3c33ac69cf059bbb9d1a71eeaba76781b450bc307e2291f8a4764d779a6b28"}, + {file = "Pillow-9.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:12875d118f21cf35604176872447cdb57b07126750a33748bac15e77f90f1f9c"}, + {file = "Pillow-9.0.1-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:514ceac913076feefbeaf89771fd6febde78b0c4c1b23aaeab082c41c694e81b"}, + {file = "Pillow-9.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d3c5c79ab7dfce6d88f1ba639b77e77a17ea33a01b07b99840d6ed08031cb2a7"}, + {file = "Pillow-9.0.1-cp39-cp39-win32.whl", hash = "sha256:718856856ba31f14f13ba885ff13874be7fefc53984d2832458f12c38205f7f7"}, + {file = "Pillow-9.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:f25ed6e28ddf50de7e7ea99d7a976d6a9c415f03adcaac9c41ff6ff41b6d86ac"}, + {file = "Pillow-9.0.1-pp37-pypy37_pp73-macosx_10_10_x86_64.whl", hash = "sha256:011233e0c42a4a7836498e98c1acf5e744c96a67dd5032a6f666cc1fb97eab97"}, + {file = "Pillow-9.0.1-pp37-pypy37_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:253e8a302a96df6927310a9d44e6103055e8fb96a6822f8b7f514bb7ef77de56"}, + {file = "Pillow-9.0.1-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6295f6763749b89c994fcb6d8a7f7ce03c3992e695f89f00b741b4580b199b7e"}, + {file = "Pillow-9.0.1-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:a9f44cd7e162ac6191491d7249cceb02b8116b0f7e847ee33f739d7cb1ea1f70"}, + {file = "Pillow-9.0.1.tar.gz", hash = "sha256:6c8bc8238a7dfdaf7a75f5ec5a663f4173f8c367e5a39f87e720495e1eed75fa"}, ] platformdirs = [ {file = "platformdirs-2.4.1-py3-none-any.whl", hash = "sha256:1d7385c7db91728b83efd0ca99a5afb296cab9d0ed8313a45ed8ba17967ecfca"}, @@ -2598,29 +2621,6 @@ pyparsing = [ {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, ] -pyrsistent = [ - {file = "pyrsistent-0.18.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:df46c854f490f81210870e509818b729db4488e1f30f2a1ce1698b2295a878d1"}, - {file = "pyrsistent-0.18.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d45866ececf4a5fff8742c25722da6d4c9e180daa7b405dc0a2a2790d668c26"}, - {file = "pyrsistent-0.18.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4ed6784ceac462a7d6fcb7e9b663e93b9a6fb373b7f43594f9ff68875788e01e"}, - {file = "pyrsistent-0.18.1-cp310-cp310-win32.whl", hash = "sha256:e4f3149fd5eb9b285d6bfb54d2e5173f6a116fe19172686797c056672689daf6"}, - {file = "pyrsistent-0.18.1-cp310-cp310-win_amd64.whl", hash = "sha256:636ce2dc235046ccd3d8c56a7ad54e99d5c1cd0ef07d9ae847306c91d11b5fec"}, - {file = "pyrsistent-0.18.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e92a52c166426efbe0d1ec1332ee9119b6d32fc1f0bbfd55d5c1088070e7fc1b"}, - {file = "pyrsistent-0.18.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d7a096646eab884bf8bed965bad63ea327e0d0c38989fc83c5ea7b8a87037bfc"}, - {file = "pyrsistent-0.18.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cdfd2c361b8a8e5d9499b9082b501c452ade8bbf42aef97ea04854f4a3f43b22"}, - {file = "pyrsistent-0.18.1-cp37-cp37m-win32.whl", hash = "sha256:7ec335fc998faa4febe75cc5268a9eac0478b3f681602c1f27befaf2a1abe1d8"}, - {file = "pyrsistent-0.18.1-cp37-cp37m-win_amd64.whl", hash = "sha256:6455fc599df93d1f60e1c5c4fe471499f08d190d57eca040c0ea182301321286"}, - {file = "pyrsistent-0.18.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:fd8da6d0124efa2f67d86fa70c851022f87c98e205f0594e1fae044e7119a5a6"}, - {file = "pyrsistent-0.18.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bfe2388663fd18bd8ce7db2c91c7400bf3e1a9e8bd7d63bf7e77d39051b85ec"}, - {file = "pyrsistent-0.18.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e3e1fcc45199df76053026a51cc59ab2ea3fc7c094c6627e93b7b44cdae2c8c"}, - {file = "pyrsistent-0.18.1-cp38-cp38-win32.whl", hash = "sha256:b568f35ad53a7b07ed9b1b2bae09eb15cdd671a5ba5d2c66caee40dbf91c68ca"}, - {file = "pyrsistent-0.18.1-cp38-cp38-win_amd64.whl", hash = "sha256:d1b96547410f76078eaf66d282ddca2e4baae8964364abb4f4dcdde855cd123a"}, - {file = "pyrsistent-0.18.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:f87cc2863ef33c709e237d4b5f4502a62a00fab450c9e020892e8e2ede5847f5"}, - {file = "pyrsistent-0.18.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bc66318fb7ee012071b2792024564973ecc80e9522842eb4e17743604b5e045"}, - {file = "pyrsistent-0.18.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:914474c9f1d93080338ace89cb2acee74f4f666fb0424896fcfb8d86058bf17c"}, - {file = "pyrsistent-0.18.1-cp39-cp39-win32.whl", hash = "sha256:1b34eedd6812bf4d33814fca1b66005805d3640ce53140ab8bbb1e2651b0d9bc"}, - {file = "pyrsistent-0.18.1-cp39-cp39-win_amd64.whl", hash = "sha256:e24a828f57e0c337c8d8bb9f6b12f09dfdf0273da25fda9e314f0b684b415a07"}, - {file = "pyrsistent-0.18.1.tar.gz", hash = "sha256:d4d61f8b993a7255ba714df3aca52700f8125289f84f704cf80916517c46eb96"}, -] pysftp = [ {file = "pysftp-0.2.9.tar.gz", hash = "sha256:fbf55a802e74d663673400acd92d5373c1c7ee94d765b428d9f977567ac4854a"}, ] From 6d787cadd1e21d966332d02b3c6f0915b15633ee Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Fri, 18 Mar 2022 15:07:30 +0000 Subject: [PATCH 024/583] Implemented render publishing --- openpype/hosts/unreal/api/pipeline.py | 22 ++++ .../unreal/plugins/create/create_render.py | 113 ++++++++++++++++++ .../plugins/publish/collect_instances.py | 2 +- .../plugins/publish/collect_remove_marked.py | 24 ++++ .../publish/collect_render_instances.py | 106 ++++++++++++++++ .../publish/validate_sequence_frames.py | 45 +++++++ openpype/plugins/publish/extract_review.py | 3 +- 7 files changed, 313 insertions(+), 2 deletions(-) create mode 100644 openpype/hosts/unreal/plugins/create/create_render.py create mode 100644 openpype/hosts/unreal/plugins/publish/collect_remove_marked.py create mode 100644 openpype/hosts/unreal/plugins/publish/collect_render_instances.py create mode 100644 openpype/hosts/unreal/plugins/publish/validate_sequence_frames.py diff --git a/openpype/hosts/unreal/api/pipeline.py b/openpype/hosts/unreal/api/pipeline.py index 9ec11b942d..cf5ac6e4e0 100644 --- a/openpype/hosts/unreal/api/pipeline.py +++ b/openpype/hosts/unreal/api/pipeline.py @@ -47,6 +47,7 @@ def install(): print("installing OpenPype for Unreal ...") print("-=" * 40) logger.info("installing OpenPype for Unreal") + pyblish.api.register_host("unreal") pyblish.api.register_plugin_path(str(PUBLISH_PATH)) register_loader_plugin_path(str(LOAD_PATH)) api.register_plugin_path(LegacyCreator, str(CREATE_PATH)) @@ -416,3 +417,24 @@ def cast_map_to_str_dict(umap) -> dict: """ return {str(key): str(value) for (key, value) in umap.items()} + + +def get_subsequences(sequence: unreal.LevelSequence): + """Get list of subsequences from sequence. + + Args: + sequence (unreal.LevelSequence): Sequence + + Returns: + list(unreal.LevelSequence): List of subsequences + + """ + tracks = sequence.get_master_tracks() + subscene_track = None + for t in tracks: + if t.get_class() == unreal.MovieSceneSubTrack.static_class(): + subscene_track = t + break + if subscene_track is not None and subscene_track.get_sections(): + return subscene_track.get_sections() + return [] diff --git a/openpype/hosts/unreal/plugins/create/create_render.py b/openpype/hosts/unreal/plugins/create/create_render.py new file mode 100644 index 0000000000..49268c91f5 --- /dev/null +++ b/openpype/hosts/unreal/plugins/create/create_render.py @@ -0,0 +1,113 @@ +import unreal + +from openpype.hosts.unreal.api import pipeline +from openpype.hosts.unreal.api.plugin import Creator + + +class CreateRender(Creator): + """Create instance for sequence for rendering""" + + name = "unrealRender" + label = "Unreal - Render" + family = "render" + icon = "cube" + asset_types = ["LevelSequence"] + + root = "/Game/OpenPype/PublishInstances" + suffix = "_INS" + + def __init__(self, *args, **kwargs): + super(CreateRender, self).__init__(*args, **kwargs) + + def process(self): + subset = self.data["subset"] + + ar = unreal.AssetRegistryHelpers.get_asset_registry() + + # Get the master sequence and the master level. + # There should be only one sequence and one level in the directory. + filter = unreal.ARFilter( + class_names=["LevelSequence"], + package_paths=[f"/Game/OpenPype/{self.data['asset']}"], + recursive_paths=False) + sequences = ar.get_assets(filter) + ms = sequences[0].get_editor_property('object_path') + filter = unreal.ARFilter( + class_names=["World"], + package_paths=[f"/Game/OpenPype/{self.data['asset']}"], + recursive_paths=False) + levels = ar.get_assets(filter) + ml = levels[0].get_editor_property('object_path') + + selection = [] + if (self.options or {}).get("useSelection"): + sel_objects = unreal.EditorUtilityLibrary.get_selected_assets() + selection = [ + a.get_path_name() for a in sel_objects + if a.get_class().get_name() in self.asset_types] + else: + selection.append(self.data['sequence']) + + unreal.log(f"selection: {selection}") + + path = f"{self.root}" + unreal.EditorAssetLibrary.make_directory(path) + + ar = unreal.AssetRegistryHelpers.get_asset_registry() + + for a in selection: + ms_obj = ar.get_asset_by_object_path(ms).get_asset() + + seq_data = None + + if a == ms: + seq_data = { + "sequence": ms_obj, + "output": f"{ms_obj.get_name()}", + "frame_range": ( + ms_obj.get_playback_start(), ms_obj.get_playback_end()) + } + else: + seq_data_list = [{ + "sequence": ms_obj, + "output": f"{ms_obj.get_name()}", + "frame_range": ( + ms_obj.get_playback_start(), ms_obj.get_playback_end()) + }] + + for s in seq_data_list: + subscenes = pipeline.get_subsequences(s.get('sequence')) + + for ss in subscenes: + curr_data = { + "sequence": ss.get_sequence(), + "output": (f"{s.get('output')}/" + f"{ss.get_sequence().get_name()}"), + "frame_range": ( + ss.get_start_frame(), ss.get_end_frame() - 1) + } + + if ss.get_sequence().get_path_name() == a: + seq_data = curr_data + break + seq_data_list.append(curr_data) + + if seq_data is not None: + break + + if not seq_data: + continue + + d = self.data.copy() + d["members"] = [a] + d["sequence"] = a + d["master_sequence"] = ms + d["master_level"] = ml + d["output"] = seq_data.get('output') + d["frameStart"] = seq_data.get('frame_range')[0] + d["frameEnd"] = seq_data.get('frame_range')[1] + + container_name = f"{subset}{self.suffix}" + pipeline.create_publish_instance( + instance=container_name, path=path) + pipeline.imprint(f"{path}/{container_name}", d) diff --git a/openpype/hosts/unreal/plugins/publish/collect_instances.py b/openpype/hosts/unreal/plugins/publish/collect_instances.py index 94e732d728..2f604cb322 100644 --- a/openpype/hosts/unreal/plugins/publish/collect_instances.py +++ b/openpype/hosts/unreal/plugins/publish/collect_instances.py @@ -17,7 +17,7 @@ class CollectInstances(pyblish.api.ContextPlugin): """ label = "Collect Instances" - order = pyblish.api.CollectorOrder + order = pyblish.api.CollectorOrder - 0.1 hosts = ["unreal"] def process(self, context): diff --git a/openpype/hosts/unreal/plugins/publish/collect_remove_marked.py b/openpype/hosts/unreal/plugins/publish/collect_remove_marked.py new file mode 100644 index 0000000000..69e69f6630 --- /dev/null +++ b/openpype/hosts/unreal/plugins/publish/collect_remove_marked.py @@ -0,0 +1,24 @@ +import pyblish.api + + +class CollectRemoveMarked(pyblish.api.ContextPlugin): + """Remove marked data + + Remove instances that have 'remove' in their instance.data + + """ + + order = pyblish.api.CollectorOrder + 0.499 + label = 'Remove Marked Instances' + + def process(self, context): + + self.log.debug(context) + # make ftrack publishable + instances_to_remove = [] + for instance in context: + if instance.data.get('remove'): + instances_to_remove.append(instance) + + for instance in instances_to_remove: + context.remove(instance) diff --git a/openpype/hosts/unreal/plugins/publish/collect_render_instances.py b/openpype/hosts/unreal/plugins/publish/collect_render_instances.py new file mode 100644 index 0000000000..6eb51517c6 --- /dev/null +++ b/openpype/hosts/unreal/plugins/publish/collect_render_instances.py @@ -0,0 +1,106 @@ +from pathlib import Path +from tkinter.font import families +import unreal + +import pyblish.api +from openpype import lib +from openpype.pipeline import legacy_create +from openpype.hosts.unreal.api import pipeline + + +class CollectRenderInstances(pyblish.api.InstancePlugin): + """ This collector will try to find all the rendered frames. + + """ + order = pyblish.api.CollectorOrder + hosts = ["unreal"] + families = ["render"] + label = "Collect Render Instances" + + def process(self, instance): + self.log.debug("Preparing Rendering Instances") + + context = instance.context + + data = instance.data + data['remove'] = True + + ar = unreal.AssetRegistryHelpers.get_asset_registry() + + sequence = ar.get_asset_by_object_path( + data.get('sequence')).get_asset() + + sequences = [{ + "sequence": sequence, + "output": data.get('output'), + "frame_range": ( + data.get('frameStart'), data.get('frameEnd')) + }] + + for s in sequences: + self.log.debug(f"Processing: {s.get('sequence').get_name()}") + subscenes = pipeline.get_subsequences(s.get('sequence')) + + if subscenes: + for ss in subscenes: + sequences.append({ + "sequence": ss.get_sequence(), + "output": (f"{s.get('output')}/" + f"{ss.get_sequence().get_name()}"), + "frame_range": ( + ss.get_start_frame(), ss.get_end_frame() - 1) + }) + else: + # Avoid creating instances for camera sequences + if "_camera" not in s.get('sequence').get_name(): + seq = s.get('sequence') + seq_name = seq.get_name() + + new_instance = context.create_instance( + f"{data.get('subset')}_" + f"{seq_name}") + new_instance[:] = seq_name + + new_data = new_instance.data + + new_data["asset"] = seq_name + new_data["setMembers"] = seq_name + new_data["family"] = "render" + new_data["families"] = ["render", "review"] + new_data["parent"] = data.get("parent") + new_data["subset"] = f"{data.get('subset')}_{seq_name}" + new_data["level"] = data.get("level") + new_data["output"] = s.get('output') + new_data["fps"] = seq.get_display_rate().numerator + new_data["frameStart"] = s.get('frame_range')[0] + new_data["frameEnd"] = s.get('frame_range')[1] + new_data["sequence"] = seq.get_path_name() + new_data["master_sequence"] = data["master_sequence"] + new_data["master_level"] = data["master_level"] + + self.log.debug(f"new instance data: {new_data}") + + project_dir = unreal.Paths.project_dir() + render_dir = (f"{project_dir}/Saved/MovieRenders/" + f"{s.get('output')}") + render_path = Path(render_dir) + + frames = [] + + for x in render_path.iterdir(): + if x.is_file() and x.suffix == '.png': + frames.append(str(x.name)) + + if "representations" not in new_instance.data: + new_instance.data["representations"] = [] + + repr = { + 'frameStart': s.get('frame_range')[0], + 'frameEnd': s.get('frame_range')[1], + 'name': 'png', + 'ext': 'png', + 'files': frames, + 'stagingDir': render_dir, + 'tags': ['review'] + } + new_instance.data["representations"].append(repr) diff --git a/openpype/hosts/unreal/plugins/publish/validate_sequence_frames.py b/openpype/hosts/unreal/plugins/publish/validate_sequence_frames.py new file mode 100644 index 0000000000..0a77281d16 --- /dev/null +++ b/openpype/hosts/unreal/plugins/publish/validate_sequence_frames.py @@ -0,0 +1,45 @@ +from pathlib import Path + +import unreal + +import pyblish.api + + +class ValidateSequenceFrames(pyblish.api.InstancePlugin): + """Ensure the sequence of frames is complete + + The files found in the folder are checked against the frameStart and + frameEnd of the instance. If the first or last file is not + corresponding with the first or last frame it is flagged as invalid. + """ + + order = pyblish.api.ValidatorOrder + label = "Validate Sequence Frames" + families = ["render"] + hosts = ["unreal"] + optional = True + + def process(self, instance): + self.log.debug(instance.data) + + representations = instance.data.get("representations") + for repr in representations: + frames = [] + for x in repr.get("files"): + # Get frame number. The last one contains the file extension, + # while the one before that is the frame number. + # `lstrip` removes any leading zeros. `or "0"` is to tackle + # the case where the frame number is "00". + frame = int(str(x).split('.')[-2]) + frames.append(frame) + frames.sort() + current_range = (frames[0], frames[-1]) + required_range = (instance.data["frameStart"], + instance.data["frameEnd"]) + + if current_range != required_range: + raise ValueError(f"Invalid frame range: {current_range} - " + f"expected: {required_range}") + + assert len(frames) == int(frames[-1]) - int(frames[0]) + 1, \ + "Missing frames" diff --git a/openpype/plugins/publish/extract_review.py b/openpype/plugins/publish/extract_review.py index 3ecea1f8bd..35ad6270cf 100644 --- a/openpype/plugins/publish/extract_review.py +++ b/openpype/plugins/publish/extract_review.py @@ -51,7 +51,8 @@ class ExtractReview(pyblish.api.InstancePlugin): "resolve", "webpublisher", "aftereffects", - "flame" + "flame", + "unreal" ] # Supported extensions From b9f387dc505664f0c52c6a45330cbc2c6786a611 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Fri, 18 Mar 2022 16:09:53 +0000 Subject: [PATCH 025/583] Hound fix --- .../hosts/unreal/plugins/publish/collect_render_instances.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/openpype/hosts/unreal/plugins/publish/collect_render_instances.py b/openpype/hosts/unreal/plugins/publish/collect_render_instances.py index 6eb51517c6..9d60b65d08 100644 --- a/openpype/hosts/unreal/plugins/publish/collect_render_instances.py +++ b/openpype/hosts/unreal/plugins/publish/collect_render_instances.py @@ -1,10 +1,7 @@ from pathlib import Path -from tkinter.font import families import unreal import pyblish.api -from openpype import lib -from openpype.pipeline import legacy_create from openpype.hosts.unreal.api import pipeline From 9fab478edf926bbe45dcfa294d2b53c767ebf086 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Fri, 18 Mar 2022 16:54:20 +0000 Subject: [PATCH 026/583] Improvements and more consistency in validator for rendered frames --- .../publish/validate_sequence_frames.py | 25 +++++++++---------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/openpype/hosts/unreal/plugins/publish/validate_sequence_frames.py b/openpype/hosts/unreal/plugins/publish/validate_sequence_frames.py index 0a77281d16..2684581e9d 100644 --- a/openpype/hosts/unreal/plugins/publish/validate_sequence_frames.py +++ b/openpype/hosts/unreal/plugins/publish/validate_sequence_frames.py @@ -1,4 +1,5 @@ from pathlib import Path +import clique import unreal @@ -20,19 +21,17 @@ class ValidateSequenceFrames(pyblish.api.InstancePlugin): optional = True def process(self, instance): - self.log.debug(instance.data) - representations = instance.data.get("representations") for repr in representations: - frames = [] - for x in repr.get("files"): - # Get frame number. The last one contains the file extension, - # while the one before that is the frame number. - # `lstrip` removes any leading zeros. `or "0"` is to tackle - # the case where the frame number is "00". - frame = int(str(x).split('.')[-2]) - frames.append(frame) - frames.sort() + patterns = [clique.PATTERNS["frames"]] + collections, remainder = clique.assemble( + repr["files"], minimum_items=1, patterns=patterns) + + assert not remainder, "Must not have remainder" + assert len(collections) == 1, "Must detect single collection" + collection = collections[0] + frames = list(collection.indexes) + current_range = (frames[0], frames[-1]) required_range = (instance.data["frameStart"], instance.data["frameEnd"]) @@ -41,5 +40,5 @@ class ValidateSequenceFrames(pyblish.api.InstancePlugin): raise ValueError(f"Invalid frame range: {current_range} - " f"expected: {required_range}") - assert len(frames) == int(frames[-1]) - int(frames[0]) + 1, \ - "Missing frames" + missing = collection.holes().indexes + assert not missing, "Missing frames: %s" % (missing,) From 541f44988dc654bd2609d865875a1ed47908014e Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Fri, 18 Mar 2022 16:55:54 +0000 Subject: [PATCH 027/583] More hound fixes --- .../hosts/unreal/plugins/publish/validate_sequence_frames.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/openpype/hosts/unreal/plugins/publish/validate_sequence_frames.py b/openpype/hosts/unreal/plugins/publish/validate_sequence_frames.py index 2684581e9d..87f1338ee8 100644 --- a/openpype/hosts/unreal/plugins/publish/validate_sequence_frames.py +++ b/openpype/hosts/unreal/plugins/publish/validate_sequence_frames.py @@ -1,8 +1,5 @@ -from pathlib import Path import clique -import unreal - import pyblish.api From 0274be6bed9c7bf92c862ae33c0eab0db3b88eb4 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Fri, 18 Mar 2022 17:22:52 +0000 Subject: [PATCH 028/583] Added rendering --- openpype/hosts/unreal/api/rendering.py | 125 +++++++++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 openpype/hosts/unreal/api/rendering.py diff --git a/openpype/hosts/unreal/api/rendering.py b/openpype/hosts/unreal/api/rendering.py new file mode 100644 index 0000000000..376e1b75ce --- /dev/null +++ b/openpype/hosts/unreal/api/rendering.py @@ -0,0 +1,125 @@ +import unreal + +from openpype.hosts.unreal.api import pipeline + + +queue = None +executor = None + + +def _queue_finish_callback(exec, success): + unreal.log("Render completed. Success: " + str(success)) + + # Delete our reference so we don't keep it alive. + global executor + global queue + del executor + del queue + + +def _job_finish_callback(job, success): + # You can make any edits you want to the editor world here, and the world + # will be duplicated when the next render happens. Make sure you undo your + # edits in OnQueueFinishedCallback if you don't want to leak state changes + # into the editor world. + unreal.log("Individual job completed.") + + +def start_rendering(): + """ + Start the rendering process. + """ + print("Starting rendering...") + + # Get selected sequences + assets = unreal.EditorUtilityLibrary.get_selected_assets() + + # instances = pipeline.ls_inst() + instances = [ + a for a in assets + if a.get_class().get_name() == "OpenPypePublishInstance"] + + inst_data = [] + + for i in instances: + data = pipeline.parse_container(i.get_path_name()) + if data["family"] == "render": + inst_data.append(data) + + # subsystem = unreal.get_editor_subsystem( + # unreal.MoviePipelineQueueSubsystem) + # queue = subsystem.get_queue() + global queue + queue = unreal.MoviePipelineQueue() + + ar = unreal.AssetRegistryHelpers.get_asset_registry() + + for i in inst_data: + sequence = ar.get_asset_by_object_path(i["sequence"]).get_asset() + + sequences = [{ + "sequence": sequence, + "output": f"{i['output']}", + "frame_range": ( + int(float(i["frameStart"])), + int(float(i["frameEnd"])) + 1) + }] + render_list = [] + + # Get all the sequences to render. If there are subsequences, + # add them and their frame ranges to the render list. We also + # use the names for the output paths. + for s in sequences: + subscenes = pipeline.get_subsequences(s.get('sequence')) + + if subscenes: + for ss in subscenes: + sequences.append({ + "sequence": ss.get_sequence(), + "output": (f"{s.get('output')}/" + f"{ss.get_sequence().get_name()}"), + "frame_range": ( + ss.get_start_frame(), ss.get_end_frame()) + }) + else: + # Avoid rendering camera sequences + if "_camera" not in s.get('sequence').get_name(): + render_list.append(s) + + # Create the rendering jobs and add them to the queue. + for r in render_list: + job = queue.allocate_new_job(unreal.MoviePipelineExecutorJob) + job.sequence = unreal.SoftObjectPath(i["master_sequence"]) + job.map = unreal.SoftObjectPath(i["master_level"]) + job.author = "OpenPype" + + # User data could be used to pass data to the job, that can be + # read in the job's OnJobFinished callback. We could, + # for instance, pass the AvalonPublishInstance's path to the job. + # job.user_data = "" + + settings = job.get_configuration().find_or_add_setting_by_class( + unreal.MoviePipelineOutputSetting) + settings.output_resolution = unreal.IntPoint(1920, 1080) + settings.custom_start_frame = r.get("frame_range")[0] + settings.custom_end_frame = r.get("frame_range")[1] + settings.use_custom_playback_range = True + settings.file_name_format = "{sequence_name}.{frame_number}" + settings.output_directory.path += r.get('output') + + renderPass = job.get_configuration().find_or_add_setting_by_class( + unreal.MoviePipelineDeferredPassBase) + renderPass.disable_multisample_effects = True + + job.get_configuration().find_or_add_setting_by_class( + unreal.MoviePipelineImageSequenceOutput_PNG) + + # If there are jobs in the queue, start the rendering process. + if queue.get_jobs(): + global executor + executor = unreal.MoviePipelinePIEExecutor() + executor.on_executor_finished_delegate.add_callable_unique( + _queue_finish_callback) + executor.on_individual_job_finished_delegate.add_callable_unique( + _job_finish_callback) # Only available on PIE Executor + executor.execute(queue) From a8680e9f23e885bd3a1957876198b9c249f23fdb Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Fri, 18 Mar 2022 17:25:03 +0000 Subject: [PATCH 029/583] Code cleanup --- openpype/hosts/unreal/plugins/create/create_render.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/openpype/hosts/unreal/plugins/create/create_render.py b/openpype/hosts/unreal/plugins/create/create_render.py index 49268c91f5..77fc98bcec 100644 --- a/openpype/hosts/unreal/plugins/create/create_render.py +++ b/openpype/hosts/unreal/plugins/create/create_render.py @@ -16,9 +16,6 @@ class CreateRender(Creator): root = "/Game/OpenPype/PublishInstances" suffix = "_INS" - def __init__(self, *args, **kwargs): - super(CreateRender, self).__init__(*args, **kwargs) - def process(self): subset = self.data["subset"] From 8f8a4efab9ccf7c30089e78d6dcffb4b76142ce3 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Fri, 18 Mar 2022 17:39:43 +0000 Subject: [PATCH 030/583] Fixed import problems --- .../unreal/plugins/load/load_animation.py | 29 +++++++++++-------- .../hosts/unreal/plugins/load/load_layout.py | 1 + 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/openpype/hosts/unreal/plugins/load/load_animation.py b/openpype/hosts/unreal/plugins/load/load_animation.py index ebfce75ca9..65a9de9353 100644 --- a/openpype/hosts/unreal/plugins/load/load_animation.py +++ b/openpype/hosts/unreal/plugins/load/load_animation.py @@ -3,6 +3,11 @@ import os import json +import unreal +from unreal import EditorAssetLibrary +from unreal import MovieSceneSkeletalAnimationTrack +from unreal import MovieSceneSkeletalAnimationSection + from avalon import pipeline from openpype.pipeline import get_representation_path from openpype.hosts.unreal.api import plugin @@ -82,14 +87,14 @@ class AnimationFBXLoader(plugin.Loader): unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) - asset_content = unreal.EditorAssetLibrary.list_assets( + asset_content = EditorAssetLibrary.list_assets( asset_dir, recursive=True, include_folder=True ) animation = None for a in asset_content: - imported_asset_data = unreal.EditorAssetLibrary.find_asset_data(a) + imported_asset_data = EditorAssetLibrary.find_asset_data(a) imported_asset = unreal.AssetRegistryHelpers.get_asset( imported_asset_data) if imported_asset.__class__ == unreal.AnimSequence: @@ -149,7 +154,7 @@ class AnimationFBXLoader(plugin.Loader): container_name += suffix - unreal.EditorAssetLibrary.make_directory(asset_dir) + EditorAssetLibrary.make_directory(asset_dir) libpath = self.fname.replace("fbx", "json") @@ -160,7 +165,7 @@ class AnimationFBXLoader(plugin.Loader): animation = self._process(asset_dir, container_name, instance_name) - asset_content = unreal.EditorAssetLibrary.list_assets( + asset_content = EditorAssetLibrary.list_assets( hierarchy_dir, recursive=True, include_folder=False) # Get the sequence for the layout, excluding the camera one. @@ -211,11 +216,11 @@ class AnimationFBXLoader(plugin.Loader): unreal_pipeline.imprint( "{}/{}".format(asset_dir, container_name), data) - imported_content = unreal.EditorAssetLibrary.list_assets( + imported_content = EditorAssetLibrary.list_assets( asset_dir, recursive=True, include_folder=False) for a in imported_content: - unreal.EditorAssetLibrary.save_asset(a) + EditorAssetLibrary.save_asset(a) def update(self, container, representation): name = container["asset_name"] @@ -261,7 +266,7 @@ class AnimationFBXLoader(plugin.Loader): task.options.anim_sequence_import_data.set_editor_property( 'convert_scene', True) - skeletal_mesh = unreal.EditorAssetLibrary.load_asset( + skeletal_mesh = EditorAssetLibrary.load_asset( container.get('namespace') + "/" + container.get('asset_name')) skeleton = skeletal_mesh.get_editor_property('skeleton') task.options.set_editor_property('skeleton', skeleton) @@ -278,22 +283,22 @@ class AnimationFBXLoader(plugin.Loader): "parent": str(representation["parent"]) }) - asset_content = unreal.EditorAssetLibrary.list_assets( + asset_content = EditorAssetLibrary.list_assets( destination_path, recursive=True, include_folder=True ) for a in asset_content: - unreal.EditorAssetLibrary.save_asset(a) + EditorAssetLibrary.save_asset(a) def remove(self, container): path = container["namespace"] parent_path = os.path.dirname(path) - unreal.EditorAssetLibrary.delete_directory(path) + EditorAssetLibrary.delete_directory(path) - asset_content = unreal.EditorAssetLibrary.list_assets( + asset_content = EditorAssetLibrary.list_assets( parent_path, recursive=False, include_folder=True ) if len(asset_content) == 0: - unreal.EditorAssetLibrary.delete_directory(parent_path) + EditorAssetLibrary.delete_directory(parent_path) diff --git a/openpype/hosts/unreal/plugins/load/load_layout.py b/openpype/hosts/unreal/plugins/load/load_layout.py index 5a82ad6df6..86923ea3b4 100644 --- a/openpype/hosts/unreal/plugins/load/load_layout.py +++ b/openpype/hosts/unreal/plugins/load/load_layout.py @@ -12,6 +12,7 @@ from unreal import AssetToolsHelpers from unreal import FBXImportType from unreal import MathLibrary as umath +from avalon import io from avalon.pipeline import AVALON_CONTAINER_ID from openpype.pipeline import ( discover_loader_plugins, From 75838df107193f24fab2db52ce66ed04e976bfbd Mon Sep 17 00:00:00 2001 From: jrsndlr Date: Tue, 22 Mar 2022 13:54:45 +0100 Subject: [PATCH 031/583] Handle timecode and audio state --- .../plugins/publish/extract_review_slate.py | 122 ++++++++++++++++-- 1 file changed, 110 insertions(+), 12 deletions(-) diff --git a/openpype/plugins/publish/extract_review_slate.py b/openpype/plugins/publish/extract_review_slate.py index 505ae75169..a21751aecc 100644 --- a/openpype/plugins/publish/extract_review_slate.py +++ b/openpype/plugins/publish/extract_review_slate.py @@ -10,7 +10,6 @@ from openpype.lib import ( get_ffmpeg_format_args, ) - class ExtractReviewSlate(openpype.api.Extractor): """ Will add slate frame at the start of the video files @@ -58,6 +57,7 @@ class ExtractReviewSlate(openpype.api.Extractor): pixel_aspect = inst_data.get("pixelAspect", 1) fps = inst_data.get("fps") + self.log.debug("fps {} ".format(fps)) for idx, repre in enumerate(inst_data["representations"]): self.log.debug("repre ({}): `{}`".format(idx + 1, repre)) @@ -82,12 +82,55 @@ class ExtractReviewSlate(openpype.api.Extractor): # - there may be a better way (checking `codec_type`?) input_width = None input_height = None + input_timecode = None + input_frame_rate = None + input_audio = False + audio_channels = None + audio_sample_rate = None + audio_channel_layout = None for stream in video_streams: - if "width" in stream and "height" in stream: - input_width = int(stream["width"]) - input_height = int(stream["height"]) - break - + self.log.debug("__ ffprobe: {}".format(stream)) + if "codec_type" in stream: + if stream["codec_type"] == "video": + if stream["tags"]["timecode"]: + # get timecode of the first frame + input_timecode = stream["tags"]["timecode"] + self.log.debug("__Video Timecode : {}".format(input_timecode)) + if "width" in stream and "height" in stream: + input_width = int(stream["width"]) + input_height = int(stream["height"]) + if "r_frame_rate" in stream: + # get frame rate in a form of x/y, like 24000/1001 for 23.976 + input_frame_rate = str(stream["r_frame_rate"]) + if stream["codec_type"] == "audio": + # get audio details for generating silent audio track for slate + if stream["channels"]: + audio_channels = str(stream["channels"]) + if stream["sample_rate"]: + audio_sample_rate = stream["sample_rate"] + if stream["channel_layout"]: + audio_channel_layout = stream["channel_layout"] + # calculate duration of one frame in seconds + if input_frame_rate: + # it is divided by two to make sure audio will be shorter then video + one_frame_duration = str( float(1.0 / eval(input_frame_rate)) / 2 ) + else: + # same sane default (1 frame @ 25 fps) + one_frame_duration = 0.04 + self.log.debug( + "One frame duration is {} sec".format(one_frame_duration)) + # confirm we gathered all needed audio parameters + if audio_channel_layout: + if audio_sample_rate: + if audio_channels: + input_audio = True + self.log.debug("__Audio : channels {}".format( + audio_channels)) + self.log.debug("__Audio : sample_rate {}".format( + audio_sample_rate)) + self.log.debug("__Audio : channel_layout {}".format( + audio_channel_layout)) + # Raise exception of any stream didn't define input resolution if input_width is None: raise AssertionError(( @@ -144,18 +187,34 @@ class ExtractReviewSlate(openpype.api.Extractor): input_args = [] output_args = [] + # if input has audio, add silent audio to the slate + if input_audio: + input_args.extend( + ["-f lavfi -i anullsrc=r={}:cl={}:d={}".format( + audio_sample_rate, + audio_channel_layout, + one_frame_duration + )] + ) # preset's input data if use_legacy_code: input_args.extend(repre["_profile"].get('input', [])) else: input_args.extend(repre["outputDef"].get('input', [])) - input_args.append("-loop 1 -i {}".format( + # enforce framerate before -i + input_args.append("-framerate {} -i {}".format( + input_frame_rate, path_to_subprocess_arg(slate_path) )) - input_args.extend([ - "-r {}".format(fps), - "-t 0.04" - ]) + # add timecode from source to the slate, substract one frame + if input_timecode: + offset_timecode = self._tc_offset( + str(input_timecode), + framerate=fps, + frame_offset=-1 + ) + self.log.debug("Timecode: `{}`".format(offset_timecode)) + input_args.extend(["-timecode {}".format(offset_timecode)]) if use_legacy_code: codec_args = repre["_profile"].get('codec', []) @@ -213,11 +272,16 @@ class ExtractReviewSlate(openpype.api.Extractor): width_scale, height_scale, to_width, to_height, width_half_pad, height_half_pad ) - + # add output frame rate as a filter, just in case + scaling_arg += ",fps={}".format(input_frame_rate) vf_back = self.add_video_filter_args(output_args, scaling_arg) # add it to output_args output_args.insert(0, vf_back) + # use video duration for silent audio duration + if input_audio: + output_args.append("-shortest") + # overrides output file output_args.append("-y") @@ -371,3 +435,37 @@ class ExtractReviewSlate(openpype.api.Extractor): ) return codec_args + + def _tc_offset(self, timecode='00:00:00:00', framerate=24.0, frame_offset=-1): + """Offsets timecode by frame""" + def _seconds(value, framerate): + if isinstance(value, str): + _zip_ft = zip((3600, 60, 1, 1/framerate), value.split(':')) + _s = sum(f * float(t) for f,t in _zip_ft) + elif isinstance(value, (int, float)): + _s = value / framerate + else: + _s = 0 + return _s + + def _frames(seconds, framerate, frame_offset): + _f = seconds * framerate + frame_offset + if _f < 0: + _f = framerate * 60 * 60 * 24 + _f + return _f + + def _timecode(seconds, framerate): + return '{h:02d}:{m:02d}:{s:02d}:{f:02d}' \ + .format(h=int(seconds/3600), + m=int(seconds/60%60), + s=int(seconds%60), + f=int(round((seconds-int(seconds))*framerate))) + drop = False + if ';' in timecode: + timecode = timecode.replace(';', ':') + drop = True + frames = _frames(_seconds(timecode, framerate), framerate, frame_offset) + tc = _timecode(_seconds(frames, framerate), framerate) + if drop: + tc = ';'.join(tc.rsplit(':', 1)) + return tc \ No newline at end of file From 2dd38c74c04814c33356a05dfda32efeda470ffd Mon Sep 17 00:00:00 2001 From: jrsndlr Date: Tue, 22 Mar 2022 14:13:00 +0100 Subject: [PATCH 032/583] fix hound --- .../plugins/publish/extract_review_slate.py | 33 ++++++++++++------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/openpype/plugins/publish/extract_review_slate.py b/openpype/plugins/publish/extract_review_slate.py index a21751aecc..5cfb0997a5 100644 --- a/openpype/plugins/publish/extract_review_slate.py +++ b/openpype/plugins/publish/extract_review_slate.py @@ -95,15 +95,18 @@ class ExtractReviewSlate(openpype.api.Extractor): if stream["tags"]["timecode"]: # get timecode of the first frame input_timecode = stream["tags"]["timecode"] - self.log.debug("__Video Timecode : {}".format(input_timecode)) + self.log.debug("__Video Timecode : {}".format( + input_timecode)) if "width" in stream and "height" in stream: input_width = int(stream["width"]) input_height = int(stream["height"]) if "r_frame_rate" in stream: - # get frame rate in a form of x/y, like 24000/1001 for 23.976 + # get frame rate in a form of + # x/y, like 24000/1001 for 23.976 input_frame_rate = str(stream["r_frame_rate"]) if stream["codec_type"] == "audio": - # get audio details for generating silent audio track for slate + # get audio details + # for generating silent audio track for slate if stream["channels"]: audio_channels = str(stream["channels"]) if stream["sample_rate"]: @@ -112,8 +115,10 @@ class ExtractReviewSlate(openpype.api.Extractor): audio_channel_layout = stream["channel_layout"] # calculate duration of one frame in seconds if input_frame_rate: - # it is divided by two to make sure audio will be shorter then video - one_frame_duration = str( float(1.0 / eval(input_frame_rate)) / 2 ) + # it is halved to make sure audio will be shorter then video + one_frame_duration = str( + float(1.0 / eval(input_frame_rate)) / 2 + ) else: # same sane default (1 frame @ 25 fps) one_frame_duration = 0.04 @@ -436,7 +441,7 @@ class ExtractReviewSlate(openpype.api.Extractor): return codec_args - def _tc_offset(self, timecode='00:00:00:00', framerate=24.0, frame_offset=-1): + def _tc_offset(self, timecode, framerate=24.0, frame_offset=-1): """Offsets timecode by frame""" def _seconds(value, framerate): if isinstance(value, str): @@ -456,16 +461,20 @@ class ExtractReviewSlate(openpype.api.Extractor): def _timecode(seconds, framerate): return '{h:02d}:{m:02d}:{s:02d}:{f:02d}' \ - .format(h=int(seconds/3600), - m=int(seconds/60%60), - s=int(seconds%60), - f=int(round((seconds-int(seconds))*framerate))) + .format(h=int(seconds / 3600), + m=int(seconds / 60 % 60), + s=int(seconds % 60), + f=int(round((seconds -int(seconds)) * framerate))) drop = False if ';' in timecode: timecode = timecode.replace(';', ':') drop = True - frames = _frames(_seconds(timecode, framerate), framerate, frame_offset) + frames = _frames( + _seconds(timecode, framerate), + framerate, + frame_offset + ) tc = _timecode(_seconds(frames, framerate), framerate) if drop: tc = ';'.join(tc.rsplit(':', 1)) - return tc \ No newline at end of file + return tc From c67a5504516b2eddea46d230a1d7d6f9c7ec71c5 Mon Sep 17 00:00:00 2001 From: jrsndlr Date: Tue, 22 Mar 2022 14:17:56 +0100 Subject: [PATCH 033/583] Fix Hound2 --- openpype/plugins/publish/extract_review_slate.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/openpype/plugins/publish/extract_review_slate.py b/openpype/plugins/publish/extract_review_slate.py index 5cfb0997a5..d6857c7915 100644 --- a/openpype/plugins/publish/extract_review_slate.py +++ b/openpype/plugins/publish/extract_review_slate.py @@ -105,7 +105,7 @@ class ExtractReviewSlate(openpype.api.Extractor): # x/y, like 24000/1001 for 23.976 input_frame_rate = str(stream["r_frame_rate"]) if stream["codec_type"] == "audio": - # get audio details + # get audio details # for generating silent audio track for slate if stream["channels"]: audio_channels = str(stream["channels"]) @@ -117,7 +117,7 @@ class ExtractReviewSlate(openpype.api.Extractor): if input_frame_rate: # it is halved to make sure audio will be shorter then video one_frame_duration = str( - float(1.0 / eval(input_frame_rate)) / 2 + float(1.0 / eval(input_frame_rate)) / 2 ) else: # same sane default (1 frame @ 25 fps) @@ -460,11 +460,11 @@ class ExtractReviewSlate(openpype.api.Extractor): return _f def _timecode(seconds, framerate): - return '{h:02d}:{m:02d}:{s:02d}:{f:02d}' \ - .format(h=int(seconds / 3600), - m=int(seconds / 60 % 60), - s=int(seconds % 60), - f=int(round((seconds -int(seconds)) * framerate))) + return '{h:02d}:{m:02d}:{s:02d}:{f:02d}'.format( + h = int(seconds / 3600), + m = int(seconds / 60 % 60), + s = int(seconds % 60), + f = int(round((seconds - int(seconds)) * framerate))) drop = False if ';' in timecode: timecode = timecode.replace(';', ':') From 406575ebd8c7056febce78b0575e85204458d10b Mon Sep 17 00:00:00 2001 From: jrsndlr Date: Tue, 22 Mar 2022 14:22:27 +0100 Subject: [PATCH 034/583] Hound3 --- .../projects_schema/schemas/schema_representation_tags.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_representation_tags.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_representation_tags.json index 7607e1a8c1..484fbf9d07 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_representation_tags.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_representation_tags.json @@ -24,6 +24,9 @@ }, { "sequence": "Output as image sequence" + }, + { + "no-audio": "Do not add audio" } ] } From 16817c30f67476578d30130b1aa721c66b7aa2dc Mon Sep 17 00:00:00 2001 From: Jiri Sindelar <45896205+jrsndl@users.noreply.github.com> Date: Tue, 22 Mar 2022 14:25:07 +0100 Subject: [PATCH 035/583] remove no-audio tag settings --- .../schemas/schema_representation_tags.json | 32 ------------------- 1 file changed, 32 deletions(-) delete mode 100644 openpype/settings/entities/schemas/projects_schema/schemas/schema_representation_tags.json diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_representation_tags.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_representation_tags.json deleted file mode 100644 index 484fbf9d07..0000000000 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_representation_tags.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "key": "tags", - "label": "Tags", - "type": "enum", - "multiselection": true, - "enum_items": [ - { - "burnin": "Add burnins" - }, - { - "review": "Create review" - }, - { - "ftrackreview": "Add review to Ftrack" - }, - { - "delete": "Delete output" - }, - { - "slate-frame": "Add slate frame" - }, - { - "no-handles": "Skip handle frames" - }, - { - "sequence": "Output as image sequence" - }, - { - "no-audio": "Do not add audio" - } - ] -} From f866bf2a757540c925a95864ccd2d9ac4d9b3a03 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 22 Mar 2022 16:36:41 +0100 Subject: [PATCH 036/583] adding back `schema_representation_tags` --- .../schemas/schema_representation_tags.json | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 openpype/settings/entities/schemas/projects_schema/schemas/schema_representation_tags.json diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_representation_tags.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_representation_tags.json new file mode 100644 index 0000000000..7607e1a8c1 --- /dev/null +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_representation_tags.json @@ -0,0 +1,29 @@ +{ + "key": "tags", + "label": "Tags", + "type": "enum", + "multiselection": true, + "enum_items": [ + { + "burnin": "Add burnins" + }, + { + "review": "Create review" + }, + { + "ftrackreview": "Add review to Ftrack" + }, + { + "delete": "Delete output" + }, + { + "slate-frame": "Add slate frame" + }, + { + "no-handles": "Skip handle frames" + }, + { + "sequence": "Output as image sequence" + } + ] +} From cc602f1da0829aaa7226a1d1dc9c36111464fc7c Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Sat, 26 Mar 2022 14:37:01 +0100 Subject: [PATCH 037/583] added implementation of overlay messages --- openpype/tools/utils/overlay_messages.py | 315 +++++++++++++++++++++++ 1 file changed, 315 insertions(+) create mode 100644 openpype/tools/utils/overlay_messages.py diff --git a/openpype/tools/utils/overlay_messages.py b/openpype/tools/utils/overlay_messages.py new file mode 100644 index 0000000000..ade037817a --- /dev/null +++ b/openpype/tools/utils/overlay_messages.py @@ -0,0 +1,315 @@ +import uuid + +from Qt import QtWidgets, QtCore, QtGui + +from .lib import set_style_property + + +class CloseButton(QtWidgets.QFrame): + """Close button drawed manually.""" + + clicked = QtCore.Signal() + + def __init__(self, parent): + super(CloseButton, self).__init__(parent) + self._mouse_pressed = False + policy = QtWidgets.QSizePolicy( + QtWidgets.QSizePolicy.Fixed, + QtWidgets.QSizePolicy.Fixed + ) + self.setSizePolicy(policy) + + def sizeHint(self): + size = self.fontMetrics().height() + return QtCore.QSize(size, size) + + def mousePressEvent(self, event): + if event.button() == QtCore.Qt.LeftButton: + self._mouse_pressed = True + super(CloseButton, self).mousePressEvent(event) + + def mouseReleaseEvent(self, event): + if self._mouse_pressed: + self._mouse_pressed = False + if self.rect().contains(event.pos()): + self.clicked.emit() + + super(CloseButton, self).mouseReleaseEvent(event) + + def paintEvent(self, event): + rect = self.rect() + painter = QtGui.QPainter(self) + painter.setClipRect(event.rect()) + pen = QtGui.QPen() + pen.setWidth(2) + pen.setColor(QtGui.QColor(255, 255, 255)) + pen.setStyle(QtCore.Qt.SolidLine) + pen.setCapStyle(QtCore.Qt.RoundCap) + painter.setPen(pen) + offset = int(rect.height() / 4) + top = rect.top() + offset + left = rect.left() + offset + right = rect.right() - offset + bottom = rect.bottom() - offset + painter.drawLine( + left, top, + right, bottom + ) + painter.drawLine( + left, bottom, + right, top + ) + + +class MessageWidget(QtWidgets.QFrame): + """Message widget showed as overlay. + + Message is hidden after timeout but can be overriden by mouse hover. + Mouse hover can add additional 2 seconds of widget's visibility. + + Args: + message_id (str): Unique identifier of message widget for + 'MessageOverlayObject'. + message (str): Text shown in message. + parent (QWidget): Parent widget where message is visible. + timeout (int): Timeout of message's visibility (default 5000). + message_type (str): Property which can be used in styles for specific + kid of message. + """ + + close_requested = QtCore.Signal(str) + _default_timeout = 5000 + + def __init__( + self, message_id, message, parent, timeout=None, message_type=None + ): + super(MessageWidget, self).__init__(parent) + self.setObjectName("OverlayMessageWidget") + + if message_type: + set_style_property(self, "type", message_type) + + if not timeout: + timeout = self._default_timeout + timeout_timer = QtCore.QTimer() + timeout_timer.setInterval(timeout) + timeout_timer.setSingleShot(True) + + hover_timer = QtCore.QTimer() + hover_timer.setInterval(2000) + hover_timer.setSingleShot(True) + + label_widget = QtWidgets.QLabel(message, self) + label_widget.setAlignment(QtCore.Qt.AlignCenter) + label_widget.setWordWrap(True) + close_btn = CloseButton(self) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(5, 5, 0, 5) + layout.addWidget(label_widget, 1) + layout.addWidget(close_btn, 0) + + close_btn.clicked.connect(self._on_close_clicked) + timeout_timer.timeout.connect(self._on_timer_timeout) + hover_timer.timeout.connect(self._on_hover_timeout) + + self._label_widget = label_widget + self._message_id = message_id + self._timeout_timer = timeout_timer + self._hover_timer = hover_timer + + def size_hint_without_word_wrap(self): + """Size hint in cases that word wrap of label is disabled.""" + self._label_widget.setWordWrap(False) + size_hint = self.sizeHint() + self._label_widget.setWordWrap(True) + return size_hint + + def showEvent(self, event): + """Start timeout on show.""" + super(MessageWidget, self).showEvent(event) + self._timeout_timer.start() + + def _on_timer_timeout(self): + """On message timeout.""" + # Skip closing if hover timer is active + if not self._hover_timer.isActive(): + self._close_message() + + def _on_hover_timeout(self): + """Hover timer timed out.""" + # Check if is still under widget + if self.underMouse(): + self._hover_timer.start() + else: + self._close_message() + + def _on_close_clicked(self): + self._close_message() + + def _close_message(self): + """Emmit close request to 'MessageOverlayObject'.""" + self.close_requested.emit(self._message_id) + + def enterEvent(self, event): + """Start hover timer on hover.""" + super(MessageWidget, self).enterEvent(event) + self._hover_timer.start() + + def leaveEvent(self, event): + """Start hover timer on hover leave.""" + super(MessageWidget, self).leaveEvent(event) + self._hover_timer.start() + + +class MessageOverlayObject(QtCore.QObject): + """Object that can be used to add overlay messages. + + Args: + widget (QWidget): + """ + + def __init__(self, widget): + super(MessageOverlayObject, self).__init__() + + widget.installEventFilter(self) + + # Timer which triggers recalculation of message positions + recalculate_timer = QtCore.QTimer() + recalculate_timer.setInterval(10) + + recalculate_timer.timeout.connect(self._recalculate_positions) + + self._widget = widget + self._recalculate_timer = recalculate_timer + + self._messages_order = [] + self._closing_messages = set() + self._messages = {} + self._spacing = 5 + self._move_size = 4 + self._move_size_remove = 8 + + def add_message(self, message, timeout=None, message_type=None): + """Add single message into overlay. + + Args: + message (str): Message that will be shown. + timeout (int): Message timeout. + message_type (str): Message type can be used as property in + stylesheets. + """ + # Skip empty messages + if not message: + return + + # Create unique id of message + label_id = str(uuid.uuid4()) + # Create message widget + widget = MessageWidget( + label_id, message, self._widget, timeout, message_type + ) + widget.close_requested.connect(self._on_message_close_request) + widget.show() + + # Move widget outside of window + pos = widget.pos() + pos.setY(pos.y() - widget.height()) + widget.move(pos) + # Store message + self._messages[label_id] = widget + self._messages_order.append(label_id) + # Trigger recalculation timer + self._recalculate_timer.start() + + def _on_message_close_request(self, label_id): + """Message widget requested removement.""" + + widget = self._messages.get(label_id) + if widget is not None: + # Add message to closing messages and start recalculation + self._closing_messages.add(label_id) + self._recalculate_timer.start() + + def _recalculate_positions(self): + """Recalculate positions of widgets.""" + + # Skip if there are no messages to process + if not self._messages_order: + self._recalculate_timer.stop() + return + + # All message widgets are in expected positions + all_at_place = True + # Starting y position + pos_y = self._spacing + # Current widget width + widget_width = self._widget.width() + max_width = widget_width - (2 * self._spacing) + widget_half_width = widget_width / 2 + + # Store message ids that should be removed + message_ids_to_remove = set() + for message_id in reversed(self._messages_order): + widget = self._messages[message_id] + pos = widget.pos() + # Messages to remove are moved upwards + if message_id in self._closing_messages: + bottom = pos.y() + widget.height() + # Add message to remove if is not visible + if bottom < 0 or self._move_size_remove < 1: + message_ids_to_remove.add(message_id) + continue + + # Calculate new y position of message + dst_pos_y = pos.y() - self._move_size_remove + + else: + # Calculate y position of message + # - use y position of previous message widget and add + # move size if is not in final destination yet + if widget.underMouse(): + dst_pos_y = pos.y() + elif pos.y() == pos_y or self._move_size < 1: + dst_pos_y = pos_y + elif pos.y() < pos_y: + dst_pos_y = min(pos_y, pos.y() + self._move_size) + else: + dst_pos_y = max(pos_y, pos.y() - self._move_size) + + # Store if widget is in place where should be + if all_at_place and dst_pos_y != pos_y: + all_at_place = False + + # Calculate ideal width and height of message widget + height = widget.heightForWidth(max_width) + w_size_hint = widget.size_hint_without_word_wrap() + widget.resize(min(max_width, w_size_hint.width()), height) + + # Center message widget + size = widget.size() + pos_x = widget_half_width - (size.width() / 2) + # Move widget to destination position + widget.move(pos_x, dst_pos_y) + + # Add message widget height and spacing for next message widget + pos_y += size.height() + self._spacing + + # Remove widgets to remove + for message_id in message_ids_to_remove: + self._messages_order.remove(message_id) + self._closing_messages.remove(message_id) + widget = self._messages.pop(message_id) + widget.hide() + widget.deleteLater() + + # Stop recalculation timer if all widgets are where should be + if all_at_place: + self._recalculate_timer.stop() + + def eventFilter(self, source, event): + # Trigger recalculation of timer on resize of widget + if source is self._widget and event.type() == QtCore.QEvent.Resize: + self._recalculate_timer.start() + + return super(MessageOverlayObject, self).eventFilter(source, event) From 8bc010a4f409a66f5536fc8bdc39dd4094dee05d Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Sat, 26 Mar 2022 14:54:08 +0100 Subject: [PATCH 038/583] define default styles for overlay messages --- openpype/style/data.json | 6 +++++- openpype/style/style.css | 20 +++++++++++++++++++ openpype/tools/utils/overlay_messages.py | 25 ++++++++++++++---------- 3 files changed, 40 insertions(+), 11 deletions(-) diff --git a/openpype/style/data.json b/openpype/style/data.json index a76a77015b..15d9472e3e 100644 --- a/openpype/style/data.json +++ b/openpype/style/data.json @@ -61,7 +61,11 @@ "icon-entity-default": "#bfccd6", "icon-entity-disabled": "#808080", "font-entity-deprecated": "#666666", - + "overlay-messages": { + "close-btn": "#D3D8DE", + "bg-success": "#458056", + "bg-success-hover": "#55a066" + }, "tab-widget": { "bg": "#21252B", "bg-selected": "#434a56", diff --git a/openpype/style/style.css b/openpype/style/style.css index df83600973..4d83e39780 100644 --- a/openpype/style/style.css +++ b/openpype/style/style.css @@ -687,6 +687,26 @@ QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { background: none; } +/* Messages overlay */ +#OverlayMessageWidget { + border-radius: 0.2em; + background: {color:bg-buttons}; +} + +#OverlayMessageWidget:hover { + background: {color:bg-button-hover}; +} +#OverlayMessageWidget[type="success"] { + background: {color:overlay-messages:bg-success}; +} +#OverlayMessageWidget[type="success"]:hover { + background: {color:overlay-messages:bg-success-hover}; +} + +#OverlayMessageWidget QWidget { + background: transparent; +} + /* Password dialog*/ #PasswordBtn { border: none; diff --git a/openpype/tools/utils/overlay_messages.py b/openpype/tools/utils/overlay_messages.py index ade037817a..93082b9fb7 100644 --- a/openpype/tools/utils/overlay_messages.py +++ b/openpype/tools/utils/overlay_messages.py @@ -2,6 +2,8 @@ import uuid from Qt import QtWidgets, QtCore, QtGui +from openpype.style import get_objected_colors + from .lib import set_style_property @@ -12,6 +14,9 @@ class CloseButton(QtWidgets.QFrame): def __init__(self, parent): super(CloseButton, self).__init__(parent) + colors = get_objected_colors() + close_btn_color = colors["overlay-messages"]["close-btn"] + self._color = close_btn_color.get_qcolor() self._mouse_pressed = False policy = QtWidgets.QSizePolicy( QtWidgets.QSizePolicy.Fixed, @@ -42,7 +47,7 @@ class CloseButton(QtWidgets.QFrame): painter.setClipRect(event.rect()) pen = QtGui.QPen() pen.setWidth(2) - pen.setColor(QtGui.QColor(255, 255, 255)) + pen.setColor(self._color) pen.setStyle(QtCore.Qt.SolidLine) pen.setCapStyle(QtCore.Qt.RoundCap) painter.setPen(pen) @@ -61,7 +66,7 @@ class CloseButton(QtWidgets.QFrame): ) -class MessageWidget(QtWidgets.QFrame): +class OverlayMessageWidget(QtWidgets.QFrame): """Message widget showed as overlay. Message is hidden after timeout but can be overriden by mouse hover. @@ -81,9 +86,9 @@ class MessageWidget(QtWidgets.QFrame): _default_timeout = 5000 def __init__( - self, message_id, message, parent, timeout=None, message_type=None + self, message_id, message, parent, message_type=None, timeout=None ): - super(MessageWidget, self).__init__(parent) + super(OverlayMessageWidget, self).__init__(parent) self.setObjectName("OverlayMessageWidget") if message_type: @@ -127,7 +132,7 @@ class MessageWidget(QtWidgets.QFrame): def showEvent(self, event): """Start timeout on show.""" - super(MessageWidget, self).showEvent(event) + super(OverlayMessageWidget, self).showEvent(event) self._timeout_timer.start() def _on_timer_timeout(self): @@ -153,12 +158,12 @@ class MessageWidget(QtWidgets.QFrame): def enterEvent(self, event): """Start hover timer on hover.""" - super(MessageWidget, self).enterEvent(event) + super(OverlayMessageWidget, self).enterEvent(event) self._hover_timer.start() def leaveEvent(self, event): """Start hover timer on hover leave.""" - super(MessageWidget, self).leaveEvent(event) + super(OverlayMessageWidget, self).leaveEvent(event) self._hover_timer.start() @@ -190,7 +195,7 @@ class MessageOverlayObject(QtCore.QObject): self._move_size = 4 self._move_size_remove = 8 - def add_message(self, message, timeout=None, message_type=None): + def add_message(self, message, message_type=None, timeout=None): """Add single message into overlay. Args: @@ -206,8 +211,8 @@ class MessageOverlayObject(QtCore.QObject): # Create unique id of message label_id = str(uuid.uuid4()) # Create message widget - widget = MessageWidget( - label_id, message, self._widget, timeout, message_type + widget = OverlayMessageWidget( + label_id, message, self._widget, message_type, timeout ) widget.close_requested.connect(self._on_message_close_request) widget.show() From ef0210a98aba43e8a17ba23dc0a1cf57ad38034f Mon Sep 17 00:00:00 2001 From: jrsndlr Date: Mon, 28 Mar 2022 16:48:01 +0200 Subject: [PATCH 039/583] Error Handling, ffmpeg fixes --- igniter/3} | 0 .../plugins/publish/extract_review_slate.py | 172 +++++++++--------- 2 files changed, 90 insertions(+), 82 deletions(-) create mode 100644 igniter/3} diff --git a/igniter/3} b/igniter/3} new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openpype/plugins/publish/extract_review_slate.py b/openpype/plugins/publish/extract_review_slate.py index d6857c7915..2854108022 100644 --- a/openpype/plugins/publish/extract_review_slate.py +++ b/openpype/plugins/publish/extract_review_slate.py @@ -1,4 +1,5 @@ import os +import shutil import openpype.api import pyblish from openpype.lib import ( @@ -73,74 +74,72 @@ class ExtractReviewSlate(openpype.api.Extractor): os.path.normpath(stagingdir), repre["files"]) self.log.debug("__ input_path: {}".format(input_path)) - video_streams = get_ffprobe_streams( + streams = get_ffprobe_streams( input_path, self.log ) - - # Try to find first stream with defined 'width' and 'height' - # - this is to avoid order of streams where audio can be as first - # - there may be a better way (checking `codec_type`?) - input_width = None - input_height = None - input_timecode = None - input_frame_rate = None - input_audio = False - audio_channels = None - audio_sample_rate = None - audio_channel_layout = None - for stream in video_streams: - self.log.debug("__ ffprobe: {}".format(stream)) + # get video metadata + for stream in streams: + input_timecode = None + input_width = None + input_height = None + input_frame_rate = None if "codec_type" in stream: if stream["codec_type"] == "video": - if stream["tags"]["timecode"]: - # get timecode of the first frame - input_timecode = stream["tags"]["timecode"] - self.log.debug("__Video Timecode : {}".format( - input_timecode)) + self.log.debug("__Ffprobe Video: {}".format(stream)) + tags = stream.get("tags") or {} + input_timecode = tags.get("timecode") or "" if "width" in stream and "height" in stream: - input_width = int(stream["width"]) - input_height = int(stream["height"]) + input_width = int(stream.get("width")) + input_height = int(stream.get("height")) if "r_frame_rate" in stream: # get frame rate in a form of # x/y, like 24000/1001 for 23.976 - input_frame_rate = str(stream["r_frame_rate"]) - if stream["codec_type"] == "audio": - # get audio details - # for generating silent audio track for slate - if stream["channels"]: - audio_channels = str(stream["channels"]) - if stream["sample_rate"]: - audio_sample_rate = stream["sample_rate"] - if stream["channel_layout"]: - audio_channel_layout = stream["channel_layout"] - # calculate duration of one frame in seconds - if input_frame_rate: - # it is halved to make sure audio will be shorter then video - one_frame_duration = str( - float(1.0 / eval(input_frame_rate)) / 2 - ) - else: - # same sane default (1 frame @ 25 fps) - one_frame_duration = 0.04 - self.log.debug( - "One frame duration is {} sec".format(one_frame_duration)) - # confirm we gathered all needed audio parameters - if audio_channel_layout: - if audio_sample_rate: - if audio_channels: - input_audio = True - self.log.debug("__Audio : channels {}".format( - audio_channels)) - self.log.debug("__Audio : sample_rate {}".format( - audio_sample_rate)) - self.log.debug("__Audio : channel_layout {}".format( - audio_channel_layout)) - + input_frame_rate = str(stream.get("r_frame_rate")) + if ( + input_timecode + and input_width + and input_height + and input_frame_rate + ): + break # Raise exception of any stream didn't define input resolution if input_width is None: raise AssertionError(( "FFprobe couldn't read resolution from input file: \"{}\"" ).format(input_path)) + # Get audio metadata + for stream in streams: + audio_channels = None + audio_sample_rate = None + audio_channel_layout = None + input_audio = False + if stream["codec_type"] == "audio": + self.log.debug("__Ffprobe Audio: {}".format(stream)) + if stream["channels"]: + audio_channels = str(stream.get("channels")) + if stream["sample_rate"]: + audio_sample_rate = str(stream.get("sample_rate")) + if stream["channel_layout"]: + audio_channel_layout = str(stream.get("channel_layout")) + if ( + audio_channels + and audio_sample_rate + and audio_channel_layout + ): + input_audio = True + break + # Get duration of one frame in micro seconds + one_frame_duration = "40000us" + if input_frame_rate: + items = input_frame_rate.split("/") + if len(items) == 1: + one_frame_duration = float(1.0) / float(items[0]) + elif len(items) == 2: + one_frame_duration = float(items[1]) / float(items[0]) + one_frame_duration *= 1000000 + one_frame_duration = str(int(one_frame_duration))+"us" + self.log.debug( + "One frame duration is {}".format(one_frame_duration)) # values are set in ExtractReview if use_legacy_code: @@ -192,7 +191,16 @@ class ExtractReviewSlate(openpype.api.Extractor): input_args = [] output_args = [] - # if input has audio, add silent audio to the slate + # preset's input data + if use_legacy_code: + input_args.extend(repre["_profile"].get('input', [])) + else: + input_args.extend(repre["outputDef"].get('input', [])) + + input_args.append("-loop 1 -i {}".format( + openpype.lib.path_to_subprocess_arg(slate_path) + )) + # if input has an audio, add silent audio to the slate if input_audio: input_args.extend( ["-f lavfi -i anullsrc=r={}:cl={}:d={}".format( @@ -201,16 +209,9 @@ class ExtractReviewSlate(openpype.api.Extractor): one_frame_duration )] ) - # preset's input data - if use_legacy_code: - input_args.extend(repre["_profile"].get('input', [])) - else: - input_args.extend(repre["outputDef"].get('input', [])) - # enforce framerate before -i - input_args.append("-framerate {} -i {}".format( - input_frame_rate, - path_to_subprocess_arg(slate_path) - )) + + input_args.extend(["-r {}".format(input_frame_rate)]) + input_args.extend(["-frames:v 1"]) # add timecode from source to the slate, substract one frame if input_timecode: offset_timecode = self._tc_offset( @@ -218,9 +219,14 @@ class ExtractReviewSlate(openpype.api.Extractor): framerate=fps, frame_offset=-1 ) - self.log.debug("Timecode: `{}`".format(offset_timecode)) - input_args.extend(["-timecode {}".format(offset_timecode)]) - + self.log.debug("Slate Timecode: `{}`".format( + offset_timecode + )) + if offset_timecode: + input_args.extend(["-timecode {}".format(offset_timecode)]) + else: + # fall back to input timecode if offset fails + input_args.extend(["-timecode {}".format(input_timecode)]) if use_legacy_code: codec_args = repre["_profile"].get('codec', []) output_args.extend(codec_args) @@ -283,10 +289,6 @@ class ExtractReviewSlate(openpype.api.Extractor): # add it to output_args output_args.insert(0, vf_back) - # use video duration for silent audio duration - if input_audio: - output_args.append("-shortest") - # overrides output file output_args.append("-y") @@ -334,17 +336,23 @@ class ExtractReviewSlate(openpype.api.Extractor): "-f", "concat", "-safe", "0", "-i", conc_text_path, - "-c", "copy", + "-c:v", "copy", output_path ] - - # ffmpeg concat subprocess - self.log.debug( - "Executing concat: {}".format(" ".join(concat_args)) - ) - openpype.api.run_subprocess( - concat_args, logger=self.log - ) + if not input_audio: + # ffmpeg concat subprocess + self.log.debug( + "Executing concat: {}".format(" ".join(concat_args)) + ) + openpype.api.run_subprocess( + concat_args, logger=self.log + ) + else: + self.log.warning("Audio found. Creating slate with audio" + " is not supported at this time. Outputing slate-less" + ":\n{}".format(input_file)) + # skip concatenating slate, use slate-less file instead + shutil.copyfile(input_path, output_path) self.log.debug("__ repre[tags]: {}".format(repre["tags"])) repre_update = { From 027081700e47ef4c5eddee20cb30d885b9a3c8d7 Mon Sep 17 00:00:00 2001 From: jrsndlr Date: Mon, 28 Mar 2022 19:14:46 +0200 Subject: [PATCH 040/583] hound --- openpype/plugins/publish/extract_review_slate.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/plugins/publish/extract_review_slate.py b/openpype/plugins/publish/extract_review_slate.py index 2854108022..d2a7b9a12a 100644 --- a/openpype/plugins/publish/extract_review_slate.py +++ b/openpype/plugins/publish/extract_review_slate.py @@ -120,7 +120,8 @@ class ExtractReviewSlate(openpype.api.Extractor): if stream["sample_rate"]: audio_sample_rate = str(stream.get("sample_rate")) if stream["channel_layout"]: - audio_channel_layout = str(stream.get("channel_layout")) + audio_channel_layout = str( + stream.get("channel_layout")) if ( audio_channels and audio_sample_rate From 90e29c6f521bd78d84444c357171b6f7b499a8bf Mon Sep 17 00:00:00 2001 From: jrsndlr Date: Mon, 28 Mar 2022 19:35:25 +0200 Subject: [PATCH 041/583] hound2 --- openpype/plugins/publish/extract_review_slate.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/openpype/plugins/publish/extract_review_slate.py b/openpype/plugins/publish/extract_review_slate.py index d2a7b9a12a..037535342d 100644 --- a/openpype/plugins/publish/extract_review_slate.py +++ b/openpype/plugins/publish/extract_review_slate.py @@ -138,7 +138,7 @@ class ExtractReviewSlate(openpype.api.Extractor): elif len(items) == 2: one_frame_duration = float(items[1]) / float(items[0]) one_frame_duration *= 1000000 - one_frame_duration = str(int(one_frame_duration))+"us" + one_frame_duration = str(int(one_frame_duration)) + "us" self.log.debug( "One frame duration is {}".format(one_frame_duration)) @@ -199,8 +199,7 @@ class ExtractReviewSlate(openpype.api.Extractor): input_args.extend(repre["outputDef"].get('input', [])) input_args.append("-loop 1 -i {}".format( - openpype.lib.path_to_subprocess_arg(slate_path) - )) + openpype.lib.path_to_subprocess_arg(slate_path))) # if input has an audio, add silent audio to the slate if input_audio: input_args.extend( @@ -350,10 +349,10 @@ class ExtractReviewSlate(openpype.api.Extractor): ) else: self.log.warning("Audio found. Creating slate with audio" - " is not supported at this time. Outputing slate-less" - ":\n{}".format(input_file)) + " is not supported at this time. Outputing slate-less" + ":\n{}".format(input_file)) # skip concatenating slate, use slate-less file instead - shutil.copyfile(input_path, output_path) + shutil.copyfile(input_path, output_path) self.log.debug("__ repre[tags]: {}".format(repre["tags"])) repre_update = { From 13f6b03637e7d696ad45418856c6576883c9892f Mon Sep 17 00:00:00 2001 From: jrsndlr Date: Mon, 28 Mar 2022 20:38:47 +0200 Subject: [PATCH 042/583] hound3 --- openpype/plugins/publish/extract_review_slate.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/plugins/publish/extract_review_slate.py b/openpype/plugins/publish/extract_review_slate.py index 037535342d..c8ee2ec7ed 100644 --- a/openpype/plugins/publish/extract_review_slate.py +++ b/openpype/plugins/publish/extract_review_slate.py @@ -348,7 +348,8 @@ class ExtractReviewSlate(openpype.api.Extractor): concat_args, logger=self.log ) else: - self.log.warning("Audio found. Creating slate with audio" + self.log.warning( + "Audio found. Creating slate with audio" " is not supported at this time. Outputing slate-less" ":\n{}".format(input_file)) # skip concatenating slate, use slate-less file instead From 0db0fa0de9fe085f48f62d2b1ffe766edf95e897 Mon Sep 17 00:00:00 2001 From: Jiri Sindelar <45896205+jrsndl@users.noreply.github.com> Date: Tue, 29 Mar 2022 09:27:33 +0200 Subject: [PATCH 043/583] stray empty file? --- igniter/3} | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 igniter/3} diff --git a/igniter/3} b/igniter/3} deleted file mode 100644 index e69de29bb2..0000000000 From bd61eb99d4b88d640785ea77c10b4a1a5657b279 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Fri, 1 Apr 2022 17:56:28 +0200 Subject: [PATCH 044/583] fix support for renderman in maya --- openpype/hosts/maya/api/lib_renderproducts.py | 8 ++--- .../maya/plugins/create/create_render.py | 8 +++-- .../publish/validate_rendersettings.py | 3 +- .../plugins/publish/submit_maya_deadline.py | 18 +++++++++++ .../defaults/system_settings/tools.json | 31 ++++++++++++++++++- 5 files changed, 60 insertions(+), 8 deletions(-) diff --git a/openpype/hosts/maya/api/lib_renderproducts.py b/openpype/hosts/maya/api/lib_renderproducts.py index 0c34998874..8b282094db 100644 --- a/openpype/hosts/maya/api/lib_renderproducts.py +++ b/openpype/hosts/maya/api/lib_renderproducts.py @@ -1069,7 +1069,7 @@ class RenderProductsRenderman(ARenderProducts): default_ext = "exr" displays = cmds.listConnections("rmanGlobals.displays") for aov in displays: - enabled = self._get_attr(aov, "enabled") + enabled = self._get_attr(aov, "enable") if not enabled: continue @@ -1085,7 +1085,7 @@ class RenderProductsRenderman(ARenderProducts): return products - def get_files(self, product, camera): + def get_files(self, product): """Get expected files. In renderman we hack it with prepending path. This path would @@ -1094,13 +1094,13 @@ class RenderProductsRenderman(ARenderProducts): to mess around with this settings anyway and it is enforced in render settings validator. """ - files = super(RenderProductsRenderman, self).get_files(product, camera) + files = super(RenderProductsRenderman, self).get_files(product) layer_data = self.layer_data new_files = [] for file in files: new_file = "{}/{}/{}".format( - layer_data["sceneName"], layer_data["layerName"], file + layer_data.sceneName, layer_data.layerName, file ) new_files.append(new_file) diff --git a/openpype/hosts/maya/plugins/create/create_render.py b/openpype/hosts/maya/plugins/create/create_render.py index 9002ae3876..4d3e6dc9f5 100644 --- a/openpype/hosts/maya/plugins/create/create_render.py +++ b/openpype/hosts/maya/plugins/create/create_render.py @@ -76,7 +76,7 @@ class CreateRender(plugin.Creator): 'mentalray': 'defaultRenderGlobals.imageFilePrefix', 'vray': 'vraySettings.fileNamePrefix', 'arnold': 'defaultRenderGlobals.imageFilePrefix', - 'renderman': 'defaultRenderGlobals.imageFilePrefix', + 'renderman': 'rmanGlobals.imageFileFormat', 'redshift': 'defaultRenderGlobals.imageFilePrefix' } @@ -84,7 +84,7 @@ class CreateRender(plugin.Creator): 'mentalray': 'maya///{aov_separator}', # noqa 'vray': 'maya///', 'arnold': 'maya///{aov_separator}', # noqa - 'renderman': 'maya///{aov_separator}', + 'renderman': '_..', # this needs `imageOutputDir` set separately 'redshift': 'maya///' # noqa } @@ -463,6 +463,10 @@ class CreateRender(plugin.Creator): self._set_global_output_settings() + if renderer == "renderman": + cmds.setAttr("rmanGlobals.imageOutputDir", + "/maya//", type="string") + def _set_vray_settings(self, asset): # type: (dict) -> None """Sets important settings for Vray.""" diff --git a/openpype/hosts/maya/plugins/publish/validate_rendersettings.py b/openpype/hosts/maya/plugins/publish/validate_rendersettings.py index e24e88cab7..966ebac95a 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rendersettings.py +++ b/openpype/hosts/maya/plugins/publish/validate_rendersettings.py @@ -121,7 +121,8 @@ class ValidateRenderSettings(pyblish.api.InstancePlugin): cls.log.error("Animation needs to be enabled. Use the same " "frame for start and end to render single frame") - if not prefix.lower().startswith("maya/"): + if not prefix.lower().startswith("maya/") and \ + renderer != "renderman": invalid = True cls.log.error("Wrong image prefix [ {} ] - " "doesn't start with: 'maya/'".format(prefix)) diff --git a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py index 15a6f8d828..498397b81b 100644 --- a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py @@ -215,6 +215,24 @@ def get_renderer_variables(renderlayer, root): filename_0 = os.path.normpath(os.path.join(root, filename_0)) elif renderer == "renderman": prefix_attr = "rmanGlobals.imageFileFormat" + # NOTE: This is guessing extensions from renderman display types. + # Some of them are just framebuffers, d_texture format can be + # set in display setting. We set those now to None, but it + # should be handled more gracefully. + display_types = { + "d_deepexr": "exr", + "d_it": None, + "d_null": None, + "d_openexr": "exr", + "d_png": "png", + "d_pointcloud": "ptc", + "d_targa": "tga", + "d_texture": None, + "d_tiff": "tif" + } + extension = display_types.get( + cmds.listConnections("rmanDefaultDisplay.displayType")[0] + ) elif renderer == "redshift": # mapping redshift extension dropdown values to strings ext_mapping = ["iff", "exr", "tif", "png", "tga", "jpg"] diff --git a/openpype/settings/defaults/system_settings/tools.json b/openpype/settings/defaults/system_settings/tools.json index 9e08465195..49c00bec7d 100644 --- a/openpype/settings/defaults/system_settings/tools.json +++ b/openpype/settings/defaults/system_settings/tools.json @@ -52,10 +52,39 @@ "environment": {}, "variants": {} }, + "renderman": { + "environment": {}, + "variants": { + "24-3-maya": { + "host_names": [ + "maya" + ], + "app_variants": [ + "maya/2022" + ], + "environment": { + "RFMTREE": { + "windows": "C:\\Program Files\\Pixar\\RenderManForMaya-24.3", + "darwin": "/Applications/Pixar/RenderManForMaya-24.3", + "linux": "/opt/pixar/RenderManForMaya-24.3" + }, + "RMANTREE": { + "windows": "C:\\Program Files\\Pixar\\RenderManProServer-24.3", + "darwin": "/Applications/Pixar/RenderManProServer-24.3", + "linux": "/opt/pixar/RenderManProServer-24.3" + } + } + }, + "__dynamic_keys_labels__": { + "24-3-maya": "24.3 RFM" + } + } + }, "__dynamic_keys_labels__": { "mtoa": "Autodesk Arnold", "vray": "Chaos Group Vray", - "yeti": "Pergrine Labs Yeti" + "yeti": "Pergrine Labs Yeti", + "renderman": "Pixar Renderman" } } } \ No newline at end of file From 7df6c29b4e08f78ad25ac57a65427540c54b5106 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= <33513211+antirotor@users.noreply.github.com> Date: Mon, 11 Apr 2022 11:28:24 +0200 Subject: [PATCH 045/583] fixing unrelated typo Co-authored-by: Roy Nieterau --- openpype/settings/defaults/system_settings/tools.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/settings/defaults/system_settings/tools.json b/openpype/settings/defaults/system_settings/tools.json index 49c00bec7d..243cde40cc 100644 --- a/openpype/settings/defaults/system_settings/tools.json +++ b/openpype/settings/defaults/system_settings/tools.json @@ -83,7 +83,7 @@ "__dynamic_keys_labels__": { "mtoa": "Autodesk Arnold", "vray": "Chaos Group Vray", - "yeti": "Pergrine Labs Yeti", + "yeti": "Peregrine Labs Yeti", "renderman": "Pixar Renderman" } } From b90f54943b527fd98b808a3b4ca8be405a2ff367 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 12 Apr 2022 13:11:43 +0200 Subject: [PATCH 046/583] =?UTF-8?q?fixes=20=F0=9F=90=A9=20and=20optimize?= =?UTF-8?q?=20renderman=20prefix=20condition?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- openpype/hosts/maya/plugins/create/create_render.py | 4 +++- .../maya/plugins/publish/validate_rendersettings.py | 13 ++++++++++--- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/maya/plugins/create/create_render.py b/openpype/hosts/maya/plugins/create/create_render.py index 4d3e6dc9f5..13bfe1bf37 100644 --- a/openpype/hosts/maya/plugins/create/create_render.py +++ b/openpype/hosts/maya/plugins/create/create_render.py @@ -84,7 +84,9 @@ class CreateRender(plugin.Creator): 'mentalray': 'maya///{aov_separator}', # noqa 'vray': 'maya///', 'arnold': 'maya///{aov_separator}', # noqa - 'renderman': '_..', # this needs `imageOutputDir` set separately + # this needs `imageOutputDir` + # (/renders/maya/) set separately + 'renderman': '_..', 'redshift': 'maya///' # noqa } diff --git a/openpype/hosts/maya/plugins/publish/validate_rendersettings.py b/openpype/hosts/maya/plugins/publish/validate_rendersettings.py index 966ebac95a..92aa3af05a 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rendersettings.py +++ b/openpype/hosts/maya/plugins/publish/validate_rendersettings.py @@ -116,16 +116,23 @@ class ValidateRenderSettings(pyblish.api.InstancePlugin): prefix = prefix.replace( "{aov_separator}", instance.data.get("aovSeparator", "_")) + + required_prefix = "maya/" + + if renderer == "renderman": + # renderman has prefix set differently + required_prefix = "/renders/{}".format(required_prefix) + if not anim_override: invalid = True cls.log.error("Animation needs to be enabled. Use the same " "frame for start and end to render single frame") - if not prefix.lower().startswith("maya/") and \ - renderer != "renderman": + if not prefix.lower().startswith(required_prefix): invalid = True cls.log.error("Wrong image prefix [ {} ] - " - "doesn't start with: 'maya/'".format(prefix)) + "doesn't start with: '{}'".format( + prefix, required_prefix)) if not re.search(cls.R_LAYER_TOKEN, prefix): invalid = True From b895efac5ba8d9430a54b282aebd8552c3171114 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 12 Apr 2022 13:19:09 +0200 Subject: [PATCH 047/583] fix ident --- .../hosts/maya/plugins/publish/validate_rendersettings.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_rendersettings.py b/openpype/hosts/maya/plugins/publish/validate_rendersettings.py index 92aa3af05a..28fe2d317c 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rendersettings.py +++ b/openpype/hosts/maya/plugins/publish/validate_rendersettings.py @@ -130,9 +130,10 @@ class ValidateRenderSettings(pyblish.api.InstancePlugin): if not prefix.lower().startswith(required_prefix): invalid = True - cls.log.error("Wrong image prefix [ {} ] - " - "doesn't start with: '{}'".format( - prefix, required_prefix)) + cls.log.error( + "Wrong image prefix [ {} ] - doesn't start with: '{}'".format( + prefix, required_prefix) + ) if not re.search(cls.R_LAYER_TOKEN, prefix): invalid = True From 1c153ebb6089664e9c841f0cafb70cba1192149b Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 13 Apr 2022 21:33:02 +0200 Subject: [PATCH 048/583] flame: remove clip in reels dependency --- .../publish/collect_timeline_instances.py | 13 ---------- .../plugins/publish/validate_source_clip.py | 26 ------------------- 2 files changed, 39 deletions(-) delete mode 100644 openpype/hosts/flame/plugins/publish/validate_source_clip.py diff --git a/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py b/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py index 95c2002bd9..bc849a4742 100644 --- a/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py +++ b/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py @@ -31,7 +31,6 @@ class CollectTimelineInstances(pyblish.api.ContextPlugin): self.log.debug("__ selected_segments: {}".format(selected_segments)) self.otio_timeline = context.data["otioTimeline"] - self.clips_in_reels = opfapi.get_clips_in_reels(project) self.fps = context.data["fps"] # process all sellected @@ -63,9 +62,6 @@ class CollectTimelineInstances(pyblish.api.ContextPlugin): # get file path file_path = clip_data["fpath"] - # get source clip - source_clip = self._get_reel_clip(file_path) - first_frame = opfapi.get_frame_from_filename(file_path) or 0 head, tail = self._get_head_tail(clip_data, first_frame) @@ -103,7 +99,6 @@ class CollectTimelineInstances(pyblish.api.ContextPlugin): "families": families, "publish": marker_data["publish"], "fps": self.fps, - "flameSourceClip": source_clip, "sourceFirstFrame": int(first_frame), "path": file_path, "flameAddTasks": self.add_tasks, @@ -258,14 +253,6 @@ class CollectTimelineInstances(pyblish.api.ContextPlugin): ) return head, tail - def _get_reel_clip(self, path): - match_reel_clip = [ - clip for clip in self.clips_in_reels - if clip["fpath"] == path - ] - if match_reel_clip: - return match_reel_clip.pop() - def _get_resolution_to_data(self, data, context): assert data.get("otioClip"), "Missing `otioClip` data" diff --git a/openpype/hosts/flame/plugins/publish/validate_source_clip.py b/openpype/hosts/flame/plugins/publish/validate_source_clip.py deleted file mode 100644 index 345c00e05a..0000000000 --- a/openpype/hosts/flame/plugins/publish/validate_source_clip.py +++ /dev/null @@ -1,26 +0,0 @@ -import pyblish - - -@pyblish.api.log -class ValidateSourceClip(pyblish.api.InstancePlugin): - """Validate instance is not having empty `flameSourceClip`""" - - order = pyblish.api.ValidatorOrder - label = "Validate Source Clip" - hosts = ["flame"] - families = ["clip"] - optional = True - active = False - - def process(self, instance): - flame_source_clip = instance.data["flameSourceClip"] - - self.log.debug("_ flame_source_clip: {}".format(flame_source_clip)) - - if flame_source_clip is None: - raise AttributeError(( - "Timeline segment `{}` is not having " - "relative clip in reels. Please make sure " - "you push `Save Sources` button in Conform Tab").format( - instance.data["asset"] - )) From f245ca5073a68fcdae21045b45db6ad390c751ca Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 13 Apr 2022 21:34:23 +0200 Subject: [PATCH 049/583] flame: refectory of extractor settings --- .../defaults/project_settings/flame.json | 16 +- .../projects_schema/schema_project_flame.json | 147 ++++++++++++------ 2 files changed, 106 insertions(+), 57 deletions(-) diff --git a/openpype/settings/defaults/project_settings/flame.json b/openpype/settings/defaults/project_settings/flame.json index ef7a2a4467..028fda2e66 100644 --- a/openpype/settings/defaults/project_settings/flame.json +++ b/openpype/settings/defaults/project_settings/flame.json @@ -55,16 +55,18 @@ "keep_original_representation": false, "export_presets_mapping": { "exr16fpdwaa": { + "active": true, + "export_type": "File Sequence", "ext": "exr", "xml_preset_file": "OpenEXR (16-bit fp DWAA).xml", - "xml_preset_dir": "", - "export_type": "File Sequence", - "ignore_comment_attrs": false, "colorspace_out": "ACES - ACEScg", + "xml_preset_dir": "", + "parsed_comment_attrs": true, "representation_add_range": true, "representation_tags": [], "load_to_batch_group": true, - "batch_group_loader_name": "LoadClip" + "batch_group_loader_name": "LoadClipBatch", + "filter_path_regex": ".*" } } } @@ -87,7 +89,8 @@ "png", "h264", "mov", - "mp4" + "mp4", + "exr16fpdwaa" ], "reel_group_name": "OpenPype_Reels", "reel_name": "Loaded", @@ -110,7 +113,8 @@ "png", "h264", "mov", - "mp4" + "mp4", + "exr16fpdwaa" ], "reel_name": "OP_LoadedReel", "clip_name_template": "{asset}_{subset}_{output}" diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json b/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json index fe11d63ac2..fcbbddbe29 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json @@ -238,25 +238,19 @@ "type": "dict", "children": [ { - "key": "ext", - "label": "Output extension", - "type": "text" + "type": "boolean", + "key": "active", + "label": "Is active", + "default": true }, { - "key": "xml_preset_file", - "label": "XML preset file (with ext)", - "type": "text" - }, - { - "key": "xml_preset_dir", - "label": "XML preset folder (optional)", - "type": "text" + "type": "separator" }, { "key": "export_type", "label": "Eport clip type", "type": "enum", - "default": "File Sequence", + "default": "Sequence Publish", "enum_items": [ { "Movie": "Movie" @@ -268,54 +262,105 @@ "Sequence Publish": "Sequence Publish" } ] - }, { - "type": "separator" + "key": "ext", + "label": "Output extension", + "type": "text", + "default": "exr" }, { - "type": "boolean", - "key": "ignore_comment_attrs", - "label": "Ignore attributes parsed from a segment comments" - }, - { - "type": "separator" + "key": "xml_preset_file", + "label": "XML preset file (with ext)", + "type": "text" }, { "key": "colorspace_out", "label": "Output color (imageio)", - "type": "text" - }, - { - "type": "separator" - }, - { - "type": "boolean", - "key": "representation_add_range", - "label": "Add frame range to representation" - }, - { - "type": "list", - "key": "representation_tags", - "label": "Add representation tags", - "object_type": { - "type": "text", - "multiline": false - } - }, - { - "type": "separator" - }, - { - "type": "boolean", - "key": "load_to_batch_group", - "label": "Load to batch group reel", - "default": false - }, - { "type": "text", - "key": "batch_group_loader_name", - "label": "Use loader name" + "default": "linear" + }, + { + "type": "collapsible-wrap", + "label": "Other parameters", + "collapsible": true, + "collapsed": true, + "children": [ + { + "key": "xml_preset_dir", + "label": "XML preset folder (optional)", + "type": "text" + }, + { + "type": "separator" + }, + { + "type": "boolean", + "key": "parsed_comment_attrs", + "label": "Include parsed attributes from comments", + "default": false + + }, + { + "type": "separator" + }, + { + "type": "collapsible-wrap", + "label": "Representation", + "collapsible": true, + "collapsed": true, + "children": [ + { + "type": "boolean", + "key": "representation_add_range", + "label": "Add frame range to representation" + }, + { + "type": "list", + "key": "representation_tags", + "label": "Add representation tags", + "object_type": { + "type": "text", + "multiline": false + } + } + ] + }, + { + "type": "collapsible-wrap", + "label": "Loading during publish", + "collapsible": true, + "collapsed": true, + "children": [ + { + "type": "boolean", + "key": "load_to_batch_group", + "label": "Load to batch group reel", + "default": false + }, + { + "type": "text", + "key": "batch_group_loader_name", + "label": "Use loader name" + } + ] + } + + ] + }, + { + "type": "collapsible-wrap", + "label": "Filtering", + "collapsible": true, + "collapsed": true, + "children": [ + { + "key": "filter_path_regex", + "label": "Regex in clip path", + "type": "text", + "default": ".*" + } + ] } ] } From 9174e437a6d4f5cff2df2d0de9cd19ece954ec89 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 13 Apr 2022 21:38:47 +0200 Subject: [PATCH 050/583] flame: add new settings with filter - removing reel clip dependency --- .../publish/extract_subset_resources.py | 278 ++++++++++-------- 1 file changed, 154 insertions(+), 124 deletions(-) diff --git a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py index a780f8c9e5..f1eca9a67d 100644 --- a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py +++ b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py @@ -1,4 +1,5 @@ import os +import re from pprint import pformat from copy import deepcopy @@ -6,6 +7,8 @@ import pyblish.api import openpype.api from openpype.hosts.flame import api as opfapi +import flame + class ExtractSubsetResources(openpype.api.Extractor): """ @@ -20,27 +23,31 @@ class ExtractSubsetResources(openpype.api.Extractor): # plugin defaults default_presets = { "thumbnail": { + "active": True, "ext": "jpg", "xml_preset_file": "Jpeg (8-bit).xml", "xml_preset_dir": "", "export_type": "File Sequence", - "ignore_comment_attrs": True, + "parsed_comment_attrs": False, "colorspace_out": "Output - sRGB", "representation_add_range": False, - "representation_tags": ["thumbnail"] + "representation_tags": ["thumbnail"], + "path_regex": ".*" }, "ftrackpreview": { + "active": True, "ext": "mov", "xml_preset_file": "Apple iPad (1920x1080).xml", "xml_preset_dir": "", "export_type": "Movie", - "ignore_comment_attrs": True, + "parsed_comment_attrs": False, "colorspace_out": "Output - Rec.709", "representation_add_range": True, "representation_tags": [ "review", "delete" - ] + ], + "path_regex": ".*" } } keep_original_representation = False @@ -62,12 +69,8 @@ class ExtractSubsetResources(openpype.api.Extractor): # flame objects segment = instance.data["item"] segment_name = segment.name.get_value() + clip_path = instance.data["path"] sequence_clip = instance.context.data["flameSequence"] - clip_data = instance.data["flameSourceClip"] - - reel_clip = None - if clip_data: - reel_clip = clip_data["PyClip"] # segment's parent track name s_track_name = segment.parent.name.get_value() @@ -104,14 +107,41 @@ class ExtractSubsetResources(openpype.api.Extractor): for unique_name, preset_config in export_presets.items(): modify_xml_data = {} + # get activating attributes + activated_preset = preset_config["active"] + filter_path_regex = preset_config["filter_path_regex"] + + self.log.info( + "Preset `{}` is active `{}` with filter `{}`".format( + unique_name, activated_preset, filter_path_regex + ) + ) + self.log.debug( + "__ clip_path: `{}`".format(clip_path)) + + # skip if not activated presete + if not activated_preset: + continue + + # exclude by regex filter + if not re.search(filter_path_regex, clip_path): + continue + # get all presets attributes + extension = preset_config["ext"] preset_file = preset_config["xml_preset_file"] preset_dir = preset_config["xml_preset_dir"] export_type = preset_config["export_type"] repre_tags = preset_config["representation_tags"] - ignore_comment_attrs = preset_config["ignore_comment_attrs"] + parsed_comment_attrs = preset_config["parsed_comment_attrs"] color_out = preset_config["colorspace_out"] + self.log.info( + "Processing `{}` as `{}` to `{}` type...".format( + preset_file, export_type, extension + ) + ) + # get attribures related loading in integrate_batch_group load_to_batch_group = preset_config.get( "load_to_batch_group") @@ -131,24 +161,14 @@ class ExtractSubsetResources(openpype.api.Extractor): in_mark = (source_start_handles - source_first_frame) + 1 out_mark = in_mark + source_duration_handles - # make test for type of preset and available reel_clip - if ( - not reel_clip - and export_type != "Sequence Publish" - ): - self.log.warning(( - "Skipping preset {}. Not available " - "reel clip for {}").format( - preset_file, segment_name - )) - continue - - # by default export source clips - exporting_clip = reel_clip - + exporting_clip = None if export_type == "Sequence Publish": # change export clip to sequence - exporting_clip = sequence_clip + exporting_clip = flame.duplicate(sequence_clip) + + # only keep visible layer where instance segment is child + self.hide_others( + exporting_clip, segment_name, s_track_name) # change in/out marks to timeline in/out in_mark = clip_in @@ -161,131 +181,126 @@ class ExtractSubsetResources(openpype.api.Extractor): "startFrame": frame_start }) - if not ignore_comment_attrs: + if parsed_comment_attrs: # add any xml overrides collected form segment.comment modify_xml_data.update(instance.data["xml_overrides"]) self.log.debug("__ modify_xml_data: {}".format(pformat( modify_xml_data ))) + else: + exporting_clip = self.import_clip(clip_path) - # with maintained duplication loop all presets - with opfapi.maintained_object_duplication( - exporting_clip) as duplclip: - kwargs = {} + export_kwargs = {} + # validate xml preset file is filled + if preset_file == "": + raise ValueError( + ("Check Settings for {} preset: " + "`XML preset file` is not filled").format( + unique_name) + ) - if export_type == "Sequence Publish": - # only keep visible layer where instance segment is child - self.hide_others(duplclip, segment_name, s_track_name) + # resolve xml preset dir if not filled + if preset_dir == "": + preset_dir = opfapi.get_preset_path_by_xml_name( + preset_file) - # validate xml preset file is filled - if preset_file == "": + if not preset_dir: raise ValueError( ("Check Settings for {} preset: " - "`XML preset file` is not filled").format( - unique_name) + "`XML preset file` {} is not found").format( + unique_name, preset_file) ) - # resolve xml preset dir if not filled - if preset_dir == "": - preset_dir = opfapi.get_preset_path_by_xml_name( - preset_file) + # create preset path + preset_orig_xml_path = str(os.path.join( + preset_dir, preset_file + )) - if not preset_dir: - raise ValueError( - ("Check Settings for {} preset: " - "`XML preset file` {} is not found").format( - unique_name, preset_file) - ) + preset_path = opfapi.modify_preset_file( + preset_orig_xml_path, staging_dir, modify_xml_data) - # create preset path - preset_orig_xml_path = str(os.path.join( - preset_dir, preset_file - )) + # define kwargs based on preset type + if "thumbnail" in unique_name: + export_kwargs["thumb_frame_number"] = in_mark + ( + source_duration_handles / 2) + else: + export_kwargs.update({ + "in_mark": in_mark, + "out_mark": out_mark + }) - preset_path = opfapi.modify_preset_file( - preset_orig_xml_path, staging_dir, modify_xml_data) + # get and make export dir paths + export_dir_path = str(os.path.join( + staging_dir, unique_name + )) + os.makedirs(export_dir_path) - # define kwargs based on preset type - if "thumbnail" in unique_name: - kwargs["thumb_frame_number"] = in_mark + ( - source_duration_handles / 2) - else: - kwargs.update({ - "in_mark": in_mark, - "out_mark": out_mark - }) + # export + opfapi.export_clip( + export_dir_path, exporting_clip, preset_path, **export_kwargs) - # get and make export dir paths - export_dir_path = str(os.path.join( - staging_dir, unique_name - )) - os.makedirs(export_dir_path) + # create representation data + representation_data = { + "name": unique_name, + "outputName": unique_name, + "ext": extension, + "stagingDir": export_dir_path, + "tags": repre_tags, + "data": { + "colorspace": color_out + }, + "load_to_batch_group": load_to_batch_group, + "batch_group_loader_name": batch_group_loader_name + } - # export - opfapi.export_clip( - export_dir_path, duplclip, preset_path, **kwargs) + # collect all available content of export dir + files = os.listdir(export_dir_path) - extension = preset_config["ext"] + # make sure no nested folders inside + n_stage_dir, n_files = self._unfolds_nested_folders( + export_dir_path, files, extension) - # create representation data - representation_data = { - "name": unique_name, - "outputName": unique_name, - "ext": extension, - "stagingDir": export_dir_path, - "tags": repre_tags, - "data": { - "colorspace": color_out - }, - "load_to_batch_group": load_to_batch_group, - "batch_group_loader_name": batch_group_loader_name - } + # fix representation in case of nested folders + if n_stage_dir: + representation_data["stagingDir"] = n_stage_dir + files = n_files - # collect all available content of export dir - files = os.listdir(export_dir_path) + # add files to represetation but add + # imagesequence as list + if ( + # first check if path in files is not mov extension + [ + f for f in files + if os.path.splitext(f)[-1] == ".mov" + ] + # then try if thumbnail is not in unique name + or unique_name == "thumbnail" + ): + representation_data["files"] = files.pop() + else: + representation_data["files"] = files - # make sure no nested folders inside - n_stage_dir, n_files = self._unfolds_nested_folders( - export_dir_path, files, extension) + # add frame range + if preset_config["representation_add_range"]: + representation_data.update({ + "frameStart": frame_start_handle, + "frameEnd": ( + frame_start_handle + source_duration_handles), + "fps": instance.data["fps"] + }) - # fix representation in case of nested folders - if n_stage_dir: - representation_data["stagingDir"] = n_stage_dir - files = n_files + instance.data["representations"].append(representation_data) - # add files to represetation but add - # imagesequence as list - if ( - # first check if path in files is not mov extension - [ - f for f in files - if os.path.splitext(f)[-1] == ".mov" - ] - # then try if thumbnail is not in unique name - or unique_name == "thumbnail" - ): - representation_data["files"] = files.pop() - else: - representation_data["files"] = files + # add review family if found in tags + if "review" in repre_tags: + instance.data["families"].append("review") - # add frame range - if preset_config["representation_add_range"]: - representation_data.update({ - "frameStart": frame_start_handle, - "frameEnd": ( - frame_start_handle + source_duration_handles), - "fps": instance.data["fps"] - }) + self.log.info("Added representation: {}".format( + representation_data)) - instance.data["representations"].append(representation_data) - - # add review family if found in tags - if "review" in repre_tags: - instance.data["families"].append("review") - - self.log.info("Added representation: {}".format( - representation_data)) + # at the end remove the duplicated clip + flame.delete(exporting_clip) self.log.debug("All representations: {}".format( pformat(instance.data["representations"]))) @@ -373,3 +388,18 @@ class ExtractSubsetResources(openpype.api.Extractor): for segment in track.segments: if segment.name.get_value() != segment_name: segment.hidden = True + + def import_clip(self, path): + """ + Import clip from path + """ + clips = flame.import_clips(path) + self.log.info("Clips [{}] imported from `{}`".format(clips, path)) + if not clips: + self.log.warning("Path `{}` is not having any clips".format(path)) + return None + elif len(clips) > 1: + self.log.warning( + "Path `{}` is containing more that one clip".format(path) + ) + return clips.pop() From b086680289c2a897164d9db5f868c1c5d78690e6 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 14 Apr 2022 08:55:30 +0200 Subject: [PATCH 051/583] flame: improving work with presets --- .../publish/extract_subset_resources.py | 31 ++++++++++--------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py index f1eca9a67d..ba4a8c41ad 100644 --- a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py +++ b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py @@ -169,27 +169,30 @@ class ExtractSubsetResources(openpype.api.Extractor): # only keep visible layer where instance segment is child self.hide_others( exporting_clip, segment_name, s_track_name) + else: + exporting_clip = self.import_clip(clip_path) - # change in/out marks to timeline in/out - in_mark = clip_in - out_mark = clip_out + # change in/out marks to timeline in/out + in_mark = clip_in + out_mark = clip_out - # add xml tags modifications - modify_xml_data.update({ - "exportHandles": True, - "nbHandles": handles, - "startFrame": frame_start - }) + # add xml tags modifications + modify_xml_data.update({ + "exportHandles": True, + "nbHandles": handles, + "startFrame": frame_start, + "namePattern": ( + "<segment name>_<shot name>_{}.").format( + unique_name) + }) - if parsed_comment_attrs: - # add any xml overrides collected form segment.comment - modify_xml_data.update(instance.data["xml_overrides"]) + if parsed_comment_attrs: + # add any xml overrides collected form segment.comment + modify_xml_data.update(instance.data["xml_overrides"]) self.log.debug("__ modify_xml_data: {}".format(pformat( modify_xml_data ))) - else: - exporting_clip = self.import_clip(clip_path) export_kwargs = {} # validate xml preset file is filled From b3b1938a43d2832be5f72c2439f1701ec229c977 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 14 Apr 2022 11:44:13 +0200 Subject: [PATCH 052/583] flame: IntegrateBatchGroup disable from settings --- .../settings/defaults/project_settings/flame.json | 3 +++ .../projects_schema/schema_project_flame.json | 15 +++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/openpype/settings/defaults/project_settings/flame.json b/openpype/settings/defaults/project_settings/flame.json index 028fda2e66..dd8c05d460 100644 --- a/openpype/settings/defaults/project_settings/flame.json +++ b/openpype/settings/defaults/project_settings/flame.json @@ -69,6 +69,9 @@ "filter_path_regex": ".*" } } + }, + "IntegrateBatchGroup": { + "enabled": false } }, "load": { diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json b/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json index fcbbddbe29..ace404b47a 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json @@ -366,6 +366,21 @@ } } ] + }, + { + "type": "dict", + "collapsible": true, + "key": "IntegrateBatchGroup", + "label": "IntegrateBatchGroup", + "is_group": true, + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + } + ] } ] }, From 891ba74d6c6e2d7370a9c8eec1b13e1a311b8094 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 14 Apr 2022 11:46:16 +0200 Subject: [PATCH 053/583] flame: no need to assign project object anymore --- .../hosts/flame/plugins/publish/collect_timeline_instances.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py b/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py index bc849a4742..5174f9db48 100644 --- a/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py +++ b/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py @@ -26,7 +26,6 @@ class CollectTimelineInstances(pyblish.api.ContextPlugin): add_tasks = [] def process(self, context): - project = context.data["flameProject"] selected_segments = context.data["flameSelectedSegments"] self.log.debug("__ selected_segments: {}".format(selected_segments)) From 30a959f429072449dbadf7d7425a181eb9a6cb7b Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 14 Apr 2022 11:50:35 +0200 Subject: [PATCH 054/583] flame: improving extractor's preset filtering --- .../publish/extract_subset_resources.py | 30 +++++++++++++------ 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py index ba4a8c41ad..4598405923 100644 --- a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py +++ b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py @@ -68,6 +68,7 @@ class ExtractSubsetResources(openpype.api.Extractor): # flame objects segment = instance.data["item"] + asset_name = instance.data["asset"] segment_name = segment.name.get_value() clip_path = instance.data["path"] sequence_clip = instance.context.data["flameSequence"] @@ -109,7 +110,7 @@ class ExtractSubsetResources(openpype.api.Extractor): # get activating attributes activated_preset = preset_config["active"] - filter_path_regex = preset_config["filter_path_regex"] + filter_path_regex = preset_config.get("filter_path_regex") self.log.info( "Preset `{}` is active `{}` with filter `{}`".format( @@ -123,8 +124,11 @@ class ExtractSubsetResources(openpype.api.Extractor): if not activated_preset: continue - # exclude by regex filter - if not re.search(filter_path_regex, clip_path): + # exclude by regex filter if any + if ( + filter_path_regex + and not re.search(filter_path_regex, clip_path) + ): continue # get all presets attributes @@ -162,6 +166,8 @@ class ExtractSubsetResources(openpype.api.Extractor): out_mark = in_mark + source_duration_handles exporting_clip = None + name_patern_xml = "_{}.".format( + unique_name) if export_type == "Sequence Publish": # change export clip to sequence exporting_clip = flame.duplicate(sequence_clip) @@ -169,8 +175,15 @@ class ExtractSubsetResources(openpype.api.Extractor): # only keep visible layer where instance segment is child self.hide_others( exporting_clip, segment_name, s_track_name) + + # change name patern + name_patern_xml = ( + "__{}.").format( + unique_name) else: exporting_clip = self.import_clip(clip_path) + exporting_clip.name.set_value("{}_{}".format( + asset_name, segment_name)) # change in/out marks to timeline in/out in_mark = clip_in @@ -181,9 +194,7 @@ class ExtractSubsetResources(openpype.api.Extractor): "exportHandles": True, "nbHandles": handles, "startFrame": frame_start, - "namePattern": ( - "<segment name>_<shot name>_{}.").format( - unique_name) + "namePattern": name_patern_xml }) if parsed_comment_attrs: @@ -302,8 +313,9 @@ class ExtractSubsetResources(openpype.api.Extractor): self.log.info("Added representation: {}".format( representation_data)) - # at the end remove the duplicated clip - flame.delete(exporting_clip) + if export_type == "Sequence Publish": + # at the end remove the duplicated clip + flame.delete(exporting_clip) self.log.debug("All representations: {}".format( pformat(instance.data["representations"]))) @@ -405,4 +417,4 @@ class ExtractSubsetResources(openpype.api.Extractor): self.log.warning( "Path `{}` is containing more that one clip".format(path) ) - return clips.pop() + return clips[0] From 5dadfb29386ff242ad335dccc14b2ef5df49297e Mon Sep 17 00:00:00 2001 From: Jiri Sindelar <45896205+jrsndl@users.noreply.github.com> Date: Thu, 14 Apr 2022 19:00:58 +0200 Subject: [PATCH 055/583] fix merge conflict --- .../plugins/publish/extract_review_slate.py | 2393 +++-------------- 1 file changed, 400 insertions(+), 1993 deletions(-) diff --git a/openpype/plugins/publish/extract_review_slate.py b/openpype/plugins/publish/extract_review_slate.py index 3ecea1f8bd..c8ee2ec7ed 100644 --- a/openpype/plugins/publish/extract_review_slate.py +++ b/openpype/plugins/publish/extract_review_slate.py @@ -1,1755 +1,387 @@ import os -import re -import copy -import json import shutil - -from abc import ABCMeta, abstractmethod -import six - -import clique - -import pyblish.api import openpype.api +import pyblish from openpype.lib import ( - get_ffmpeg_tool_path, - get_ffprobe_streams, - path_to_subprocess_arg, - - should_convert_for_ffmpeg, - convert_for_ffmpeg, - get_transcode_temp_directory + get_ffmpeg_tool_path, + get_ffprobe_data, + get_ffprobe_streams, + get_ffmpeg_codec_args, + get_ffmpeg_format_args, ) -import speedcopy - -class ExtractReview(pyblish.api.InstancePlugin): - """Extracting Review mov file for Ftrack - - Compulsory attribute of representation is tags list with "review", - otherwise the representation is ignored. - - All new representations are created and encoded by ffmpeg following - presets found in OpenPype Settings interface at - `project_settings/global/publish/ExtractReview/profiles:outputs`. +class ExtractReviewSlate(openpype.api.Extractor): + """ + Will add slate frame at the start of the video files """ - label = "Extract Review" - order = pyblish.api.ExtractorOrder + 0.02 - families = ["review"] - hosts = [ - "nuke", - "maya", - "shell", - "hiero", - "premiere", - "harmony", - "standalonepublisher", - "fusion", - "tvpaint", - "resolve", - "webpublisher", - "aftereffects", - "flame" - ] + label = "Review with Slate frame" + order = pyblish.api.ExtractorOrder + 0.031 + families = ["slate", "review"] + match = pyblish.api.Subset - # Supported extensions - image_exts = ["exr", "jpg", "jpeg", "png", "dpx"] - video_exts = ["mov", "mp4"] - supported_exts = image_exts + video_exts - - alpha_exts = ["exr", "png", "dpx"] - - # FFmpeg tools paths - ffmpeg_path = get_ffmpeg_tool_path("ffmpeg") - - # Preset attributes - profiles = None + hosts = ["nuke", "shell"] + optional = True def process(self, instance): - self.log.debug(str(instance.data["representations"])) - # Skip review when requested. - if not instance.data.get("review", True): - return + inst_data = instance.data + if "representations" not in inst_data: + raise RuntimeError("Burnin needs already created mov to work on.") - # Run processing - self.main_process(instance) - - # Make sure cleanup happens and pop representations with "delete" tag. - for repre in tuple(instance.data["representations"]): - tags = repre.get("tags") or [] - if "delete" in tags and "thumbnail" not in tags: - instance.data["representations"].remove(repre) - - def _get_outputs_for_instance(self, instance): - host_name = instance.context.data["hostName"] - task_name = os.environ["AVALON_TASK"] - family = self.main_family_from_instance(instance) - - self.log.info("Host: \"{}\"".format(host_name)) - self.log.info("Task: \"{}\"".format(task_name)) - self.log.info("Family: \"{}\"".format(family)) - - profile = self.find_matching_profile( - host_name, task_name, family - ) - if not profile: - self.log.info(( - "Skipped instance. None of profiles in presets are for" - " Host: \"{}\" | Family: \"{}\" | Task \"{}\"" - ).format(host_name, family, task_name)) - return - - self.log.debug("Matching profile: \"{}\"".format(json.dumps(profile))) - - subset_name = instance.data.get("subset") - instance_families = self.families_from_instance(instance) - filtered_outputs = self.filter_output_defs( - profile, subset_name, instance_families - ) - # Store `filename_suffix` to save arguments - profile_outputs = [] - for filename_suffix, definition in filtered_outputs.items(): - definition["filename_suffix"] = filename_suffix - profile_outputs.append(definition) - - if not filtered_outputs: - self.log.info(( - "Skipped instance. All output definitions from selected" - " profile does not match to instance families. \"{}\"" - ).format(str(instance_families))) - return profile_outputs - - def _get_outputs_per_representations(self, instance, profile_outputs): - outputs_per_representations = [] - for repre in instance.data["representations"]: - repre_name = str(repre.get("name")) - tags = repre.get("tags") or [] - if "review" not in tags: - self.log.debug(( - "Repre: {} - Didn't found \"review\" in tags. Skipping" - ).format(repre_name)) - continue - - if "thumbnail" in tags: - self.log.debug(( - "Repre: {} - Found \"thumbnail\" in tags. Skipping" - ).format(repre_name)) - continue - - if "passing" in tags: - self.log.debug(( - "Repre: {} - Found \"passing\" in tags. Skipping" - ).format(repre_name)) - continue - - input_ext = repre["ext"] - if input_ext.startswith("."): - input_ext = input_ext[1:] - - if input_ext not in self.supported_exts: - self.log.info( - "Representation has unsupported extension \"{}\"".format( - input_ext - ) - ) - continue - - # Filter output definition by representation tags (optional) - outputs = self.filter_outputs_by_tags(profile_outputs, tags) - if not outputs: - self.log.info(( - "Skipped representation. All output definitions from" - " selected profile does not match to representation's" - " tags. \"{}\"" - ).format(str(tags))) - continue - outputs_per_representations.append((repre, outputs)) - return outputs_per_representations - - @staticmethod - def get_instance_label(instance): - return ( - getattr(instance, "label", None) - or instance.data.get("label") - or instance.data.get("name") - or str(instance) - ) - - def main_process(self, instance): - instance_label = self.get_instance_label(instance) - self.log.debug("Processing instance \"{}\"".format(instance_label)) - profile_outputs = self._get_outputs_for_instance(instance) - if not profile_outputs: - return - - # Loop through representations - outputs_per_repres = self._get_outputs_per_representations( - instance, profile_outputs - ) - fill_data = copy.deepcopy(instance.data["anatomyData"]) - for repre, outputs in outputs_per_repres: - # Check if input should be preconverted before processing - # Store original staging dir (it's value may change) - src_repre_staging_dir = repre["stagingDir"] - # Receive filepath to first file in representation - first_input_path = None - if not self.input_is_sequence(repre): - first_input_path = os.path.join( - src_repre_staging_dir, repre["files"] - ) - else: - for filename in repre["files"]: - first_input_path = os.path.join( - src_repre_staging_dir, filename - ) - break - - # Skip if file is not set - if first_input_path is None: - self.log.warning(( - "Representation \"{}\" have empty files. Skipped." - ).format(repre["name"])) - continue - - # Determine if representation requires pre conversion for ffmpeg - do_convert = should_convert_for_ffmpeg(first_input_path) - # If result is None the requirement of conversion can't be - # determined - if do_convert is None: - self.log.info(( - "Can't determine if representation requires conversion." - " Skipped." - )) - continue - - # Do conversion if needed - # - change staging dir of source representation - # - must be set back after output definitions processing - if do_convert: - new_staging_dir = get_transcode_temp_directory() - repre["stagingDir"] = new_staging_dir - - frame_start = instance.data["frameStart"] - frame_end = instance.data["frameEnd"] - convert_for_ffmpeg( - first_input_path, - new_staging_dir, - frame_start, - frame_end, - self.log - ) - - for _output_def in outputs: - output_def = copy.deepcopy(_output_def) - # Make sure output definition has "tags" key - if "tags" not in output_def: - output_def["tags"] = [] - - if "burnins" not in output_def: - output_def["burnins"] = [] - - # Create copy of representation - new_repre = copy.deepcopy(repre) - # Make sure new representation has origin staging dir - # - this is because source representation may change - # it's staging dir because of ffmpeg conversion - new_repre["stagingDir"] = src_repre_staging_dir - - # Remove "delete" tag from new repre if there is - if "delete" in new_repre["tags"]: - new_repre["tags"].remove("delete") - - # Add additional tags from output definition to representation - for tag in output_def["tags"]: - if tag not in new_repre["tags"]: - new_repre["tags"].append(tag) - - # Add burnin link from output definition to representation - for burnin in output_def["burnins"]: - if burnin not in new_repre.get("burnins", []): - if not new_repre.get("burnins"): - new_repre["burnins"] = [] - new_repre["burnins"].append(str(burnin)) - - self.log.debug( - "Linked burnins: `{}`".format(new_repre.get("burnins")) - ) - - self.log.debug( - "New representation tags: `{}`".format( - new_repre.get("tags")) - ) - - temp_data = self.prepare_temp_data( - instance, repre, output_def) - files_to_clean = [] - if temp_data["input_is_sequence"]: - self.log.info("Filling gaps in sequence.") - files_to_clean = self.fill_sequence_gaps( - temp_data["origin_repre"]["files"], - new_repre["stagingDir"], - temp_data["frame_start"], - temp_data["frame_end"]) - - # create or update outputName - output_name = new_repre.get("outputName", "") - output_ext = new_repre["ext"] - if output_name: - output_name += "_" - output_name += output_def["filename_suffix"] - if temp_data["without_handles"]: - output_name += "_noHandles" - - # add outputName to anatomy format fill_data - fill_data.update({ - "output": output_name, - "ext": output_ext - }) - - try: # temporary until oiiotool is supported cross platform - ffmpeg_args = self._ffmpeg_arguments( - output_def, instance, new_repre, temp_data, fill_data - ) - except ZeroDivisionError: - if 'exr' in temp_data["origin_repre"]["ext"]: - self.log.debug("Unsupported compression on input " + - "files. Skipping!!!") - return - raise NotImplementedError - - subprcs_cmd = " ".join(ffmpeg_args) - - # run subprocess - self.log.debug("Executing: {}".format(subprcs_cmd)) - - openpype.api.run_subprocess( - subprcs_cmd, shell=True, logger=self.log - ) - - # delete files added to fill gaps - if files_to_clean: - for f in files_to_clean: - os.unlink(f) - - new_repre.update({ - "name": "{}_{}".format(output_name, output_ext), - "outputName": output_name, - "outputDef": output_def, - "frameStartFtrack": temp_data["output_frame_start"], - "frameEndFtrack": temp_data["output_frame_end"], - "ffmpeg_cmd": subprcs_cmd - }) - - # Force to pop these key if are in new repre - new_repre.pop("preview", None) - new_repre.pop("thumbnail", None) - if "clean_name" in new_repre.get("tags", []): - new_repre.pop("outputName") - - # adding representation - self.log.debug( - "Adding new representation: {}".format(new_repre) - ) - instance.data["representations"].append(new_repre) - - # Cleanup temp staging dir after procesisng of output definitions - if do_convert: - temp_dir = repre["stagingDir"] - shutil.rmtree(temp_dir) - # Set staging dir of source representation back to previous - # value - repre["stagingDir"] = src_repre_staging_dir - - def input_is_sequence(self, repre): - """Deduce from representation data if input is sequence.""" - # TODO GLOBAL ISSUE - Find better way how to find out if input - # is sequence. Issues( in theory): - # - there may be multiple files ant not be sequence - # - remainders are not checked at all - # - there can be more than one collection - return isinstance(repre["files"], (list, tuple)) - - def prepare_temp_data(self, instance, repre, output_def): - """Prepare dictionary with values used across extractor's process. - - All data are collected from instance, context, origin representation - and output definition. - - There are few required keys in Instance data: "frameStart", "frameEnd" - and "fps". - - Args: - instance (Instance): Currently processed instance. - repre (dict): Representation from which new representation was - copied. - output_def (dict): Definition of output of this plugin. - - Returns: - dict: All data which are used across methods during process. - Their values should not change during process but new keys - with values may be added. - """ - - frame_start = instance.data["frameStart"] - frame_end = instance.data["frameEnd"] - - # Try to get handles from instance - handle_start = instance.data.get("handleStart") - handle_end = instance.data.get("handleEnd") - # If even one of handle values is not set on instance use - # handles from context - if handle_start is None or handle_end is None: - handle_start = instance.context.data["handleStart"] - handle_end = instance.context.data["handleEnd"] - - frame_start_handle = frame_start - handle_start - frame_end_handle = frame_end + handle_end - - # Change output frames when output should be without handles - without_handles = bool("no-handles" in output_def["tags"]) - if without_handles: - output_frame_start = frame_start - output_frame_end = frame_end - else: - output_frame_start = frame_start_handle - output_frame_end = frame_end_handle - - handles_are_set = handle_start > 0 or handle_end > 0 - - with_audio = True - if ( - # Check if has `no-audio` tag - "no-audio" in output_def["tags"] - # Check if instance has ny audio in data - or not instance.data.get("audio") - ): - with_audio = False - - input_is_sequence = self.input_is_sequence(repre) - input_allow_bg = False - if input_is_sequence and repre["files"]: - ext = os.path.splitext(repre["files"][0])[1].replace(".", "") - if ext in self.alpha_exts: - input_allow_bg = True - - return { - "fps": float(instance.data["fps"]), - "frame_start": frame_start, - "frame_end": frame_end, - "handle_start": handle_start, - "handle_end": handle_end, - "frame_start_handle": frame_start_handle, - "frame_end_handle": frame_end_handle, - "output_frame_start": int(output_frame_start), - "output_frame_end": int(output_frame_end), - "pixel_aspect": instance.data.get("pixelAspect", 1), - "resolution_width": instance.data.get("resolutionWidth"), - "resolution_height": instance.data.get("resolutionHeight"), - "origin_repre": repre, - "input_is_sequence": input_is_sequence, - "input_allow_bg": input_allow_bg, - "with_audio": with_audio, - "without_handles": without_handles, - "handles_are_set": handles_are_set - } - - def _ffmpeg_arguments( - self, output_def, instance, new_repre, temp_data, fill_data - ): - """Prepares ffmpeg arguments for expected extraction. - - Prepares input and output arguments based on output definition and - input files. - - Args: - output_def (dict): Currently processed output definition. - instance (Instance): Currently processed instance. - new_repre (dict): Representation representing output of this - process. - temp_data (dict): Base data for successful process. - """ - - # Get FFmpeg arguments from profile presets - out_def_ffmpeg_args = output_def.get("ffmpeg_args") or {} - - _ffmpeg_input_args = out_def_ffmpeg_args.get("input") or [] - _ffmpeg_output_args = out_def_ffmpeg_args.get("output") or [] - _ffmpeg_video_filters = out_def_ffmpeg_args.get("video_filters") or [] - _ffmpeg_audio_filters = out_def_ffmpeg_args.get("audio_filters") or [] - - # Cleanup empty strings - ffmpeg_input_args = [ - value for value in _ffmpeg_input_args if value.strip() - ] - ffmpeg_video_filters = [ - value for value in _ffmpeg_video_filters if value.strip() - ] - ffmpeg_audio_filters = [ - value for value in _ffmpeg_audio_filters if value.strip() - ] - - ffmpeg_output_args = [] - for value in _ffmpeg_output_args: - value = value.strip() - if not value: - continue - try: - value = value.format(**fill_data) - except Exception: - self.log.warning( - "Failed to format ffmpeg argument: {}".format(value), - exc_info=True - ) - pass - ffmpeg_output_args.append(value) - - # Prepare input and output filepaths - self.input_output_paths(new_repre, output_def, temp_data) - - # Set output frames len to 1 when ouput is single image - if ( - temp_data["output_ext_is_image"] - and not temp_data["output_is_sequence"] - ): - output_frames_len = 1 - - else: - output_frames_len = ( - temp_data["output_frame_end"] - - temp_data["output_frame_start"] - + 1 - ) - - duration_seconds = float(output_frames_len / temp_data["fps"]) - - if temp_data["input_is_sequence"]: - # Set start frame of input sequence (just frame in filename) - # - definition of input filepath - ffmpeg_input_args.append( - "-start_number {}".format(temp_data["output_frame_start"]) - ) - - # TODO add fps mapping `{fps: fraction}` ? - # - e.g.: { - # "25": "25/1", - # "24": "24/1", - # "23.976": "24000/1001" - # } - # Add framerate to input when input is sequence - ffmpeg_input_args.append( - "-framerate {}".format(temp_data["fps"]) - ) - - if temp_data["output_is_sequence"]: - # Set start frame of output sequence (just frame in filename) - # - this is definition of an output - ffmpeg_output_args.append( - "-start_number {}".format(temp_data["output_frame_start"]) - ) - - # Change output's duration and start point if should not contain - # handles - start_sec = 0 - if temp_data["without_handles"] and temp_data["handles_are_set"]: - # Set start time without handles - # - check if handle_start is bigger than 0 to avoid zero division - if temp_data["handle_start"] > 0: - start_sec = float(temp_data["handle_start"]) / temp_data["fps"] - ffmpeg_input_args.append("-ss {:0.10f}".format(start_sec)) - - # Set output duration inn seconds - ffmpeg_output_args.append("-t {:0.10}".format(duration_seconds)) - - # Set frame range of output when input or output is sequence - elif temp_data["output_is_sequence"]: - ffmpeg_output_args.append("-frames:v {}".format(output_frames_len)) - - # Add duration of an input sequence if output is video - if ( - temp_data["input_is_sequence"] - and not temp_data["output_is_sequence"] - ): - ffmpeg_input_args.append("-to {:0.10f}".format( - duration_seconds + start_sec - )) - - # Add video/image input path - ffmpeg_input_args.append( - "-i {}".format( - path_to_subprocess_arg(temp_data["full_input_path"]) - ) - ) - - # Add audio arguments if there are any. Skipped when output are images. - if not temp_data["output_ext_is_image"] and temp_data["with_audio"]: - audio_in_args, audio_filters, audio_out_args = self.audio_args( - instance, temp_data, duration_seconds - ) - ffmpeg_input_args.extend(audio_in_args) - ffmpeg_audio_filters.extend(audio_filters) - ffmpeg_output_args.extend(audio_out_args) - - res_filters = self.rescaling_filters(temp_data, output_def, new_repre) - ffmpeg_video_filters.extend(res_filters) - - ffmpeg_input_args = self.split_ffmpeg_args(ffmpeg_input_args) - - lut_filters = self.lut_filters(new_repre, instance, ffmpeg_input_args) - ffmpeg_video_filters.extend(lut_filters) - - bg_alpha = 0 - bg_color = output_def.get("bg_color") - if bg_color: - bg_red, bg_green, bg_blue, bg_alpha = bg_color - - if bg_alpha > 0: - if not temp_data["input_allow_bg"]: - self.log.info(( - "Output definition has defined BG color input was" - " resolved as does not support adding BG." - )) - else: - bg_color_hex = "#{0:0>2X}{1:0>2X}{2:0>2X}".format( - bg_red, bg_green, bg_blue - ) - bg_color_alpha = float(bg_alpha) / 255 - bg_color_str = "{}@{}".format(bg_color_hex, bg_color_alpha) - - self.log.info("Applying BG color {}".format(bg_color_str)) - color_args = [ - "split=2[bg][fg]", - "[bg]drawbox=c={}:replace=1:t=fill[bg]".format( - bg_color_str - ), - "[bg][fg]overlay=format=auto" - ] - # Prepend bg color change before all video filters - # NOTE at the time of creation it is required as video filters - # from settings may affect color of BG - # e.g. `eq` can remove alpha from input - for arg in reversed(color_args): - ffmpeg_video_filters.insert(0, arg) - - # Add argument to override output file - ffmpeg_output_args.append("-y") - - # NOTE This must be latest added item to output arguments. - ffmpeg_output_args.append( - path_to_subprocess_arg(temp_data["full_output_path"]) - ) - - return self.ffmpeg_full_args( - ffmpeg_input_args, - ffmpeg_video_filters, - ffmpeg_audio_filters, - ffmpeg_output_args - ) - - def split_ffmpeg_args(self, in_args): - """Makes sure all entered arguments are separated in individual items. - - Split each argument string with " -" to identify if string contains - one or more arguments. - """ - splitted_args = [] - for arg in in_args: - sub_args = arg.split(" -") - if len(sub_args) == 1: - if arg and arg not in splitted_args: - splitted_args.append(arg) - continue - - for idx, arg in enumerate(sub_args): - if idx != 0: - arg = "-" + arg - - if arg and arg not in splitted_args: - splitted_args.append(arg) - return splitted_args - - def ffmpeg_full_args( - self, input_args, video_filters, audio_filters, output_args - ): - """Post processing of collected FFmpeg arguments. - - Just verify that output arguments does not contain video or audio - filters which may cause issues because of duplicated argument entry. - Filters found in output arguments are moved to list they belong to. - - Args: - input_args (list): All collected ffmpeg arguments with inputs. - video_filters (list): All collected video filters. - audio_filters (list): All collected audio filters. - output_args (list): All collected ffmpeg output arguments with - output filepath. - - Returns: - list: Containing all arguments ready to run in subprocess. - """ - output_args = self.split_ffmpeg_args(output_args) - - video_args_dentifiers = ["-vf", "-filter:v"] - audio_args_dentifiers = ["-af", "-filter:a"] - for arg in tuple(output_args): - for identifier in video_args_dentifiers: - if arg.startswith("{} ".format(identifier)): - output_args.remove(arg) - arg = arg.replace(identifier, "").strip() - video_filters.append(arg) - - for identifier in audio_args_dentifiers: - if arg.startswith("{} ".format(identifier)): - output_args.remove(arg) - arg = arg.replace(identifier, "").strip() - audio_filters.append(arg) - - all_args = [] - all_args.append(path_to_subprocess_arg(self.ffmpeg_path)) - all_args.extend(input_args) - if video_filters: - all_args.append("-filter:v") - all_args.append("\"{}\"".format(",".join(video_filters))) - - if audio_filters: - all_args.append("-filter:a") - all_args.append("\"{}\"".format(",".join(audio_filters))) - - all_args.extend(output_args) - - return all_args - - def fill_sequence_gaps(self, files, staging_dir, start_frame, end_frame): - # type: (list, str, int, int) -> list - """Fill missing files in sequence by duplicating existing ones. - - This will take nearest frame file and copy it with so as to fill - gaps in sequence. Last existing file there is is used to for the - hole ahead. - - Args: - files (list): List of representation files. - staging_dir (str): Path to staging directory. - start_frame (int): Sequence start (no matter what files are there) - end_frame (int): Sequence end (no matter what files are there) - - Returns: - list of added files. Those should be cleaned after work - is done. - - Raises: - AssertionError: if more then one collection is obtained. - - """ - start_frame = int(start_frame) - end_frame = int(end_frame) - collections = clique.assemble(files)[0] - assert len(collections) == 1, "Multiple collections found." - col = collections[0] - - # do nothing if no gap is found in input range - not_gap = True - for fr in range(start_frame, end_frame + 1): - if fr not in col.indexes: - not_gap = False - - if not_gap: - return [] - - holes = col.holes() - - # generate ideal sequence - complete_col = clique.assemble( - [("{}{:0" + str(col.padding) + "d}{}").format( - col.head, f, col.tail - ) for f in range(start_frame, end_frame)] - )[0][0] # type: clique.Collection - - new_files = {} - last_existing_file = None - - for idx in holes.indexes: - # get previous existing file - test_file = os.path.normpath(os.path.join( - staging_dir, - ("{}{:0" + str(complete_col.padding) + "d}{}").format( - complete_col.head, idx - 1, complete_col.tail))) - if os.path.isfile(test_file): - new_files[idx] = test_file - last_existing_file = test_file - else: - if not last_existing_file: - # previous file is not found (sequence has a hole - # at the beginning. Use first available frame - # there is. - try: - last_existing_file = list(col)[0] - except IndexError: - # empty collection? - raise AssertionError( - "Invalid sequence collected") - new_files[idx] = os.path.normpath( - os.path.join(staging_dir, last_existing_file)) - - files_to_clean = [] - if new_files: - # so now new files are dict with missing frame as a key and - # existing file as a value. - for frame, file in new_files.items(): - self.log.info( - "Filling gap {} with {}".format(frame, file)) - - hole = os.path.join( - staging_dir, - ("{}{:0" + str(col.padding) + "d}{}").format( - col.head, frame, col.tail)) - speedcopy.copyfile(file, hole) - files_to_clean.append(hole) - - return files_to_clean - - def input_output_paths(self, new_repre, output_def, temp_data): - """Deduce input nad output file paths based on entered data. - - Input may be sequence of images, video file or single image file and - same can be said about output, this method helps to find out what - their paths are. - - It is validated that output directory exist and creates if not. - - During process are set "files", "stagingDir", "ext" and - "sequence_file" (if output is sequence) keys to new representation. - """ - - repre = temp_data["origin_repre"] - src_staging_dir = repre["stagingDir"] - dst_staging_dir = new_repre["stagingDir"] - - if temp_data["input_is_sequence"]: - collections = clique.assemble(repre["files"])[0] - full_input_path = os.path.join( - src_staging_dir, - collections[0].format("{head}{padding}{tail}") - ) - - filename = collections[0].format("{head}") - if filename.endswith("."): - filename = filename[:-1] - - # Make sure to have full path to one input file - full_input_path_single_file = os.path.join( - src_staging_dir, repre["files"][0] - ) - - else: - full_input_path = os.path.join( - src_staging_dir, repre["files"] - ) - filename = os.path.splitext(repre["files"])[0] - - # Make sure to have full path to one input file - full_input_path_single_file = full_input_path - - filename_suffix = output_def["filename_suffix"] - - output_ext = output_def.get("ext") - # Use input extension if output definition do not specify it - if output_ext is None: - output_ext = os.path.splitext(full_input_path)[1] - - # TODO Define if extension should have dot or not - if output_ext.startswith("."): - output_ext = output_ext[1:] - - # Store extension to representation - new_repre["ext"] = output_ext - - self.log.debug("New representation ext: `{}`".format(output_ext)) - - # Output is image file sequence witht frames - output_ext_is_image = bool(output_ext in self.image_exts) - output_is_sequence = bool( - output_ext_is_image - and "sequence" in output_def["tags"] - ) - if output_is_sequence: - new_repre_files = [] - frame_start = temp_data["output_frame_start"] - frame_end = temp_data["output_frame_end"] - - filename_base = "{}_{}".format(filename, filename_suffix) - # Temporary tempalte for frame filling. Example output: - # "basename.%04d.exr" when `frame_end` == 1001 - repr_file = "{}.%{:0>2}d.{}".format( - filename_base, len(str(frame_end)), output_ext - ) - - for frame in range(frame_start, frame_end + 1): - new_repre_files.append(repr_file % frame) - - new_repre["sequence_file"] = repr_file - full_output_path = os.path.join( - dst_staging_dir, filename_base, repr_file - ) - - else: - repr_file = "{}_{}.{}".format( - filename, filename_suffix, output_ext - ) - full_output_path = os.path.join(dst_staging_dir, repr_file) - new_repre_files = repr_file - - # Store files to representation - new_repre["files"] = new_repre_files - - # Make sure stagingDire exists - dst_staging_dir = os.path.normpath(os.path.dirname(full_output_path)) - if not os.path.exists(dst_staging_dir): - self.log.debug("Creating dir: {}".format(dst_staging_dir)) - os.makedirs(dst_staging_dir) - - # Store stagingDir to representaion - new_repre["stagingDir"] = dst_staging_dir - - # Store paths to temp data - temp_data["full_input_path"] = full_input_path - temp_data["full_input_path_single_file"] = full_input_path_single_file - temp_data["full_output_path"] = full_output_path - - # Store information about output - temp_data["output_ext_is_image"] = output_ext_is_image - temp_data["output_is_sequence"] = output_is_sequence - - self.log.debug("Input path {}".format(full_input_path)) - self.log.debug("Output path {}".format(full_output_path)) - - def audio_args(self, instance, temp_data, duration_seconds): - """Prepares FFMpeg arguments for audio inputs.""" - audio_in_args = [] - audio_filters = [] - audio_out_args = [] - audio_inputs = instance.data.get("audio") - if not audio_inputs: - return audio_in_args, audio_filters, audio_out_args - - for audio in audio_inputs: - # NOTE modified, always was expected "frameStartFtrack" which is - # STRANGE?!!! There should be different key, right? - # TODO use different frame start! - offset_seconds = 0 - frame_start_ftrack = instance.data.get("frameStartFtrack") - if frame_start_ftrack is not None: - offset_frames = frame_start_ftrack - audio["offset"] - offset_seconds = offset_frames / temp_data["fps"] - - if offset_seconds > 0: - audio_in_args.append( - "-ss {}".format(offset_seconds) - ) - - elif offset_seconds < 0: - audio_in_args.append( - "-itsoffset {}".format(abs(offset_seconds)) - ) - - # Audio duration is offset from `-ss` - audio_duration = duration_seconds + offset_seconds - - # Set audio duration - audio_in_args.append("-to {:0.10f}".format(audio_duration)) - - # Add audio input path - audio_in_args.append("-i {}".format( - path_to_subprocess_arg(audio["filename"]) - )) - - # NOTE: These were changed from input to output arguments. - # NOTE: value in "-ac" was hardcoded to 2, changed to audio inputs len. - # Need to merge audio if there are more than 1 input. - if len(audio_inputs) > 1: - audio_out_args.append("-filter_complex amerge") - audio_out_args.append("-ac {}".format(len(audio_inputs))) - - return audio_in_args, audio_filters, audio_out_args - - def get_letterbox_filters( - self, - letter_box_def, - output_width, - output_height - ): - output = [] - - ratio = letter_box_def["ratio"] - fill_color = letter_box_def["fill_color"] - f_red, f_green, f_blue, f_alpha = fill_color - fill_color_hex = "{0:0>2X}{1:0>2X}{2:0>2X}".format( - f_red, f_green, f_blue - ) - fill_color_alpha = float(f_alpha) / 255 - - line_thickness = letter_box_def["line_thickness"] - line_color = letter_box_def["line_color"] - l_red, l_green, l_blue, l_alpha = line_color - line_color_hex = "{0:0>2X}{1:0>2X}{2:0>2X}".format( - l_red, l_green, l_blue - ) - line_color_alpha = float(l_alpha) / 255 - - # test ratios and define if pillar or letter boxes - output_ratio = float(output_width) / float(output_height) - self.log.debug("Output ratio: {} LetterBox ratio: {}".format( - output_ratio, ratio - )) - pillar = output_ratio > ratio - need_mask = format(output_ratio, ".3f") != format(ratio, ".3f") - if not need_mask: - return [] - - if not pillar: - if fill_color_alpha > 0: - top_box = ( - "drawbox=0:0:{width}" - ":round(({height}-({width}/{ratio}))/2)" - ":t=fill:c={color}@{alpha}" - ).format( - width=output_width, - height=output_height, - ratio=ratio, - color=fill_color_hex, - alpha=fill_color_alpha - ) - - bottom_box = ( - "drawbox=0" - ":{height}-round(({height}-({width}/{ratio}))/2)" - ":{width}" - ":round(({height}-({width}/{ratio}))/2)" - ":t=fill:c={color}@{alpha}" - ).format( - width=output_width, - height=output_height, - ratio=ratio, - color=fill_color_hex, - alpha=fill_color_alpha - ) - output.extend([top_box, bottom_box]) - - if line_color_alpha > 0 and line_thickness > 0: - top_line = ( - "drawbox=0" - ":round(({height}-({width}/{ratio}))/2)-{l_thick}" - ":{width}:{l_thick}:t=fill:c={l_color}@{l_alpha}" - ).format( - width=output_width, - height=output_height, - ratio=ratio, - l_thick=line_thickness, - l_color=line_color_hex, - l_alpha=line_color_alpha - ) - bottom_line = ( - "drawbox=0" - ":{height}-round(({height}-({width}/{ratio}))/2)" - ":{width}:{l_thick}:t=fill:c={l_color}@{l_alpha}" - ).format( - width=output_width, - height=output_height, - ratio=ratio, - l_thick=line_thickness, - l_color=line_color_hex, - l_alpha=line_color_alpha - ) - output.extend([top_line, bottom_line]) - - else: - if fill_color_alpha > 0: - left_box = ( - "drawbox=0:0" - ":round(({width}-({height}*{ratio}))/2)" - ":{height}" - ":t=fill:c={color}@{alpha}" - ).format( - width=output_width, - height=output_height, - ratio=ratio, - color=fill_color_hex, - alpha=fill_color_alpha - ) - - right_box = ( - "drawbox=" - "{width}-round(({width}-({height}*{ratio}))/2)" - ":0" - ":round(({width}-({height}*{ratio}))/2)" - ":{height}" - ":t=fill:c={color}@{alpha}" - ).format( - width=output_width, - height=output_height, - ratio=ratio, - color=fill_color_hex, - alpha=fill_color_alpha - ) - output.extend([left_box, right_box]) - - if line_color_alpha > 0 and line_thickness > 0: - left_line = ( - "drawbox=round(({width}-({height}*{ratio}))/2)" - ":0:{l_thick}:{height}:t=fill:c={l_color}@{l_alpha}" - ).format( - width=output_width, - height=output_height, - ratio=ratio, - l_thick=line_thickness, - l_color=line_color_hex, - l_alpha=line_color_alpha - ) - - right_line = ( - "drawbox={width}-round(({width}-({height}*{ratio}))/2)" - ":0:{l_thick}:{height}:t=fill:c={l_color}@{l_alpha}" - ).format( - width=output_width, - height=output_height, - ratio=ratio, - l_thick=line_thickness, - l_color=line_color_hex, - l_alpha=line_color_alpha - ) - output.extend([left_line, right_line]) - - return output - - def rescaling_filters(self, temp_data, output_def, new_repre): - """Prepare vieo filters based on tags in new representation. - - It is possible to add letterboxes to output video or rescale to - different resolution. - - During this preparation "resolutionWidth" and "resolutionHeight" are - set to new representation. - """ - filters = [] - - # if reformat input video file is already reforamted from upstream - reformat_in_baking = bool("reformated" in new_repre["tags"]) - self.log.debug("reformat_in_baking: `{}`".format(reformat_in_baking)) - - # Get instance data - pixel_aspect = temp_data["pixel_aspect"] - - if reformat_in_baking: - self.log.debug(( - "Using resolution from input. It is already " - "reformated from upstream process" - )) - pixel_aspect = 1 - - # NOTE Skipped using instance's resolution - full_input_path_single_file = temp_data["full_input_path_single_file"] - try: - streams = get_ffprobe_streams( - full_input_path_single_file, self.log - ) - except Exception as exc: - raise AssertionError(( - "FFprobe couldn't read information about input file: \"{}\"." - " Error message: {}" - ).format(full_input_path_single_file, str(exc))) + suffix = "_slate" + slate_path = inst_data.get("slateFrame") + ffmpeg_path = get_ffmpeg_tool_path("ffmpeg") + slate_streams = get_ffprobe_streams(slate_path, self.log) # Try to find first stream with defined 'width' and 'height' # - this is to avoid order of streams where audio can be as first - # - there may be a better way (checking `codec_type`?) - input_width = None - input_height = None - output_width = None - output_height = None - for stream in streams: - if "width" in stream and "height" in stream: - input_width = int(stream["width"]) - input_height = int(stream["height"]) + # - there may be a better way (checking `codec_type`?)+ + slate_width = None + slate_height = None + for slate_stream in slate_streams: + if "width" in slate_stream and "height" in slate_stream: + slate_width = int(slate_stream["width"]) + slate_height = int(slate_stream["height"]) break - # Get instance data - pixel_aspect = temp_data["pixel_aspect"] - - if reformat_in_baking: - self.log.debug(( - "Using resolution from input. It is already " - "reformated from upstream process" - )) - pixel_aspect = 1 - output_width = input_width - output_height = input_height - # Raise exception of any stream didn't define input resolution - if input_width is None: + if slate_width is None: raise AssertionError(( "FFprobe couldn't read resolution from input file: \"{}\"" - ).format(full_input_path_single_file)) + ).format(slate_path)) - # NOTE Setting only one of `width` or `heigth` is not allowed - # - settings value can't have None but has value of 0 - output_width = output_def.get("width") or output_width or None - output_height = output_def.get("height") or output_height or None + if "reviewToWidth" in inst_data: + use_legacy_code = True + else: + use_legacy_code = False - # Overscal color - overscan_color_value = "black" - overscan_color = output_def.get("overscan_color") - if overscan_color: - bg_red, bg_green, bg_blue, _ = overscan_color - overscan_color_value = "#{0:0>2X}{1:0>2X}{2:0>2X}".format( - bg_red, bg_green, bg_blue + pixel_aspect = inst_data.get("pixelAspect", 1) + fps = inst_data.get("fps") + self.log.debug("fps {} ".format(fps)) + + for idx, repre in enumerate(inst_data["representations"]): + self.log.debug("repre ({}): `{}`".format(idx + 1, repre)) + + p_tags = repre.get("tags", []) + if "slate-frame" not in p_tags: + continue + + # get repre file + stagingdir = repre["stagingDir"] + input_file = "{0}".format(repre["files"]) + input_path = os.path.join( + os.path.normpath(stagingdir), repre["files"]) + self.log.debug("__ input_path: {}".format(input_path)) + + streams = get_ffprobe_streams( + input_path, self.log ) - self.log.debug("Overscan color: `{}`".format(overscan_color_value)) - - # Convert overscan value video filters - overscan_crop = output_def.get("overscan_crop") - overscan = OverscanCrop( - input_width, input_height, overscan_crop, overscan_color_value - ) - overscan_crop_filters = overscan.video_filters() - # Add overscan filters to filters if are any and modify input - # resolution by it's values - if overscan_crop_filters: - filters.extend(overscan_crop_filters) - input_width = overscan.width() - input_height = overscan.height() - # Use output resolution as inputs after cropping to skip usage of - # instance data resolution - if output_width is None or output_height is None: - output_width = input_width - output_height = input_height - - # Make sure input width and height is not an odd number - input_width_is_odd = bool(input_width % 2 != 0) - input_height_is_odd = bool(input_height % 2 != 0) - if input_width_is_odd or input_height_is_odd: - # Add padding to input and make sure this filter is at first place - filters.append("pad=width=ceil(iw/2)*2:height=ceil(ih/2)*2") - - # Change input width or height as first filter will change them - if input_width_is_odd: - self.log.info(( - "Converting input width from odd to even number. {} -> {}" - ).format(input_width, input_width + 1)) - input_width += 1 - - if input_height_is_odd: - self.log.info(( - "Converting input height from odd to even number. {} -> {}" - ).format(input_height, input_height + 1)) - input_height += 1 - - self.log.debug("pixel_aspect: `{}`".format(pixel_aspect)) - self.log.debug("input_width: `{}`".format(input_width)) - self.log.debug("input_height: `{}`".format(input_height)) - - # Use instance resolution if output definition has not set it. - if output_width is None or output_height is None: - output_width = temp_data["resolution_width"] - output_height = temp_data["resolution_height"] - - # Use source's input resolution instance does not have set it. - if output_width is None or output_height is None: - self.log.debug("Using resolution from input.") - output_width = input_width - output_height = input_height - - output_width = int(output_width) - output_height = int(output_height) - - # Make sure output width and height is not an odd number - # When this can happen: - # - if output definition has set width and height with odd number - # - `instance.data` contain width and height with odd numbeer - if output_width % 2 != 0: - self.log.warning(( - "Converting output width from odd to even number. {} -> {}" - ).format(output_width, output_width + 1)) - output_width += 1 - - if output_height % 2 != 0: - self.log.warning(( - "Converting output height from odd to even number. {} -> {}" - ).format(output_height, output_height + 1)) - output_height += 1 - - self.log.debug( - "Output resolution is {}x{}".format(output_width, output_height) - ) - - letter_box_def = output_def["letter_box"] - letter_box_enabled = letter_box_def["enabled"] - - # Skip processing if resolution is same as input's and letterbox is - # not set - if ( - output_width == input_width - and output_height == input_height - and not letter_box_enabled - and pixel_aspect == 1 - ): + # get video metadata + for stream in streams: + input_timecode = None + input_width = None + input_height = None + input_frame_rate = None + if "codec_type" in stream: + if stream["codec_type"] == "video": + self.log.debug("__Ffprobe Video: {}".format(stream)) + tags = stream.get("tags") or {} + input_timecode = tags.get("timecode") or "" + if "width" in stream and "height" in stream: + input_width = int(stream.get("width")) + input_height = int(stream.get("height")) + if "r_frame_rate" in stream: + # get frame rate in a form of + # x/y, like 24000/1001 for 23.976 + input_frame_rate = str(stream.get("r_frame_rate")) + if ( + input_timecode + and input_width + and input_height + and input_frame_rate + ): + break + # Raise exception of any stream didn't define input resolution + if input_width is None: + raise AssertionError(( + "FFprobe couldn't read resolution from input file: \"{}\"" + ).format(input_path)) + # Get audio metadata + for stream in streams: + audio_channels = None + audio_sample_rate = None + audio_channel_layout = None + input_audio = False + if stream["codec_type"] == "audio": + self.log.debug("__Ffprobe Audio: {}".format(stream)) + if stream["channels"]: + audio_channels = str(stream.get("channels")) + if stream["sample_rate"]: + audio_sample_rate = str(stream.get("sample_rate")) + if stream["channel_layout"]: + audio_channel_layout = str( + stream.get("channel_layout")) + if ( + audio_channels + and audio_sample_rate + and audio_channel_layout + ): + input_audio = True + break + # Get duration of one frame in micro seconds + one_frame_duration = "40000us" + if input_frame_rate: + items = input_frame_rate.split("/") + if len(items) == 1: + one_frame_duration = float(1.0) / float(items[0]) + elif len(items) == 2: + one_frame_duration = float(items[1]) / float(items[0]) + one_frame_duration *= 1000000 + one_frame_duration = str(int(one_frame_duration)) + "us" self.log.debug( - "Output resolution is same as input's" - " and \"letter_box\" key is not set. Skipping reformat part." - ) - new_repre["resolutionWidth"] = input_width - new_repre["resolutionHeight"] = input_height - return filters + "One frame duration is {}".format(one_frame_duration)) - # defining image ratios - input_res_ratio = ( - (float(input_width) * pixel_aspect) / input_height - ) - output_res_ratio = float(output_width) / float(output_height) - self.log.debug("input_res_ratio: `{}`".format(input_res_ratio)) - self.log.debug("output_res_ratio: `{}`".format(output_res_ratio)) - - # Round ratios to 2 decimal places for comparing - input_res_ratio = round(input_res_ratio, 2) - output_res_ratio = round(output_res_ratio, 2) - - # get scale factor - scale_factor_by_width = ( - float(output_width) / (input_width * pixel_aspect) - ) - scale_factor_by_height = ( - float(output_height) / input_height - ) - - self.log.debug( - "scale_factor_by_with: `{}`".format(scale_factor_by_width) - ) - self.log.debug( - "scale_factor_by_height: `{}`".format(scale_factor_by_height) - ) - - # scaling none square pixels and 1920 width - if ( - input_height != output_height - or input_width != output_width - or pixel_aspect != 1 - ): - if input_res_ratio < output_res_ratio: - self.log.debug( - "Input's resolution ratio is lower then output's" - ) - width_scale = int(input_width * scale_factor_by_height) - width_half_pad = int((output_width - width_scale) / 2) - height_scale = output_height - height_half_pad = 0 + # values are set in ExtractReview + if use_legacy_code: + to_width = inst_data["reviewToWidth"] + to_height = inst_data["reviewToHeight"] else: - self.log.debug("Input is heigher then output") - width_scale = output_width - width_half_pad = 0 - height_scale = int(input_height * scale_factor_by_width) - height_half_pad = int((output_height - height_scale) / 2) + to_width = input_width + to_height = input_height - self.log.debug("width_scale: `{}`".format(width_scale)) - self.log.debug("width_half_pad: `{}`".format(width_half_pad)) - self.log.debug("height_scale: `{}`".format(height_scale)) - self.log.debug("height_half_pad: `{}`".format(height_half_pad)) + self.log.debug("to_width: `{}`".format(to_width)) + self.log.debug("to_height: `{}`".format(to_height)) - filters.extend([ - "scale={}x{}:flags=lanczos".format( - width_scale, height_scale - ), - "pad={}:{}:{}:{}:{}".format( - output_width, output_height, - width_half_pad, height_half_pad, - overscan_color_value - ), - "setsar=1" + # defining image ratios + resolution_ratio = ( + (float(slate_width) * pixel_aspect) / slate_height + ) + delivery_ratio = float(to_width) / float(to_height) + self.log.debug("resolution_ratio: `{}`".format(resolution_ratio)) + self.log.debug("delivery_ratio: `{}`".format(delivery_ratio)) + + # get scale factor + scale_factor_by_height = float(to_height) / slate_height + scale_factor_by_width = float(to_width) / ( + slate_width * pixel_aspect + ) + + # shorten two decimals long float number for testing conditions + resolution_ratio_test = float("{:0.2f}".format(resolution_ratio)) + delivery_ratio_test = float("{:0.2f}".format(delivery_ratio)) + + self.log.debug("__ scale_factor_by_width: `{}`".format( + scale_factor_by_width + )) + self.log.debug("__ scale_factor_by_height: `{}`".format( + scale_factor_by_height + )) + + _remove_at_end = [] + + ext = os.path.splitext(input_file)[1] + output_file = input_file.replace(ext, "") + suffix + ext + + _remove_at_end.append(input_path) + + output_path = os.path.join( + os.path.normpath(stagingdir), output_file) + self.log.debug("__ output_path: {}".format(output_path)) + + input_args = [] + output_args = [] + + # preset's input data + if use_legacy_code: + input_args.extend(repre["_profile"].get('input', [])) + else: + input_args.extend(repre["outputDef"].get('input', [])) + + input_args.append("-loop 1 -i {}".format( + openpype.lib.path_to_subprocess_arg(slate_path))) + # if input has an audio, add silent audio to the slate + if input_audio: + input_args.extend( + ["-f lavfi -i anullsrc=r={}:cl={}:d={}".format( + audio_sample_rate, + audio_channel_layout, + one_frame_duration + )] + ) + + input_args.extend(["-r {}".format(input_frame_rate)]) + input_args.extend(["-frames:v 1"]) + # add timecode from source to the slate, substract one frame + if input_timecode: + offset_timecode = self._tc_offset( + str(input_timecode), + framerate=fps, + frame_offset=-1 + ) + self.log.debug("Slate Timecode: `{}`".format( + offset_timecode + )) + if offset_timecode: + input_args.extend(["-timecode {}".format(offset_timecode)]) + else: + # fall back to input timecode if offset fails + input_args.extend(["-timecode {}".format(input_timecode)]) + if use_legacy_code: + codec_args = repre["_profile"].get('codec', []) + output_args.extend(codec_args) + # preset's output data + output_args.extend(repre["_profile"].get('output', [])) + else: + # Codecs are copied from source for whole input + codec_args = self._get_codec_args(repre) + output_args.extend(codec_args) + + # make sure colors are correct + output_args.extend([ + "-vf scale=out_color_matrix=bt709", + "-color_primaries bt709", + "-color_trc bt709", + "-colorspace bt709" ]) - # letter_box - if letter_box_enabled: - filters.extend( - self.get_letterbox_filters( - letter_box_def, - output_width, - output_height + # scaling none square pixels and 1920 width + if ( + # Always scale slate if not legacy + not use_legacy_code or + # Legacy code required reformat tag + (use_legacy_code and "reformat" in p_tags) + ): + if resolution_ratio_test < delivery_ratio_test: + self.log.debug("lower then delivery") + width_scale = int(slate_width * scale_factor_by_height) + width_half_pad = int((to_width - width_scale) / 2) + height_scale = to_height + height_half_pad = 0 + else: + self.log.debug("heigher then delivery") + width_scale = to_width + width_half_pad = 0 + height_scale = int(slate_height * scale_factor_by_width) + height_half_pad = int((to_height - height_scale) / 2) + + self.log.debug( + "__ width_scale: `{}`".format(width_scale) ) + self.log.debug( + "__ width_half_pad: `{}`".format(width_half_pad) + ) + self.log.debug( + "__ height_scale: `{}`".format(height_scale) + ) + self.log.debug( + "__ height_half_pad: `{}`".format(height_half_pad) + ) + + scaling_arg = ("scale={0}x{1}:flags=lanczos," + "pad={2}:{3}:{4}:{5}:black,setsar=1").format( + width_scale, height_scale, to_width, to_height, + width_half_pad, height_half_pad + ) + # add output frame rate as a filter, just in case + scaling_arg += ",fps={}".format(input_frame_rate) + vf_back = self.add_video_filter_args(output_args, scaling_arg) + # add it to output_args + output_args.insert(0, vf_back) + + # overrides output file + output_args.append("-y") + + slate_v_path = slate_path.replace(".png", ext) + output_args.append( + path_to_subprocess_arg(slate_v_path) + ) + _remove_at_end.append(slate_v_path) + + slate_args = [ + path_to_subprocess_arg(ffmpeg_path), + " ".join(input_args), + " ".join(output_args) + ] + slate_subprocess_cmd = " ".join(slate_args) + + # run slate generation subprocess + self.log.debug( + "Slate Executing: {}".format(slate_subprocess_cmd) + ) + openpype.api.run_subprocess( + slate_subprocess_cmd, shell=True, logger=self.log ) - new_repre["resolutionWidth"] = output_width - new_repre["resolutionHeight"] = output_height + # create ffmpeg concat text file path + conc_text_file = input_file.replace(ext, "") + "_concat" + ".txt" + conc_text_path = os.path.join( + os.path.normpath(stagingdir), conc_text_file) + _remove_at_end.append(conc_text_path) + self.log.debug("__ conc_text_path: {}".format(conc_text_path)) - return filters + new_line = "\n" + with open(conc_text_path, "w") as conc_text_f: + conc_text_f.writelines([ + "file {}".format( + slate_v_path.replace("\\", "/")), + new_line, + "file {}".format(input_path.replace("\\", "/")) + ]) - def lut_filters(self, new_repre, instance, input_args): - """Add lut file to output ffmpeg filters.""" - filters = [] - # baking lut file application - lut_path = instance.data.get("lutPath") - if not lut_path or "bake-lut" not in new_repre["tags"]: - return filters - - # Prepare path for ffmpeg argument - lut_path = lut_path.replace("\\", "/").replace(":", "\\:") - - # Remove gamma from input arguments - if "-gamma" in input_args: - input_args.remove("-gamme") - - # Prepare filters - filters.append("lut3d=file='{}'".format(lut_path)) - # QUESTION hardcoded colormatrix? - filters.append("colormatrix=bt601:bt709") - - self.log.info("Added Lut to ffmpeg command.") - - return filters - - def main_family_from_instance(self, instance): - """Returns main family of entered instance.""" - family = instance.data.get("family") - if not family: - family = instance.data["families"][0] - return family - - def families_from_instance(self, instance): - """Returns all families of entered instance.""" - families = [] - family = instance.data.get("family") - if family: - families.append(family) - - for family in (instance.data.get("families") or tuple()): - if family not in families: - families.append(family) - return families - - def compile_list_of_regexes(self, in_list): - """Convert strings in entered list to compiled regex objects.""" - regexes = [] - if not in_list: - return regexes - - for item in in_list: - if not item: - continue - - try: - regexes.append(re.compile(item)) - except TypeError: - self.log.warning(( - "Invalid type \"{}\" value \"{}\"." - " Expected string based object. Skipping." - ).format(str(type(item)), str(item))) - - return regexes - - def validate_value_by_regexes(self, value, in_list): - """Validates in any regex from list match entered value. - - Args: - in_list (list): List with regexes. - value (str): String where regexes is checked. - - Returns: - int: Returns `0` when list is not set or is empty. Returns `1` when - any regex match value and returns `-1` when none of regexes - match value entered. - """ - if not in_list: - return 0 - - output = -1 - regexes = self.compile_list_of_regexes(in_list) - for regex in regexes: - if re.match(regex, value): - output = 1 - break - return output - - def profile_exclusion(self, matching_profiles): - """Find out most matching profile byt host, task and family match. - - Profiles are selectively filtered. Each profile should have - "__value__" key with list of booleans. Each boolean represents - existence of filter for specific key (host, tasks, family). - Profiles are looped in sequence. In each sequence are split into - true_list and false_list. For next sequence loop are used profiles in - true_list if there are any profiles else false_list is used. - - Filtering ends when only one profile left in true_list. Or when all - existence booleans loops passed, in that case first profile from left - profiles is returned. - - Args: - matching_profiles (list): Profiles with same values. - - Returns: - dict: Most matching profile. - """ - self.log.info( - "Search for first most matching profile in match order:" - " Host name -> Task name -> Family." - ) - # Filter all profiles with highest points value. First filter profiles - # with matching host if there are any then filter profiles by task - # name if there are any and lastly filter by family. Else use first in - # list. - idx = 0 - final_profile = None - while True: - profiles_true = [] - profiles_false = [] - for profile in matching_profiles: - value = profile["__value__"] - # Just use first profile when idx is greater than values. - if not idx < len(value): - final_profile = profile - break - - if value[idx]: - profiles_true.append(profile) - else: - profiles_false.append(profile) - - if final_profile is not None: - break - - if profiles_true: - matching_profiles = profiles_true - else: - matching_profiles = profiles_false - - if len(matching_profiles) == 1: - final_profile = matching_profiles[0] - break - idx += 1 - - final_profile.pop("__value__") - return final_profile - - def find_matching_profile(self, host_name, task_name, family): - """ Filter profiles by Host name, Task name and main Family. - - Filtering keys are "hosts" (list), "tasks" (list), "families" (list). - If key is not find or is empty than it's expected to match. - - Args: - profiles (list): Profiles definition from presets. - host_name (str): Current running host name. - task_name (str): Current context task name. - family (str): Main family of current Instance. - - Returns: - dict/None: Return most matching profile or None if none of profiles - match at least one criteria. - """ - - matching_profiles = None - if not self.profiles: - return matching_profiles - - highest_profile_points = -1 - # Each profile get 1 point for each matching filter. Profile with most - # points is returned. For cases when more than one profile will match - # are also stored ordered lists of matching values. - for profile in self.profiles: - profile_points = 0 - profile_value = [] - - # Host filtering - host_names = profile.get("hosts") - match = self.validate_value_by_regexes(host_name, host_names) - if match == -1: - self.log.debug( - "\"{}\" not found in {}".format(host_name, host_names) - ) - continue - profile_points += match - profile_value.append(bool(match)) - - # Task filtering - task_names = profile.get("tasks") - match = self.validate_value_by_regexes(task_name, task_names) - if match == -1: - self.log.debug( - "\"{}\" not found in {}".format(task_name, task_names) - ) - continue - profile_points += match - profile_value.append(bool(match)) - - # Family filtering - families = profile.get("families") - match = self.validate_value_by_regexes(family, families) - if match == -1: - self.log.debug( - "\"{}\" not found in {}".format(family, families) - ) - continue - profile_points += match - profile_value.append(bool(match)) - - if profile_points < highest_profile_points: - continue - - if profile_points > highest_profile_points: - matching_profiles = [] - highest_profile_points = profile_points - - if profile_points == highest_profile_points: - profile["__value__"] = profile_value - matching_profiles.append(profile) - - if not matching_profiles: - self.log.warning(( - "None of profiles match your setup." - " Host \"{}\" | Task: \"{}\" | Family: \"{}\"" - ).format(host_name, task_name, family)) - return - - if len(matching_profiles) == 1: - # Pop temporary key `__value__` - matching_profiles[0].pop("__value__") - return matching_profiles[0] - - self.log.warning(( - "More than one profile match your setup." - " Host \"{}\" | Task: \"{}\" | Family: \"{}\"" - ).format(host_name, task_name, family)) - - return self.profile_exclusion(matching_profiles) - - def families_filter_validation(self, families, output_families_filter): - """Determines if entered families intersect with families filters. - - All family values are lowered to avoid unexpected results. - """ - if not output_families_filter: - return True - - single_families = [] - combination_families = [] - for family_filter in output_families_filter: - if not family_filter: - continue - if isinstance(family_filter, (list, tuple)): - _family_filter = [] - for family in family_filter: - if family: - _family_filter.append(family.lower()) - combination_families.append(_family_filter) - else: - single_families.append(family_filter.lower()) - - for family in single_families: - if family in families: - return True - - for family_combination in combination_families: - valid = True - for family in family_combination: - if family not in families: - valid = False - break - - if valid: - return True - return False - - def filter_output_defs(self, profile, subset_name, families): - """Return outputs matching input instance families. - - Output definitions without families filter are marked as valid. - - Args: - profile (dict): Profile from presets matching current context. - families (list): All families of current instance. - - Returns: - list: Containg all output definitions matching entered families. - """ - outputs = profile.get("outputs") or [] - if not outputs: - return outputs - - # lower values - # QUESTION is this valid operation? - families = [family.lower() for family in families] - - filtered_outputs = {} - for filename_suffix, output_def in outputs.items(): - output_filters = output_def.get("filter") - # If no filter on output preset, skip filtering and add output - # profile for farther processing - if not output_filters: - filtered_outputs[filename_suffix] = output_def - continue - - families_filters = output_filters.get("families") - if not self.families_filter_validation(families, families_filters): - continue - - # Subsets name filters - subset_filters = [ - subset_filter - for subset_filter in output_filters.get("subsets", []) - # Skip empty strings - if subset_filter + # concat slate and videos together + concat_args = [ + ffmpeg_path, + "-y", + "-f", "concat", + "-safe", "0", + "-i", conc_text_path, + "-c:v", "copy", + output_path ] - if subset_name and subset_filters: - match = False - for subset_filter in subset_filters: - compiled = re.compile(subset_filter) - if compiled.search(subset_name): - match = True - break + if not input_audio: + # ffmpeg concat subprocess + self.log.debug( + "Executing concat: {}".format(" ".join(concat_args)) + ) + openpype.api.run_subprocess( + concat_args, logger=self.log + ) + else: + self.log.warning( + "Audio found. Creating slate with audio" + " is not supported at this time. Outputing slate-less" + ":\n{}".format(input_file)) + # skip concatenating slate, use slate-less file instead + shutil.copyfile(input_path, output_path) - if not match: - continue + self.log.debug("__ repre[tags]: {}".format(repre["tags"])) + repre_update = { + "files": output_file, + "name": repre["name"], + "tags": [x for x in repre["tags"] if x != "delete"] + } + inst_data["representations"][idx].update(repre_update) + self.log.debug( + "_ representation {}: `{}`".format( + idx, inst_data["representations"][idx])) - filtered_outputs[filename_suffix] = output_def + # removing temp files + for f in _remove_at_end: + os.remove(f) + self.log.debug("Removed: `{}`".format(f)) - return filtered_outputs + # Remove any representations tagged for deletion. + for repre in inst_data.get("representations", []): + if "delete" in repre.get("tags", []): + self.log.debug("Removing representation: {}".format(repre)) + inst_data["representations"].remove(repre) - def filter_outputs_by_tags(self, outputs, tags): - """Filter output definitions by entered representation tags. - - Output definitions without tags filter are marked as valid. - - Args: - outputs (list): Contain list of output definitions from presets. - tags (list): Tags of processed representation. - - Returns: - list: Containg all output definitions matching entered tags. - """ - filtered_outputs = [] - repre_tags_low = [tag.lower() for tag in tags] - for output_def in outputs: - valid = True - output_filters = output_def.get("filter") - if output_filters: - # Check tag filters - tag_filters = output_filters.get("tags") - if tag_filters: - tag_filters_low = [tag.lower() for tag in tag_filters] - valid = False - for tag in repre_tags_low: - if tag in tag_filters_low: - valid = True - break - - if not valid: - continue - - if valid: - filtered_outputs.append(output_def) - - return filtered_outputs + self.log.debug(inst_data["representations"]) def add_video_filter_args(self, args, inserting_arg): """ - Fixing video filter arguments to be one long string + Fixing video filter argumets to be one long string Args: args (list): list of string arguments @@ -1784,299 +416,74 @@ class ExtractReview(pyblish.api.InstancePlugin): return vf_back + def _get_codec_args(self, repre): + """Detect possible codec arguments from representation.""" + codec_args = [] -@six.add_metaclass(ABCMeta) -class _OverscanValue: - def __repr__(self): - return "<{}> {}".format(self.__class__.__name__, str(self)) + # Get one filename of representation files + filename = repre["files"] + # If files is list then pick first filename in list + if isinstance(filename, (tuple, list)): + filename = filename[0] + # Get full path to the file + full_input_path = os.path.join(repre["stagingDir"], filename) - @abstractmethod - def copy(self): - """Create a copy of object.""" - pass - - @abstractmethod - def size_for(self, value): - """Calculate new value for passed value.""" - pass - - -class PixValueExplicit(_OverscanValue): - def __init__(self, value): - self._value = int(value) - - def __str__(self): - return "{}px".format(self._value) - - def copy(self): - return PixValueExplicit(self._value) - - def size_for(self, value): - if self._value == 0: - return value - return self._value - - -class PercentValueExplicit(_OverscanValue): - def __init__(self, value): - self._value = float(value) - - def __str__(self): - return "{}%".format(abs(self._value)) - - def copy(self): - return PercentValueExplicit(self._value) - - def size_for(self, value): - if self._value == 0: - return value - return int((value / 100) * self._value) - - -class PixValueRelative(_OverscanValue): - def __init__(self, value): - self._value = int(value) - - def __str__(self): - sign = "-" if self._value < 0 else "+" - return "{}{}px".format(sign, abs(self._value)) - - def copy(self): - return PixValueRelative(self._value) - - def size_for(self, value): - return value + self._value - - -class PercentValueRelative(_OverscanValue): - def __init__(self, value): - self._value = float(value) - - def __str__(self): - return "{}%".format(self._value) - - def copy(self): - return PercentValueRelative(self._value) - - def size_for(self, value): - if self._value == 0: - return value - - offset = int((value / 100) * self._value) - - return value + offset - - -class PercentValueRelativeSource(_OverscanValue): - def __init__(self, value, source_sign): - self._value = float(value) - if source_sign not in ("-", "+"): - raise ValueError( - "Invalid sign value \"{}\" expected \"-\" or \"+\"".format( - source_sign - ) + try: + # Get information about input file via ffprobe tool + ffprobe_data = get_ffprobe_data(full_input_path, self.log) + except Exception: + self.log.warning( + "Could not get codec data from input.", + exc_info=True ) - self._source_sign = source_sign + return codec_args - def __str__(self): - return "{}%{}".format(self._value, self._source_sign) - - def copy(self): - return PercentValueRelativeSource(self._value, self._source_sign) - - def size_for(self, value): - if self._value == 0: - return value - return int((value * 100) / (100 - self._value)) - - -class OverscanCrop: - """Helper class to read overscan string and calculate output resolution. - - It is possible to enter single value for both width and heigh or two values - for width and height. Overscan string may have a few variants. Each variant - define output size for input size. - - ### Example - For input size: 2200px - - | String | Output | Description | - |----------|--------|-------------------------------------------------| - | "" | 2200px | Empty string does nothing. | - | "10%" | 220px | Explicit percent size. | - | "-10%" | 1980px | Relative percent size (decrease). | - | "+10%" | 2420px | Relative percent size (increase). | - | "-10%+" | 2000px | Relative percent size to output size. | - | "300px" | 300px | Explicit output size cropped or expanded. | - | "-300px" | 1900px | Relative pixel size (decrease). | - | "+300px" | 2500px | Relative pixel size (increase). | - | "300" | 300px | Value without "%" and "px" is used as has "px". | - - Value without sign (+/-) in is always explicit and value with sign is - relative. Output size for "200px" and "+200px" are not the same. - Values "0", "0px" or "0%" are ignored. - - All values that cause output resolution smaller than 1 pixel are invalid. - - Value "-10%+" is a special case which says that input's resolution is - bigger by 10% than expected output. - - It is possible to combine these variants to define different output for - width and height. - - Resolution: 2000px 1000px - - | String | Output | - |---------------|---------------| - | "100px 120px" | 2100px 1120px | - | "-10% -200px" | 1800px 800px | - """ - - item_regex = re.compile(r"([\+\-])?([0-9]+)(.+)?") - relative_source_regex = re.compile(r"%([\+\-])") - - def __init__( - self, input_width, input_height, string_value, overscal_color=None - ): - # Make sure that is not None - string_value = string_value or "" - - self.input_width = input_width - self.input_height = input_height - self.overscal_color = overscal_color - - width, height = self._convert_string_to_values(string_value) - self._width_value = width - self._height_value = height - - self._string_value = string_value - - def __str__(self): - return "{}".format(self._string_value) - - def __repr__(self): - return "<{}>".format(self.__class__.__name__) - - def width(self): - """Calculated width.""" - return self._width_value.size_for(self.input_width) - - def height(self): - """Calculated height.""" - return self._height_value.size_for(self.input_height) - - def video_filters(self): - """FFmpeg video filters to achieve expected result. - - Filter may be empty, use "crop" filter, "pad" filter or combination of - "crop" and "pad". - - Returns: - list: FFmpeg video filters. - """ - # crop=width:height:x:y - explicit start x, y position - # crop=width:height - x, y are related to center by width/height - # pad=width:heigth:x:y - explicit start x, y position - # pad=width:heigth - x, y are set to 0 by default - - width = self.width() - height = self.height() - - output = [] - if self.input_width == width and self.input_height == height: - return output - - # Make sure resolution has odd numbers - if width % 2 == 1: - width -= 1 - - if height % 2 == 1: - height -= 1 - - if width <= self.input_width and height <= self.input_height: - output.append("crop={}:{}".format(width, height)) - - elif width >= self.input_width and height >= self.input_height: - output.append( - "pad={}:{}:(iw-ow)/2:(ih-oh)/2:{}".format( - width, height, self.overscal_color - ) + source_ffmpeg_cmd = repre.get("ffmpeg_cmd") + codec_args.extend( + get_ffmpeg_format_args(ffprobe_data, source_ffmpeg_cmd) + ) + codec_args.extend( + get_ffmpeg_codec_args( + ffprobe_data, source_ffmpeg_cmd, logger=self.log ) - - elif width > self.input_width and height < self.input_height: - output.append("crop=iw:{}".format(height)) - output.append("pad={}:ih:(iw-ow)/2:(ih-oh)/2:{}".format( - width, self.overscal_color - )) - - elif width < self.input_width and height > self.input_height: - output.append("crop={}:ih".format(width)) - output.append("pad=iw:{}:(iw-ow)/2:(ih-oh)/2:{}".format( - height, self.overscal_color - )) - - return output - - def _convert_string_to_values(self, orig_string_value): - string_value = orig_string_value.strip().lower() - if not string_value: - return [PixValueRelative(0), PixValueRelative(0)] - - # Replace "px" (and spaces before) with single space - string_value = re.sub(r"([ ]+)?px", " ", string_value) - string_value = re.sub(r"([ ]+)%", "%", string_value) - # Make sure +/- sign at the beggining of string is next to number - string_value = re.sub(r"^([\+\-])[ ]+", "\g<1>", string_value) - # Make sure +/- sign in the middle has zero spaces before number under - # which belongs - string_value = re.sub( - r"[ ]([\+\-])[ ]+([0-9])", - r" \g<1>\g<2>", - string_value ) - string_parts = [ - part - for part in string_value.split(" ") - if part - ] - error_msg = "Invalid string for rescaling \"{}\"".format( - orig_string_value - ) - if 1 > len(string_parts) > 2: - raise ValueError(error_msg) + return codec_args - output = [] - for item in string_parts: - groups = self.item_regex.findall(item) - if not groups: - raise ValueError(error_msg) - - relative_sign, value, ending = groups[0] - if not relative_sign: - if not ending: - output.append(PixValueExplicit(value)) - else: - output.append(PercentValueExplicit(value)) + def _tc_offset(self, timecode, framerate=24.0, frame_offset=-1): + """Offsets timecode by frame""" + def _seconds(value, framerate): + if isinstance(value, str): + _zip_ft = zip((3600, 60, 1, 1/framerate), value.split(':')) + _s = sum(f * float(t) for f,t in _zip_ft) + elif isinstance(value, (int, float)): + _s = value / framerate else: - source_sign_group = self.relative_source_regex.findall(ending) - if not ending: - output.append(PixValueRelative(int(relative_sign + value))) + _s = 0 + return _s - elif source_sign_group: - source_sign = source_sign_group[0] - output.append(PercentValueRelativeSource( - float(relative_sign + value), source_sign - )) - else: - output.append( - PercentValueRelative(float(relative_sign + value)) - ) + def _frames(seconds, framerate, frame_offset): + _f = seconds * framerate + frame_offset + if _f < 0: + _f = framerate * 60 * 60 * 24 + _f + return _f - if len(output) == 1: - width = output.pop(0) - height = width.copy() - else: - width, height = output - - return width, height + def _timecode(seconds, framerate): + return '{h:02d}:{m:02d}:{s:02d}:{f:02d}'.format( + h = int(seconds / 3600), + m = int(seconds / 60 % 60), + s = int(seconds % 60), + f = int(round((seconds - int(seconds)) * framerate))) + drop = False + if ';' in timecode: + timecode = timecode.replace(';', ':') + drop = True + frames = _frames( + _seconds(timecode, framerate), + framerate, + frame_offset + ) + tc = _timecode(_seconds(frames, framerate), framerate) + if drop: + tc = ';'.join(tc.rsplit(':', 1)) + return tc From 8abc3ff7953001b687af2d860e0892a3d968c0a6 Mon Sep 17 00:00:00 2001 From: Jiri Sindelar <45896205+jrsndl@users.noreply.github.com> Date: Thu, 14 Apr 2022 19:19:28 +0200 Subject: [PATCH 056/583] hound --- openpype/plugins/publish/extract_review_slate.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/openpype/plugins/publish/extract_review_slate.py b/openpype/plugins/publish/extract_review_slate.py index c8ee2ec7ed..13526ece66 100644 --- a/openpype/plugins/publish/extract_review_slate.py +++ b/openpype/plugins/publish/extract_review_slate.py @@ -454,8 +454,8 @@ class ExtractReviewSlate(openpype.api.Extractor): """Offsets timecode by frame""" def _seconds(value, framerate): if isinstance(value, str): - _zip_ft = zip((3600, 60, 1, 1/framerate), value.split(':')) - _s = sum(f * float(t) for f,t in _zip_ft) + _zip_ft = zip((3600, 60, 1, 1 / framerate), value.split(':')) + _s = sum(f * float(t) for f, t in _zip_ft) elif isinstance(value, (int, float)): _s = value / framerate else: @@ -470,10 +470,10 @@ class ExtractReviewSlate(openpype.api.Extractor): def _timecode(seconds, framerate): return '{h:02d}:{m:02d}:{s:02d}:{f:02d}'.format( - h = int(seconds / 3600), - m = int(seconds / 60 % 60), - s = int(seconds % 60), - f = int(round((seconds - int(seconds)) * framerate))) + h=int(seconds / 3600), + m=int(seconds / 60 % 60), + s=int(seconds % 60), + f=int(round((seconds - int(seconds)) * framerate))) drop = False if ';' in timecode: timecode = timecode.replace(';', ':') From 31020f6a9c9764649040c874110eeeaec2b50269 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 15 Apr 2022 11:53:53 +0200 Subject: [PATCH 057/583] flame: fixing flair to flare --- .../schemas/projects_schema/schemas/schema_anatomy_imageio.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_imageio.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_imageio.json index 9f142bad09..1d6c428fe0 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_imageio.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_imageio.json @@ -446,7 +446,7 @@ { "key": "flame", "type": "dict", - "label": "Flame/Flair", + "label": "Flame & Flare", "children": [ { "key": "project", From eae3934aa82751988f282381203343db21b60a9f Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 15 Apr 2022 15:21:26 +0200 Subject: [PATCH 058/583] flame: fixing loading --- openpype/hosts/flame/api/plugin.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/openpype/hosts/flame/api/plugin.py b/openpype/hosts/flame/api/plugin.py index c87445fdd3..11108ba49f 100644 --- a/openpype/hosts/flame/api/plugin.py +++ b/openpype/hosts/flame/api/plugin.py @@ -873,6 +873,5 @@ class OpenClipSolver(flib.MediaInfoFile): if feed_clr_obj is not None: feed_clr_obj = ET.Element( "colourSpace", {"type": "string"}) + feed_clr_obj.text = profile_name feed_storage_obj.append(feed_clr_obj) - - feed_clr_obj.text = profile_name From 884e1a409ed6d0b5b47af640a2f82b38aab7b5bd Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 20 Apr 2022 14:18:00 +0200 Subject: [PATCH 059/583] removed env_group_key from schemas --- openpype/settings/entities/schemas/README.md | 3 +-- .../schemas/system_schema/example_schema.json | 13 ------------- .../schemas/system_schema/example_template.json | 3 +-- .../schemas/system_schema/schema_general.json | 1 - openpype/tools/settings/settings/README.md | 3 +-- 5 files changed, 3 insertions(+), 20 deletions(-) diff --git a/openpype/settings/entities/schemas/README.md b/openpype/settings/entities/schemas/README.md index b4bfef2972..b4c878fe0f 100644 --- a/openpype/settings/entities/schemas/README.md +++ b/openpype/settings/entities/schemas/README.md @@ -46,8 +46,7 @@ }, { "type": "raw-json", "label": "{host_label} Environments", - "key": "{host_name}_environments", - "env_group_key": "{host_name}" + "key": "{host_name}_environments" }, { "type": "path", "key": "{host_name}_executables", diff --git a/openpype/settings/entities/schemas/system_schema/example_schema.json b/openpype/settings/entities/schemas/system_schema/example_schema.json index 6a86dae259..b9747b5f4f 100644 --- a/openpype/settings/entities/schemas/system_schema/example_schema.json +++ b/openpype/settings/entities/schemas/system_schema/example_schema.json @@ -117,19 +117,6 @@ } ] }, - { - "key": "env_group_test", - "label": "EnvGroup Test", - "type": "dict", - "children": [ - { - "key": "key_to_store_in_system_settings", - "label": "Testing environment group", - "type": "raw-json", - "env_group_key": "test_group" - } - ] - }, { "key": "dict_wrapper", "type": "dict", diff --git a/openpype/settings/entities/schemas/system_schema/example_template.json b/openpype/settings/entities/schemas/system_schema/example_template.json index ff78c78e8f..9955cf5651 100644 --- a/openpype/settings/entities/schemas/system_schema/example_template.json +++ b/openpype/settings/entities/schemas/system_schema/example_template.json @@ -7,8 +7,7 @@ { "type": "raw-json", "label": "{host_label} Environments", - "key": "{host_name}_environments", - "env_group_key": "{host_name}" + "key": "{host_name}_environments" }, { "type": "path", diff --git a/openpype/settings/entities/schemas/system_schema/schema_general.json b/openpype/settings/entities/schemas/system_schema/schema_general.json index fcab4cd5d8..695ab8bceb 100644 --- a/openpype/settings/entities/schemas/system_schema/schema_general.json +++ b/openpype/settings/entities/schemas/system_schema/schema_general.json @@ -34,7 +34,6 @@ "key": "environment", "label": "Environment", "type": "raw-json", - "env_group_key": "global", "require_restart": true }, { diff --git a/openpype/tools/settings/settings/README.md b/openpype/tools/settings/settings/README.md index 1c916ddff2..c29664a907 100644 --- a/openpype/tools/settings/settings/README.md +++ b/openpype/tools/settings/settings/README.md @@ -44,8 +44,7 @@ }, { "type": "raw-json", "label": "{host_label} Environments", - "key": "{host_name}_environments", - "env_group_key": "{host_name}" + "key": "{host_name}_environments" }, { "type": "path-widget", "key": "{host_name}_executables", From ecbf5d859b13332b9afbada6524dff8b25d9b72a Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 20 Apr 2022 14:19:03 +0200 Subject: [PATCH 060/583] removed env group ogic from entities --- openpype/settings/entities/base_entity.py | 21 ----------- .../entities/dict_mutable_keys_entity.py | 33 ++++------------- openpype/settings/entities/input_entities.py | 20 +---------- openpype/settings/entities/root_entities.py | 36 ------------------- .../settings/settings/dict_mutable_widget.py | 4 --- 5 files changed, 7 insertions(+), 107 deletions(-) diff --git a/openpype/settings/entities/base_entity.py b/openpype/settings/entities/base_entity.py index 21ee44ae77..741f13c49b 100644 --- a/openpype/settings/entities/base_entity.py +++ b/openpype/settings/entities/base_entity.py @@ -127,12 +127,6 @@ class BaseItemEntity(BaseEntity): # Entity is in hierarchy of dynamically created entity self.is_in_dynamic_item = False - # Entity will save metadata about environments - # - this is current possible only for RawJsonEnity - self.is_env_group = False - # Key of environment group key must be unique across system settings - self.env_group_key = None - # Roles of an entity self.roles = None @@ -286,16 +280,6 @@ class BaseItemEntity(BaseEntity): ).format(self.group_item.path) raise EntitySchemaError(self, reason) - # Validate that env group entities will be stored into file. - # - env group entities must store metadata which is not possible if - # metadata would be outside of file - if self.file_item is None and self.is_env_group: - reason = ( - "Environment item is not inside file" - " item so can't store metadata for defaults." - ) - raise EntitySchemaError(self, reason) - # Dynamic items must not have defined labels. (UI specific) if self.label and self.is_dynamic_item: raise EntitySchemaError( @@ -862,11 +846,6 @@ class ItemEntity(BaseItemEntity): if self.is_dynamic_item: self.require_key = False - # If value should be stored to environments and uder which group key - # - the key may be dynamically changed by it's parent on save - self.env_group_key = self.schema_data.get("env_group_key") - self.is_env_group = bool(self.env_group_key is not None) - # Root item reference self.root_item = self.parent.root_item diff --git a/openpype/settings/entities/dict_mutable_keys_entity.py b/openpype/settings/entities/dict_mutable_keys_entity.py index a0c93b97a7..3dc07524af 100644 --- a/openpype/settings/entities/dict_mutable_keys_entity.py +++ b/openpype/settings/entities/dict_mutable_keys_entity.py @@ -148,11 +148,7 @@ class DictMutableKeysEntity(EndpointEntity): ): raise InvalidKeySymbols(self.path, key) - if self.value_is_env_group: - item_schema = copy.deepcopy(self.item_schema) - item_schema["env_group_key"] = key - else: - item_schema = self.item_schema + item_schema = self.item_schema new_child = self.create_schema_object(item_schema, self, True) self.children_by_key[key] = new_child @@ -216,9 +212,7 @@ class DictMutableKeysEntity(EndpointEntity): self.children_label_by_id = {} self.store_as_list = self.schema_data.get("store_as_list") or False - self.value_is_env_group = ( - self.schema_data.get("value_is_env_group") or False - ) + self.required_keys = self.schema_data.get("required_keys") or [] self.collapsible_key = self.schema_data.get("collapsible_key") or False # GUI attributes @@ -241,9 +235,6 @@ class DictMutableKeysEntity(EndpointEntity): object_type.update(input_modifiers) self.item_schema = object_type - if self.value_is_env_group: - self.item_schema["env_group_key"] = "" - if self.group_item is None: self.is_group = True @@ -259,10 +250,6 @@ class DictMutableKeysEntity(EndpointEntity): if used_temp_label: self.label = None - if self.value_is_env_group and self.store_as_list: - reason = "Item can't store environments metadata to list output." - raise EntitySchemaError(self, reason) - if not self.schema_data.get("object_type"): reason = ( "Modifiable dictionary must have specified `object_type`." @@ -579,18 +566,10 @@ class DictMutableKeysEntity(EndpointEntity): output.append([key, child_value]) return output - output = {} - for key, child_entity in self.children_by_key.items(): - child_value = child_entity.settings_value() - # TODO child should have setter of env group key se child can - # know what env group represents. - if self.value_is_env_group: - if key not in child_value[M_ENVIRONMENT_KEY]: - _metadata = child_value[M_ENVIRONMENT_KEY] - _m_keykey = tuple(_metadata.keys())[0] - env_keys = child_value[M_ENVIRONMENT_KEY].pop(_m_keykey) - child_value[M_ENVIRONMENT_KEY][key] = env_keys - output[key] = child_value + output = { + key: child_entity.settings_value() + for key, child_entity in self.children_by_key.items() + } output.update(self.metadata) return output diff --git a/openpype/settings/entities/input_entities.py b/openpype/settings/entities/input_entities.py index 3dcd238672..32eedf3b3e 100644 --- a/openpype/settings/entities/input_entities.py +++ b/openpype/settings/entities/input_entities.py @@ -534,13 +534,7 @@ class RawJsonEntity(InputEntity): @property def metadata(self): - output = {} - if isinstance(self._current_value, dict) and self.is_env_group: - output[M_ENVIRONMENT_KEY] = { - self.env_group_key: list(self._current_value.keys()) - } - - return output + return {} @property def has_unsaved_changes(self): @@ -549,15 +543,6 @@ class RawJsonEntity(InputEntity): result = self.metadata != self._metadata_for_current_state() return result - def schema_validations(self): - if self.store_as_string and self.is_env_group: - reason = ( - "RawJson entity can't store environment group metadata" - " as string." - ) - raise EntitySchemaError(self, reason) - super(RawJsonEntity, self).schema_validations() - def _convert_to_valid_type(self, value): if isinstance(value, STRING_TYPE): try: @@ -583,9 +568,6 @@ class RawJsonEntity(InputEntity): def _settings_value(self): value = super(RawJsonEntity, self)._settings_value() - if self.is_env_group and isinstance(value, dict): - value.update(self.metadata) - if self.store_as_string: return json.dumps(value) return value diff --git a/openpype/settings/entities/root_entities.py b/openpype/settings/entities/root_entities.py index edb4407679..ff76fa5180 100644 --- a/openpype/settings/entities/root_entities.py +++ b/openpype/settings/entities/root_entities.py @@ -52,7 +52,6 @@ from openpype.settings.lib import ( get_available_studio_project_settings_overrides_versions, get_available_studio_project_anatomy_overrides_versions, - find_environments, apply_overrides ) @@ -422,11 +421,6 @@ class RootEntity(BaseItemEntity): """ pass - @abstractmethod - def _validate_defaults_to_save(self, value): - """Validate default values before save.""" - pass - def _save_default_values(self): """Save default values. @@ -435,7 +429,6 @@ class RootEntity(BaseItemEntity): DEFAULTS. """ settings_value = self.settings_value() - self._validate_defaults_to_save(settings_value) defaults_dir = self.defaults_dir() for file_path, value in settings_value.items(): @@ -604,8 +597,6 @@ class SystemSettings(RootEntity): def _save_studio_values(self): settings_value = self.settings_value() - self._validate_duplicated_env_group(settings_value) - self.log.debug("Saving system settings: {}".format( json.dumps(settings_value, indent=4) )) @@ -613,29 +604,6 @@ class SystemSettings(RootEntity): # Reset source version after restart self._source_version = None - def _validate_defaults_to_save(self, value): - """Valiations of default values before save.""" - self._validate_duplicated_env_group(value) - - def _validate_duplicated_env_group(self, value, override_state=None): - """ Validate duplicated environment groups. - - Raises: - DuplicatedEnvGroups: When value contain duplicated env groups. - """ - value = copy.deepcopy(value) - if override_state is None: - override_state = self._override_state - - if override_state is OverrideState.STUDIO: - default_values = get_default_settings()[SYSTEM_SETTINGS_KEY] - final_value = apply_overrides(default_values, value) - else: - final_value = value - - # Check if final_value contain duplicated environment groups - find_environments(final_value) - def _save_project_values(self): """System settings can't have project overrides. @@ -911,10 +879,6 @@ class ProjectSettings(RootEntity): if warnings: raise SaveWarningExc(warnings) - def _validate_defaults_to_save(self, value): - """Valiations of default values before save.""" - pass - def _validate_values_to_save(self, value): pass diff --git a/openpype/tools/settings/settings/dict_mutable_widget.py b/openpype/tools/settings/settings/dict_mutable_widget.py index 6489266131..1c704b3cd5 100644 --- a/openpype/tools/settings/settings/dict_mutable_widget.py +++ b/openpype/tools/settings/settings/dict_mutable_widget.py @@ -465,10 +465,6 @@ class ModifiableDictItem(QtWidgets.QWidget): self.entity_widget.change_key(key, self) self.update_style() - @property - def value_is_env_group(self): - return self.entity_widget.value_is_env_group - def update_key_label(self): if not self.collapsible_key: return From 95a8ccb47488dbf0d3c4be9333c3222887bfe017 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 20 Apr 2022 14:20:33 +0200 Subject: [PATCH 061/583] removed logic related to env groups --- openpype/api.py | 8 +- openpype/lib/env_tools.py | 54 ----------- openpype/settings/__init__.py | 2 - openpype/settings/constants.py | 4 - .../entities/dict_mutable_keys_entity.py | 1 - openpype/settings/entities/input_entities.py | 5 +- openpype/settings/lib.py | 95 ------------------- 7 files changed, 4 insertions(+), 165 deletions(-) diff --git a/openpype/api.py b/openpype/api.py index b692b36065..9ce745b653 100644 --- a/openpype/api.py +++ b/openpype/api.py @@ -3,7 +3,6 @@ from .settings import ( get_project_settings, get_current_project_settings, get_anatomy_settings, - get_environments, SystemSettings, ProjectSettings @@ -23,7 +22,6 @@ from .lib import ( get_app_environments_for_context, source_hash, get_latest_version, - get_global_environments, get_local_site_id, change_openpype_mongo_url, create_project_folders, @@ -69,10 +67,10 @@ __all__ = [ "get_project_settings", "get_current_project_settings", "get_anatomy_settings", - "get_environments", "get_project_basic_paths", "SystemSettings", + "ProjectSettings", "PypeLogger", "Logger", @@ -102,8 +100,9 @@ __all__ = [ # get contextual data "version_up", - "get_hierarchy", "get_asset", + "get_hierarchy", + "get_workdir_data", "get_version_from_path", "get_last_version_from_path", "get_app_environments_for_context", @@ -111,7 +110,6 @@ __all__ = [ "run_subprocess", "get_latest_version", - "get_global_environments", "get_local_site_id", "change_openpype_mongo_url", diff --git a/openpype/lib/env_tools.py b/openpype/lib/env_tools.py index 6521d20f1e..25bcbf7c1b 100644 --- a/openpype/lib/env_tools.py +++ b/openpype/lib/env_tools.py @@ -69,57 +69,3 @@ def get_paths_from_environ(env_key=None, env_value=None, return_first=False): return None # Return all existing paths from environment variable return existing_paths - - -def get_global_environments(env=None): - """Load global environments from Pype. - - Return prepared and parsed global environments by pype's settings. Use - combination of "global" environments set in pype's settings and enabled - modules. - - Args: - env (dict, optional): Initial environments. Empty dictionary is used - when not entered. - - Returns; - dict of str: Loaded and processed environments. - - """ - import acre - from openpype.modules import ModulesManager - from openpype.settings import get_environments - - if env is None: - env = {} - - # Get global environments from settings - all_settings_env = get_environments() - parsed_global_env = acre.parse(all_settings_env["global"]) - - # Merge with entered environments - merged_env = acre.append(env, parsed_global_env) - - # Get environments from Pype modules - modules_manager = ModulesManager() - - module_envs = modules_manager.collect_global_environments() - publish_plugin_dirs = modules_manager.collect_plugin_paths()["publish"] - - # Set pyblish plugins paths if any module want to register them - if publish_plugin_dirs: - publish_paths_str = os.environ.get("PYBLISHPLUGINPATH") or "" - publish_paths = publish_paths_str.split(os.pathsep) - _publish_paths = { - os.path.normpath(path) for path in publish_paths if path - } - for path in publish_plugin_dirs: - _publish_paths.add(os.path.normpath(path)) - module_envs["PYBLISHPLUGINPATH"] = os.pathsep.join(_publish_paths) - - # Merge environments with current environments and update values - if module_envs: - parsed_envs = acre.parse(module_envs) - merged_env = acre.merge(parsed_envs, merged_env) - - return acre.compute(merged_env, cleanup=True) diff --git a/openpype/settings/__init__.py b/openpype/settings/__init__.py index 14e4678050..ca7157812d 100644 --- a/openpype/settings/__init__.py +++ b/openpype/settings/__init__.py @@ -22,7 +22,6 @@ from .lib import ( get_project_settings, get_current_project_settings, get_anatomy_settings, - get_environments, get_local_settings ) from .entities import ( @@ -54,7 +53,6 @@ __all__ = ( "get_project_settings", "get_current_project_settings", "get_anatomy_settings", - "get_environments", "get_local_settings", "SystemSettings", diff --git a/openpype/settings/constants.py b/openpype/settings/constants.py index 19ff953eb4..cd84d4db1c 100644 --- a/openpype/settings/constants.py +++ b/openpype/settings/constants.py @@ -3,14 +3,11 @@ import re # Metadata keys for work with studio and project overrides M_OVERRIDDEN_KEY = "__overriden_keys__" -# Metadata key for storing information about environments -M_ENVIRONMENT_KEY = "__environment_keys__" # Metadata key for storing dynamic created labels M_DYNAMIC_KEY_LABEL = "__dynamic_keys_labels__" METADATA_KEYS = frozenset([ M_OVERRIDDEN_KEY, - M_ENVIRONMENT_KEY, M_DYNAMIC_KEY_LABEL ]) @@ -35,7 +32,6 @@ KEY_REGEX = re.compile(r"^[{}]+$".format(KEY_ALLOWED_SYMBOLS)) __all__ = ( "M_OVERRIDDEN_KEY", - "M_ENVIRONMENT_KEY", "M_DYNAMIC_KEY_LABEL", "METADATA_KEYS", diff --git a/openpype/settings/entities/dict_mutable_keys_entity.py b/openpype/settings/entities/dict_mutable_keys_entity.py index 3dc07524af..e6d332b9ad 100644 --- a/openpype/settings/entities/dict_mutable_keys_entity.py +++ b/openpype/settings/entities/dict_mutable_keys_entity.py @@ -15,7 +15,6 @@ from .exceptions import ( from openpype.settings.constants import ( METADATA_KEYS, M_DYNAMIC_KEY_LABEL, - M_ENVIRONMENT_KEY, KEY_REGEX, KEY_ALLOWED_SYMBOLS ) diff --git a/openpype/settings/entities/input_entities.py b/openpype/settings/entities/input_entities.py index 32eedf3b3e..89f12afd9b 100644 --- a/openpype/settings/entities/input_entities.py +++ b/openpype/settings/entities/input_entities.py @@ -15,10 +15,7 @@ from .exceptions import ( EntitySchemaError ) -from openpype.settings.constants import ( - METADATA_KEYS, - M_ENVIRONMENT_KEY -) +from openpype.settings.constants import METADATA_KEYS class EndpointEntity(ItemEntity): diff --git a/openpype/settings/lib.py b/openpype/settings/lib.py index 937329b417..f921b9c318 100644 --- a/openpype/settings/lib.py +++ b/openpype/settings/lib.py @@ -9,7 +9,6 @@ from .exceptions import ( ) from .constants import ( M_OVERRIDDEN_KEY, - M_ENVIRONMENT_KEY, METADATA_KEYS, @@ -457,24 +456,6 @@ def get_local_settings(): return _LOCAL_SETTINGS_HANDLER.get_local_settings() -class DuplicatedEnvGroups(Exception): - def __init__(self, duplicated): - self.origin_duplicated = duplicated - self.duplicated = {} - for key, items in duplicated.items(): - self.duplicated[key] = [] - for item in items: - self.duplicated[key].append("/".join(item["parents"])) - - msg = "Duplicated environment group keys. {}".format( - ", ".join([ - "\"{}\"".format(env_key) for env_key in self.duplicated.keys() - ]) - ) - - super(DuplicatedEnvGroups, self).__init__(msg) - - def load_openpype_default_settings(): """Load openpype default settings.""" return load_jsons_from_dir(DEFAULTS_DIR) @@ -624,69 +605,6 @@ def load_jsons_from_dir(path, *args, **kwargs): return output -def find_environments(data, with_items=False, parents=None): - """ Find environemnt values from system settings by it's metadata. - - Args: - data(dict): System settings data or dictionary which may contain - environments metadata. - - Returns: - dict: Key as Environment key and value for `acre` module. - """ - if not data or not isinstance(data, dict): - return {} - - output = {} - if parents is None: - parents = [] - - if M_ENVIRONMENT_KEY in data: - metadata = data.get(M_ENVIRONMENT_KEY) - for env_group_key, env_keys in metadata.items(): - if env_group_key not in output: - output[env_group_key] = [] - - _env_values = {} - for key in env_keys: - _env_values[key] = data[key] - - item = { - "env": _env_values, - "parents": parents[:-1] - } - output[env_group_key].append(item) - - for key, value in data.items(): - _parents = copy.deepcopy(parents) - _parents.append(key) - result = find_environments(value, True, _parents) - if not result: - continue - - for env_group_key, env_values in result.items(): - if env_group_key not in output: - output[env_group_key] = [] - - for env_values_item in env_values: - output[env_group_key].append(env_values_item) - - if with_items: - return output - - duplicated_env_groups = {} - final_output = {} - for key, value_in_list in output.items(): - if len(value_in_list) > 1: - duplicated_env_groups[key] = value_in_list - else: - final_output[key] = value_in_list[0]["env"] - - if duplicated_env_groups: - raise DuplicatedEnvGroups(duplicated_env_groups) - return final_output - - def subkey_merge(_dict, value, keys): key = keys.pop(0) if not keys: @@ -1082,19 +1000,6 @@ def get_current_project_settings(): return get_project_settings(project_name) -def get_environments(): - """Calculated environment based on defaults and system settings. - - Any default environment also found in the system settings will be fully - overridden by the one from the system settings. - - Returns: - dict: Output should be ready for `acre` module. - """ - - return find_environments(get_system_settings(False)) - - def get_general_environments(): """Get general environments. From 475654f51f5d98a4230dd46e66910a60959d276e Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 20 Apr 2022 18:33:10 +0200 Subject: [PATCH 062/583] fix report messages --- openpype/pipeline/plugin_discover.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/pipeline/plugin_discover.py b/openpype/pipeline/plugin_discover.py index fb860fe5f2..004e530b1c 100644 --- a/openpype/pipeline/plugin_discover.py +++ b/openpype/pipeline/plugin_discover.py @@ -59,7 +59,7 @@ class DiscoverResult: self.ignored_plugins ))) for cls in self.ignored_plugins: - lines.append("- {}".format(cls.__class__.__name__)) + lines.append("- {}".format(cls.__name__)) # Abstract classes if self.abstract_plugins or full_report: @@ -67,7 +67,7 @@ class DiscoverResult: self.abstract_plugins ))) for cls in self.abstract_plugins: - lines.append("- {}".format(cls.__class__.__name__)) + lines.append("- {}".format(cls.__name__)) # Abstract classes if self.duplicated_plugins or full_report: @@ -75,7 +75,7 @@ class DiscoverResult: self.duplicated_plugins ))) for cls in self.duplicated_plugins: - lines.append("- {}".format(cls.__class__.__name__)) + lines.append("- {}".format(cls.__name__)) if self.crashed_file_paths or full_report: lines.append("*** Failed to load {} files".format(len( From e50d8ee1ed596064cd0fb1b3d83d3823b4184af3 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 21 Apr 2022 11:58:20 +0200 Subject: [PATCH 063/583] initial settings for tray publisher --- .../project_settings/traypublisher.json | 38 ++++++ .../schemas/projects_schema/schema_main.json | 4 + .../schema_project_traypublisher.json | 117 ++++++++++++++++++ 3 files changed, 159 insertions(+) create mode 100644 openpype/settings/defaults/project_settings/traypublisher.json create mode 100644 openpype/settings/entities/schemas/projects_schema/schema_project_traypublisher.json diff --git a/openpype/settings/defaults/project_settings/traypublisher.json b/openpype/settings/defaults/project_settings/traypublisher.json new file mode 100644 index 0000000000..e6c6747ca2 --- /dev/null +++ b/openpype/settings/defaults/project_settings/traypublisher.json @@ -0,0 +1,38 @@ +{ + "simple_creators": [ + { + "family": "workfile", + "identifier": "", + "label": "Workfile", + "icon": "fa.file", + "default_variants": [ + "Main" + ], + "enable_review": false, + "description": "Publish workfile backup", + "detailed_description": "", + "extensions": [ + ".ma", + ".mb", + ".nk", + ".hrox", + ".hip", + ".hiplc", + ".hipnc", + ".blend", + ".scn", + ".tvpp", + ".comp", + ".zip", + ".prproj", + ".drp", + ".psd", + ".psb", + ".aep" + ], + "allow_sequences": { + "allow": "no" + } + } + ] +} \ No newline at end of file diff --git a/openpype/settings/entities/schemas/projects_schema/schema_main.json b/openpype/settings/entities/schemas/projects_schema/schema_main.json index 8e4eba86ef..dbddd18c80 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_main.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_main.json @@ -126,6 +126,10 @@ "type": "schema", "name": "schema_project_standalonepublisher" }, + { + "type": "schema", + "name": "schema_project_traypublisher" + }, { "type": "schema", "name": "schema_project_webpublisher" diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_traypublisher.json b/openpype/settings/entities/schemas/projects_schema/schema_project_traypublisher.json new file mode 100644 index 0000000000..00deb84172 --- /dev/null +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_traypublisher.json @@ -0,0 +1,117 @@ +{ + "type": "dict", + "collapsible": true, + "key": "traypublisher", + "label": "Tray Publisher", + "is_file": true, + "children": [ + { + "type": "list", + "collapsible": true, + "key": "simple_creators", + "label": "Creator plugins", + "use_label_wrap": true, + "collapsible_key": true, + "object_type": { + "type": "dict", + "children": [ + { + "type": "text", + "key": "family", + "label": "Family" + }, + { + "type": "text", + "key": "identifier", + "label": "Identifier", + "placeholder": "< Use 'Family' >", + "tooltip": "All creators must have unique identifier.\nBy default is used 'family' but if you need to have more creators with same families\nyou have to set identifier too." + }, + { + "type": "text", + "key": "label", + "label": "Label" + }, + { + "type": "text", + "key": "icon", + "label": "Icon" + }, + { + "type": "list", + "key": "default_variants", + "label": "Default variants", + "object_type": { + "type": "text" + } + }, + { + "type": "boolean", + "key": "enable_review", + "label": "Enable review", + "tooltip": "Allow to create review from source file/s.\nFiles must be supported to be able create review." + }, + { + "type": "separator" + }, + { + "type": "text", + "key": "description", + "label": "Description" + }, + { + "type": "text", + "key": "detailed_description", + "label": "Detailed Description", + "multiline": true + }, + { + "type": "separator" + }, + { + "type": "list", + "key": "extensions", + "label": "Extensions", + "use_label_wrap": true, + "collapsible_key": true, + "collapsed": false, + "object_type": "text" + }, + { + "key": "allow_sequences", + "label": "Allow sequences", + "type": "dict-conditional", + "use_label_wrap": true, + "collapsible_key": true, + "enum_key": "allow", + "enum_children": [ + { + "key": "all", + "label": "Yes (all extensions)" + }, + { + "key": "selection", + "label": "Yes (limited extensions)", + "children": [ + { + "type": "list", + "key": "extensions", + "label": "Extensions", + "use_label_wrap": true, + "collapsible_key": true, + "collapsed": false, + "object_type": "text" + } + ] + }, + { + "key": "no", + "label": "No" + } + ] + } + ] + } + } + ] +} From 9780de94c53244426e48f192d59f476da9cbb606 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 21 Apr 2022 11:58:55 +0200 Subject: [PATCH 064/583] added file adding creators from settings --- .../plugins/create/create_from_settings.py | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 openpype/hosts/traypublisher/plugins/create/create_from_settings.py diff --git a/openpype/hosts/traypublisher/plugins/create/create_from_settings.py b/openpype/hosts/traypublisher/plugins/create/create_from_settings.py new file mode 100644 index 0000000000..19ade437ab --- /dev/null +++ b/openpype/hosts/traypublisher/plugins/create/create_from_settings.py @@ -0,0 +1,34 @@ +import os +import copy + +from openpype.api import get_project_settings + + +def initialize(): + from openpype.hosts.traypublisher.api.plugin import SettingsCreator + + project_name = os.environ["AVALON_PROJECT"] + project_settings = get_project_settings(project_name) + + simple_creators = project_settings["traypublisher"]["simple_creators"] + + global_variables = globals() + for item in simple_creators: + allow_sequences_value = item["allow_sequences"] + allow_sequences = allow_sequences_value["allow"] + if allow_sequences == "all": + sequence_extensions = copy.deepcopy(item["extensions"]) + + elif allow_sequences == "no": + sequence_extensions = [] + + elif allow_sequences == "selection": + sequence_extensions = allow_sequences_value["extensions"] + + item["sequence_extensions"] = sequence_extensions + item["enable_review"] = False + dynamic_plugin = SettingsCreator.from_settings(item) + global_variables[dynamic_plugin.__name__] = dynamic_plugin + + +initialize() From 20ef8b0c58358992f242c3c286cfca44d102999e Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 21 Apr 2022 11:59:04 +0200 Subject: [PATCH 065/583] removed current workfile creator --- .../plugins/create/create_workfile.py | 97 ------------------- 1 file changed, 97 deletions(-) delete mode 100644 openpype/hosts/traypublisher/plugins/create/create_workfile.py diff --git a/openpype/hosts/traypublisher/plugins/create/create_workfile.py b/openpype/hosts/traypublisher/plugins/create/create_workfile.py deleted file mode 100644 index 5e0af350f0..0000000000 --- a/openpype/hosts/traypublisher/plugins/create/create_workfile.py +++ /dev/null @@ -1,97 +0,0 @@ -from openpype.hosts.traypublisher.api import pipeline -from openpype.lib import FileDef -from openpype.pipeline import ( - Creator, - CreatedInstance -) - - -class WorkfileCreator(Creator): - identifier = "workfile" - label = "Workfile" - family = "workfile" - description = "Publish backup of workfile" - - create_allow_context_change = True - - extensions = [ - # Maya - ".ma", ".mb", - # Nuke - ".nk", - # Hiero - ".hrox", - # Houdini - ".hip", ".hiplc", ".hipnc", - # Blender - ".blend", - # Celaction - ".scn", - # TVPaint - ".tvpp", - # Fusion - ".comp", - # Harmony - ".zip", - # Premiere - ".prproj", - # Resolve - ".drp", - # Photoshop - ".psd", ".psb", - # Aftereffects - ".aep" - ] - - def get_icon(self): - return "fa.file" - - def collect_instances(self): - for instance_data in pipeline.list_instances(): - creator_id = instance_data.get("creator_identifier") - if creator_id == self.identifier: - instance = CreatedInstance.from_existing( - instance_data, self - ) - self._add_instance_to_context(instance) - - def update_instances(self, update_list): - pipeline.update_instances(update_list) - - def remove_instances(self, instances): - pipeline.remove_instances(instances) - for instance in instances: - self._remove_instance_from_context(instance) - - def create(self, subset_name, data, pre_create_data): - # Pass precreate data to creator attributes - data["creator_attributes"] = pre_create_data - # Create new instance - new_instance = CreatedInstance(self.family, subset_name, data, self) - # Host implementation of storing metadata about instance - pipeline.HostContext.add_instance(new_instance.data_to_store()) - # Add instance to current context - self._add_instance_to_context(new_instance) - - def get_default_variants(self): - return [ - "Main" - ] - - def get_instance_attr_defs(self): - output = [ - FileDef( - "filepath", - folders=False, - extensions=self.extensions, - label="Filepath" - ) - ] - return output - - def get_pre_create_attr_defs(self): - # Use same attributes as for instance attrobites - return self.get_instance_attr_defs() - - def get_detail_description(self): - return """# Publish workfile backup""" From cf37cd3e8c25b23691555ba34143da7e35efc47a Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Thu, 21 Apr 2022 17:43:16 +0200 Subject: [PATCH 066/583] fix deadline renderman version handling --- .../plugins/publish/submit_maya_deadline.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py index 498397b81b..14e458a401 100644 --- a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py @@ -837,6 +837,23 @@ class MayaSubmitDeadline(pyblish.api.InstancePlugin): "AssetDependency0": data["filepath"], } + renderer = self._instance.data["renderer"] + + # This hack is here because of how Deadline handles Renderman version. + # it considers everything with `renderman` set as version older than + # Renderman 22, and so if we are using renderman > 21 we need to set + # renderer string on the job to `renderman22`. We will have to change + # this when Deadline releases new version handling this. + if self._instance.data["renderer"] == "renderman": + try: + from rfm2.config import cfg # noqa + except ImportError: + raise Exception("Cannot determine renderman version") + + rman_version = cfg().build_info.version() # type: str + if int(rman_version.split(".")[0]) > 22: + renderer = "renderman22" + plugin_info = { "SceneFile": data["filepath"], # Output directory and filename @@ -850,7 +867,7 @@ class MayaSubmitDeadline(pyblish.api.InstancePlugin): "RenderLayer": data["renderlayer"], # Determine which renderer to use from the file itself - "Renderer": self._instance.data["renderer"], + "Renderer": renderer, # Resolve relative references "ProjectPath": data["workspace"], From 0666af82e6ec8f2ec2b8694877c193df598c1dc5 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 21 Apr 2022 18:45:36 +0200 Subject: [PATCH 067/583] variant input has aligned options button --- openpype/style/style.css | 11 +++++- .../tools/publisher/widgets/create_dialog.py | 37 +++++++++++++++---- 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/openpype/style/style.css b/openpype/style/style.css index b5f6962eee..9df615d953 100644 --- a/openpype/style/style.css +++ b/openpype/style/style.css @@ -852,7 +852,16 @@ QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { #PublishLogConsole { font-family: "Noto Sans Mono"; } - +VariantInputsWidget QLineEdit { + border-bottom-right-radius: 0px; + border-top-right-radius: 0px; +} +VariantInputsWidget QToolButton { + border-bottom-left-radius: 0px; + border-top-left-radius: 0px; + padding-top: 0.5em; + padding-bottom: 0.5em; +} #VariantInput[state="new"], #VariantInput[state="new"]:focus, #VariantInput[state="new"]:hover { border-color: {color:publisher:success}; } diff --git a/openpype/tools/publisher/widgets/create_dialog.py b/openpype/tools/publisher/widgets/create_dialog.py index 7d98609c2c..21e1bd5cfc 100644 --- a/openpype/tools/publisher/widgets/create_dialog.py +++ b/openpype/tools/publisher/widgets/create_dialog.py @@ -29,6 +29,14 @@ from ..constants import ( SEPARATORS = ("---separator---", "---") +class VariantInputsWidget(QtWidgets.QWidget): + resized = QtCore.Signal() + + def resizeEvent(self, event): + super(VariantInputsWidget, self).resizeEvent(event) + self.resized.emit() + + class CreateErrorMessageBox(ErrorMessageBox): def __init__( self, @@ -247,22 +255,25 @@ class CreateDialog(QtWidgets.QDialog): creators_model = QtGui.QStandardItemModel() creators_view.setModel(creators_model) - variant_input = QtWidgets.QLineEdit(self) + variant_widget = VariantInputsWidget(self) + + variant_input = QtWidgets.QLineEdit(variant_widget) variant_input.setObjectName("VariantInput") variant_input.setToolTip(VARIANT_TOOLTIP) - variant_hints_btn = QtWidgets.QPushButton(self) - variant_hints_btn.setFixedWidth(18) + variant_hints_btn = QtWidgets.QToolButton(variant_widget) + variant_hints_btn.setArrowType(QtCore.Qt.DownArrow) + variant_hints_btn.setIconSize(QtCore.QSize(12, 12)) - variant_hints_menu = QtWidgets.QMenu(variant_hints_btn) + variant_hints_menu = QtWidgets.QMenu(variant_widget) variant_hints_group = QtWidgets.QActionGroup(variant_hints_menu) - variant_hints_btn.setMenu(variant_hints_menu) + # variant_hints_btn.setMenu(variant_hints_menu) - variant_layout = QtWidgets.QHBoxLayout() + variant_layout = QtWidgets.QHBoxLayout(variant_widget) variant_layout.setContentsMargins(0, 0, 0, 0) variant_layout.setSpacing(0) variant_layout.addWidget(variant_input, 1) - variant_layout.addWidget(variant_hints_btn, 0) + variant_layout.addWidget(variant_hints_btn, 0, QtCore.Qt.AlignVCenter) subset_name_input = QtWidgets.QLineEdit(self) subset_name_input.setEnabled(False) @@ -271,7 +282,7 @@ class CreateDialog(QtWidgets.QDialog): create_btn.setEnabled(False) form_layout = QtWidgets.QFormLayout() - form_layout.addRow("Variant:", variant_layout) + form_layout.addRow("Variant:", variant_widget) form_layout.addRow("Subset:", subset_name_input) mid_widget = QtWidgets.QWidget(self) @@ -341,11 +352,13 @@ class CreateDialog(QtWidgets.QDialog): help_btn.resized.connect(self._on_help_btn_resize) create_btn.clicked.connect(self._on_create) + variant_widget.resized.connect(self._on_variant_widget_resize) variant_input.returnPressed.connect(self._on_create) variant_input.textChanged.connect(self._on_variant_change) creators_view.selectionModel().currentChanged.connect( self._on_creator_item_change ) + variant_hints_btn.clicked.connect(self._on_variant_btn_click) variant_hints_menu.triggered.connect(self._on_variant_action) assets_widget.selection_changed.connect(self._on_asset_change) assets_widget.current_context_required.connect( @@ -660,6 +673,14 @@ class CreateDialog(QtWidgets.QDialog): self.variant_input.setText(default_variant or "Main") + def _on_variant_widget_resize(self): + self.variant_hints_btn.setFixedHeight(self.variant_input.height()) + + def _on_variant_btn_click(self): + pos = self.variant_hints_btn.rect().bottomLeft() + point = self.variant_hints_btn.mapToGlobal(pos) + self.variant_hints_menu.popup(point) + def _on_variant_action(self, action): value = action.text() if self.variant_input.text() != value: From 7415c857905a8eddb71eff26e2e1c1456330b113 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 22 Apr 2022 10:27:58 +0200 Subject: [PATCH 068/583] use operational patter to recognize op atom mxf format --- openpype/lib/transcoding.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/lib/transcoding.py b/openpype/lib/transcoding.py index fcec5d4216..f20bef3854 100644 --- a/openpype/lib/transcoding.py +++ b/openpype/lib/transcoding.py @@ -727,9 +727,9 @@ def get_ffmpeg_format_args(ffprobe_data, source_ffmpeg_cmd=None): def _ffmpeg_mxf_format_args(ffprobe_data, source_ffmpeg_cmd): input_format = ffprobe_data["format"] format_tags = input_format.get("tags") or {} - product_name = format_tags.get("product_name") or "" + operational_pattern_ul = format_tags.get("operational_pattern_ul") or "" output = [] - if "opatom" in product_name.lower(): + if operational_pattern_ul == "060e2b34.04010102.0d010201.10030000": output.extend(["-f", "mxf_opatom"]) return output From e311a48ef47f0ba8d80c59e30c1fb3dcf3f1c93a Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 22 Apr 2022 18:23:03 +0200 Subject: [PATCH 069/583] skip containers with not found versions --- .../plugins/publish/collect_scene_loaded_versions.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/openpype/plugins/publish/collect_scene_loaded_versions.py b/openpype/plugins/publish/collect_scene_loaded_versions.py index e54592abb8..4c54a7d46c 100644 --- a/openpype/plugins/publish/collect_scene_loaded_versions.py +++ b/openpype/plugins/publish/collect_scene_loaded_versions.py @@ -44,12 +44,20 @@ class CollectSceneLoadedVersions(pyblish.api.ContextPlugin): } for con in _containers: + repre_id = con["representation"] + version_id = version_by_repr.get(repre_id) + if version_id is None: + self.log.warning(( + "Skipping container, did not find version document. {}" + ).format(str(con))) + continue + # NOTE: # may have more then one representation that are same version version = { "subsetName": con["name"], - "representation": ObjectId(con["representation"]), - "version": version_by_repr[con["representation"]], # _id + "representation": ObjectId(repre_id), + "version": version_id, } loaded_versions.append(version) From 7c460886442aa0ec5097f7f93c097b2987386882 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 22 Apr 2022 18:27:30 +0200 Subject: [PATCH 070/583] better log message --- openpype/plugins/publish/collect_scene_loaded_versions.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/openpype/plugins/publish/collect_scene_loaded_versions.py b/openpype/plugins/publish/collect_scene_loaded_versions.py index 4c54a7d46c..7b44aa7963 100644 --- a/openpype/plugins/publish/collect_scene_loaded_versions.py +++ b/openpype/plugins/publish/collect_scene_loaded_versions.py @@ -43,12 +43,15 @@ class CollectSceneLoadedVersions(pyblish.api.ContextPlugin): io.find({"_id": {"$in": _repr_ids}}, projection={"parent": 1}) } + # QUESTION should we add same representation id when loaded multiple + # times? for con in _containers: repre_id = con["representation"] version_id = version_by_repr.get(repre_id) if version_id is None: self.log.warning(( - "Skipping container, did not find version document. {}" + "Skipping container," + " did not find representation document. {}" ).format(str(con))) continue From a5826ae33667c67d424376a45edb59ca80c31c6f Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 22 Apr 2022 18:27:39 +0200 Subject: [PATCH 071/583] reorganized code a little bit --- openpype/plugins/publish/collect_scene_loaded_versions.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/openpype/plugins/publish/collect_scene_loaded_versions.py b/openpype/plugins/publish/collect_scene_loaded_versions.py index 7b44aa7963..ffdd532df2 100644 --- a/openpype/plugins/publish/collect_scene_loaded_versions.py +++ b/openpype/plugins/publish/collect_scene_loaded_versions.py @@ -38,9 +38,13 @@ class CollectSceneLoadedVersions(pyblish.api.ContextPlugin): loaded_versions = [] _containers = list(host.ls()) _repr_ids = [ObjectId(c["representation"]) for c in _containers] + repre_docs = io.find( + {"_id": {"$in": _repr_ids}}, + projection={"_id": 1, "parent": 1} + ) version_by_repr = { - str(doc["_id"]): doc["parent"] for doc in - io.find({"_id": {"$in": _repr_ids}}, projection={"parent": 1}) + str(doc["_id"]): doc["parent"] + for doc in repre_docs } # QUESTION should we add same representation id when loaded multiple From 750ec30c55d63daedf2a5741010a415fe479390f Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 22 Apr 2022 18:50:50 +0200 Subject: [PATCH 072/583] files widget has only one widget --- .../widgets/attribute_defs/files_widget.py | 122 +++++------------- openpype/widgets/attribute_defs/widgets.py | 11 +- 2 files changed, 34 insertions(+), 99 deletions(-) diff --git a/openpype/widgets/attribute_defs/files_widget.py b/openpype/widgets/attribute_defs/files_widget.py index 34f7d159ad..af00ffe5ad 100644 --- a/openpype/widgets/attribute_defs/files_widget.py +++ b/openpype/widgets/attribute_defs/files_widget.py @@ -87,15 +87,29 @@ class FilesModel(QtGui.QStandardItemModel): ".xpm", ".xwd" ] - def __init__(self): + def __init__(self, multivalue): super(FilesModel, self).__init__() self._filenames_by_dirpath = collections.defaultdict(set) self._items_by_dirpath = collections.defaultdict(list) + self._multivalue = multivalue + def add_filepaths(self, filepaths): if not filepaths: return + if not self._multivalue: + filepaths = [filepaths[0]] + item_ids = [] + for items in self._items_by_dirpath.values(): + for item in items: + item_id = item.data(ITEM_ID_ROLE) + if item_id: + item_ids.append(item_id) + + if item_ids: + self.remove_item_by_ids(item_ids) + new_dirpaths = set() for filepath in filepaths: filename = os.path.basename(filepath) @@ -368,16 +382,16 @@ class FilesView(QtWidgets.QListView): return super(FilesView, self).event(event) -class MultiFilesWidget(QtWidgets.QFrame): +class FilesWidget(QtWidgets.QFrame): value_changed = QtCore.Signal() - def __init__(self, parent): - super(MultiFilesWidget, self).__init__(parent) + def __init__(self, multiselect, parent): + super(FilesWidget, self).__init__(parent) self.setAcceptDrops(True) empty_widget = DropEmpty(self) - files_model = FilesModel() + files_model = FilesModel(multiselect) files_proxy_model = FilesProxyModel() files_proxy_model.setSourceModel(files_model) files_view = FilesView(self) @@ -392,6 +406,11 @@ class MultiFilesWidget(QtWidgets.QFrame): files_proxy_model.rowsInserted.connect(self._on_rows_inserted) files_proxy_model.rowsRemoved.connect(self._on_rows_removed) + drag_label = DragLabel() + drag_label.setVisible(False) + + self._drag_label = drag_label + self._in_set_value = False self._empty_widget = empty_widget @@ -501,7 +520,7 @@ class MultiFilesWidget(QtWidgets.QFrame): def sizeHint(self): # Get size hints of widget and visible widgets - result = super(MultiFilesWidget, self).sizeHint() + result = super(FilesWidget, self).sizeHint() if not self._files_view.isVisible(): not_visible_hint = self._files_view.sizeHint() else: @@ -557,90 +576,11 @@ class MultiFilesWidget(QtWidgets.QFrame): self._empty_widget.setVisible(not files_exists) -class SingleFileWidget(QtWidgets.QWidget): - value_changed = QtCore.Signal() - - def __init__(self, parent): - super(SingleFileWidget, self).__init__(parent) - - self.setAcceptDrops(True) - - filepath_input = QtWidgets.QLineEdit(self) - - browse_btn = QtWidgets.QPushButton("Browse", self) - browse_btn.setVisible(False) +class DragLabel(QtWidgets.QWidget): + def __init__(self, parent=None): + super(DragLabel, self).__init__(parent) + t_label = QtWidgets.QLabel("TESTING", self) layout = QtWidgets.QHBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.addWidget(filepath_input, 1) - layout.addWidget(browse_btn, 0) - - browse_btn.clicked.connect(self._on_browse_clicked) - filepath_input.textChanged.connect(self._on_text_change) - - self._in_set_value = False - - self._filepath_input = filepath_input - self._folders_allowed = False - self._exts_filter = [] - - def set_value(self, value, multivalue): - self._in_set_value = True - - if multivalue: - set_value = set(value) - if len(set_value) == 1: - value = tuple(set_value)[0] - else: - value = "< Multiselection >" - self._filepath_input.setText(value) - - self._in_set_value = False - - def current_value(self): - return self._filepath_input.text() - - def set_filters(self, folders_allowed, exts_filter): - self._folders_allowed = folders_allowed - self._exts_filter = exts_filter - - def _on_text_change(self, text): - if not self._in_set_value: - self.value_changed.emit() - - def _on_browse_clicked(self): - # TODO implement file dialog logic in '_on_browse_clicked' - print("_on_browse_clicked") - - def dragEnterEvent(self, event): - mime_data = event.mimeData() - if not mime_data.hasUrls(): - return - - filepaths = [] - for url in mime_data.urls(): - filepath = url.toLocalFile() - if os.path.exists(filepath): - filepaths.append(filepath) - - # TODO add folder, extensions check - if len(filepaths) == 1: - event.setDropAction(QtCore.Qt.CopyAction) - event.accept() - - def dragLeaveEvent(self, event): - event.accept() - - def dropEvent(self, event): - mime_data = event.mimeData() - if mime_data.hasUrls(): - filepaths = [] - for url in mime_data.urls(): - filepath = url.toLocalFile() - if os.path.exists(filepath): - filepaths.append(filepath) - # TODO filter check - if len(filepaths) == 1: - self._filepath_input.setText(filepaths[0]) - - event.accept() + layout.addWidget(t_label) + self._t_label = t_label diff --git a/openpype/widgets/attribute_defs/widgets.py b/openpype/widgets/attribute_defs/widgets.py index 23f025967d..83eeaea61f 100644 --- a/openpype/widgets/attribute_defs/widgets.py +++ b/openpype/widgets/attribute_defs/widgets.py @@ -15,6 +15,8 @@ from openpype.lib.attribute_definitions import ( ) from openpype.widgets.nice_checkbox import NiceCheckbox +from .files_widget import FilesWidget + def create_widget_for_attr_def(attr_def, parent=None): if not isinstance(attr_def, AbtractAttrDef): @@ -337,15 +339,8 @@ class UnknownAttrWidget(_BaseAttrDefWidget): class FileAttrWidget(_BaseAttrDefWidget): def _ui_init(self): self.multipath = self.attr_def.multipath - if self.multipath: - from .files_widget import MultiFilesWidget - input_widget = MultiFilesWidget(self) - - else: - from .files_widget import SingleFileWidget - - input_widget = SingleFileWidget(self) + input_widget = FilesWidget(self.multipath, self) if self.attr_def.tooltip: input_widget.setToolTip(self.attr_def.tooltip) From 7965001b162b4aa09028584fc447acac66e21a3f Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 22 Apr 2022 18:51:08 +0200 Subject: [PATCH 073/583] added first idea of FileDefItem for FileDef --- openpype/lib/attribute_definitions.py | 173 +++++++++++++++++++++++--- 1 file changed, 157 insertions(+), 16 deletions(-) diff --git a/openpype/lib/attribute_definitions.py b/openpype/lib/attribute_definitions.py index 189a5e7acd..3d17818ecb 100644 --- a/openpype/lib/attribute_definitions.py +++ b/openpype/lib/attribute_definitions.py @@ -1,8 +1,12 @@ +import os import re import collections import uuid +import json from abc import ABCMeta, abstractmethod + import six +import clique class AbstractAttrDefMeta(ABCMeta): @@ -302,6 +306,126 @@ class BoolDef(AbtractAttrDef): return self.default +class FileDefItem(object): + def __init__( + self, directory, filenames, frames=None, template=None + ): + self.directory = directory + + self.filenames = [] + self.is_sequence = False + self.template = None + self.frames = [] + + self.set_filenames(filenames, frames, template) + + def __str__(self): + return json.dumps(self.to_dict()) + + def __repr__(self): + if self.is_sequence: + filename = self.template + else: + filename = self.filenames[0] + + return "<{}: \"{}\">".format( + self.__class__.__name__, + os.path.join(self.directory, filename) + ) + + def set_directory(self, directory): + self.directory = directory + + def set_filenames(self, filenames, frames=None, template=None): + if frames is None: + frames = [] + is_sequence = False + if frames: + is_sequence = True + + if is_sequence and not template: + raise ValueError("Missing template for sequence") + + self.filenames = filenames + self.template = template + self.frames = frames + self.is_sequence = is_sequence + + @classmethod + def create_empty_item(cls): + return cls("", "") + + @classmethod + def from_value(cls, value): + multi = isinstance(value, (list, tuple, set)) + if not multi: + value = [value] + + output = [] + for item in value: + if isinstance(item, dict): + output.append(cls.from_dict(item)) + elif isinstance(item, six.string_types): + output.extend(cls.from_paths([item])) + else: + raise TypeError( + "Unknown type \"{}\". Can't convert to {}".format( + str(type(item)), cls.__name__ + ) + ) + if multi: + return output + return output[0] + + @classmethod + def from_dict(cls, data): + return cls( + data["directory"], + data["filenames"], + data.get("frames"), + data.get("template") + ) + + @classmethod + def from_paths(cls, paths): + filenames_by_dir = collections.defaultdict(list) + for path in paths: + normalized = os.path.normpath(path) + directory, filename = os.path.split(normalized) + filenames_by_dir[directory].append(filename) + + output = [] + for directory, filenames in filenames_by_dir.items(): + cols, remainders = clique.assemble(filenames) + for remainder in remainders: + output.append(cls(directory, [remainder])) + + for col in cols: + frames = list(col.indexes) + paths = [filename for filename in col] + template = col.format("{head}{padding}{tail}") + + output.append(cls( + directory, paths, frames, template + )) + + return output + + def to_dict(self): + output = { + "is_sequence": self.is_sequence, + "directory": self.directory, + "filenames": list(self.filenames), + } + if self.is_sequence: + output.update({ + "template": self.template, + "frames": list(sorted(self.frames)), + }) + + return output + + class FileDef(AbtractAttrDef): """File definition. It is possible to define filters of allowed file extensions and if supports @@ -326,7 +450,7 @@ class FileDef(AbtractAttrDef): if multipath: default = [] else: - default = "" + default = FileDefItem.create_empty_item().to_dict() else: if multipath: if not isinstance(default, (tuple, list, set)): @@ -336,11 +460,16 @@ class FileDef(AbtractAttrDef): ).format(type(default))) else: - if not isinstance(default, six.string_types): + if isinstance(default, dict): + FileDefItem.from_dict(default) + + elif isinstance(default, six.string_types): + default = FileDefItem.from_paths([default.strip()])[0] + + else: raise TypeError(( - "'default' argument must be 'str' not '{}'" + "'default' argument must be 'str' or 'dict' not '{}'" ).format(type(default))) - default = default.strip() # Change horizontal label is_label_horizontal = kwargs.get("is_label_horizontal") @@ -366,24 +495,36 @@ class FileDef(AbtractAttrDef): ) def convert_value(self, value): - if isinstance(value, six.string_types): - if self.multipath: - value = [value.strip()] - else: - value = value.strip() - return value + if isinstance(value, six.string_types) or isinstance(value, dict): + value = [value] if isinstance(value, (tuple, list, set)): - _value = [] + string_paths = [] + dict_items = [] for item in value: if isinstance(item, six.string_types): - _value.append(item.strip()) + string_paths.append(item.strip()) + elif isinstance(item, dict): + try: + FileDefItem.from_dict(item) + dict_items.append(item) + except (ValueError, KeyError): + pass + + if string_paths: + file_items = FileDefItem.from_paths(string_paths) + dict_items.extend([ + file_item.to_dict() + for file_item in file_items + ]) if self.multipath: - return _value + return dict_items - if not _value: + if not dict_items: return self.default - return _value[0].strip() + return dict_items[0] - return str(value).strip() + if self.multipath: + return [] + return FileDefItem.create_empty_item().to_dict() From a7b3a85712b1a0ee29d547dc5ddacc9dab80e160 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 25 Apr 2022 11:55:34 +0200 Subject: [PATCH 074/583] added sequence handling to files widget --- openpype/lib/attribute_definitions.py | 24 ++++++- .../widgets/attribute_defs/files_widget.py | 63 ++++++++----------- openpype/widgets/attribute_defs/widgets.py | 4 +- 3 files changed, 52 insertions(+), 39 deletions(-) diff --git a/openpype/lib/attribute_definitions.py b/openpype/lib/attribute_definitions.py index 3d17818ecb..2cf1706b78 100644 --- a/openpype/lib/attribute_definitions.py +++ b/openpype/lib/attribute_definitions.py @@ -3,6 +3,7 @@ import re import collections import uuid import json +import copy from abc import ABCMeta, abstractmethod import six @@ -438,9 +439,23 @@ class FileDef(AbtractAttrDef): default(str, list): Defautl value. """ + default_sequence_extensions = [ + ".ani", ".anim", ".apng", ".art", ".bmp", ".bpg", ".bsave", + ".cal", ".cin", ".cpc", ".cpt", ".dds", ".dpx", ".ecw", ".exr", + ".fits", ".flic", ".flif", ".fpx", ".gif", ".hdri", ".hevc", + ".icer", ".icns", ".ico", ".cur", ".ics", ".ilbm", ".jbig", + ".jbig2", ".jng", ".jpeg", ".jpeg-ls", ".2000", ".jpg", ".xr", + ".jpeg-hdr", ".kra", ".mng", ".miff", ".nrrd", + ".ora", ".pam", ".pbm", ".pgm", ".ppm", ".pnm", ".pcx", ".pgf", + ".pictor", ".png", ".psb", ".psp", ".qtvr", ".ras", + ".rgbe", ".logluv", ".tiff", ".sgi", ".tga", ".tiff", + ".tiff/ep", ".tiff/it", ".ufo", ".ufp", ".wbmp", ".webp", + ".xbm", ".xcf", ".xpm", ".xwd" + ] + def __init__( self, key, multipath=False, folders=None, extensions=None, - default=None, **kwargs + sequence_extensions=None, default=None, **kwargs ): if folders is None and extensions is None: folders = True @@ -479,9 +494,13 @@ class FileDef(AbtractAttrDef): is_label_horizontal = False kwargs["is_label_horizontal"] = is_label_horizontal + if sequence_extensions is None: + sequence_extensions = self.default_sequence_extensions + self.multipath = multipath self.folders = folders - self.extensions = extensions + self.extensions = set(extensions) + self.sequence_extensions = set(sequence_extensions) super(FileDef, self).__init__(key, default=default, **kwargs) def __eq__(self, other): @@ -492,6 +511,7 @@ class FileDef(AbtractAttrDef): self.multipath == other.multipath and self.folders == other.folders and self.extensions == other.extensions + and self.sequence_extensions == self.sequence_extensions ) def convert_value(self, value): diff --git a/openpype/widgets/attribute_defs/files_widget.py b/openpype/widgets/attribute_defs/files_widget.py index af00ffe5ad..ffdc730455 100644 --- a/openpype/widgets/attribute_defs/files_widget.py +++ b/openpype/widgets/attribute_defs/files_widget.py @@ -73,32 +73,19 @@ class DropEmpty(QtWidgets.QWidget): class FilesModel(QtGui.QStandardItemModel): - sequence_exts = [ - ".ani", ".anim", ".apng", ".art", ".bmp", ".bpg", ".bsave", ".cal", - ".cin", ".cpc", ".cpt", ".dds", ".dpx", ".ecw", ".exr", ".fits", - ".flic", ".flif", ".fpx", ".gif", ".hdri", ".hevc", ".icer", - ".icns", ".ico", ".cur", ".ics", ".ilbm", ".jbig", ".jbig2", - ".jng", ".jpeg", ".jpeg-ls", ".2000", ".jpg", ".xr", - ".jpeg-hdr", ".kra", ".mng", ".miff", ".nrrd", - ".ora", ".pam", ".pbm", ".pgm", ".ppm", ".pnm", ".pcx", ".pgf", - ".pictor", ".png", ".psb", ".psp", ".qtvr", ".ras", - ".rgbe", ".logluv", ".tiff", ".sgi", ".tga", ".tiff", ".tiff/ep", - ".tiff/it", ".ufo", ".ufp", ".wbmp", ".webp", ".xbm", ".xcf", - ".xpm", ".xwd" - ] - - def __init__(self, multivalue): + def __init__(self, allow_multiple_items, sequence_exts): super(FilesModel, self).__init__() self._filenames_by_dirpath = collections.defaultdict(set) self._items_by_dirpath = collections.defaultdict(list) - self._multivalue = multivalue + self._allow_multiple_items = allow_multiple_items + self.sequence_exts = sequence_exts def add_filepaths(self, filepaths): if not filepaths: return - if not self._multivalue: + if not self._allow_multiple_items: filepaths = [filepaths[0]] item_ids = [] for items in self._items_by_dirpath.values(): @@ -281,6 +268,17 @@ class FilesProxyModel(QtCore.QSortFilterProxyModel): self._allowed_extensions = extensions self.invalidateFilter() + def are_valid_files(self, filepaths): + for filepath in filepaths: + if os.path.isfile(filepath): + _, ext = os.path.splitext(filepath) + if ext in self._allowed_extensions: + return True + + elif self._allow_folders: + return True + return False + def filterAcceptsRow(self, row, parent_index): model = self.sourceModel() index = model.index(row, self.filterKeyColumn(), parent_index) @@ -385,13 +383,13 @@ class FilesView(QtWidgets.QListView): class FilesWidget(QtWidgets.QFrame): value_changed = QtCore.Signal() - def __init__(self, multiselect, parent): + def __init__(self, allow_multiple_items, sequence_exts, parent): super(FilesWidget, self).__init__(parent) self.setAcceptDrops(True) empty_widget = DropEmpty(self) - files_model = FilesModel(multiselect) + files_model = FilesModel(allow_multiple_items, sequence_exts) files_proxy_model = FilesProxyModel() files_proxy_model.setSourceModel(files_model) files_view = FilesView(self) @@ -406,13 +404,9 @@ class FilesWidget(QtWidgets.QFrame): files_proxy_model.rowsInserted.connect(self._on_rows_inserted) files_proxy_model.rowsRemoved.connect(self._on_rows_removed) - drag_label = DragLabel() - drag_label.setVisible(False) - - self._drag_label = drag_label - self._in_set_value = False + self._allow_multiple_items = allow_multiple_items self._empty_widget = empty_widget self._files_model = files_model self._files_proxy_model = files_proxy_model @@ -544,8 +538,15 @@ class FilesWidget(QtWidgets.QFrame): def dragEnterEvent(self, event): mime_data = event.mimeData() if mime_data.hasUrls(): - event.setDropAction(QtCore.Qt.CopyAction) - event.accept() + filepaths = [] + for url in mime_data.urls(): + filepath = url.toLocalFile() + if os.path.exists(filepath): + filepaths.append(filepath) + + if self._files_proxy_model.are_valid_files(filepaths): + event.setDropAction(QtCore.Qt.CopyAction) + event.accept() def dragLeaveEvent(self, event): event.accept() @@ -574,13 +575,3 @@ class FilesWidget(QtWidgets.QFrame): files_exists = self._files_proxy_model.rowCount() > 0 self._files_view.setVisible(files_exists) self._empty_widget.setVisible(not files_exists) - - -class DragLabel(QtWidgets.QWidget): - def __init__(self, parent=None): - super(DragLabel, self).__init__(parent) - - t_label = QtWidgets.QLabel("TESTING", self) - layout = QtWidgets.QHBoxLayout(self) - layout.addWidget(t_label) - self._t_label = t_label diff --git a/openpype/widgets/attribute_defs/widgets.py b/openpype/widgets/attribute_defs/widgets.py index 83eeaea61f..d3f53de032 100644 --- a/openpype/widgets/attribute_defs/widgets.py +++ b/openpype/widgets/attribute_defs/widgets.py @@ -340,7 +340,9 @@ class FileAttrWidget(_BaseAttrDefWidget): def _ui_init(self): self.multipath = self.attr_def.multipath - input_widget = FilesWidget(self.multipath, self) + input_widget = FilesWidget( + self.multipath, self.attr_def.sequence_extensions, self + ) if self.attr_def.tooltip: input_widget.setToolTip(self.attr_def.tooltip) From 0b7cdeee840650816fd379066915b53c1a9f58fa Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 25 Apr 2022 12:12:43 +0200 Subject: [PATCH 075/583] disable always on top flags --- openpype/tools/traypublisher/window.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/openpype/tools/traypublisher/window.py b/openpype/tools/traypublisher/window.py index a550c88ead..306b567acd 100644 --- a/openpype/tools/traypublisher/window.py +++ b/openpype/tools/traypublisher/window.py @@ -108,6 +108,13 @@ class TrayPublishWindow(PublisherWindow): def __init__(self, *args, **kwargs): super(TrayPublishWindow, self).__init__(reset_on_show=False) + flags = self.windowFlags() + # Disable always on top hint + if flags & QtCore.Qt.WindowStaysOnTopHint: + flags ^= QtCore.Qt.WindowStaysOnTopHint + + self.setWindowFlags(flags) + overlay_widget = StandaloneOverlayWidget(self) btns_widget = QtWidgets.QWidget(self) From ed98bbcd322dd98aade17e229e745ed890e3c85a Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 25 Apr 2022 14:54:19 +0200 Subject: [PATCH 076/583] changed how files widget works with extensions and handle file items --- openpype/lib/__init__.py | 2 + openpype/lib/attribute_definitions.py | 83 ++++++- .../widgets/attribute_defs/files_widget.py | 214 +++++------------- 3 files changed, 141 insertions(+), 158 deletions(-) diff --git a/openpype/lib/__init__.py b/openpype/lib/__init__.py index b57e469f5b..d053ec8636 100644 --- a/openpype/lib/__init__.py +++ b/openpype/lib/__init__.py @@ -42,6 +42,7 @@ from .attribute_definitions import ( EnumDef, BoolDef, FileDef, + FileDefItem, ) from .env_tools import ( @@ -266,6 +267,7 @@ __all__ = [ "EnumDef", "BoolDef", "FileDef", + "FileDefItem", "import_filepath", "modules_from_path", diff --git a/openpype/lib/attribute_definitions.py b/openpype/lib/attribute_definitions.py index 2cf1706b78..6e754e6668 100644 --- a/openpype/lib/attribute_definitions.py +++ b/openpype/lib/attribute_definitions.py @@ -334,6 +334,61 @@ class FileDefItem(object): os.path.join(self.directory, filename) ) + @property + def label(self): + if not self.is_sequence: + return self.filenames[0] + + frame_start = self.frames[0] + filename_template = os.path.basename(self.template) + if len(self.frames) == 1: + return "{} [{}]".format(filename_template, frame_start) + + frame_end = self.frames[-1] + expected_len = (frame_end - frame_start) + 1 + if expected_len == len(self.frames): + return "{} [{}-{}]".format( + filename_template, frame_start, frame_end + ) + + ranges = [] + _frame_start = None + _frame_end = None + for frame in range(frame_start, frame_end + 1): + if frame not in self.frames: + add_to_ranges = _frame_start is not None + elif _frame_start is None: + _frame_start = _frame_end = frame + add_to_ranges = frame == frame_end + else: + _frame_end = frame + add_to_ranges = frame == frame_end + + if add_to_ranges: + if _frame_start != _frame_end: + _range = "{}-{}".format(_frame_start, _frame_end) + else: + _range = str(_frame_start) + ranges.append(_range) + _frame_start = _frame_end = None + return "{} [{}]".format( + filename_template, ",".join(ranges) + ) + + @property + def ext(self): + _, ext = os.path.splitext(self.filenames[0]) + if ext: + return ext + return None + + @property + def is_dir(self): + # QUESTION a better way how to define folder (in init argument?) + if self.ext: + return False + return True + def set_directory(self, directory): self.directory = directory @@ -357,23 +412,30 @@ class FileDefItem(object): return cls("", "") @classmethod - def from_value(cls, value): + def from_value(cls, value, sequence_extensions): multi = isinstance(value, (list, tuple, set)) if not multi: value = [value] output = [] + str_filepaths = [] for item in value: - if isinstance(item, dict): + if isinstance(item, FileDefItem): + output.append(item) + elif isinstance(item, dict): output.append(cls.from_dict(item)) elif isinstance(item, six.string_types): - output.extend(cls.from_paths([item])) + str_filepaths.append(item) else: raise TypeError( "Unknown type \"{}\". Can't convert to {}".format( str(type(item)), cls.__name__ ) ) + + if str_filepaths: + output.extend(cls.from_paths(str_filepaths, sequence_extensions)) + if multi: return output return output[0] @@ -388,7 +450,7 @@ class FileDefItem(object): ) @classmethod - def from_paths(cls, paths): + def from_paths(cls, paths, sequence_extensions): filenames_by_dir = collections.defaultdict(list) for path in paths: normalized = os.path.normpath(path) @@ -397,7 +459,18 @@ class FileDefItem(object): output = [] for directory, filenames in filenames_by_dir.items(): - cols, remainders = clique.assemble(filenames) + filtered_filenames = [] + for filename in filenames: + _, ext = os.path.splitext(filename) + if ext in sequence_extensions: + filtered_filenames.append(filename) + else: + output.append(cls(directory, [filename])) + + if not filtered_filenames: + continue + + cols, remainders = clique.assemble(filtered_filenames) for remainder in remainders: output.append(cls(directory, [remainder])) diff --git a/openpype/widgets/attribute_defs/files_widget.py b/openpype/widgets/attribute_defs/files_widget.py index ffdc730455..6e2ab6e4f2 100644 --- a/openpype/widgets/attribute_defs/files_widget.py +++ b/openpype/widgets/attribute_defs/files_widget.py @@ -2,8 +2,10 @@ import os import collections import uuid import clique +import six from Qt import QtWidgets, QtCore, QtGui +from openpype.lib import FileDefItem from openpype.tools.utils import paint_image_with_color # TODO change imports from openpype.tools.resources import ( @@ -75,174 +77,76 @@ class DropEmpty(QtWidgets.QWidget): class FilesModel(QtGui.QStandardItemModel): def __init__(self, allow_multiple_items, sequence_exts): super(FilesModel, self).__init__() + + self._allow_multiple_items = allow_multiple_items + self._sequence_exts = sequence_exts + + self._items_by_id = {} + self._file_items_by_id = {} self._filenames_by_dirpath = collections.defaultdict(set) self._items_by_dirpath = collections.defaultdict(list) - self._allow_multiple_items = allow_multiple_items - self.sequence_exts = sequence_exts + def add_filepaths(self, items): + if not items: + return - def add_filepaths(self, filepaths): - if not filepaths: + obj_items = FileDefItem.from_value(items, self._sequence_exts) + if not obj_items: return if not self._allow_multiple_items: - filepaths = [filepaths[0]] - item_ids = [] - for items in self._items_by_dirpath.values(): - for item in items: - item_id = item.data(ITEM_ID_ROLE) - if item_id: - item_ids.append(item_id) + obj_items = [obj_items[0]] + current_ids = list(self._file_items_by_id.keys()) + if current_ids: + self.remove_item_by_ids(current_ids) - if item_ids: - self.remove_item_by_ids(item_ids) + new_model_items = [] + for obj_item in obj_items: + _, ext = os.path.splitext(obj_item.filenames[0]) + if ext: + icon_pixmap = get_pixmap(filename="file.png") + else: + icon_pixmap = get_pixmap(filename="folder.png") - new_dirpaths = set() - for filepath in filepaths: - filename = os.path.basename(filepath) - dirpath = os.path.dirname(filepath) - filenames = self._filenames_by_dirpath[dirpath] - if filename not in filenames: - new_dirpaths.add(dirpath) - filenames.add(filename) - self._refresh_items(new_dirpaths) + item_id, model_item = self._create_item(obj_item, icon_pixmap) + new_model_items.append(model_item) + self._file_items_by_id[item_id] = obj_item + self._items_by_id[item_id] = model_item + + if new_model_items: + self.invisibleRootItem().appendRows(new_model_items) def remove_item_by_ids(self, item_ids): if not item_ids: return - remaining_ids = set(item_ids) - result = collections.defaultdict(list) - for dirpath, items in self._items_by_dirpath.items(): - if not remaining_ids: - break + items = [] + for item_id in set(item_ids): + if item_id not in self._items_by_id: + continue + item = self._items_by_id.pop(item_id) + self._file_items_by_id.pop(item_id) + items.append(item) + + if items: for item in items: - if not remaining_ids: - break - item_id = item.data(ITEM_ID_ROLE) - if item_id in remaining_ids: - remaining_ids.remove(item_id) - result[dirpath].append(item) - - if not result: - return - - dirpaths = set(result.keys()) - for dirpath, items in result.items(): - filenames_cache = self._filenames_by_dirpath[dirpath] - for item in items: - filenames = item.data(FILENAMES_ROLE) - - self._items_by_dirpath[dirpath].remove(item) - self.removeRows(item.row(), 1) - for filename in filenames: - if filename in filenames_cache: - filenames_cache.remove(filename) - - self._refresh_items(dirpaths) - - def _refresh_items(self, dirpaths=None): - if dirpaths is None: - dirpaths = set(self._items_by_dirpath.keys()) - - new_items = [] - for dirpath in dirpaths: - items_to_remove = list(self._items_by_dirpath[dirpath]) - cols, remainders = clique.assemble( - self._filenames_by_dirpath[dirpath] - ) - filtered_cols = [] - for collection in cols: - filenames = set(collection) - valid_col = True - for filename in filenames: - ext = os.path.splitext(filename)[-1] - valid_col = ext in self.sequence_exts - break - - if valid_col: - filtered_cols.append(collection) - else: - for filename in filenames: - remainders.append(filename) - - for filename in remainders: - found = False - for item in items_to_remove: - item_filenames = item.data(FILENAMES_ROLE) - if filename in item_filenames and len(item_filenames) == 1: - found = True - items_to_remove.remove(item) - break - - if found: - continue - - fullpath = os.path.join(dirpath, filename) - if os.path.isdir(fullpath): - icon_pixmap = get_pixmap(filename="folder.png") - else: - icon_pixmap = get_pixmap(filename="file.png") - label = filename - filenames = [filename] - item = self._create_item( - label, filenames, dirpath, icon_pixmap - ) - new_items.append(item) - self._items_by_dirpath[dirpath].append(item) - - for collection in filtered_cols: - filenames = set(collection) - found = False - for item in items_to_remove: - item_filenames = item.data(FILENAMES_ROLE) - if item_filenames == filenames: - found = True - items_to_remove.remove(item) - break - - if found: - continue - - col_range = collection.format("{ranges}") - label = "{}<{}>{}".format( - collection.head, col_range, collection.tail - ) - icon_pixmap = get_pixmap(filename="files.png") - item = self._create_item( - label, filenames, dirpath, icon_pixmap - ) - new_items.append(item) - self._items_by_dirpath[dirpath].append(item) - - for item in items_to_remove: - self._items_by_dirpath[dirpath].remove(item) self.removeRows(item.row(), 1) - if new_items: - self.invisibleRootItem().appendRows(new_items) - - def _create_item(self, label, filenames, dirpath, icon_pixmap=None): - first_filename = None - for filename in filenames: - first_filename = filename - break - ext = os.path.splitext(first_filename)[-1] - is_dir = False - if len(filenames) == 1: - filepath = os.path.join(dirpath, first_filename) - is_dir = os.path.isdir(filepath) + def get_file_item_by_id(self, item_id): + return self._file_items_by_id.get(item_id) + def _create_item(self, file_item, icon_pixmap=None): item = QtGui.QStandardItem() - item.setData(str(uuid.uuid4()), ITEM_ID_ROLE) - item.setData(label, ITEM_LABEL_ROLE) - item.setData(filenames, FILENAMES_ROLE) - item.setData(dirpath, DIRPATH_ROLE) + item_id = str(uuid.uuid4()) + item.setData(item_id, ITEM_ID_ROLE) + item.setData(file_item.label, ITEM_LABEL_ROLE) + item.setData(file_item.filenames, FILENAMES_ROLE) + item.setData(file_item.directory, DIRPATH_ROLE) item.setData(icon_pixmap, ITEM_ICON_ROLE) - item.setData(ext, EXT_ROLE) - item.setData(is_dir, IS_DIR_ROLE) + item.setData(file_item.ext, EXT_ROLE) + item.setData(file_item.is_dir, IS_DIR_ROLE) - return item + return item_id, item class FilesProxyModel(QtCore.QSortFilterProxyModel): @@ -344,6 +248,7 @@ class ItemWidget(QtWidgets.QWidget): class FilesView(QtWidgets.QListView): """View showing instances and their groups.""" + def __init__(self, *args, **kwargs): super(FilesView, self).__init__(*args, **kwargs) @@ -439,14 +344,17 @@ class FilesWidget(QtWidgets.QFrame): def current_value(self): model = self._files_proxy_model - filepaths = set() + item_ids = set() for row in range(model.rowCount()): index = model.index(row, 0) - dirpath = index.data(DIRPATH_ROLE) - filenames = index.data(FILENAMES_ROLE) - for filename in filenames: - filepaths.add(os.path.join(dirpath, filename)) - return list(filepaths) + item_ids.add(index.data(ITEM_ID_ROLE)) + + file_items = [] + for item_id in item_ids: + file_item = self._files_model.get_file_item_by_id(item_id) + if file_item is not None: + file_items.append(file_item.to_dict()) + return file_items def set_filters(self, folders_allowed, exts_filter): self._files_proxy_model.set_allow_folders(folders_allowed) From 7fe279fda5ff295e9673d6d54366f8a0dd662b1b Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 25 Apr 2022 15:10:18 +0200 Subject: [PATCH 077/583] added missing plugins file --- openpype/hosts/traypublisher/api/plugin.py | 104 +++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 openpype/hosts/traypublisher/api/plugin.py diff --git a/openpype/hosts/traypublisher/api/plugin.py b/openpype/hosts/traypublisher/api/plugin.py new file mode 100644 index 0000000000..6907450b15 --- /dev/null +++ b/openpype/hosts/traypublisher/api/plugin.py @@ -0,0 +1,104 @@ +from openpype.pipeline import ( + Creator, + CreatedInstance +) +from openpype.lib import ( + FileDef, + BoolDef, +) + +from .pipeline import ( + list_instances, + update_instances, + remove_instances, + HostContext, +) + + +class TrayPublishCreator(Creator): + create_allow_context_change = True + + def collect_instances(self): + for instance_data in list_instances(): + creator_id = instance_data.get("creator_identifier") + if creator_id == self.identifier: + instance = CreatedInstance.from_existing( + instance_data, self + ) + self._add_instance_to_context(instance) + + def update_instances(self, update_list): + update_instances(update_list) + + def remove_instances(self, instances): + remove_instances(instances) + for instance in instances: + self._remove_instance_from_context(instance) + + def get_pre_create_attr_defs(self): + # Use same attributes as for instance attrobites + return self.get_instance_attr_defs() + + +class SettingsCreator(TrayPublishCreator): + create_allow_context_change = True + + enable_review = False + extensions = [] + sequence_extensions = [] + + def collect_instances(self): + for instance_data in list_instances(): + creator_id = instance_data.get("creator_identifier") + if creator_id == self.identifier: + instance = CreatedInstance.from_existing( + instance_data, self + ) + self._add_instance_to_context(instance) + + def create(self, subset_name, data, pre_create_data): + # Pass precreate data to creator attributes + data["creator_attributes"] = pre_create_data + # Create new instance + new_instance = CreatedInstance(self.family, subset_name, data, self) + # Host implementation of storing metadata about instance + HostContext.add_instance(new_instance.data_to_store()) + # Add instance to current context + self._add_instance_to_context(new_instance) + + def get_instance_attr_defs(self): + output = [] + + file_def = FileDef( + "filepath", + folders=False, + extensions=self.extensions, + sequence_extensions=self.sequence_extensions, + label="Filepath" + ) + output.append(file_def) + if self.enable_review: + output.append(BoolDef("review", label="Review")) + return output + + @classmethod + def from_settings(cls, item_data): + identifier = item_data["identifier"] + family = item_data["family"] + if not identifier: + identifier = "settings_{}".format(family) + return type( + "{}{}".format(cls.__name__, identifier), + (cls, ), + { + "family": family, + "identifier": identifier, + "label": item_data["label"].strip(), + "icon": item_data["icon"], + "description": item_data["description"], + "enable_review": item_data["enable_review"], + "extensions": item_data["extensions"], + "sequence_extensions": item_data["sequence_extensions"], + "default_variants": item_data["default_variants"] + } + ) From 2efd8b774cf4e24823fd98225fd277a2353f273c Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 25 Apr 2022 15:24:37 +0200 Subject: [PATCH 078/583] changed multi item to single item --- openpype/hosts/traypublisher/api/plugin.py | 2 +- openpype/lib/attribute_definitions.py | 43 ++++++++++--------- .../widgets/attribute_defs/files_widget.py | 11 +++-- openpype/widgets/attribute_defs/widgets.py | 4 +- 4 files changed, 29 insertions(+), 31 deletions(-) diff --git a/openpype/hosts/traypublisher/api/plugin.py b/openpype/hosts/traypublisher/api/plugin.py index 6907450b15..d31e0a1ef7 100644 --- a/openpype/hosts/traypublisher/api/plugin.py +++ b/openpype/hosts/traypublisher/api/plugin.py @@ -74,7 +74,7 @@ class SettingsCreator(TrayPublishCreator): folders=False, extensions=self.extensions, sequence_extensions=self.sequence_extensions, - label="Filepath" + label="Filepath", ) output.append(file_def) if self.enable_review: diff --git a/openpype/lib/attribute_definitions.py b/openpype/lib/attribute_definitions.py index 6e754e6668..7a00fcdeb4 100644 --- a/openpype/lib/attribute_definitions.py +++ b/openpype/lib/attribute_definitions.py @@ -505,7 +505,7 @@ class FileDef(AbtractAttrDef): It is possible to define filters of allowed file extensions and if supports folders. Args: - multipath(bool): Allow multiple path. + single_item(bool): Allow only single path item. folders(bool): Allow folder paths. extensions(list): Allow files with extensions. Empty list will allow all extensions and None will disable files completely. @@ -527,7 +527,7 @@ class FileDef(AbtractAttrDef): ] def __init__( - self, key, multipath=False, folders=None, extensions=None, + self, key, single_item=True, folders=None, extensions=None, sequence_extensions=None, default=None, **kwargs ): if folders is None and extensions is None: @@ -535,19 +535,12 @@ class FileDef(AbtractAttrDef): extensions = [] if default is None: - if multipath: - default = [] - else: + if single_item: default = FileDefItem.create_empty_item().to_dict() - else: - if multipath: - if not isinstance(default, (tuple, list, set)): - raise TypeError(( - "'default' argument must be 'list', 'tuple' or 'set'" - ", not '{}'" - ).format(type(default))) - else: + default = [] + else: + if single_item: if isinstance(default, dict): FileDefItem.from_dict(default) @@ -559,18 +552,26 @@ class FileDef(AbtractAttrDef): "'default' argument must be 'str' or 'dict' not '{}'" ).format(type(default))) + else: + if not isinstance(default, (tuple, list, set)): + raise TypeError(( + "'default' argument must be 'list', 'tuple' or 'set'" + ", not '{}'" + ).format(type(default))) + # Change horizontal label is_label_horizontal = kwargs.get("is_label_horizontal") if is_label_horizontal is None: - is_label_horizontal = True - if multipath: + if single_item: + is_label_horizontal = True + else: is_label_horizontal = False kwargs["is_label_horizontal"] = is_label_horizontal if sequence_extensions is None: sequence_extensions = self.default_sequence_extensions - self.multipath = multipath + self.single_item = single_item self.folders = folders self.extensions = set(extensions) self.sequence_extensions = set(sequence_extensions) @@ -581,7 +582,7 @@ class FileDef(AbtractAttrDef): return False return ( - self.multipath == other.multipath + self.single_item == other.single_item and self.folders == other.folders and self.extensions == other.extensions and self.sequence_extensions == self.sequence_extensions @@ -611,13 +612,13 @@ class FileDef(AbtractAttrDef): for file_item in file_items ]) - if self.multipath: + if not self.single_item: return dict_items if not dict_items: return self.default return dict_items[0] - if self.multipath: - return [] - return FileDefItem.create_empty_item().to_dict() + if self.single_item: + return FileDefItem.create_empty_item().to_dict() + return [] diff --git a/openpype/widgets/attribute_defs/files_widget.py b/openpype/widgets/attribute_defs/files_widget.py index 6e2ab6e4f2..e41387e0e5 100644 --- a/openpype/widgets/attribute_defs/files_widget.py +++ b/openpype/widgets/attribute_defs/files_widget.py @@ -75,10 +75,10 @@ class DropEmpty(QtWidgets.QWidget): class FilesModel(QtGui.QStandardItemModel): - def __init__(self, allow_multiple_items, sequence_exts): + def __init__(self, single_item, sequence_exts): super(FilesModel, self).__init__() - self._allow_multiple_items = allow_multiple_items + self._single_item = single_item self._sequence_exts = sequence_exts self._items_by_id = {} @@ -94,7 +94,7 @@ class FilesModel(QtGui.QStandardItemModel): if not obj_items: return - if not self._allow_multiple_items: + if self._single_item: obj_items = [obj_items[0]] current_ids = list(self._file_items_by_id.keys()) if current_ids: @@ -288,13 +288,13 @@ class FilesView(QtWidgets.QListView): class FilesWidget(QtWidgets.QFrame): value_changed = QtCore.Signal() - def __init__(self, allow_multiple_items, sequence_exts, parent): + def __init__(self, single_item, sequence_exts, parent): super(FilesWidget, self).__init__(parent) self.setAcceptDrops(True) empty_widget = DropEmpty(self) - files_model = FilesModel(allow_multiple_items, sequence_exts) + files_model = FilesModel(single_item, sequence_exts) files_proxy_model = FilesProxyModel() files_proxy_model.setSourceModel(files_model) files_view = FilesView(self) @@ -311,7 +311,6 @@ class FilesWidget(QtWidgets.QFrame): self._in_set_value = False - self._allow_multiple_items = allow_multiple_items self._empty_widget = empty_widget self._files_model = files_model self._files_proxy_model = files_proxy_model diff --git a/openpype/widgets/attribute_defs/widgets.py b/openpype/widgets/attribute_defs/widgets.py index d3f53de032..62877be4cf 100644 --- a/openpype/widgets/attribute_defs/widgets.py +++ b/openpype/widgets/attribute_defs/widgets.py @@ -338,10 +338,8 @@ class UnknownAttrWidget(_BaseAttrDefWidget): class FileAttrWidget(_BaseAttrDefWidget): def _ui_init(self): - self.multipath = self.attr_def.multipath - input_widget = FilesWidget( - self.multipath, self.attr_def.sequence_extensions, self + self.attr_def.single_item, self.attr_def.sequence_extensions, self ) if self.attr_def.tooltip: From c4e826e77f97191b267421ccf23f9b2401ddc35b Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 25 Apr 2022 17:29:32 +0200 Subject: [PATCH 079/583] added ability to create copy of TemplateResult --- openpype/lib/path_templates.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/openpype/lib/path_templates.py b/openpype/lib/path_templates.py index 14e5fe59f8..5c40aa4549 100644 --- a/openpype/lib/path_templates.py +++ b/openpype/lib/path_templates.py @@ -365,6 +365,7 @@ class TemplateResult(str): when value of key in data is dictionary but template expect string of number. """ + used_values = None solved = None template = None @@ -383,6 +384,12 @@ class TemplateResult(str): new_obj.invalid_types = invalid_types return new_obj + def __copy__(self, *args, **kwargs): + return self.copy() + + def __deepcopy__(self, *args, **kwargs): + return self.copy() + def validate(self): if not self.solved: raise TemplateUnsolved( @@ -391,6 +398,17 @@ class TemplateResult(str): self.invalid_types ) + def copy(self): + cls = self.__class__ + return cls( + str(self), + self.template, + self.solved, + self.used_values, + self.missing_keys, + self.invalid_types + ) + class TemplatesResultDict(dict): """Holds and wrap TemplateResults for easy bug report.""" From ddddb86e77939214d818a54efc7a0eda5587588d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Mon, 25 Apr 2022 18:56:20 +0200 Subject: [PATCH 080/583] wip on ue5 support --- openpype/hosts/unreal/lib.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/unreal/lib.py b/openpype/hosts/unreal/lib.py index d4a776e892..c0b4c7061c 100644 --- a/openpype/hosts/unreal/lib.py +++ b/openpype/hosts/unreal/lib.py @@ -74,7 +74,7 @@ def get_editor_executable_path(engine_path: Path) -> Path: """Get UE4 Editor executable path.""" ue4_path = engine_path / "Engine/Binaries" if platform.system().lower() == "windows": - ue4_path /= "Win64/UE4Editor.exe" + ue4_path /= "Win64/UnrealEditor.exe" elif platform.system().lower() == "linux": ue4_path /= "Linux/UE4Editor" @@ -420,7 +420,7 @@ class {1}_API A{0}GameModeBase : public AGameModeBase f.write(game_mode_h) u_build_tool = Path( - engine_path / "Engine/Binaries/DotNET/UnrealBuildTool.exe") + engine_path / "Engine/Binaries/DotNET/UnrealBuildTool/UnrealBuildTool.exe") u_header_tool = None arch = "Win64" From aace513c84c1f2f901be54139759dcc3bddf3a5d Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 25 Apr 2022 19:25:57 +0200 Subject: [PATCH 081/583] moved remove button to view's bottom --- .../widgets/attribute_defs/files_widget.py | 70 ++++++++++--------- 1 file changed, 36 insertions(+), 34 deletions(-) diff --git a/openpype/widgets/attribute_defs/files_widget.py b/openpype/widgets/attribute_defs/files_widget.py index e41387e0e5..f483fe7ef5 100644 --- a/openpype/widgets/attribute_defs/files_widget.py +++ b/openpype/widgets/attribute_defs/files_widget.py @@ -1,8 +1,7 @@ import os import collections import uuid -import clique -import six + from Qt import QtWidgets, QtCore, QtGui from openpype.lib import FileDefItem @@ -213,8 +212,6 @@ class FilesProxyModel(QtCore.QSortFilterProxyModel): class ItemWidget(QtWidgets.QWidget): - remove_requested = QtCore.Signal(str) - def __init__(self, item_id, label, pixmap_icon, parent=None): self._item_id = item_id @@ -224,31 +221,21 @@ class ItemWidget(QtWidgets.QWidget): icon_widget = PixmapLabel(pixmap_icon, self) label_widget = QtWidgets.QLabel(label, self) - pixmap = paint_image_with_color( - get_image(filename="delete.png"), QtCore.Qt.white - ) - remove_btn = IconButton(self) - remove_btn.setIcon(QtGui.QIcon(pixmap)) layout = QtWidgets.QHBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.addWidget(icon_widget, 0) layout.addWidget(label_widget, 1) - layout.addWidget(remove_btn, 0) - - remove_btn.clicked.connect(self._on_remove_clicked) self._icon_widget = icon_widget self._label_widget = label_widget - self._remove_btn = remove_btn - - def _on_remove_clicked(self): - self.remove_requested.emit(self._item_id) class FilesView(QtWidgets.QListView): """View showing instances and their groups.""" + remove_requested = QtCore.Signal() + def __init__(self, *args, **kwargs): super(FilesView, self).__init__(*args, **kwargs) @@ -257,6 +244,17 @@ class FilesView(QtWidgets.QListView): QtWidgets.QAbstractItemView.ExtendedSelection ) + remove_btn = IconButton(self) + pix = paint_image_with_color( + get_image(filename="delete.png"), QtCore.Qt.white + ) + icon = QtGui.QIcon(pix) + remove_btn.setIcon(icon) + + remove_btn.clicked.connect(self._on_remove_clicked) + + self._remove_btn = remove_btn + def get_selected_item_ids(self): """Ids of selected instances.""" selected_item_ids = set() @@ -284,6 +282,24 @@ class FilesView(QtWidgets.QListView): return super(FilesView, self).event(event) + def _on_remove_clicked(self): + self.remove_requested.emit() + + def _update_remove_btn(self): + viewport = self.viewport() + height = viewport.height() + pos_x = viewport.width() - self._remove_btn.width() - 5 + pos_y = height - self._remove_btn.height() - 5 + self._remove_btn.move(max(0, pos_x), max(0, pos_y)) + + def resizeEvent(self, event): + super(FilesView, self).resizeEvent(event) + self._update_remove_btn() + + def showEvent(self, event): + super(FilesView, self).showEvent(event) + self._update_remove_btn() + class FilesWidget(QtWidgets.QFrame): value_changed = QtCore.Signal() @@ -308,7 +324,7 @@ class FilesWidget(QtWidgets.QFrame): files_proxy_model.rowsInserted.connect(self._on_rows_inserted) files_proxy_model.rowsRemoved.connect(self._on_rows_removed) - + files_view.remove_requested.connect(self._on_remove_requested) self._in_set_value = False self._empty_widget = empty_widget @@ -373,7 +389,6 @@ class FilesWidget(QtWidgets.QFrame): self._files_proxy_model.setData( index, widget.sizeHint(), QtCore.Qt.SizeHintRole ) - widget.remove_requested.connect(self._on_remove_request) self._widgets_by_id[item_id] = widget self._files_proxy_model.sort(0) @@ -401,23 +416,10 @@ class FilesWidget(QtWidgets.QFrame): if not self._in_set_value: self.value_changed.emit() - def _on_remove_request(self, item_id): - found_index = None - for row in range(self._files_model.rowCount()): - index = self._files_model.index(row, 0) - _item_id = index.data(ITEM_ID_ROLE) - if item_id == _item_id: - found_index = index - break - - if found_index is None: - return - + def _on_remove_requested(self): items_to_delete = self._files_view.get_selected_item_ids() - if item_id not in items_to_delete: - items_to_delete = [item_id] - - self._remove_item_by_ids(items_to_delete) + if items_to_delete: + self._remove_item_by_ids(items_to_delete) def sizeHint(self): # Get size hints of widget and visible widgets From 05ede8031e26f3f86408d1aa88aef8b10b6ad2de Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 25 Apr 2022 19:31:48 +0200 Subject: [PATCH 082/583] a little bit nicer look of items in files widget --- .../widgets/attribute_defs/files_widget.py | 34 +++++++++++-------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/openpype/widgets/attribute_defs/files_widget.py b/openpype/widgets/attribute_defs/files_widget.py index f483fe7ef5..72bfd6cfa2 100644 --- a/openpype/widgets/attribute_defs/files_widget.py +++ b/openpype/widgets/attribute_defs/files_widget.py @@ -89,31 +89,26 @@ class FilesModel(QtGui.QStandardItemModel): if not items: return - obj_items = FileDefItem.from_value(items, self._sequence_exts) - if not obj_items: + file_items = FileDefItem.from_value(items, self._sequence_exts) + if not file_items: return if self._single_item: - obj_items = [obj_items[0]] + file_items = [file_items[0]] current_ids = list(self._file_items_by_id.keys()) if current_ids: self.remove_item_by_ids(current_ids) new_model_items = [] - for obj_item in obj_items: - _, ext = os.path.splitext(obj_item.filenames[0]) - if ext: - icon_pixmap = get_pixmap(filename="file.png") - else: - icon_pixmap = get_pixmap(filename="folder.png") - - item_id, model_item = self._create_item(obj_item, icon_pixmap) + for file_item in file_items: + item_id, model_item = self._create_item(file_item) new_model_items.append(model_item) - self._file_items_by_id[item_id] = obj_item + self._file_items_by_id[item_id] = file_item self._items_by_id[item_id] = model_item if new_model_items: - self.invisibleRootItem().appendRows(new_model_items) + roow_item = self.invisibleRootItem() + roow_item.appendRows(new_model_items) def remove_item_by_ids(self, item_ids): if not item_ids: @@ -134,7 +129,16 @@ class FilesModel(QtGui.QStandardItemModel): def get_file_item_by_id(self, item_id): return self._file_items_by_id.get(item_id) - def _create_item(self, file_item, icon_pixmap=None): + def _create_item(self, file_item): + if file_item.is_dir: + icon_pixmap = paint_image_with_color( + get_image(filename="folder.png"), QtCore.Qt.white + ) + else: + icon_pixmap = paint_image_with_color( + get_image(filename="file.png"), QtCore.Qt.white + ) + item = QtGui.QStandardItem() item_id = str(uuid.uuid4()) item.setData(item_id, ITEM_ID_ROLE) @@ -223,7 +227,7 @@ class ItemWidget(QtWidgets.QWidget): label_widget = QtWidgets.QLabel(label, self) layout = QtWidgets.QHBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) + layout.setContentsMargins(5, 5, 0, 5) layout.addWidget(icon_widget, 0) layout.addWidget(label_widget, 1) From cc1f800740c77b5a5fafdb665a03ad9b630b239f Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 26 Apr 2022 10:12:35 +0200 Subject: [PATCH 083/583] added testing widget for attribute definitions --- openpype/widgets/attribute_defs/__init__.py | 6 +- openpype/widgets/attribute_defs/widgets.py | 102 ++++++++++++++++++++ 2 files changed, 107 insertions(+), 1 deletion(-) diff --git a/openpype/widgets/attribute_defs/__init__.py b/openpype/widgets/attribute_defs/__init__.py index 147efeb3d6..ce6b80109e 100644 --- a/openpype/widgets/attribute_defs/__init__.py +++ b/openpype/widgets/attribute_defs/__init__.py @@ -1,6 +1,10 @@ -from .widgets import create_widget_for_attr_def +from .widgets import ( + create_widget_for_attr_def, + AttributeDefinitionsWidget, +) __all__ = ( "create_widget_for_attr_def", + "AttributeDefinitionsWidget", ) diff --git a/openpype/widgets/attribute_defs/widgets.py b/openpype/widgets/attribute_defs/widgets.py index 62877be4cf..3f36c078cb 100644 --- a/openpype/widgets/attribute_defs/widgets.py +++ b/openpype/widgets/attribute_defs/widgets.py @@ -1,4 +1,5 @@ import uuid +import copy from Qt import QtWidgets, QtCore @@ -10,6 +11,7 @@ from openpype.lib.attribute_definitions import ( EnumDef, BoolDef, FileDef, + UIDef, UISeparatorDef, UILabelDef ) @@ -53,6 +55,106 @@ def create_widget_for_attr_def(attr_def, parent=None): )) +class AttributeDefinitionsWidget(QtWidgets.QWidget): + """Create widgets for attribute definitions in grid layout. + + Widget creates input widgets for passed attribute definitions. + + Widget can't handle multiselection values. + """ + + def __init__(self, attr_defs=None, parent=None): + super(AttributeDefinitionsWidget, self).__init__(parent) + + self._widgets = [] + self._current_keys = set() + + self.set_attr_defs(attr_defs) + + def clear_attr_defs(self): + """Remove all existing widgets and reset layout if needed.""" + self._widgets = [] + self._current_keys = set() + + layout = self.layout() + if layout is not None: + if layout.count() == 0: + return + + while layout.count(): + item = layout.takeAt(0) + widget = item.widget() + if widget: + widget.setVisible(False) + widget.deleteLater() + + layout.deleteLater() + + new_layout = QtWidgets.QGridLayout() + self.setLayout(new_layout) + + def set_attr_defs(self, attr_defs): + """Replace current attribute definitions with passed.""" + self.clear_attr_defs() + if attr_defs: + self.add_attr_defs(attr_defs) + + def add_attr_defs(self, attr_defs): + """Add attribute definitions to current.""" + layout = self.layout() + + row = 0 + for attr_def in attr_defs: + if attr_def.key in self._current_keys: + raise KeyError("Duplicated key \"{}\"".format(attr_def.key)) + + self._current_keys.add(attr_def.key) + widget = create_widget_for_attr_def(attr_def, self) + + expand_cols = 2 + if attr_def.is_value_def and attr_def.is_label_horizontal: + expand_cols = 1 + + col_num = 2 - expand_cols + + if attr_def.label: + label_widget = QtWidgets.QLabel(attr_def.label, self) + layout.addWidget( + label_widget, row, 0, 1, expand_cols + ) + if not attr_def.is_label_horizontal: + row += 1 + + layout.addWidget( + widget, row, col_num, 1, expand_cols + ) + self._widgets.append(widget) + row += 1 + + def set_value(self, value): + new_value = copy.deepcopy(value) + unused_keys = set(new_value.keys()) + for widget in self._widgets: + attr_def = widget.attr_def + if attr_def.key not in new_value: + continue + unused_keys.remove(attr_def.key) + + widget_value = new_value[attr_def.key] + if widget_value is None: + widget_value = copy.deepcopy(attr_def.default) + widget.set_value(widget_value) + + def current_value(self): + output = {} + for widget in self._widgets: + attr_def = widget.attr_def + if not isinstance(attr_def, UIDef): + output[attr_def.key] = widget.current_value() + + return output + + class _BaseAttrDefWidget(QtWidgets.QWidget): # Type 'object' may not work with older PySide versions value_changed = QtCore.Signal(object, uuid.UUID) From c68b1e42c5b6991db3d633b8462323b333384e4f Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 26 Apr 2022 11:08:23 +0200 Subject: [PATCH 084/583] added MessageOverlayObject to utils init --- openpype/tools/utils/__init__.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/openpype/tools/utils/__init__.py b/openpype/tools/utils/__init__.py index ea1133c442..0f367510bd 100644 --- a/openpype/tools/utils/__init__.py +++ b/openpype/tools/utils/__init__.py @@ -22,6 +22,10 @@ from .lib import ( from .models import ( RecursiveSortFilterProxyModel, ) +from .overlay_messages import ( + MessageOverlayObject, +) + __all__ = ( "PlaceholderLineEdit", @@ -45,4 +49,6 @@ __all__ = ( "get_asset_icon", "RecursiveSortFilterProxyModel", + + "MessageOverlayObject", ) From 24728400eac2a954f8f82e70db8fc6bdd5491c38 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 26 Apr 2022 11:14:57 +0200 Subject: [PATCH 085/583] MessageOverlayObject can have it's own default timeout --- openpype/tools/utils/overlay_messages.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/openpype/tools/utils/overlay_messages.py b/openpype/tools/utils/overlay_messages.py index 93082b9fb7..62de2cf272 100644 --- a/openpype/tools/utils/overlay_messages.py +++ b/openpype/tools/utils/overlay_messages.py @@ -174,7 +174,7 @@ class MessageOverlayObject(QtCore.QObject): widget (QWidget): """ - def __init__(self, widget): + def __init__(self, widget, default_timeout=None): super(MessageOverlayObject, self).__init__() widget.installEventFilter(self) @@ -194,6 +194,7 @@ class MessageOverlayObject(QtCore.QObject): self._spacing = 5 self._move_size = 4 self._move_size_remove = 8 + self._default_timeout = default_timeout def add_message(self, message, message_type=None, timeout=None): """Add single message into overlay. @@ -208,6 +209,9 @@ class MessageOverlayObject(QtCore.QObject): if not message: return + if timeout is None: + timeout = self._default_timeout + # Create unique id of message label_id = str(uuid.uuid4()) # Create message widget From d5e7353b665cfd8ab12b243dfebbcd75d6b38cd4 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 26 Apr 2022 11:20:56 +0200 Subject: [PATCH 086/583] changed success to default --- openpype/style/style.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/style/style.css b/openpype/style/style.css index bae648b860..f2b0cdd6ac 100644 --- a/openpype/style/style.css +++ b/openpype/style/style.css @@ -696,10 +696,10 @@ QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { #OverlayMessageWidget:hover { background: {color:bg-button-hover}; } -#OverlayMessageWidget[type="success"] { +#OverlayMessageWidget { background: {color:overlay-messages:bg-success}; } -#OverlayMessageWidget[type="success"]:hover { +#OverlayMessageWidget:hover { background: {color:overlay-messages:bg-success-hover}; } From 0ebe84adf4e0181a0c842aa105d6de5c80b1d243 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 26 Apr 2022 11:21:35 +0200 Subject: [PATCH 087/583] use overlay messages in local settings --- openpype/tools/settings/local_settings/window.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/openpype/tools/settings/local_settings/window.py b/openpype/tools/settings/local_settings/window.py index 4db0e01476..6a2db3fff5 100644 --- a/openpype/tools/settings/local_settings/window.py +++ b/openpype/tools/settings/local_settings/window.py @@ -8,6 +8,7 @@ from openpype.settings.lib import ( save_local_settings ) from openpype.tools.settings import CHILD_OFFSET +from openpype.tools.utils import MessageOverlayObject from openpype.api import ( Logger, SystemSettings, @@ -221,6 +222,8 @@ class LocalSettingsWindow(QtWidgets.QWidget): self.setWindowTitle("OpenPype Local settings") + overlay_object = MessageOverlayObject(self) + stylesheet = style.load_stylesheet() self.setStyleSheet(stylesheet) self.setWindowIcon(QtGui.QIcon(style.app_icon_path())) @@ -247,6 +250,7 @@ class LocalSettingsWindow(QtWidgets.QWidget): save_btn.clicked.connect(self._on_save_clicked) reset_btn.clicked.connect(self._on_reset_clicked) + self._overlay_object = overlay_object # Do not create local settings widget in init phase as it's using # settings objects that must be OK to be able create this widget # - we want to show dialog if anything goes wrong @@ -312,8 +316,10 @@ class LocalSettingsWindow(QtWidgets.QWidget): def _on_reset_clicked(self): self.reset() + self._overlay_object.add_message("Refreshed...") def _on_save_clicked(self): value = self._settings_widget.settings_value() save_local_settings(value) + self._overlay_object.add_message("Saved...", message_type="success") self.reset() From c9f35c480507471128efbca377ad71464f028788 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 26 Apr 2022 12:08:07 +0200 Subject: [PATCH 088/583] remove button is enabled/disabled on selection change --- openpype/style/style.css | 8 +++++ .../widgets/attribute_defs/files_widget.py | 30 +++++++++++++++++-- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/openpype/style/style.css b/openpype/style/style.css index 9df615d953..59253a474c 100644 --- a/openpype/style/style.css +++ b/openpype/style/style.css @@ -1340,3 +1340,11 @@ VariantInputsWidget QToolButton { #LikeDisabledInput:focus { border-color: {color:border}; } + +/* Attribute Definition widgets */ +InViewButton, InViewButton:disabled { + background: transparent; +} +InViewButton:hover { + background: rgba(255, 255, 255, 37); +} diff --git a/openpype/widgets/attribute_defs/files_widget.py b/openpype/widgets/attribute_defs/files_widget.py index 72bfd6cfa2..3a9455584c 100644 --- a/openpype/widgets/attribute_defs/files_widget.py +++ b/openpype/widgets/attribute_defs/files_widget.py @@ -235,6 +235,10 @@ class ItemWidget(QtWidgets.QWidget): self._label_widget = label_widget +class InViewButton(IconButton): + pass + + class FilesView(QtWidgets.QListView): """View showing instances and their groups.""" @@ -248,17 +252,34 @@ class FilesView(QtWidgets.QListView): QtWidgets.QAbstractItemView.ExtendedSelection ) - remove_btn = IconButton(self) - pix = paint_image_with_color( + remove_btn = InViewButton(self) + pix_enabled = paint_image_with_color( get_image(filename="delete.png"), QtCore.Qt.white ) - icon = QtGui.QIcon(pix) + pix_disabled = paint_image_with_color( + get_image(filename="delete.png"), QtCore.Qt.gray + ) + icon = QtGui.QIcon(pix_enabled) + icon.addPixmap(pix_disabled, icon.Disabled, icon.Off) remove_btn.setIcon(icon) + remove_btn.setEnabled(False) remove_btn.clicked.connect(self._on_remove_clicked) self._remove_btn = remove_btn + def setSelectionModel(self, *args, **kwargs): + super(FilesView, self).setSelectionModel(*args, **kwargs) + selection_model = self.selectionModel() + selection_model.selectionChanged.connect(self._on_selection_change) + + def has_selected_item_ids(self): + for index in self.selectionModel().selectedIndexes(): + instance_id = index.data(ITEM_ID_ROLE) + if instance_id is not None: + return True + return False + def get_selected_item_ids(self): """Ids of selected instances.""" selected_item_ids = set() @@ -286,6 +307,9 @@ class FilesView(QtWidgets.QListView): return super(FilesView, self).event(event) + def _on_selection_change(self): + self._remove_btn.setEnabled(self.has_selected_item_ids()) + def _on_remove_clicked(self): self.remove_requested.emit() From 171e73bf218da9130b58a59647840320a42eca40 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 26 Apr 2022 12:10:33 +0200 Subject: [PATCH 089/583] fixed event handling on files view --- .../widgets/attribute_defs/files_widget.py | 21 +++++++------------ 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/openpype/widgets/attribute_defs/files_widget.py b/openpype/widgets/attribute_defs/files_widget.py index 3a9455584c..cb339f3d52 100644 --- a/openpype/widgets/attribute_defs/files_widget.py +++ b/openpype/widgets/attribute_defs/files_widget.py @@ -290,20 +290,13 @@ class FilesView(QtWidgets.QListView): return selected_item_ids def event(self, event): - if not event.type() == QtCore.QEvent.KeyPress: - pass - - elif event.key() == QtCore.Qt.Key_Space: - self.toggle_requested.emit(-1) - return True - - elif event.key() == QtCore.Qt.Key_Backspace: - self.toggle_requested.emit(0) - return True - - elif event.key() == QtCore.Qt.Key_Return: - self.toggle_requested.emit(1) - return True + if event.type() == QtCore.QEvent.KeyPress: + if ( + event.key() == QtCore.Qt.Key_Delete + and self.has_selected_item_ids() + ): + self.remove_requested.emit() + return True return super(FilesView, self).event(event) From 1bfe4232855a79720d08dcb8a7500c06057507d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Tue, 26 Apr 2022 12:11:36 +0200 Subject: [PATCH 090/583] fix proper output directory --- openpype/hosts/maya/api/lib_renderproducts.py | 45 +++++++++++++------ .../maya/plugins/create/create_render.py | 2 +- .../publish/validate_rendersettings.py | 30 +++++-------- .../plugins/publish/submit_maya_deadline.py | 24 +++++++++- 4 files changed, 64 insertions(+), 37 deletions(-) diff --git a/openpype/hosts/maya/api/lib_renderproducts.py b/openpype/hosts/maya/api/lib_renderproducts.py index 8b282094db..5956cc482c 100644 --- a/openpype/hosts/maya/api/lib_renderproducts.py +++ b/openpype/hosts/maya/api/lib_renderproducts.py @@ -79,6 +79,7 @@ IMAGE_PREFIXES = { "redshift": "defaultRenderGlobals.imageFilePrefix", } +RENDERMAN_IMAGE_DIR = "maya//" @attr.s class LayerMetadata(object): @@ -1054,6 +1055,8 @@ class RenderProductsRenderman(ARenderProducts): :func:`ARenderProducts.get_render_products()` """ + from rfm2.api.displays import get_displays # noqa + cameras = [ self.sanitize_camera_name(c) for c in self.get_renderable_cameras() @@ -1066,20 +1069,38 @@ class RenderProductsRenderman(ARenderProducts): ] products = [] - default_ext = "exr" - displays = cmds.listConnections("rmanGlobals.displays") - for aov in displays: - enabled = self._get_attr(aov, "enable") + # NOTE: This is guessing extensions from renderman display types. + # Some of them are just framebuffers, d_texture format can be + # set in display setting. We set those now to None, but it + # should be handled more gracefully. + display_types = { + "d_deepexr": "exr", + "d_it": None, + "d_null": None, + "d_openexr": "exr", + "d_png": "png", + "d_pointcloud": "ptc", + "d_targa": "tga", + "d_texture": None, + "d_tiff": "tif" + } + + displays = get_displays()["displays"] + for name, display in displays.items(): + enabled = display["params"]["enable"]["value"] if not enabled: continue - aov_name = str(aov) + aov_name = name if aov_name == "rmanDefaultDisplay": aov_name = "beauty" + extensions = display_types.get( + display["driverNode"]["type"], "exr") + for camera in cameras: product = RenderProduct(productName=aov_name, - ext=default_ext, + ext=extensions, camera=camera) products.append(product) @@ -1088,20 +1109,16 @@ class RenderProductsRenderman(ARenderProducts): def get_files(self, product): """Get expected files. - In renderman we hack it with prepending path. This path would - normally be translated from `rmanGlobals.imageOutputDir`. We skip - this and hardcode prepend path we expect. There is no place for user - to mess around with this settings anyway and it is enforced in - render settings validator. """ files = super(RenderProductsRenderman, self).get_files(product) layer_data = self.layer_data new_files = [] + + resolved_image_dir = re.sub("", layer_data.sceneName, RENDERMAN_IMAGE_DIR, flags=re.IGNORECASE) # noqa: E501 + resolved_image_dir = re.sub("", layer_data.layerName, resolved_image_dir, flags=re.IGNORECASE) # noqa: E501 for file in files: - new_file = "{}/{}/{}".format( - layer_data.sceneName, layer_data.layerName, file - ) + new_file = "{}/{}".format(resolved_image_dir, file) new_files.append(new_file) return new_files diff --git a/openpype/hosts/maya/plugins/create/create_render.py b/openpype/hosts/maya/plugins/create/create_render.py index 13bfe1bf37..f2cf73557e 100644 --- a/openpype/hosts/maya/plugins/create/create_render.py +++ b/openpype/hosts/maya/plugins/create/create_render.py @@ -467,7 +467,7 @@ class CreateRender(plugin.Creator): if renderer == "renderman": cmds.setAttr("rmanGlobals.imageOutputDir", - "/maya//", type="string") + "maya//", type="string") def _set_vray_settings(self, asset): # type: (dict) -> None diff --git a/openpype/hosts/maya/plugins/publish/validate_rendersettings.py b/openpype/hosts/maya/plugins/publish/validate_rendersettings.py index 28fe2d317c..a513c8ebc1 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rendersettings.py +++ b/openpype/hosts/maya/plugins/publish/validate_rendersettings.py @@ -69,14 +69,7 @@ class ValidateRenderSettings(pyblish.api.InstancePlugin): redshift_AOV_prefix = "/{aov_separator}" # noqa: E501 - # WARNING: There is bug? in renderman, translating token - # to something left behind mayas default image prefix. So instead - # `SceneName_v01` it translates to: - # `SceneName_v01//` that means - # for example: - # `SceneName_v01/Main/Main_`. Possible solution is to define - # custom token like to point to determined scene name. - RendermanDirPrefix = "/renders/maya//" + renderman_dir_prefix = "maya//" R_AOV_TOKEN = re.compile( r'%a||', re.IGNORECASE) @@ -119,21 +112,18 @@ class ValidateRenderSettings(pyblish.api.InstancePlugin): required_prefix = "maya/" - if renderer == "renderman": - # renderman has prefix set differently - required_prefix = "/renders/{}".format(required_prefix) - if not anim_override: invalid = True cls.log.error("Animation needs to be enabled. Use the same " "frame for start and end to render single frame") - if not prefix.lower().startswith(required_prefix): - invalid = True - cls.log.error( - "Wrong image prefix [ {} ] - doesn't start with: '{}'".format( - prefix, required_prefix) - ) + if renderer != "renderman": + if not prefix.lower().startswith(required_prefix): + invalid = True + cls.log.error( + "Wrong image prefix [ {} ] - doesn't start with: '{}'".format( + prefix, required_prefix) + ) if not re.search(cls.R_LAYER_TOKEN, prefix): invalid = True @@ -207,7 +197,7 @@ class ValidateRenderSettings(pyblish.api.InstancePlugin): invalid = True cls.log.error("Wrong image prefix [ {} ]".format(file_prefix)) - if dir_prefix.lower() != cls.RendermanDirPrefix.lower(): + if dir_prefix.lower() != cls.renderman_dir_prefix.lower(): invalid = True cls.log.error("Wrong directory prefix [ {} ]".format( dir_prefix)) @@ -313,7 +303,7 @@ class ValidateRenderSettings(pyblish.api.InstancePlugin): default_prefix, type="string") cmds.setAttr("rmanGlobals.imageOutputDir", - cls.RendermanDirPrefix, + cls.renderman_dir_prefix, type="string") if renderer == "vray": diff --git a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py index 14e458a401..3f036dbca7 100644 --- a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py @@ -187,6 +187,10 @@ def get_renderer_variables(renderlayer, root): filename_0 = re.sub('_', '_beauty', filename_0, flags=re.IGNORECASE) prefix_attr = "defaultRenderGlobals.imageFilePrefix" + + scene = cmds.file(query=True, sceneName=True) + scene, _ = os.path.splitext(os.path.basename(scene)) + if renderer == "vray": renderlayer = renderlayer.split("_")[-1] # Maya's renderSettings function does not return V-Ray file extension @@ -206,8 +210,7 @@ def get_renderer_variables(renderlayer, root): filename_prefix = cmds.getAttr(prefix_attr) # we need to determine path for vray as maya `renderSettings` query # does not work for vray. - scene = cmds.file(query=True, sceneName=True) - scene, _ = os.path.splitext(os.path.basename(scene)) + filename_0 = re.sub('', scene, filename_prefix, flags=re.IGNORECASE) # noqa: E501 filename_0 = re.sub('', renderlayer, filename_0, flags=re.IGNORECASE) # noqa: E501 filename_0 = "{}.{}.{}".format( @@ -224,15 +227,30 @@ def get_renderer_variables(renderlayer, root): "d_it": None, "d_null": None, "d_openexr": "exr", + "d_openexr3": "exr", "d_png": "png", "d_pointcloud": "ptc", "d_targa": "tga", "d_texture": None, "d_tiff": "tif" } + extension = display_types.get( cmds.listConnections("rmanDefaultDisplay.displayType")[0] ) + + filename_prefix = "{}/{}".format( + cmds.getAttr("rmanGlobals.imageOutputDir"), + cmds.getAttr("rmanGlobals.imageFileFormat") + ) + + renderlayer = renderlayer.split("_")[-1] + + filename_0 = re.sub('', scene, filename_prefix, flags=re.IGNORECASE) # noqa: E501 + filename_0 = re.sub('', renderlayer, filename_0, flags=re.IGNORECASE) # noqa: E501 + filename_0 = re.sub('', "#" * int(padding), filename_0, flags=re.IGNORECASE) # noqa: E501 + filename_0 = re.sub('', extension, filename_0, flags=re.IGNORECASE) # noqa: E501 + filename_0 = os.path.normpath(os.path.join(root, filename_0)) elif renderer == "redshift": # mapping redshift extension dropdown values to strings ext_mapping = ["iff", "exr", "tif", "png", "tga", "jpg"] @@ -442,6 +460,8 @@ class MayaSubmitDeadline(pyblish.api.InstancePlugin): output_filename_0 = filename_0 + dirname = os.path.dirname(output_filename_0) + # Create render folder ---------------------------------------------- try: # Ensure render folder exists From a2b2dfb1ef93b59c88097b97fcc314708ba529be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Tue, 26 Apr 2022 12:18:12 +0200 Subject: [PATCH 091/583] hound fixes --- .../plugins/publish/validate_rendersettings.py | 15 ++++++++------- .../plugins/publish/submit_maya_deadline.py | 2 +- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_rendersettings.py b/openpype/hosts/maya/plugins/publish/validate_rendersettings.py index a513c8ebc1..023e27de17 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rendersettings.py +++ b/openpype/hosts/maya/plugins/publish/validate_rendersettings.py @@ -117,13 +117,14 @@ class ValidateRenderSettings(pyblish.api.InstancePlugin): cls.log.error("Animation needs to be enabled. Use the same " "frame for start and end to render single frame") - if renderer != "renderman": - if not prefix.lower().startswith(required_prefix): - invalid = True - cls.log.error( - "Wrong image prefix [ {} ] - doesn't start with: '{}'".format( - prefix, required_prefix) - ) + if renderer != "renderman" and not prefix.lower().startswith( + required_prefix): + invalid = True + cls.log.error( + ("Wrong image prefix [ {} ] " + " - doesn't start with: '{}'").format( + prefix, required_prefix) + ) if not re.search(cls.R_LAYER_TOKEN, prefix): invalid = True diff --git a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py index 3f036dbca7..347b6ab0fe 100644 --- a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py @@ -858,7 +858,7 @@ class MayaSubmitDeadline(pyblish.api.InstancePlugin): } renderer = self._instance.data["renderer"] - + # This hack is here because of how Deadline handles Renderman version. # it considers everything with `renderman` set as version older than # Renderman 22, and so if we are using renderman > 21 we need to set From 911163756e994edd9e332f6ef26f973c50cd24d9 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 26 Apr 2022 13:35:53 +0200 Subject: [PATCH 092/583] simplified allow sequence --- openpype/hosts/traypublisher/api/plugin.py | 5 +- .../plugins/create/create_from_settings.py | 13 ---- openpype/lib/attribute_definitions.py | 66 ++++++++----------- .../project_settings/traypublisher.json | 6 +- .../schema_project_traypublisher.json | 38 ++--------- .../widgets/attribute_defs/files_widget.py | 10 +-- openpype/widgets/attribute_defs/widgets.py | 2 +- 7 files changed, 44 insertions(+), 96 deletions(-) diff --git a/openpype/hosts/traypublisher/api/plugin.py b/openpype/hosts/traypublisher/api/plugin.py index d31e0a1ef7..d4bbe4c9d6 100644 --- a/openpype/hosts/traypublisher/api/plugin.py +++ b/openpype/hosts/traypublisher/api/plugin.py @@ -45,7 +45,6 @@ class SettingsCreator(TrayPublishCreator): enable_review = False extensions = [] - sequence_extensions = [] def collect_instances(self): for instance_data in list_instances(): @@ -73,7 +72,7 @@ class SettingsCreator(TrayPublishCreator): "filepath", folders=False, extensions=self.extensions, - sequence_extensions=self.sequence_extensions, + allow_sequences=self.allow_sequences, label="Filepath", ) output.append(file_def) @@ -98,7 +97,7 @@ class SettingsCreator(TrayPublishCreator): "description": item_data["description"], "enable_review": item_data["enable_review"], "extensions": item_data["extensions"], - "sequence_extensions": item_data["sequence_extensions"], + "allow_sequences": item_data["allow_sequences"], "default_variants": item_data["default_variants"] } ) diff --git a/openpype/hosts/traypublisher/plugins/create/create_from_settings.py b/openpype/hosts/traypublisher/plugins/create/create_from_settings.py index 19ade437ab..836939fe94 100644 --- a/openpype/hosts/traypublisher/plugins/create/create_from_settings.py +++ b/openpype/hosts/traypublisher/plugins/create/create_from_settings.py @@ -14,19 +14,6 @@ def initialize(): global_variables = globals() for item in simple_creators: - allow_sequences_value = item["allow_sequences"] - allow_sequences = allow_sequences_value["allow"] - if allow_sequences == "all": - sequence_extensions = copy.deepcopy(item["extensions"]) - - elif allow_sequences == "no": - sequence_extensions = [] - - elif allow_sequences == "selection": - sequence_extensions = allow_sequences_value["extensions"] - - item["sequence_extensions"] = sequence_extensions - item["enable_review"] = False dynamic_plugin = SettingsCreator.from_settings(item) global_variables[dynamic_plugin.__name__] = dynamic_plugin diff --git a/openpype/lib/attribute_definitions.py b/openpype/lib/attribute_definitions.py index 7a00fcdeb4..ea3da53a9e 100644 --- a/openpype/lib/attribute_definitions.py +++ b/openpype/lib/attribute_definitions.py @@ -375,6 +375,16 @@ class FileDefItem(object): filename_template, ",".join(ranges) ) + def split_sequence(self): + if not self.is_sequence: + raise ValueError("Cannot split single file item") + + output = [] + for filename in self.filenames: + path = os.path.join(self.directory, filename) + output.append(self.from_paths([path])) + return output + @property def ext(self): _, ext = os.path.splitext(self.filenames[0]) @@ -412,7 +422,7 @@ class FileDefItem(object): return cls("", "") @classmethod - def from_value(cls, value, sequence_extensions): + def from_value(cls, value, allow_sequences): multi = isinstance(value, (list, tuple, set)) if not multi: value = [value] @@ -420,10 +430,15 @@ class FileDefItem(object): output = [] str_filepaths = [] for item in value: + if isinstance(item, dict): + item = cls.from_dict(item) + if isinstance(item, FileDefItem): - output.append(item) - elif isinstance(item, dict): - output.append(cls.from_dict(item)) + if not allow_sequences and item.is_sequence: + output.extend(item.split_sequence()) + else: + output.append(item) + elif isinstance(item, six.string_types): str_filepaths.append(item) else: @@ -434,7 +449,7 @@ class FileDefItem(object): ) if str_filepaths: - output.extend(cls.from_paths(str_filepaths, sequence_extensions)) + output.extend(cls.from_paths(str_filepaths, allow_sequences)) if multi: return output @@ -450,7 +465,7 @@ class FileDefItem(object): ) @classmethod - def from_paths(cls, paths, sequence_extensions): + def from_paths(cls, paths, allow_sequences): filenames_by_dir = collections.defaultdict(list) for path in paths: normalized = os.path.normpath(path) @@ -459,18 +474,12 @@ class FileDefItem(object): output = [] for directory, filenames in filenames_by_dir.items(): - filtered_filenames = [] - for filename in filenames: - _, ext = os.path.splitext(filename) - if ext in sequence_extensions: - filtered_filenames.append(filename) - else: - output.append(cls(directory, [filename])) + if allow_sequences: + cols, remainders = clique.assemble(filenames) + else: + cols = [] + remainders = filenames - if not filtered_filenames: - continue - - cols, remainders = clique.assemble(filtered_filenames) for remainder in remainders: output.append(cls(directory, [remainder])) @@ -512,23 +521,9 @@ class FileDef(AbtractAttrDef): default(str, list): Defautl value. """ - default_sequence_extensions = [ - ".ani", ".anim", ".apng", ".art", ".bmp", ".bpg", ".bsave", - ".cal", ".cin", ".cpc", ".cpt", ".dds", ".dpx", ".ecw", ".exr", - ".fits", ".flic", ".flif", ".fpx", ".gif", ".hdri", ".hevc", - ".icer", ".icns", ".ico", ".cur", ".ics", ".ilbm", ".jbig", - ".jbig2", ".jng", ".jpeg", ".jpeg-ls", ".2000", ".jpg", ".xr", - ".jpeg-hdr", ".kra", ".mng", ".miff", ".nrrd", - ".ora", ".pam", ".pbm", ".pgm", ".ppm", ".pnm", ".pcx", ".pgf", - ".pictor", ".png", ".psb", ".psp", ".qtvr", ".ras", - ".rgbe", ".logluv", ".tiff", ".sgi", ".tga", ".tiff", - ".tiff/ep", ".tiff/it", ".ufo", ".ufp", ".wbmp", ".webp", - ".xbm", ".xcf", ".xpm", ".xwd" - ] - def __init__( self, key, single_item=True, folders=None, extensions=None, - sequence_extensions=None, default=None, **kwargs + allow_sequences=True, default=None, **kwargs ): if folders is None and extensions is None: folders = True @@ -568,13 +563,10 @@ class FileDef(AbtractAttrDef): is_label_horizontal = False kwargs["is_label_horizontal"] = is_label_horizontal - if sequence_extensions is None: - sequence_extensions = self.default_sequence_extensions - self.single_item = single_item self.folders = folders self.extensions = set(extensions) - self.sequence_extensions = set(sequence_extensions) + self.allow_sequences = allow_sequences super(FileDef, self).__init__(key, default=default, **kwargs) def __eq__(self, other): @@ -585,7 +577,7 @@ class FileDef(AbtractAttrDef): self.single_item == other.single_item and self.folders == other.folders and self.extensions == other.extensions - and self.sequence_extensions == self.sequence_extensions + and self.allow_sequences == other.allow_sequences ) def convert_value(self, value): diff --git a/openpype/settings/defaults/project_settings/traypublisher.json b/openpype/settings/defaults/project_settings/traypublisher.json index e6c6747ca2..1b0ad67abb 100644 --- a/openpype/settings/defaults/project_settings/traypublisher.json +++ b/openpype/settings/defaults/project_settings/traypublisher.json @@ -11,6 +11,7 @@ "enable_review": false, "description": "Publish workfile backup", "detailed_description": "", + "allow_sequences": true, "extensions": [ ".ma", ".mb", @@ -29,10 +30,7 @@ ".psd", ".psb", ".aep" - ], - "allow_sequences": { - "allow": "no" - } + ] } ] } \ No newline at end of file diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_traypublisher.json b/openpype/settings/entities/schemas/projects_schema/schema_project_traypublisher.json index 00deb84172..59c675d411 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_traypublisher.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_traypublisher.json @@ -68,6 +68,11 @@ { "type": "separator" }, + { + "key": "allow_sequences", + "label": "Allow sequences", + "type": "boolean" + }, { "type": "list", "key": "extensions", @@ -76,39 +81,6 @@ "collapsible_key": true, "collapsed": false, "object_type": "text" - }, - { - "key": "allow_sequences", - "label": "Allow sequences", - "type": "dict-conditional", - "use_label_wrap": true, - "collapsible_key": true, - "enum_key": "allow", - "enum_children": [ - { - "key": "all", - "label": "Yes (all extensions)" - }, - { - "key": "selection", - "label": "Yes (limited extensions)", - "children": [ - { - "type": "list", - "key": "extensions", - "label": "Extensions", - "use_label_wrap": true, - "collapsible_key": true, - "collapsed": false, - "object_type": "text" - } - ] - }, - { - "key": "no", - "label": "No" - } - ] } ] } diff --git a/openpype/widgets/attribute_defs/files_widget.py b/openpype/widgets/attribute_defs/files_widget.py index cb339f3d52..f694f2473f 100644 --- a/openpype/widgets/attribute_defs/files_widget.py +++ b/openpype/widgets/attribute_defs/files_widget.py @@ -74,11 +74,11 @@ class DropEmpty(QtWidgets.QWidget): class FilesModel(QtGui.QStandardItemModel): - def __init__(self, single_item, sequence_exts): + def __init__(self, single_item, allow_sequences): super(FilesModel, self).__init__() self._single_item = single_item - self._sequence_exts = sequence_exts + self._allow_sequences = allow_sequences self._items_by_id = {} self._file_items_by_id = {} @@ -89,7 +89,7 @@ class FilesModel(QtGui.QStandardItemModel): if not items: return - file_items = FileDefItem.from_value(items, self._sequence_exts) + file_items = FileDefItem.from_value(items, self._allow_sequences) if not file_items: return @@ -325,13 +325,13 @@ class FilesView(QtWidgets.QListView): class FilesWidget(QtWidgets.QFrame): value_changed = QtCore.Signal() - def __init__(self, single_item, sequence_exts, parent): + def __init__(self, single_item, allow_sequences, parent): super(FilesWidget, self).__init__(parent) self.setAcceptDrops(True) empty_widget = DropEmpty(self) - files_model = FilesModel(single_item, sequence_exts) + files_model = FilesModel(single_item, allow_sequences) files_proxy_model = FilesProxyModel() files_proxy_model.setSourceModel(files_model) files_view = FilesView(self) diff --git a/openpype/widgets/attribute_defs/widgets.py b/openpype/widgets/attribute_defs/widgets.py index 3f36c078cb..97e7d698b5 100644 --- a/openpype/widgets/attribute_defs/widgets.py +++ b/openpype/widgets/attribute_defs/widgets.py @@ -441,7 +441,7 @@ class UnknownAttrWidget(_BaseAttrDefWidget): class FileAttrWidget(_BaseAttrDefWidget): def _ui_init(self): input_widget = FilesWidget( - self.attr_def.single_item, self.attr_def.sequence_extensions, self + self.attr_def.single_item, self.attr_def.allow_sequences, self ) if self.attr_def.tooltip: From e5f3d28c17989647ecfe0b406e6e4f75ac02b433 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 26 Apr 2022 15:04:08 +0200 Subject: [PATCH 093/583] OP-3113 - moved plugins into addon Plugins were only for specific customer (and not working because of missing psd-tools). --- .../publish/collect_batch_instances.py | 70 ----- .../publish/extract_bg_for_compositing.py | 243 ----------------- .../plugins/publish/extract_bg_main_groups.py | 248 ------------------ .../publish/extract_images_from_psd.py | 171 ------------ 4 files changed, 732 deletions(-) delete mode 100644 openpype/hosts/standalonepublisher/plugins/publish/collect_batch_instances.py delete mode 100644 openpype/hosts/standalonepublisher/plugins/publish/extract_bg_for_compositing.py delete mode 100644 openpype/hosts/standalonepublisher/plugins/publish/extract_bg_main_groups.py delete mode 100644 openpype/hosts/standalonepublisher/plugins/publish/extract_images_from_psd.py diff --git a/openpype/hosts/standalonepublisher/plugins/publish/collect_batch_instances.py b/openpype/hosts/standalonepublisher/plugins/publish/collect_batch_instances.py deleted file mode 100644 index 4ca1f72cc4..0000000000 --- a/openpype/hosts/standalonepublisher/plugins/publish/collect_batch_instances.py +++ /dev/null @@ -1,70 +0,0 @@ -import copy -import pyblish.api -from pprint import pformat - - -class CollectBatchInstances(pyblish.api.InstancePlugin): - """Collect all available instances for batch publish.""" - - label = "Collect Batch Instances" - order = pyblish.api.CollectorOrder + 0.489 - hosts = ["standalonepublisher"] - families = ["background_batch"] - - # presets - default_subset_task = { - "background_batch": "background" - } - subsets = { - "background_batch": { - "backgroundLayout": { - "task": "background", - "family": "backgroundLayout" - }, - "backgroundComp": { - "task": "background", - "family": "backgroundComp" - }, - "workfileBackground": { - "task": "background", - "family": "workfile" - } - } - } - unchecked_by_default = [] - - def process(self, instance): - context = instance.context - asset_name = instance.data["asset"] - family = instance.data["family"] - - default_task_name = self.default_subset_task.get(family) - for subset_name, subset_data in self.subsets[family].items(): - instance_name = f"{asset_name}_{subset_name}" - task_name = subset_data.get("task") or default_task_name - - # create new instance - new_instance = context.create_instance(instance_name) - - # add original instance data except name key - for key, value in instance.data.items(): - if key not in ["name"]: - # Make sure value is copy since value may be object which - # can be shared across all new created objects - new_instance.data[key] = copy.deepcopy(value) - - # add subset data from preset - new_instance.data.update(subset_data) - - new_instance.data["label"] = instance_name - new_instance.data["subset"] = subset_name - new_instance.data["task"] = task_name - - if subset_name in self.unchecked_by_default: - new_instance.data["publish"] = False - - self.log.info(f"Created new instance: {instance_name}") - self.log.debug(f"_ inst_data: {pformat(new_instance.data)}") - - # delete original instance - context.remove(instance) diff --git a/openpype/hosts/standalonepublisher/plugins/publish/extract_bg_for_compositing.py b/openpype/hosts/standalonepublisher/plugins/publish/extract_bg_for_compositing.py deleted file mode 100644 index 9621d70739..0000000000 --- a/openpype/hosts/standalonepublisher/plugins/publish/extract_bg_for_compositing.py +++ /dev/null @@ -1,243 +0,0 @@ -import os -import json -import copy - -import openpype.api -from openpype.pipeline import legacy_io - -PSDImage = None - - -class ExtractBGForComp(openpype.api.Extractor): - label = "Extract Background for Compositing" - families = ["backgroundComp"] - hosts = ["standalonepublisher"] - - new_instance_family = "background" - - # Presetable - allowed_group_names = [ - "OL", "BG", "MG", "FG", "SB", "UL", "SKY", "Field Guide", "Field_Guide", - "ANIM" - ] - - def process(self, instance): - # Check if python module `psd_tools` is installed - try: - global PSDImage - from psd_tools import PSDImage - except Exception: - raise AssertionError( - "BUG: Python module `psd-tools` is not installed!" - ) - - self.allowed_group_names = [ - name.lower() - for name in self.allowed_group_names - ] - - self.redo_global_plugins(instance) - - repres = instance.data.get("representations") - if not repres: - self.log.info("There are no representations on instance.") - return - - if not instance.data.get("transfers"): - instance.data["transfers"] = [] - - # Prepare staging dir - staging_dir = self.staging_dir(instance) - if not os.path.exists(staging_dir): - os.makedirs(staging_dir) - - for repre in tuple(repres): - # Skip all files without .psd extension - repre_ext = repre["ext"].lower() - if repre_ext.startswith("."): - repre_ext = repre_ext[1:] - - if repre_ext != "psd": - continue - - # Prepare publish dir for transfers - publish_dir = instance.data["publishDir"] - - # Prepare json filepath where extracted metadata are stored - json_filename = "{}.json".format(instance.name) - json_full_path = os.path.join(staging_dir, json_filename) - - self.log.debug(f"`staging_dir` is \"{staging_dir}\"") - - # Prepare new repre data - new_repre = { - "name": "json", - "ext": "json", - "files": json_filename, - "stagingDir": staging_dir - } - - # TODO add check of list - psd_filename = repre["files"] - psd_folder_path = repre["stagingDir"] - psd_filepath = os.path.join(psd_folder_path, psd_filename) - self.log.debug(f"psd_filepath: \"{psd_filepath}\"") - psd_object = PSDImage.open(psd_filepath) - - json_data, transfers = self.export_compositing_images( - psd_object, staging_dir, publish_dir - ) - self.log.info("Json file path: {}".format(json_full_path)) - with open(json_full_path, "w") as json_filestream: - json.dump(json_data, json_filestream, indent=4) - - instance.data["transfers"].extend(transfers) - instance.data["representations"].remove(repre) - instance.data["representations"].append(new_repre) - - def export_compositing_images(self, psd_object, output_dir, publish_dir): - json_data = { - "__schema_version__": 1, - "children": [] - } - transfers = [] - for main_idx, main_layer in enumerate(psd_object): - if ( - not main_layer.is_visible() - or main_layer.name.lower() not in self.allowed_group_names - or not main_layer.is_group - ): - continue - - export_layers = [] - layers_idx = 0 - for layer in main_layer: - # TODO this way may be added also layers next to "ADJ" - if layer.name.lower() == "adj": - for _layer in layer: - export_layers.append((layers_idx, _layer)) - layers_idx += 1 - - else: - export_layers.append((layers_idx, layer)) - layers_idx += 1 - - if not export_layers: - continue - - main_layer_data = { - "index": main_idx, - "name": main_layer.name, - "children": [] - } - - for layer_idx, layer in export_layers: - has_size = layer.width > 0 and layer.height > 0 - if not has_size: - self.log.debug(( - "Skipping layer \"{}\" because does " - "not have any content." - ).format(layer.name)) - continue - - main_layer_name = main_layer.name.replace(" ", "_") - layer_name = layer.name.replace(" ", "_") - - filename = "{:0>2}_{}_{:0>2}_{}.png".format( - main_idx + 1, main_layer_name, layer_idx + 1, layer_name - ) - layer_data = { - "index": layer_idx, - "name": layer.name, - "filename": filename - } - output_filepath = os.path.join(output_dir, filename) - dst_filepath = os.path.join(publish_dir, filename) - transfers.append((output_filepath, dst_filepath)) - - pil_object = layer.composite(viewport=psd_object.viewbox) - pil_object.save(output_filepath, "PNG") - - main_layer_data["children"].append(layer_data) - - if main_layer_data["children"]: - json_data["children"].append(main_layer_data) - - return json_data, transfers - - def redo_global_plugins(self, instance): - # TODO do this in collection phase - # Copy `families` and check if `family` is not in current families - families = instance.data.get("families") or list() - if families: - families = list(set(families)) - - if self.new_instance_family in families: - families.remove(self.new_instance_family) - - self.log.debug( - "Setting new instance families {}".format(str(families)) - ) - instance.data["families"] = families - - # Override instance data with new information - instance.data["family"] = self.new_instance_family - - subset_name = instance.data["anatomyData"]["subset"] - asset_doc = instance.data["assetEntity"] - latest_version = self.find_last_version(subset_name, asset_doc) - version_number = 1 - if latest_version is not None: - version_number += latest_version - - instance.data["latestVersion"] = latest_version - instance.data["version"] = version_number - - # Same data apply to anatomy data - instance.data["anatomyData"].update({ - "family": self.new_instance_family, - "version": version_number - }) - - # Redo publish and resources dir - anatomy = instance.context.data["anatomy"] - template_data = copy.deepcopy(instance.data["anatomyData"]) - template_data.update({ - "frame": "FRAME_TEMP", - "representation": "TEMP" - }) - anatomy_filled = anatomy.format(template_data) - if "folder" in anatomy.templates["publish"]: - publish_folder = anatomy_filled["publish"]["folder"] - else: - publish_folder = os.path.dirname(anatomy_filled["publish"]["path"]) - - publish_folder = os.path.normpath(publish_folder) - resources_folder = os.path.join(publish_folder, "resources") - - instance.data["publishDir"] = publish_folder - instance.data["resourcesDir"] = resources_folder - - self.log.debug("publishDir: \"{}\"".format(publish_folder)) - self.log.debug("resourcesDir: \"{}\"".format(resources_folder)) - - def find_last_version(self, subset_name, asset_doc): - subset_doc = legacy_io.find_one({ - "type": "subset", - "name": subset_name, - "parent": asset_doc["_id"] - }) - - if subset_doc is None: - self.log.debug("Subset entity does not exist yet.") - else: - version_doc = legacy_io.find_one( - { - "type": "version", - "parent": subset_doc["_id"] - }, - sort=[("name", -1)] - ) - if version_doc: - return int(version_doc["name"]) - return None diff --git a/openpype/hosts/standalonepublisher/plugins/publish/extract_bg_main_groups.py b/openpype/hosts/standalonepublisher/plugins/publish/extract_bg_main_groups.py deleted file mode 100644 index b45f04e574..0000000000 --- a/openpype/hosts/standalonepublisher/plugins/publish/extract_bg_main_groups.py +++ /dev/null @@ -1,248 +0,0 @@ -import os -import copy -import json - -import pyblish.api - -import openpype.api -from openpype.pipeline import legacy_io - -PSDImage = None - - -class ExtractBGMainGroups(openpype.api.Extractor): - label = "Extract Background Layout" - order = pyblish.api.ExtractorOrder + 0.02 - families = ["backgroundLayout"] - hosts = ["standalonepublisher"] - - new_instance_family = "background" - - # Presetable - allowed_group_names = [ - "OL", "BG", "MG", "FG", "UL", "SB", "SKY", "Field Guide", "Field_Guide", - "ANIM" - ] - - def process(self, instance): - # Check if python module `psd_tools` is installed - try: - global PSDImage - from psd_tools import PSDImage - except Exception: - raise AssertionError( - "BUG: Python module `psd-tools` is not installed!" - ) - - self.allowed_group_names = [ - name.lower() - for name in self.allowed_group_names - ] - repres = instance.data.get("representations") - if not repres: - self.log.info("There are no representations on instance.") - return - - self.redo_global_plugins(instance) - - repres = instance.data.get("representations") - if not repres: - self.log.info("There are no representations on instance.") - return - - if not instance.data.get("transfers"): - instance.data["transfers"] = [] - - # Prepare staging dir - staging_dir = self.staging_dir(instance) - if not os.path.exists(staging_dir): - os.makedirs(staging_dir) - - # Prepare publish dir for transfers - publish_dir = instance.data["publishDir"] - - for repre in tuple(repres): - # Skip all files without .psd extension - repre_ext = repre["ext"].lower() - if repre_ext.startswith("."): - repre_ext = repre_ext[1:] - - if repre_ext != "psd": - continue - - # Prepare json filepath where extracted metadata are stored - json_filename = "{}.json".format(instance.name) - json_full_path = os.path.join(staging_dir, json_filename) - - self.log.debug(f"`staging_dir` is \"{staging_dir}\"") - - # Prepare new repre data - new_repre = { - "name": "json", - "ext": "json", - "files": json_filename, - "stagingDir": staging_dir - } - - # TODO add check of list - psd_filename = repre["files"] - psd_folder_path = repre["stagingDir"] - psd_filepath = os.path.join(psd_folder_path, psd_filename) - self.log.debug(f"psd_filepath: \"{psd_filepath}\"") - psd_object = PSDImage.open(psd_filepath) - - json_data, transfers = self.export_compositing_images( - psd_object, staging_dir, publish_dir - ) - self.log.info("Json file path: {}".format(json_full_path)) - with open(json_full_path, "w") as json_filestream: - json.dump(json_data, json_filestream, indent=4) - - instance.data["transfers"].extend(transfers) - instance.data["representations"].remove(repre) - instance.data["representations"].append(new_repre) - - def export_compositing_images(self, psd_object, output_dir, publish_dir): - json_data = { - "__schema_version__": 1, - "children": [] - } - output_ext = ".png" - - to_export = [] - for layer_idx, layer in enumerate(psd_object): - layer_name = layer.name.replace(" ", "_") - if ( - not layer.is_visible() - or layer_name.lower() not in self.allowed_group_names - ): - continue - - has_size = layer.width > 0 and layer.height > 0 - if not has_size: - self.log.debug(( - "Skipping layer \"{}\" because does not have any content." - ).format(layer.name)) - continue - - filebase = "{:0>2}_{}".format(layer_idx, layer_name) - if layer_name.lower() == "anim": - if not layer.is_group: - self.log.warning("ANIM layer is not a group layer.") - continue - - children = [] - for anim_idx, anim_layer in enumerate(layer): - anim_layer_name = anim_layer.name.replace(" ", "_") - filename = "{}_{:0>2}_{}{}".format( - filebase, anim_idx, anim_layer_name, output_ext - ) - children.append({ - "index": anim_idx, - "name": anim_layer.name, - "filename": filename - }) - to_export.append((anim_layer, filename)) - - json_data["children"].append({ - "index": layer_idx, - "name": layer.name, - "children": children - }) - continue - - filename = filebase + output_ext - json_data["children"].append({ - "index": layer_idx, - "name": layer.name, - "filename": filename - }) - to_export.append((layer, filename)) - - transfers = [] - for layer, filename in to_export: - output_filepath = os.path.join(output_dir, filename) - dst_filepath = os.path.join(publish_dir, filename) - transfers.append((output_filepath, dst_filepath)) - - pil_object = layer.composite(viewport=psd_object.viewbox) - pil_object.save(output_filepath, "PNG") - - return json_data, transfers - - def redo_global_plugins(self, instance): - # TODO do this in collection phase - # Copy `families` and check if `family` is not in current families - families = instance.data.get("families") or list() - if families: - families = list(set(families)) - - if self.new_instance_family in families: - families.remove(self.new_instance_family) - - self.log.debug( - "Setting new instance families {}".format(str(families)) - ) - instance.data["families"] = families - - # Override instance data with new information - instance.data["family"] = self.new_instance_family - - subset_name = instance.data["anatomyData"]["subset"] - asset_doc = instance.data["assetEntity"] - latest_version = self.find_last_version(subset_name, asset_doc) - version_number = 1 - if latest_version is not None: - version_number += latest_version - - instance.data["latestVersion"] = latest_version - instance.data["version"] = version_number - - # Same data apply to anatomy data - instance.data["anatomyData"].update({ - "family": self.new_instance_family, - "version": version_number - }) - - # Redo publish and resources dir - anatomy = instance.context.data["anatomy"] - template_data = copy.deepcopy(instance.data["anatomyData"]) - template_data.update({ - "frame": "FRAME_TEMP", - "representation": "TEMP" - }) - anatomy_filled = anatomy.format(template_data) - if "folder" in anatomy.templates["publish"]: - publish_folder = anatomy_filled["publish"]["folder"] - else: - publish_folder = os.path.dirname(anatomy_filled["publish"]["path"]) - - publish_folder = os.path.normpath(publish_folder) - resources_folder = os.path.join(publish_folder, "resources") - - instance.data["publishDir"] = publish_folder - instance.data["resourcesDir"] = resources_folder - - self.log.debug("publishDir: \"{}\"".format(publish_folder)) - self.log.debug("resourcesDir: \"{}\"".format(resources_folder)) - - def find_last_version(self, subset_name, asset_doc): - subset_doc = legacy_io.find_one({ - "type": "subset", - "name": subset_name, - "parent": asset_doc["_id"] - }) - - if subset_doc is None: - self.log.debug("Subset entity does not exist yet.") - else: - version_doc = legacy_io.find_one( - { - "type": "version", - "parent": subset_doc["_id"] - }, - sort=[("name", -1)] - ) - if version_doc: - return int(version_doc["name"]) - return None diff --git a/openpype/hosts/standalonepublisher/plugins/publish/extract_images_from_psd.py b/openpype/hosts/standalonepublisher/plugins/publish/extract_images_from_psd.py deleted file mode 100644 index 8485fa0915..0000000000 --- a/openpype/hosts/standalonepublisher/plugins/publish/extract_images_from_psd.py +++ /dev/null @@ -1,171 +0,0 @@ -import os -import copy -import pyblish.api - -import openpype.api -from openpype.pipeline import legacy_io - -PSDImage = None - - -class ExtractImagesFromPSD(openpype.api.Extractor): - # PLUGIN is not currently enabled because was decided to use different - # approach - enabled = False - active = False - label = "Extract Images from PSD" - order = pyblish.api.ExtractorOrder + 0.02 - families = ["backgroundLayout"] - hosts = ["standalonepublisher"] - - new_instance_family = "image" - ignored_instance_data_keys = ("name", "label", "stagingDir", "version") - # Presetable - allowed_group_names = [ - "OL", "BG", "MG", "FG", "UL", "SKY", "Field Guide", "Field_Guide", - "ANIM" - ] - - def process(self, instance): - # Check if python module `psd_tools` is installed - try: - global PSDImage - from psd_tools import PSDImage - except Exception: - raise AssertionError( - "BUG: Python module `psd-tools` is not installed!" - ) - - self.allowed_group_names = [ - name.lower() - for name in self.allowed_group_names - ] - repres = instance.data.get("representations") - if not repres: - self.log.info("There are no representations on instance.") - return - - for repre in tuple(repres): - # Skip all files without .psd extension - repre_ext = repre["ext"].lower() - if repre_ext.startswith("."): - repre_ext = repre_ext[1:] - - if repre_ext != "psd": - continue - - # TODO add check of list of "files" value - psd_filename = repre["files"] - psd_folder_path = repre["stagingDir"] - psd_filepath = os.path.join(psd_folder_path, psd_filename) - self.log.debug(f"psd_filepath: \"{psd_filepath}\"") - psd_object = PSDImage.open(psd_filepath) - - self.create_new_instances(instance, psd_object) - - # Remove the instance from context - instance.context.remove(instance) - - def create_new_instances(self, instance, psd_object): - asset_doc = instance.data["assetEntity"] - for layer in psd_object: - if ( - not layer.is_visible() - or layer.name.lower() not in self.allowed_group_names - ): - continue - - has_size = layer.width > 0 and layer.height > 0 - if not has_size: - self.log.debug(( - "Skipping layer \"{}\" because does " - "not have any content." - ).format(layer.name)) - continue - - layer_name = layer.name.replace(" ", "_") - instance_name = subset_name = f"image{layer_name}" - self.log.info( - f"Creating new instance with name \"{instance_name}\"" - ) - new_instance = instance.context.create_instance(instance_name) - for key, value in instance.data.items(): - if key not in self.ignored_instance_data_keys: - new_instance.data[key] = copy.deepcopy(value) - - new_instance.data["label"] = " ".join( - (new_instance.data["asset"], instance_name) - ) - - # Find latest version - latest_version = self.find_last_version(subset_name, asset_doc) - version_number = 1 - if latest_version is not None: - version_number += latest_version - - self.log.info( - "Next version of instance \"{}\" will be {}".format( - instance_name, version_number - ) - ) - - # Set family and subset - new_instance.data["family"] = self.new_instance_family - new_instance.data["subset"] = subset_name - new_instance.data["version"] = version_number - new_instance.data["latestVersion"] = latest_version - - new_instance.data["anatomyData"].update({ - "subset": subset_name, - "family": self.new_instance_family, - "version": version_number - }) - - # Copy `families` and check if `family` is not in current families - families = new_instance.data.get("families") or list() - if families: - families = list(set(families)) - - if self.new_instance_family in families: - families.remove(self.new_instance_family) - new_instance.data["families"] = families - - # Prepare staging dir for new instance - staging_dir = self.staging_dir(new_instance) - - output_filename = "{}.png".format(layer_name) - output_filepath = os.path.join(staging_dir, output_filename) - pil_object = layer.composite(viewport=psd_object.viewbox) - pil_object.save(output_filepath, "PNG") - - new_repre = { - "name": "png", - "ext": "png", - "files": output_filename, - "stagingDir": staging_dir - } - self.log.debug( - "Creating new representation: {}".format(new_repre) - ) - new_instance.data["representations"] = [new_repre] - - def find_last_version(self, subset_name, asset_doc): - subset_doc = legacy_io.find_one({ - "type": "subset", - "name": subset_name, - "parent": asset_doc["_id"] - }) - - if subset_doc is None: - self.log.debug("Subset entity does not exist yet.") - else: - version_doc = legacy_io.find_one( - { - "type": "version", - "parent": subset_doc["_id"] - }, - sort=[("name", -1)] - ) - if version_doc: - return int(version_doc["name"]) - return None From 604263b22e0cdb5ac6d98150d767732e2a068f98 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 26 Apr 2022 15:06:16 +0200 Subject: [PATCH 094/583] OP-3113 - added Background task to default Task enum --- openpype/settings/defaults/project_anatomy/tasks.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openpype/settings/defaults/project_anatomy/tasks.json b/openpype/settings/defaults/project_anatomy/tasks.json index 74504cc4d7..178f2af639 100644 --- a/openpype/settings/defaults/project_anatomy/tasks.json +++ b/openpype/settings/defaults/project_anatomy/tasks.json @@ -40,5 +40,8 @@ }, "Compositing": { "short_name": "comp" + }, + "Background": { + "short_name": "back" } } \ No newline at end of file From b0fcfc6feaa772df0a1f1e21b6dd641194169b34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Tue, 26 Apr 2022 15:24:08 +0200 Subject: [PATCH 095/583] handle default extension --- .../deadline/plugins/publish/submit_maya_deadline.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py index 347b6ab0fe..2fc495fa76 100644 --- a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py @@ -227,7 +227,6 @@ def get_renderer_variables(renderlayer, root): "d_it": None, "d_null": None, "d_openexr": "exr", - "d_openexr3": "exr", "d_png": "png", "d_pointcloud": "ptc", "d_targa": "tga", @@ -236,8 +235,9 @@ def get_renderer_variables(renderlayer, root): } extension = display_types.get( - cmds.listConnections("rmanDefaultDisplay.displayType")[0] - ) + cmds.listConnections("rmanDefaultDisplay.displayType")[0], + "exr" + ) or "exr" filename_prefix = "{}/{}".format( cmds.getAttr("rmanGlobals.imageOutputDir"), From 342fda6315a48a40580a885969940b32588de19e Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 26 Apr 2022 15:49:22 +0200 Subject: [PATCH 096/583] fixed splitting of sequence --- openpype/lib/attribute_definitions.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/openpype/lib/attribute_definitions.py b/openpype/lib/attribute_definitions.py index ea3da53a9e..0c40d0f195 100644 --- a/openpype/lib/attribute_definitions.py +++ b/openpype/lib/attribute_definitions.py @@ -379,11 +379,11 @@ class FileDefItem(object): if not self.is_sequence: raise ValueError("Cannot split single file item") - output = [] - for filename in self.filenames: - path = os.path.join(self.directory, filename) - output.append(self.from_paths([path])) - return output + paths = [ + os.path.join(self.directory, filename) + for filename in self.filenames + ] + return self.from_paths(paths, False) @property def ext(self): From 1c7c0fef32180917772d24932dc11a1952740f92 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 26 Apr 2022 15:49:46 +0200 Subject: [PATCH 097/583] added splitting menu option to files item widget --- openpype/tools/resources/images/menu.png | Bin 0 -> 8472 bytes .../widgets/attribute_defs/files_widget.py | 87 ++++++++++++++++-- 2 files changed, 78 insertions(+), 9 deletions(-) create mode 100644 openpype/tools/resources/images/menu.png diff --git a/openpype/tools/resources/images/menu.png b/openpype/tools/resources/images/menu.png new file mode 100644 index 0000000000000000000000000000000000000000..14a991f092fb9cf54363eccb16d1836704846ad8 GIT binary patch literal 8472 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 AivR!s literal 0 HcmV?d00001 diff --git a/openpype/widgets/attribute_defs/files_widget.py b/openpype/widgets/attribute_defs/files_widget.py index f694f2473f..5ea7fcc5eb 100644 --- a/openpype/widgets/attribute_defs/files_widget.py +++ b/openpype/widgets/attribute_defs/files_widget.py @@ -5,12 +5,12 @@ import uuid from Qt import QtWidgets, QtCore, QtGui from openpype.lib import FileDefItem -from openpype.tools.utils import paint_image_with_color -# TODO change imports -from openpype.tools.resources import ( - get_pixmap, - get_image, +from openpype.tools.utils import ( + paint_image_with_color, + ClickableLabel, ) +# TODO change imports +from openpype.tools.resources import get_image from openpype.tools.utils import ( IconButton, PixmapLabel @@ -22,7 +22,8 @@ ITEM_ICON_ROLE = QtCore.Qt.UserRole + 3 FILENAMES_ROLE = QtCore.Qt.UserRole + 4 DIRPATH_ROLE = QtCore.Qt.UserRole + 5 IS_DIR_ROLE = QtCore.Qt.UserRole + 6 -EXT_ROLE = QtCore.Qt.UserRole + 7 +IS_SEQUENCE_ROLE = QtCore.Qt.UserRole + 7 +EXT_ROLE = QtCore.Qt.UserRole + 8 class DropEmpty(QtWidgets.QWidget): @@ -148,6 +149,7 @@ class FilesModel(QtGui.QStandardItemModel): item.setData(icon_pixmap, ITEM_ICON_ROLE) item.setData(file_item.ext, EXT_ROLE) item.setData(file_item.is_dir, IS_DIR_ROLE) + item.setData(file_item.is_sequence, IS_SEQUENCE_ROLE) return item_id, item @@ -216,7 +218,9 @@ class FilesProxyModel(QtCore.QSortFilterProxyModel): class ItemWidget(QtWidgets.QWidget): - def __init__(self, item_id, label, pixmap_icon, parent=None): + split_requested = QtCore.Signal(str) + + def __init__(self, item_id, label, pixmap_icon, is_sequence, parent=None): self._item_id = item_id super(ItemWidget, self).__init__(parent) @@ -226,13 +230,67 @@ class ItemWidget(QtWidgets.QWidget): icon_widget = PixmapLabel(pixmap_icon, self) label_widget = QtWidgets.QLabel(label, self) + label_size_hint = label_widget.sizeHint() + height = label_size_hint.height() + actions_menu_pix = paint_image_with_color( + get_image(filename="menu.png"), QtCore.Qt.white + ) + + split_btn = ClickableLabel(self) + split_btn.setFixedSize(height, height) + split_btn.setPixmap(actions_menu_pix) + split_btn.setVisible(is_sequence) + layout = QtWidgets.QHBoxLayout(self) - layout.setContentsMargins(5, 5, 0, 5) + layout.setContentsMargins(5, 5, 5, 5) layout.addWidget(icon_widget, 0) layout.addWidget(label_widget, 1) + layout.addWidget(split_btn, 0) + + split_btn.clicked.connect(self._on_actions_clicked) self._icon_widget = icon_widget self._label_widget = label_widget + self._split_btn = split_btn + self._actions_menu_pix = actions_menu_pix + self._last_scaled_pix_height = None + + def _update_btn_size(self): + label_size_hint = self._label_widget.sizeHint() + height = label_size_hint.height() + if height == self._last_scaled_pix_height: + return + self._last_scaled_pix_height = height + self._split_btn.setFixedSize(height, height) + pix = self._actions_menu_pix.scaled( + height, height, + QtCore.Qt.KeepAspectRatio, + QtCore.Qt.SmoothTransformation + ) + self._split_btn.setPixmap(pix) + + def showEvent(self, event): + super(ItemWidget, self).showEvent(event) + self._update_btn_size() + + def resizeEvent(self, event): + super(ItemWidget, self).resizeEvent(event) + self._update_btn_size() + + def _on_actions_clicked(self): + menu = QtWidgets.QMenu(self._split_btn) + + action = QtWidgets.QAction("Split sequence", menu) + action.triggered.connect(self._on_split_sequence) + + menu.addAction(action) + + pos = self._split_btn.rect().bottomLeft() + point = self._split_btn.mapToGlobal(pos) + menu.popup(point) + + def _on_split_sequence(self): + self.split_requested.emit(self._item_id) class InViewButton(IconButton): @@ -404,8 +462,10 @@ class FilesWidget(QtWidgets.QFrame): continue label = index.data(ITEM_LABEL_ROLE) pixmap_icon = index.data(ITEM_ICON_ROLE) + is_sequence = index.data(IS_SEQUENCE_ROLE) - widget = ItemWidget(item_id, label, pixmap_icon) + widget = ItemWidget(item_id, label, pixmap_icon, is_sequence) + widget.split_requested.connect(self._on_split_request) self._files_view.setIndexWidget(index, widget) self._files_proxy_model.setData( index, widget.sizeHint(), QtCore.Qt.SizeHintRole @@ -437,6 +497,15 @@ class FilesWidget(QtWidgets.QFrame): if not self._in_set_value: self.value_changed.emit() + def _on_split_request(self, item_id): + file_item = self._files_model.get_file_item_by_id(item_id) + if not file_item: + return + + new_items = file_item.split_sequence() + self._remove_item_by_ids([item_id]) + self._add_filepaths(new_items) + def _on_remove_requested(self): items_to_delete = self._files_view.get_selected_item_ids() if items_to_delete: From f1434fa175b42f314bb92d13e32ef6ae8e466a55 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 26 Apr 2022 16:15:08 +0200 Subject: [PATCH 098/583] Modified publishing plugins to work with general families --- openpype/hosts/traypublisher/api/plugin.py | 1 + .../publish/collect_simple_instances.py | 48 +++++++++++++++++++ .../plugins/publish/collect_workfile.py | 31 ------------ .../plugins/publish/validate_filepaths.py | 45 +++++++++++++++++ .../plugins/publish/validate_workfile.py | 35 -------------- .../widgets/attribute_defs/files_widget.py | 11 ++++- 6 files changed, 104 insertions(+), 67 deletions(-) create mode 100644 openpype/hosts/traypublisher/plugins/publish/collect_simple_instances.py delete mode 100644 openpype/hosts/traypublisher/plugins/publish/collect_workfile.py create mode 100644 openpype/hosts/traypublisher/plugins/publish/validate_filepaths.py delete mode 100644 openpype/hosts/traypublisher/plugins/publish/validate_workfile.py diff --git a/openpype/hosts/traypublisher/api/plugin.py b/openpype/hosts/traypublisher/api/plugin.py index d4bbe4c9d6..731bf7918a 100644 --- a/openpype/hosts/traypublisher/api/plugin.py +++ b/openpype/hosts/traypublisher/api/plugin.py @@ -58,6 +58,7 @@ class SettingsCreator(TrayPublishCreator): def create(self, subset_name, data, pre_create_data): # Pass precreate data to creator attributes data["creator_attributes"] = pre_create_data + data["settings_creator"] = True # Create new instance new_instance = CreatedInstance(self.family, subset_name, data, self) # Host implementation of storing metadata about instance diff --git a/openpype/hosts/traypublisher/plugins/publish/collect_simple_instances.py b/openpype/hosts/traypublisher/plugins/publish/collect_simple_instances.py new file mode 100644 index 0000000000..5fc66084d6 --- /dev/null +++ b/openpype/hosts/traypublisher/plugins/publish/collect_simple_instances.py @@ -0,0 +1,48 @@ +import os +import pyblish.api + + +class CollectSettingsSimpleInstances(pyblish.api.InstancePlugin): + """Collect data for instances created by settings creators.""" + + label = "Collect Settings Simple Instances" + order = pyblish.api.CollectorOrder - 0.49 + + hosts = ["traypublisher"] + + def process(self, instance): + if not instance.data.get("settings_creator"): + return + + if "families" not in instance.data: + instance.data["families"] = [] + + if "representations" not in instance.data: + instance.data["representations"] = [] + repres = instance.data["representations"] + + creator_attributes = instance.data["creator_attributes"] + + if creator_attributes.get("review"): + instance.data["families"].append("review") + + filepath_item = creator_attributes["filepath"] + self.log.info(filepath_item) + filepaths = [ + os.path.join(filepath_item["directory"], filename) + for filename in filepath_item["filenames"] + ] + + instance.data["sourceFilepaths"] = filepaths + + filenames = filepath_item["filenames"] + ext = os.path.splitext(filenames[0])[-1] + if len(filenames) == 1: + filenames = filenames[0] + + repres.append({ + "ext": ext, + "name": ext, + "stagingDir": filepath_item["directory"], + "files": filenames + }) diff --git a/openpype/hosts/traypublisher/plugins/publish/collect_workfile.py b/openpype/hosts/traypublisher/plugins/publish/collect_workfile.py deleted file mode 100644 index d48bace047..0000000000 --- a/openpype/hosts/traypublisher/plugins/publish/collect_workfile.py +++ /dev/null @@ -1,31 +0,0 @@ -import os -import pyblish.api - - -class CollectWorkfile(pyblish.api.InstancePlugin): - """Collect representation of workfile instances.""" - - label = "Collect Workfile" - order = pyblish.api.CollectorOrder - 0.49 - families = ["workfile"] - hosts = ["traypublisher"] - - def process(self, instance): - if "representations" not in instance.data: - instance.data["representations"] = [] - repres = instance.data["representations"] - - creator_attributes = instance.data["creator_attributes"] - filepath = creator_attributes["filepath"] - instance.data["sourceFilepath"] = filepath - - staging_dir = os.path.dirname(filepath) - filename = os.path.basename(filepath) - ext = os.path.splitext(filename)[-1] - - repres.append({ - "ext": ext, - "name": ext, - "stagingDir": staging_dir, - "files": filename - }) diff --git a/openpype/hosts/traypublisher/plugins/publish/validate_filepaths.py b/openpype/hosts/traypublisher/plugins/publish/validate_filepaths.py new file mode 100644 index 0000000000..41df638ac6 --- /dev/null +++ b/openpype/hosts/traypublisher/plugins/publish/validate_filepaths.py @@ -0,0 +1,45 @@ +import os +import pyblish.api +from openpype.pipeline import PublishValidationError + + +class ValidateWorkfilePath(pyblish.api.InstancePlugin): + """Validate existence of workfile instance existence.""" + + label = "Validate Workfile" + order = pyblish.api.ValidatorOrder - 0.49 + + hosts = ["traypublisher"] + + def process(self, instance): + if "sourceFilepaths" not in instance.data: + self.log.info(( + "Can't validate source filepaths existence." + " Instance does not have collected 'sourceFilepaths'" + )) + return + + filepaths = instance.data.get("sourceFilepaths") + + not_found_files = [ + filepath + for filepath in filepaths + if not os.path.exists(filepath) + ] + if not_found_files: + joined_paths = "\n".join([ + "- {}".format(filepath) + for filepath in not_found_files + ]) + raise PublishValidationError( + ( + "Filepath of '{}' instance \"{}\" does not exist:\n{}" + ).format( + instance.data["family"], instance.data["name"], joined_paths + ), + "File not found", + ( + "## Files were not found\nFiles\n{}" + "\n\nCheck if the path is still available." + ).format(joined_paths) + ) diff --git a/openpype/hosts/traypublisher/plugins/publish/validate_workfile.py b/openpype/hosts/traypublisher/plugins/publish/validate_workfile.py deleted file mode 100644 index 7501051669..0000000000 --- a/openpype/hosts/traypublisher/plugins/publish/validate_workfile.py +++ /dev/null @@ -1,35 +0,0 @@ -import os -import pyblish.api -from openpype.pipeline import PublishValidationError - - -class ValidateWorkfilePath(pyblish.api.InstancePlugin): - """Validate existence of workfile instance existence.""" - - label = "Validate Workfile" - order = pyblish.api.ValidatorOrder - 0.49 - families = ["workfile"] - hosts = ["traypublisher"] - - def process(self, instance): - filepath = instance.data["sourceFilepath"] - if not filepath: - raise PublishValidationError( - ( - "Filepath of 'workfile' instance \"{}\" is not set" - ).format(instance.data["name"]), - "File not filled", - "## Missing file\nYou are supposed to fill the path." - ) - - if not os.path.exists(filepath): - raise PublishValidationError( - ( - "Filepath of 'workfile' instance \"{}\" does not exist: {}" - ).format(instance.data["name"], filepath), - "File not found", - ( - "## File was not found\nFile \"{}\" was not found." - " Check if the path is still available." - ).format(filepath) - ) diff --git a/openpype/widgets/attribute_defs/files_widget.py b/openpype/widgets/attribute_defs/files_widget.py index 5ea7fcc5eb..59e9029340 100644 --- a/openpype/widgets/attribute_defs/files_widget.py +++ b/openpype/widgets/attribute_defs/files_widget.py @@ -405,6 +405,7 @@ class FilesWidget(QtWidgets.QFrame): files_proxy_model.rowsRemoved.connect(self._on_rows_removed) files_view.remove_requested.connect(self._on_remove_requested) self._in_set_value = False + self._single_item = single_item self._empty_widget = empty_widget self._files_model = files_model @@ -432,6 +433,9 @@ class FilesWidget(QtWidgets.QFrame): all_same = False value = new_value + if not isinstance(value, (list, tuple, set)): + value = [value] + if value: self._add_filepaths(value) self._in_set_value = False @@ -448,7 +452,12 @@ class FilesWidget(QtWidgets.QFrame): file_item = self._files_model.get_file_item_by_id(item_id) if file_item is not None: file_items.append(file_item.to_dict()) - return file_items + + if not self._single_item: + return file_items + if file_items: + return file_items[0] + return FileDefItem.create_empty_item() def set_filters(self, folders_allowed, exts_filter): self._files_proxy_model.set_allow_folders(folders_allowed) From faff541bc73529f1a2ebe16ff4b496d01d77f1af Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 26 Apr 2022 16:43:19 +0200 Subject: [PATCH 099/583] Removed submodule repos/avalon-core --- .gitmodules | 0 repos/avalon-core | 1 - 2 files changed, 1 deletion(-) delete mode 100644 .gitmodules delete mode 160000 repos/avalon-core diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/repos/avalon-core b/repos/avalon-core deleted file mode 160000 index 2fa14cea6f..0000000000 --- a/repos/avalon-core +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 2fa14cea6f6a9d86eec70bbb96860cbe4c75c8eb From 52fd938b5aa9cb08f78ae2391798ea8e207d9883 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 26 Apr 2022 17:20:16 +0200 Subject: [PATCH 100/583] hound fixes --- .../traypublisher/plugins/create/create_from_settings.py | 1 - .../hosts/traypublisher/plugins/publish/validate_filepaths.py | 4 +++- openpype/lib/attribute_definitions.py | 1 - openpype/widgets/attribute_defs/widgets.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/traypublisher/plugins/create/create_from_settings.py b/openpype/hosts/traypublisher/plugins/create/create_from_settings.py index 836939fe94..baca274ea6 100644 --- a/openpype/hosts/traypublisher/plugins/create/create_from_settings.py +++ b/openpype/hosts/traypublisher/plugins/create/create_from_settings.py @@ -1,5 +1,4 @@ import os -import copy from openpype.api import get_project_settings diff --git a/openpype/hosts/traypublisher/plugins/publish/validate_filepaths.py b/openpype/hosts/traypublisher/plugins/publish/validate_filepaths.py index 41df638ac6..c7302b1005 100644 --- a/openpype/hosts/traypublisher/plugins/publish/validate_filepaths.py +++ b/openpype/hosts/traypublisher/plugins/publish/validate_filepaths.py @@ -35,7 +35,9 @@ class ValidateWorkfilePath(pyblish.api.InstancePlugin): ( "Filepath of '{}' instance \"{}\" does not exist:\n{}" ).format( - instance.data["family"], instance.data["name"], joined_paths + instance.data["family"], + instance.data["name"], + joined_paths ), "File not found", ( diff --git a/openpype/lib/attribute_definitions.py b/openpype/lib/attribute_definitions.py index 0c40d0f195..ef87002a63 100644 --- a/openpype/lib/attribute_definitions.py +++ b/openpype/lib/attribute_definitions.py @@ -3,7 +3,6 @@ import re import collections import uuid import json -import copy from abc import ABCMeta, abstractmethod import six diff --git a/openpype/widgets/attribute_defs/widgets.py b/openpype/widgets/attribute_defs/widgets.py index 97e7d698b5..875b69acb4 100644 --- a/openpype/widgets/attribute_defs/widgets.py +++ b/openpype/widgets/attribute_defs/widgets.py @@ -441,7 +441,7 @@ class UnknownAttrWidget(_BaseAttrDefWidget): class FileAttrWidget(_BaseAttrDefWidget): def _ui_init(self): input_widget = FilesWidget( - self.attr_def.single_item, self.attr_def.allow_sequences, self + self.attr_def.single_item, self.attr_def.allow_sequences, self ) if self.attr_def.tooltip: From 0c643190463f82def4c3aaeaed99ea3fdf22f9e7 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 27 Apr 2022 10:49:17 +0200 Subject: [PATCH 101/583] disable files widget abilities on multivalue --- openpype/lib/attribute_definitions.py | 14 ++- .../widgets/attribute_defs/files_widget.py | 94 ++++++++++++++----- 2 files changed, 81 insertions(+), 27 deletions(-) diff --git a/openpype/lib/attribute_definitions.py b/openpype/lib/attribute_definitions.py index ef87002a63..bfac9da5ce 100644 --- a/openpype/lib/attribute_definitions.py +++ b/openpype/lib/attribute_definitions.py @@ -422,8 +422,14 @@ class FileDefItem(object): @classmethod def from_value(cls, value, allow_sequences): - multi = isinstance(value, (list, tuple, set)) - if not multi: + """Convert passed value to FileDefItem objects. + + Returns: + list: Created FileDefItem objects. + """ + + # Convert single item to iterable + if not isinstance(value, (list, tuple, set)): value = [value] output = [] @@ -450,9 +456,7 @@ class FileDefItem(object): if str_filepaths: output.extend(cls.from_paths(str_filepaths, allow_sequences)) - if multi: - return output - return output[0] + return output @classmethod def from_dict(cls, data): diff --git a/openpype/widgets/attribute_defs/files_widget.py b/openpype/widgets/attribute_defs/files_widget.py index 59e9029340..c76474d957 100644 --- a/openpype/widgets/attribute_defs/files_widget.py +++ b/openpype/widgets/attribute_defs/files_widget.py @@ -79,6 +79,7 @@ class FilesModel(QtGui.QStandardItemModel): super(FilesModel, self).__init__() self._single_item = single_item + self._multivalue = False self._allow_sequences = allow_sequences self._items_by_id = {} @@ -86,6 +87,13 @@ class FilesModel(QtGui.QStandardItemModel): self._filenames_by_dirpath = collections.defaultdict(set) self._items_by_dirpath = collections.defaultdict(list) + def set_multivalue(self, multivalue): + """Disable filtering.""" + + if self._multivalue == multivalue: + return + self._multivalue = multivalue + def add_filepaths(self, items): if not items: return @@ -94,7 +102,7 @@ class FilesModel(QtGui.QStandardItemModel): if not file_items: return - if self._single_item: + if not self._multivalue and self._single_item: file_items = [file_items[0]] current_ids = list(self._file_items_by_id.keys()) if current_ids: @@ -159,6 +167,15 @@ class FilesProxyModel(QtCore.QSortFilterProxyModel): super(FilesProxyModel, self).__init__(*args, **kwargs) self._allow_folders = False self._allowed_extensions = None + self._multivalue = False + + def set_multivalue(self, multivalue): + """Disable filtering.""" + + if self._multivalue == multivalue: + return + self._multivalue = multivalue + self.invalidateFilter() def set_allow_folders(self, allow=None): if allow is None: @@ -189,6 +206,10 @@ class FilesProxyModel(QtCore.QSortFilterProxyModel): return False def filterAcceptsRow(self, row, parent_index): + # Skip filtering if multivalue is set + if self._multivalue: + return True + model = self.sourceModel() index = model.index(row, self.filterKeyColumn(), parent_index) # First check if item is folder and if folders are enabled @@ -220,7 +241,9 @@ class FilesProxyModel(QtCore.QSortFilterProxyModel): class ItemWidget(QtWidgets.QWidget): split_requested = QtCore.Signal(str) - def __init__(self, item_id, label, pixmap_icon, is_sequence, parent=None): + def __init__( + self, item_id, label, pixmap_icon, is_sequence, multivalue, parent=None + ): self._item_id = item_id super(ItemWidget, self).__init__(parent) @@ -239,7 +262,10 @@ class ItemWidget(QtWidgets.QWidget): split_btn = ClickableLabel(self) split_btn.setFixedSize(height, height) split_btn.setPixmap(actions_menu_pix) - split_btn.setVisible(is_sequence) + if multivalue: + split_btn.setVisible(False) + else: + split_btn.setVisible(is_sequence) layout = QtWidgets.QHBoxLayout(self) layout.setContentsMargins(5, 5, 5, 5) @@ -327,11 +353,22 @@ class FilesView(QtWidgets.QListView): self._remove_btn = remove_btn def setSelectionModel(self, *args, **kwargs): + """Catch selection model set to register signal callback. + + Selection model is not available during initialization. + """ + super(FilesView, self).setSelectionModel(*args, **kwargs) selection_model = self.selectionModel() selection_model.selectionChanged.connect(self._on_selection_change) + def set_multivalue(self, multivalue): + """Disable remove button on multivalue.""" + + self._remove_btn.setVisible(not multivalue) + def has_selected_item_ids(self): + """Is any index selected.""" for index in self.selectionModel().selectedIndexes(): instance_id = index.data(ITEM_ID_ROLE) if instance_id is not None: @@ -340,6 +377,7 @@ class FilesView(QtWidgets.QListView): def get_selected_item_ids(self): """Ids of selected instances.""" + selected_item_ids = set() for index in self.selectionModel().selectedIndexes(): instance_id = index.data(ITEM_ID_ROLE) @@ -365,6 +403,8 @@ class FilesView(QtWidgets.QListView): self.remove_requested.emit() def _update_remove_btn(self): + """Position remove button to bottom right.""" + viewport = self.viewport() height = viewport.height() pos_x = viewport.width() - self._remove_btn.width() - 5 @@ -406,6 +446,7 @@ class FilesWidget(QtWidgets.QFrame): files_view.remove_requested.connect(self._on_remove_requested) self._in_set_value = False self._single_item = single_item + self._multivalue = False self._empty_widget = empty_widget self._files_model = files_model @@ -414,30 +455,24 @@ class FilesWidget(QtWidgets.QFrame): self._widgets_by_id = {} + def _set_multivalue(self, multivalue): + if self._multivalue == multivalue: + return + self._multivalue = multivalue + self._files_view.set_multivalue(multivalue) + self._files_model.set_multivalue(multivalue) + self._files_proxy_model.set_multivalue(multivalue) + def set_value(self, value, multivalue): self._in_set_value = True + widget_ids = set(self._widgets_by_id.keys()) self._remove_item_by_ids(widget_ids) - # TODO how to display multivalue? - all_same = True - if multivalue: - new_value = set() - item_row = None - for _value in value: - _value_set = set(_value) - new_value |= _value_set - if item_row is None: - item_row = _value_set - elif item_row != _value_set: - all_same = False - value = new_value + self._set_multivalue(multivalue) - if not isinstance(value, (list, tuple, set)): - value = [value] + self._add_filepaths(value) - if value: - self._add_filepaths(value) self._in_set_value = False def current_value(self): @@ -473,7 +508,13 @@ class FilesWidget(QtWidgets.QFrame): pixmap_icon = index.data(ITEM_ICON_ROLE) is_sequence = index.data(IS_SEQUENCE_ROLE) - widget = ItemWidget(item_id, label, pixmap_icon, is_sequence) + widget = ItemWidget( + item_id, + label, + pixmap_icon, + is_sequence, + self._multivalue + ) widget.split_requested.connect(self._on_split_request) self._files_view.setIndexWidget(index, widget) self._files_proxy_model.setData( @@ -507,6 +548,9 @@ class FilesWidget(QtWidgets.QFrame): self.value_changed.emit() def _on_split_request(self, item_id): + if self._multivalue: + return + file_item = self._files_model.get_file_item_by_id(item_id) if not file_item: return @@ -516,6 +560,9 @@ class FilesWidget(QtWidgets.QFrame): self._add_filepaths(new_items) def _on_remove_requested(self): + if self._multivalue: + return + items_to_delete = self._files_view.get_selected_item_ids() if items_to_delete: self._remove_item_by_ids(items_to_delete) @@ -544,6 +591,9 @@ class FilesWidget(QtWidgets.QFrame): return result def dragEnterEvent(self, event): + if self._multivalue: + return + mime_data = event.mimeData() if mime_data.hasUrls(): filepaths = [] @@ -561,7 +611,7 @@ class FilesWidget(QtWidgets.QFrame): def dropEvent(self, event): mime_data = event.mimeData() - if mime_data.hasUrls(): + if not self._multivalue and mime_data.hasUrls(): filepaths = [] for url in mime_data.urls(): filepath = url.toLocalFile() From 6f98abbee285d93c4d565fc231cba3d844691b84 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 27 Apr 2022 10:54:33 +0200 Subject: [PATCH 102/583] reset creator on create --- openpype/tools/publisher/widgets/create_dialog.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/openpype/tools/publisher/widgets/create_dialog.py b/openpype/tools/publisher/widgets/create_dialog.py index 21e1bd5cfc..22f358f1aa 100644 --- a/openpype/tools/publisher/widgets/create_dialog.py +++ b/openpype/tools/publisher/widgets/create_dialog.py @@ -553,7 +553,7 @@ class CreateDialog(QtWidgets.QDialog): identifier = index.data(CREATOR_IDENTIFIER_ROLE) - self._set_creator(identifier) + self._set_creator_by_identifier(identifier) def _on_plugins_refresh(self): # Trigger refresh only if is visible @@ -581,7 +581,7 @@ class CreateDialog(QtWidgets.QDialog): identifier = None if new_index.isValid(): identifier = new_index.data(CREATOR_IDENTIFIER_ROLE) - self._set_creator(identifier) + self._set_creator_by_identifier(identifier) def _update_help_btn(self): pos_x = self.width() - self._help_btn.width() @@ -633,9 +633,11 @@ class CreateDialog(QtWidgets.QDialog): else: self._detail_description_widget.setMarkdown(detailed_description) - def _set_creator(self, identifier): + def _set_creator_by_identifier(self, identifier): creator = self.controller.manual_creators.get(identifier) + self._set_creator(creator) + def _set_creator(self, creator): self._creator_short_desc_widget.set_plugin(creator) self._set_creator_detailed_text(creator) self._pre_create_widget.set_plugin(creator) @@ -861,7 +863,9 @@ class CreateDialog(QtWidgets.QDialog): )) error_msg = str(exc_value) - if error_msg is not None: + if error_msg is None: + self._set_creator(self._selected_creator) + else: box = CreateErrorMessageBox( creator_label, subset_name, From 9246383621aee77701d778a2252e9ef4495f33de Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 27 Apr 2022 10:58:14 +0200 Subject: [PATCH 103/583] Added overlay widget showing message thant creation finished --- openpype/tools/publisher/widgets/create_dialog.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/openpype/tools/publisher/widgets/create_dialog.py b/openpype/tools/publisher/widgets/create_dialog.py index 22f358f1aa..971799a35a 100644 --- a/openpype/tools/publisher/widgets/create_dialog.py +++ b/openpype/tools/publisher/widgets/create_dialog.py @@ -13,8 +13,10 @@ from openpype.pipeline.create import ( CreatorError, SUBSET_NAME_ALLOWED_SYMBOLS ) - -from openpype.tools.utils import ErrorMessageBox +from openpype.tools.utils import ( + ErrorMessageBox, + MessageOverlayObject +) from .widgets import IconValuePixmapLabel from .assets_widget import CreateDialogAssetsWidget @@ -239,6 +241,8 @@ class CreateDialog(QtWidgets.QDialog): self._name_pattern = name_pattern self._compiled_name_pattern = re.compile(name_pattern) + overlay_object = MessageOverlayObject(self) + context_widget = QtWidgets.QWidget(self) assets_widget = CreateDialogAssetsWidget(controller, context_widget) @@ -368,6 +372,8 @@ class CreateDialog(QtWidgets.QDialog): controller.add_plugins_refresh_callback(self._on_plugins_refresh) + self._overlay_object = overlay_object + self._splitter_widget = splitter_widget self._context_widget = context_widget @@ -393,6 +399,9 @@ class CreateDialog(QtWidgets.QDialog): self._prereq_timer = prereq_timer self._first_show = True + def _emit_message(self, message): + self._overlay_object.add_message(message) + def _context_change_is_enabled(self): return self._context_widget.isEnabled() @@ -865,6 +874,7 @@ class CreateDialog(QtWidgets.QDialog): if error_msg is None: self._set_creator(self._selected_creator) + self._emit_message("Creation finished...") else: box = CreateErrorMessageBox( creator_label, From 0653f77e06cb9a54f1f60b05500265ec7d8336de Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 27 Apr 2022 11:18:55 +0200 Subject: [PATCH 104/583] fixed Py2 compatibility --- openpype/tools/publisher/widgets/validations_widget.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/tools/publisher/widgets/validations_widget.py b/openpype/tools/publisher/widgets/validations_widget.py index 798c1f9d92..e7ab4ecf5a 100644 --- a/openpype/tools/publisher/widgets/validations_widget.py +++ b/openpype/tools/publisher/widgets/validations_widget.py @@ -142,7 +142,7 @@ class ValidationErrorTitleWidget(QtWidgets.QWidget): self._help_text_by_instance_id = help_text_by_instance_id def sizeHint(self): - result = super().sizeHint() + result = super(ValidationErrorTitleWidget, self).sizeHint() expected_width = 0 for idx in range(self._view_layout.count()): expected_width += self._view_layout.itemAt(idx).sizeHint().width() From 716a120cc1228a3bc9c6798e044b1a9a962debc5 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 27 Apr 2022 11:19:11 +0200 Subject: [PATCH 105/583] hide creator dialog on showing publish frame --- openpype/tools/publisher/window.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/openpype/tools/publisher/window.py b/openpype/tools/publisher/window.py index b74e95b227..ba0c4c54c3 100644 --- a/openpype/tools/publisher/window.py +++ b/openpype/tools/publisher/window.py @@ -340,9 +340,23 @@ class PublisherWindow(QtWidgets.QDialog): def _set_publish_visibility(self, visible): if visible: widget = self.publish_frame + publish_frame_visible = True else: widget = self.subset_frame + publish_frame_visible = False self.content_stacked_layout.setCurrentWidget(widget) + self._set_publish_frame_visible(publish_frame_visible) + + def _set_publish_frame_visible(self, publish_frame_visible): + """Publish frame visibility has changed. + + Also used in TrayPublisher to be able handle start/end of publish + widget overlay. + """ + + # Hide creator dialog if visible + if publish_frame_visible and self.creator_window.isVisible(): + self.creator_window.close() def _on_reset_clicked(self): self.controller.reset() From 6a303361b2163d778b0479ecd14f1d2f36bf588a Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 27 Apr 2022 11:19:42 +0200 Subject: [PATCH 106/583] hide Change Project button of publish frame show --- openpype/tools/traypublisher/window.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/openpype/tools/traypublisher/window.py b/openpype/tools/traypublisher/window.py index bbb6398c35..1c201230f0 100644 --- a/openpype/tools/traypublisher/window.py +++ b/openpype/tools/traypublisher/window.py @@ -143,6 +143,12 @@ class TrayPublishWindow(PublisherWindow): self._back_to_overlay_btn = back_to_overlay_btn self._overlay_widget = overlay_widget + def _set_publish_frame_visible(self, publish_frame_visible): + super(TrayPublishWindow, self)._set_publish_frame_visible( + publish_frame_visible + ) + self._back_to_overlay_btn.setVisible(not publish_frame_visible) + def _on_back_to_overlay(self): self._overlay_widget.setVisible(True) self._resize_overlay() From 67fee85cd9ec7a159ef825ccc7acce77155ea0f4 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 27 Apr 2022 11:31:22 +0200 Subject: [PATCH 107/583] put stack of frames in published into widget --- openpype/tools/publisher/window.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/openpype/tools/publisher/window.py b/openpype/tools/publisher/window.py index ba0c4c54c3..90a36b4f01 100644 --- a/openpype/tools/publisher/window.py +++ b/openpype/tools/publisher/window.py @@ -83,8 +83,10 @@ class PublisherWindow(QtWidgets.QDialog): line_widget.setMinimumHeight(2) # Content + content_stacked_widget = QtWidgets.QWidget(self) + # Subset widget - subset_frame = QtWidgets.QFrame(self) + subset_frame = QtWidgets.QFrame(content_stacked_widget) subset_views_widget = BorderedLabelWidget( "Subsets to publish", subset_frame @@ -171,9 +173,12 @@ class PublisherWindow(QtWidgets.QDialog): subset_layout.addLayout(footer_layout, 0) # Create publish frame - publish_frame = PublishFrame(controller, self) + publish_frame = PublishFrame(controller, content_stacked_widget) - content_stacked_layout = QtWidgets.QStackedLayout() + content_stacked_layout = QtWidgets.QStackedLayout( + content_stacked_widget + ) + content_stacked_layout.setContentsMargins(0, 0, 0, 0) content_stacked_layout.setStackingMode( QtWidgets.QStackedLayout.StackAll ) @@ -186,7 +191,7 @@ class PublisherWindow(QtWidgets.QDialog): main_layout.setSpacing(0) main_layout.addWidget(header_widget, 0) main_layout.addWidget(line_widget, 0) - main_layout.addLayout(content_stacked_layout, 1) + main_layout.addWidget(content_stacked_widget, 1) creator_window = CreateDialog(controller, parent=self) @@ -228,6 +233,7 @@ class PublisherWindow(QtWidgets.QDialog): # Store header for TrayPublisher self._header_layout = header_layout + self._content_stacked_widget = content_stacked_widget self.content_stacked_layout = content_stacked_layout self.publish_frame = publish_frame self.subset_frame = subset_frame From 8913b74b19aa9967738309f46bded6eedd70770c Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 27 Apr 2022 11:33:58 +0200 Subject: [PATCH 108/583] added cancel button to change project widget --- openpype/tools/traypublisher/window.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/openpype/tools/traypublisher/window.py b/openpype/tools/traypublisher/window.py index 1c201230f0..5934c4aa8a 100644 --- a/openpype/tools/traypublisher/window.py +++ b/openpype/tools/traypublisher/window.py @@ -54,8 +54,11 @@ class StandaloneOverlayWidget(QtWidgets.QFrame): ) confirm_btn = QtWidgets.QPushButton("Confirm", content_widget) + cancel_btn = QtWidgets.QPushButton("Cancel", content_widget) + cancel_btn.setVisible(False) btns_layout = QtWidgets.QHBoxLayout() btns_layout.addStretch(1) + btns_layout.addWidget(cancel_btn, 0) btns_layout.addWidget(confirm_btn, 0) content_layout = QtWidgets.QVBoxLayout(content_widget) @@ -77,15 +80,19 @@ class StandaloneOverlayWidget(QtWidgets.QFrame): projects_view.doubleClicked.connect(self._on_double_click) confirm_btn.clicked.connect(self._on_confirm_click) + cancel_btn.clicked.connect(self._on_cancel_click) self._projects_view = projects_view self._projects_model = projects_model + self._cancel_btn = cancel_btn self._confirm_btn = confirm_btn self._publisher_window = publisher_window + self._project_name = None def showEvent(self, event): self._projects_model.refresh() + self._cancel_btn.setVisible(self._project_name is not None) super(StandaloneOverlayWidget, self).showEvent(event) def _on_double_click(self): @@ -94,13 +101,18 @@ class StandaloneOverlayWidget(QtWidgets.QFrame): def _on_confirm_click(self): self.set_selected_project() + def _on_cancel_click(self): + self._set_project(self._project_name) + def set_selected_project(self): index = self._projects_view.currentIndex() project_name = index.data(PROJECT_NAME_ROLE) - if not project_name: - return + if project_name: + self._set_project(project_name) + def _set_project(self, project_name): + self._project_name = project_name traypublisher.set_project_name(project_name) self.setVisible(False) self.project_selected.emit(project_name) From 5afeccd4e5d396c3a68ae167254095156bba3ac4 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 27 Apr 2022 17:17:50 +0200 Subject: [PATCH 109/583] hiero: removing old plugins --- .../collect_clip_resolution.py | 38 --- .../collect_host_version.py | 15 -- .../collect_tag_retime.py | 32 --- .../precollect_instances.py | 223 ------------------ .../precollect_workfile.py | 74 ------ 5 files changed, 382 deletions(-) delete mode 100644 openpype/hosts/hiero/plugins/publish_old_workflow/collect_clip_resolution.py delete mode 100644 openpype/hosts/hiero/plugins/publish_old_workflow/collect_host_version.py delete mode 100644 openpype/hosts/hiero/plugins/publish_old_workflow/collect_tag_retime.py delete mode 100644 openpype/hosts/hiero/plugins/publish_old_workflow/precollect_instances.py delete mode 100644 openpype/hosts/hiero/plugins/publish_old_workflow/precollect_workfile.py diff --git a/openpype/hosts/hiero/plugins/publish_old_workflow/collect_clip_resolution.py b/openpype/hosts/hiero/plugins/publish_old_workflow/collect_clip_resolution.py deleted file mode 100644 index 1d0727d0af..0000000000 --- a/openpype/hosts/hiero/plugins/publish_old_workflow/collect_clip_resolution.py +++ /dev/null @@ -1,38 +0,0 @@ -import pyblish.api - - -class CollectClipResolution(pyblish.api.InstancePlugin): - """Collect clip geometry resolution""" - - order = pyblish.api.CollectorOrder - 0.1 - label = "Collect Clip Resolution" - hosts = ["hiero"] - families = ["clip"] - - def process(self, instance): - sequence = instance.context.data['activeSequence'] - item = instance.data["item"] - source_resolution = instance.data.get("sourceResolution", None) - - resolution_width = int(sequence.format().width()) - resolution_height = int(sequence.format().height()) - pixel_aspect = sequence.format().pixelAspect() - - # source exception - if source_resolution: - resolution_width = int(item.source().mediaSource().width()) - resolution_height = int(item.source().mediaSource().height()) - pixel_aspect = item.source().mediaSource().pixelAspect() - - resolution_data = { - "resolutionWidth": resolution_width, - "resolutionHeight": resolution_height, - "pixelAspect": pixel_aspect - } - # add to instacne data - instance.data.update(resolution_data) - - self.log.info("Resolution of instance '{}' is: {}".format( - instance, - resolution_data - )) diff --git a/openpype/hosts/hiero/plugins/publish_old_workflow/collect_host_version.py b/openpype/hosts/hiero/plugins/publish_old_workflow/collect_host_version.py deleted file mode 100644 index 76e5bd11d5..0000000000 --- a/openpype/hosts/hiero/plugins/publish_old_workflow/collect_host_version.py +++ /dev/null @@ -1,15 +0,0 @@ -import pyblish.api - - -class CollectHostVersion(pyblish.api.ContextPlugin): - """Inject the hosts version into context""" - - label = "Collect Host and HostVersion" - order = pyblish.api.CollectorOrder - 0.5 - - def process(self, context): - import nuke - import pyblish.api - - context.set_data("host", pyblish.api.current_host()) - context.set_data('hostVersion', value=nuke.NUKE_VERSION_STRING) diff --git a/openpype/hosts/hiero/plugins/publish_old_workflow/collect_tag_retime.py b/openpype/hosts/hiero/plugins/publish_old_workflow/collect_tag_retime.py deleted file mode 100644 index 0634130976..0000000000 --- a/openpype/hosts/hiero/plugins/publish_old_workflow/collect_tag_retime.py +++ /dev/null @@ -1,32 +0,0 @@ -from pyblish import api - - -class CollectTagRetime(api.InstancePlugin): - """Collect Retiming from Tags of selected track items.""" - - order = api.CollectorOrder + 0.014 - label = "Collect Retiming Tag" - hosts = ["hiero"] - families = ['clip'] - - def process(self, instance): - # gets tags - tags = instance.data["tags"] - - for t in tags: - t_metadata = dict(t["metadata"]) - t_family = t_metadata.get("tag.family", "") - - # gets only task family tags and collect labels - if "retiming" in t_family: - margin_in = t_metadata.get("tag.marginIn", "") - margin_out = t_metadata.get("tag.marginOut", "") - - instance.data["retimeMarginIn"] = int(margin_in) - instance.data["retimeMarginOut"] = int(margin_out) - instance.data["retime"] = True - - self.log.info("retimeMarginIn: `{}`".format(margin_in)) - self.log.info("retimeMarginOut: `{}`".format(margin_out)) - - instance.data["families"] += ["retime"] diff --git a/openpype/hosts/hiero/plugins/publish_old_workflow/precollect_instances.py b/openpype/hosts/hiero/plugins/publish_old_workflow/precollect_instances.py deleted file mode 100644 index f9cc158e79..0000000000 --- a/openpype/hosts/hiero/plugins/publish_old_workflow/precollect_instances.py +++ /dev/null @@ -1,223 +0,0 @@ -from compiler.ast import flatten -from pyblish import api -from openpype.hosts.hiero import api as phiero -import hiero -# from openpype.hosts.hiero.api import lib -# reload(lib) -# reload(phiero) - - -class PreCollectInstances(api.ContextPlugin): - """Collect all Track items selection.""" - - order = api.CollectorOrder - 0.509 - label = "Pre-collect Instances" - hosts = ["hiero"] - - def process(self, context): - track_items = phiero.get_track_items( - selected=True, check_tagged=True, check_enabled=True) - # only return enabled track items - if not track_items: - track_items = phiero.get_track_items( - check_enabled=True, check_tagged=True) - # get sequence and video tracks - sequence = context.data["activeSequence"] - tracks = sequence.videoTracks() - - # add collection to context - tracks_effect_items = self.collect_sub_track_items(tracks) - - context.data["tracksEffectItems"] = tracks_effect_items - - self.log.info( - "Processing enabled track items: {}".format(len(track_items))) - - for _ti in track_items: - data = {} - clip = _ti.source() - - # get clips subtracks and anotations - annotations = self.clip_annotations(clip) - subtracks = self.clip_subtrack(_ti) - self.log.debug("Annotations: {}".format(annotations)) - self.log.debug(">> Subtracks: {}".format(subtracks)) - - # get pype tag data - tag_parsed_data = phiero.get_track_item_pype_data(_ti) - # self.log.debug(pformat(tag_parsed_data)) - - if not tag_parsed_data: - continue - - if tag_parsed_data.get("id") != "pyblish.avalon.instance": - continue - # add tag data to instance data - data.update({ - k: v for k, v in tag_parsed_data.items() - if k not in ("id", "applieswhole", "label") - }) - - asset = tag_parsed_data["asset"] - subset = tag_parsed_data["subset"] - review_track = tag_parsed_data.get("reviewTrack") - hiero_track = tag_parsed_data.get("heroTrack") - audio = tag_parsed_data.get("audio") - - # remove audio attribute from data - data.pop("audio") - - # insert family into families - family = tag_parsed_data["family"] - families = [str(f) for f in tag_parsed_data["families"]] - families.insert(0, str(family)) - - track = _ti.parent() - media_source = _ti.source().mediaSource() - source_path = media_source.firstpath() - file_head = media_source.filenameHead() - file_info = media_source.fileinfos().pop() - source_first_frame = int(file_info.startFrame()) - - # apply only for review and master track instance - if review_track and hiero_track: - families += ["review", "ftrack"] - - data.update({ - "name": "{} {} {}".format(asset, subset, families), - "asset": asset, - "item": _ti, - "families": families, - - # tags - "tags": _ti.tags(), - - # track item attributes - "track": track.name(), - "trackItem": track, - "reviewTrack": review_track, - - # version data - "versionData": { - "colorspace": _ti.sourceMediaColourTransform() - }, - - # source attribute - "source": source_path, - "sourceMedia": media_source, - "sourcePath": source_path, - "sourceFileHead": file_head, - "sourceFirst": source_first_frame, - - # clip's effect - "clipEffectItems": subtracks - }) - - instance = context.create_instance(**data) - - self.log.info("Creating instance.data: {}".format(instance.data)) - - if audio: - a_data = dict() - - # add tag data to instance data - a_data.update({ - k: v for k, v in tag_parsed_data.items() - if k not in ("id", "applieswhole", "label") - }) - - # create main attributes - subset = "audioMain" - family = "audio" - families = ["clip", "ftrack"] - families.insert(0, str(family)) - - name = "{} {} {}".format(asset, subset, families) - - a_data.update({ - "name": name, - "subset": subset, - "asset": asset, - "family": family, - "families": families, - "item": _ti, - - # tags - "tags": _ti.tags(), - }) - - a_instance = context.create_instance(**a_data) - self.log.info("Creating audio instance: {}".format(a_instance)) - - @staticmethod - def clip_annotations(clip): - """ - Returns list of Clip's hiero.core.Annotation - """ - annotations = [] - subTrackItems = flatten(clip.subTrackItems()) - annotations += [item for item in subTrackItems if isinstance( - item, hiero.core.Annotation)] - return annotations - - @staticmethod - def clip_subtrack(clip): - """ - Returns list of Clip's hiero.core.SubTrackItem - """ - subtracks = [] - subTrackItems = flatten(clip.parent().subTrackItems()) - for item in subTrackItems: - # avoid all anotation - if isinstance(item, hiero.core.Annotation): - continue - # # avoid all not anaibled - if not item.isEnabled(): - continue - subtracks.append(item) - return subtracks - - @staticmethod - def collect_sub_track_items(tracks): - """ - Returns dictionary with track index as key and list of subtracks - """ - # collect all subtrack items - sub_track_items = dict() - for track in tracks: - items = track.items() - - # skip if no clips on track > need track with effect only - if items: - continue - - # skip all disabled tracks - if not track.isEnabled(): - continue - - track_index = track.trackIndex() - _sub_track_items = flatten(track.subTrackItems()) - - # continue only if any subtrack items are collected - if len(_sub_track_items) < 1: - continue - - enabled_sti = list() - # loop all found subtrack items and check if they are enabled - for _sti in _sub_track_items: - # checking if not enabled - if not _sti.isEnabled(): - continue - if isinstance(_sti, hiero.core.Annotation): - continue - # collect the subtrack item - enabled_sti.append(_sti) - - # continue only if any subtrack items are collected - if len(enabled_sti) < 1: - continue - - # add collection of subtrackitems to dict - sub_track_items[track_index] = enabled_sti - - return sub_track_items diff --git a/openpype/hosts/hiero/plugins/publish_old_workflow/precollect_workfile.py b/openpype/hosts/hiero/plugins/publish_old_workflow/precollect_workfile.py deleted file mode 100644 index 693e151f6f..0000000000 --- a/openpype/hosts/hiero/plugins/publish_old_workflow/precollect_workfile.py +++ /dev/null @@ -1,74 +0,0 @@ -import os -import pyblish.api -from openpype.hosts.hiero import api as phiero -from openpype.pipeline import legacy_io - - -class PreCollectWorkfile(pyblish.api.ContextPlugin): - """Inject the current working file into context""" - - label = "Pre-collect Workfile" - order = pyblish.api.CollectorOrder - 0.51 - - def process(self, context): - asset = legacy_io.Session["AVALON_ASSET"] - subset = "workfile" - - project = phiero.get_current_project() - active_sequence = phiero.get_current_sequence() - video_tracks = active_sequence.videoTracks() - audio_tracks = active_sequence.audioTracks() - current_file = project.path() - staging_dir = os.path.dirname(current_file) - base_name = os.path.basename(current_file) - - # get workfile's colorspace properties - _clrs = {} - _clrs["useOCIOEnvironmentOverride"] = project.useOCIOEnvironmentOverride() # noqa - _clrs["lutSetting16Bit"] = project.lutSetting16Bit() - _clrs["lutSetting8Bit"] = project.lutSetting8Bit() - _clrs["lutSettingFloat"] = project.lutSettingFloat() - _clrs["lutSettingLog"] = project.lutSettingLog() - _clrs["lutSettingViewer"] = project.lutSettingViewer() - _clrs["lutSettingWorkingSpace"] = project.lutSettingWorkingSpace() - _clrs["lutUseOCIOForExport"] = project.lutUseOCIOForExport() - _clrs["ocioConfigName"] = project.ocioConfigName() - _clrs["ocioConfigPath"] = project.ocioConfigPath() - - # set main project attributes to context - context.data["activeProject"] = project - context.data["activeSequence"] = active_sequence - context.data["videoTracks"] = video_tracks - context.data["audioTracks"] = audio_tracks - context.data["currentFile"] = current_file - context.data["colorspace"] = _clrs - - self.log.info("currentFile: {}".format(current_file)) - - # creating workfile representation - representation = { - 'name': 'hrox', - 'ext': 'hrox', - 'files': base_name, - "stagingDir": staging_dir, - } - - instance_data = { - "name": "{}_{}".format(asset, subset), - "asset": asset, - "subset": "{}{}".format(asset, subset.capitalize()), - "item": project, - "family": "workfile", - - # version data - "versionData": { - "colorspace": _clrs - }, - - # source attribute - "sourcePath": current_file, - "representations": [representation] - } - - instance = context.create_instance(**instance_data) - self.log.info("Creating instance: {}".format(instance)) From ec8e277e1d794838de3021a977090cd0c4865b47 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 27 Apr 2022 17:18:42 +0200 Subject: [PATCH 110/583] hiero: adding collector for frame tags --- openpype/hosts/hiero/api/tags.py | 20 +-- .../publish/collect_frame_tag_instances.py | 146 ++++++++++++++++++ .../plugins/publish/precollect_workfile.py | 2 + 3 files changed, 158 insertions(+), 10 deletions(-) create mode 100644 openpype/hosts/hiero/plugins/publish/collect_frame_tag_instances.py diff --git a/openpype/hosts/hiero/api/tags.py b/openpype/hosts/hiero/api/tags.py index e15e3119a6..8877b92b9d 100644 --- a/openpype/hosts/hiero/api/tags.py +++ b/openpype/hosts/hiero/api/tags.py @@ -10,16 +10,6 @@ log = Logger.get_logger(__name__) def tag_data(): return { - # "Retiming": { - # "editable": "1", - # "note": "Clip has retime or TimeWarp effects (or multiple effects stacked on the clip)", # noqa - # "icon": "retiming.png", - # "metadata": { - # "family": "retiming", - # "marginIn": 1, - # "marginOut": 1 - # } - # }, "[Lenses]": { "Set lense here": { "editable": "1", @@ -48,6 +38,16 @@ def tag_data(): "family": "comment", "subset": "main" } + }, + "FrameMain": { + "editable": "1", + "note": "Publishing a frame subset.", + "icon": "z_layer_main.png", + "metadata": { + "family": "frame", + "subset": "main", + "format": "png" + } } } diff --git a/openpype/hosts/hiero/plugins/publish/collect_frame_tag_instances.py b/openpype/hosts/hiero/plugins/publish/collect_frame_tag_instances.py new file mode 100644 index 0000000000..84b6f9149b --- /dev/null +++ b/openpype/hosts/hiero/plugins/publish/collect_frame_tag_instances.py @@ -0,0 +1,146 @@ +from pprint import pformat +import re +import ast +import json + +import pyblish.api + + +class CollectFrameTagInstances(pyblish.api.ContextPlugin): + """Collect frames from tags. + + Tag is expected to have metadata: + { + "family": "frame" + "subset": "main" + } + """ + + order = pyblish.api.CollectorOrder + label = "Collect Frames" + hosts = ["hiero"] + + def process(self, context): + self._context = context + + # collect all sequence tags + subset_data = self._create_frame_subset_data_sequence(context) + self.log.debug("__ subset_data: {}".format( + pformat(subset_data) + )) + + # if sequence tags and frame type then create instances + self._create_instances(subset_data) + + # collect all instance tags + ## if instance tags and frame type then create instances + + pass + + def _get_tag_data(self, tag): + data = {} + + # get tag metadata attribute + tag_data = tag.metadata() + + # convert tag metadata to normal keys names and values to correct types + for k, v in dict(tag_data).items(): + key = k.replace("tag.", "") + + try: + # capture exceptions which are related to strings only + if re.match(r"^[\d]+$", v): + value = int(v) + elif re.match(r"^True$", v): + value = True + elif re.match(r"^False$", v): + value = False + elif re.match(r"^None$", v): + value = None + elif re.match(r"^[\w\d_]+$", v): + value = v + else: + value = ast.literal_eval(v) + except (ValueError, SyntaxError) as msg: + value = v + + data[key] = value + + return data + + def _create_frame_subset_data_sequence(self, context): + + sequence_tags = [] + sequence = context.data["activeTimeline"] + + # get all publishable sequence frames + publish_frames = range(int(sequence.duration() + 1)) + + self.log.debug("__ publish_frames: {}".format( + pformat(publish_frames) + )) + + # get all sequence tags + for tag in sequence.tags(): + tag_data = self._get_tag_data(tag) + self.log.debug("__ tag_data: {}".format( + pformat(tag_data) + )) + if not tag_data: + continue + + if "family" not in tag_data: + continue + + if tag_data["family"] != "frame": + continue + + sequence_tags.append(tag_data) + + self.log.debug("__ sequence_tags: {}".format( + pformat(sequence_tags) + )) + + # first collect all available subset tag frames + subset_data = {} + for tag_data in sequence_tags: + frame = int(tag_data["start"]) + + if frame not in publish_frames: + continue + + subset = tag_data["subset"] + + if subset in subset_data: + # update existing subset key + subset_data[subset]["frames"].append(frame) + else: + # create new subset key + subset_data[subset] = { + "frames": [frame], + "format": tag_data["format"], + "asset": context.data["assetEntity"]["name"] + } + return subset_data + + def _create_instances(self, subset_data): + # create instance per subset + for subset_name, subset_data in subset_data.items(): + name = "frame" + subset_name.title() + data = { + "name": name, + "label": "{} {}".format(name, subset_data["frames"]), + "family": "image", + "families": ["frame"], + "asset": subset_data["asset"], + "subset": subset_name, + "format": subset_data["format"], + "frames": subset_data["frames"] + } + self._context.create_instance(**data) + + self.log.info( + "Created instance: {}".format( + json.dumps(data, sort_keys=True, indent=4) + ) + ) diff --git a/openpype/hosts/hiero/plugins/publish/precollect_workfile.py b/openpype/hosts/hiero/plugins/publish/precollect_workfile.py index 29c0397f79..b9f58c15f6 100644 --- a/openpype/hosts/hiero/plugins/publish/precollect_workfile.py +++ b/openpype/hosts/hiero/plugins/publish/precollect_workfile.py @@ -68,6 +68,7 @@ class PrecollectWorkfile(pyblish.api.ContextPlugin): "subset": "{}{}".format(asset, subset.capitalize()), "item": project, "family": "workfile", + "families": [], "representations": [workfile_representation, thumb_representation] } @@ -77,6 +78,7 @@ class PrecollectWorkfile(pyblish.api.ContextPlugin): # update context with main project attributes context_data = { "activeProject": project, + "activeTimeline": active_timeline, "otioTimeline": otio_timeline, "currentFile": curent_file, "colorspace": self.get_colorspace(project), From 4687d00a1700d1940f0c06d44835aa68db756a6b Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 27 Apr 2022 17:19:10 +0200 Subject: [PATCH 111/583] hiero: fixing families exception for hierarchy --- openpype/plugins/publish/collect_hierarchy.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openpype/plugins/publish/collect_hierarchy.py b/openpype/plugins/publish/collect_hierarchy.py index 4e94acce4a..a96d444be6 100644 --- a/openpype/plugins/publish/collect_hierarchy.py +++ b/openpype/plugins/publish/collect_hierarchy.py @@ -30,14 +30,15 @@ class CollectHierarchy(pyblish.api.ContextPlugin): # shot data dict shot_data = {} - family = instance.data.get("family") + family = instance.data["family"] + families = instance.data["families"] # filter out all unepropriate instances if not instance.data["publish"]: continue # exclude other families then self.families with intersection - if not set(self.families).intersection([family]): + if not set(self.families).intersection(set(families + [family])): continue # exclude if not masterLayer True From ca0e6592db03dfeb8d0186a443683f7bbb530f92 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 27 Apr 2022 17:19:59 +0200 Subject: [PATCH 112/583] hiero: adding frame family extractor --- .../hiero/plugins/publish/extract_frames.py | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 openpype/hosts/hiero/plugins/publish/extract_frames.py diff --git a/openpype/hosts/hiero/plugins/publish/extract_frames.py b/openpype/hosts/hiero/plugins/publish/extract_frames.py new file mode 100644 index 0000000000..5396298aac --- /dev/null +++ b/openpype/hosts/hiero/plugins/publish/extract_frames.py @@ -0,0 +1,82 @@ +import os +import pyblish.api +import openpype + + +class ExtractFrames(openpype.api.Extractor): + """Extracts frames""" + + order = pyblish.api.ExtractorOrder + label = "Extract Frames" + hosts = ["hiero"] + families = ["frame"] + movie_extensions = ["mov", "mp4"] + + def process(self, instance): + oiio_tool_path = openpype.lib.get_oiio_tools_path() + staging_dir = self.staging_dir(instance) + output_template = os.path.join(staging_dir, instance.data["name"]) + sequence = instance.context.data["activeTimeline"] + + files = [] + for frame in instance.data["frames"]: + track_item = sequence.trackItemAt(frame) + media_source = track_item.source().mediaSource() + input_path = media_source.fileinfos()[0].filename() + input_frame = ( + track_item.mapTimelineToSource(frame) + + track_item.source().mediaSource().startTime() + ) + output_ext = instance.data["format"] + output_path = output_template + output_path += ".{:04d}.{}".format(int(frame), output_ext) + + args = [oiio_tool_path] + + ext = os.path.splitext(input_path)[1][1:] + if ext in self.movie_extensions: + args.extend(["--subimage", str(int(input_frame))]) + else: + args.extend(["--frames", str(int(input_frame))]) + + if ext == "exr": + args.extend(["--powc", "0.45,0.45,0.45,1.0"]) + + args.extend([input_path, "-o", output_path]) + output = openpype.api.run_subprocess(args) + + failed_output = "oiiotool produced no output." + if failed_output in output: + raise ValueError( + "oiiotool processing failed. Args: {}".format(args) + ) + + files.append(output_path) + + # Feedback to user because "oiiotool" can make the publishing + # appear unresponsive. + self.log.info( + "Processed {} of {} frames".format( + instance.data["frames"].index(frame) + 1, + len(instance.data["frames"]) + ) + ) + + if len(files) == 1: + instance.data["representations"] = [ + { + "name": output_ext, + "ext": output_ext, + "files": os.path.basename(files[0]), + "stagingDir": staging_dir + } + ] + else: + instance.data["representations"] = [ + { + "name": output_ext, + "ext": output_ext, + "files": [os.path.basename(x) for x in files], + "stagingDir": staging_dir + } + ] \ No newline at end of file From df81486d30c4ddbbcec92cf307e30464d084e21f Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 27 Apr 2022 17:27:29 +0200 Subject: [PATCH 113/583] hound --- .../plugins/publish/collect_frame_tag_instances.py | 12 ++++-------- .../hosts/hiero/plugins/publish/extract_frames.py | 2 +- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/openpype/hosts/hiero/plugins/publish/collect_frame_tag_instances.py b/openpype/hosts/hiero/plugins/publish/collect_frame_tag_instances.py index 84b6f9149b..80a54ba2c5 100644 --- a/openpype/hosts/hiero/plugins/publish/collect_frame_tag_instances.py +++ b/openpype/hosts/hiero/plugins/publish/collect_frame_tag_instances.py @@ -25,18 +25,14 @@ class CollectFrameTagInstances(pyblish.api.ContextPlugin): # collect all sequence tags subset_data = self._create_frame_subset_data_sequence(context) + self.log.debug("__ subset_data: {}".format( pformat(subset_data) )) - # if sequence tags and frame type then create instances + # create instances self._create_instances(subset_data) - # collect all instance tags - ## if instance tags and frame type then create instances - - pass - def _get_tag_data(self, tag): data = {} @@ -61,7 +57,7 @@ class CollectFrameTagInstances(pyblish.api.ContextPlugin): value = v else: value = ast.literal_eval(v) - except (ValueError, SyntaxError) as msg: + except (ValueError, SyntaxError): value = v data[key] = value @@ -85,7 +81,7 @@ class CollectFrameTagInstances(pyblish.api.ContextPlugin): tag_data = self._get_tag_data(tag) self.log.debug("__ tag_data: {}".format( pformat(tag_data) - )) + )) if not tag_data: continue diff --git a/openpype/hosts/hiero/plugins/publish/extract_frames.py b/openpype/hosts/hiero/plugins/publish/extract_frames.py index 5396298aac..aa3eda2e9f 100644 --- a/openpype/hosts/hiero/plugins/publish/extract_frames.py +++ b/openpype/hosts/hiero/plugins/publish/extract_frames.py @@ -79,4 +79,4 @@ class ExtractFrames(openpype.api.Extractor): "files": [os.path.basename(x) for x in files], "stagingDir": staging_dir } - ] \ No newline at end of file + ] From 546b1d42015598a9b36dcfb07d942c12b391029b Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 27 Apr 2022 19:07:23 +0200 Subject: [PATCH 114/583] removed unused method --- .../plugins/publish/integrate_ftrack_api.py | 42 ------------------- 1 file changed, 42 deletions(-) diff --git a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_api.py b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_api.py index 650c59fae8..e60d00c7c3 100644 --- a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_api.py +++ b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_api.py @@ -24,48 +24,6 @@ class IntegrateFtrackApi(pyblish.api.InstancePlugin): label = "Integrate Ftrack Api" families = ["ftrack"] - def query(self, entitytype, data): - """ Generate a query expression from data supplied. - - If a value is not a string, we'll add the id of the entity to the - query. - - Args: - entitytype (str): The type of entity to query. - data (dict): The data to identify the entity. - exclusions (list): All keys to exclude from the query. - - Returns: - str: String query to use with "session.query" - """ - queries = [] - if sys.version_info[0] < 3: - for key, value in data.iteritems(): - if not isinstance(value, (basestring, int)): - self.log.info("value: {}".format(value)) - if "id" in value.keys(): - queries.append( - "{0}.id is \"{1}\"".format(key, value["id"]) - ) - else: - queries.append("{0} is \"{1}\"".format(key, value)) - else: - for key, value in data.items(): - if not isinstance(value, (str, int)): - self.log.info("value: {}".format(value)) - if "id" in value.keys(): - queries.append( - "{0}.id is \"{1}\"".format(key, value["id"]) - ) - else: - queries.append("{0} is \"{1}\"".format(key, value)) - - query = ( - "select id from " + entitytype + " where " + " and ".join(queries) - ) - self.log.debug(query) - return query - def process(self, instance): session = instance.context.data["ftrackSession"] context = instance.context From 9c1fb9de477394b8b22c1910ab2a862a3005dd96 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 27 Apr 2022 19:08:22 +0200 Subject: [PATCH 115/583] added asset status name filtering for asset version --- .../publish/integrate_ftrack_instances.py | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_instances.py b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_instances.py index 5ea0469bce..5eecf34c3d 100644 --- a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_instances.py +++ b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_instances.py @@ -3,6 +3,8 @@ import json import copy import pyblish.api +from openpype.lib.profiles_filtering import filter_profiles + class IntegrateFtrackInstance(pyblish.api.InstancePlugin): """Collect ftrack component data (not integrate yet). @@ -36,6 +38,7 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): "reference": "reference" } keep_first_subset_name_for_review = True + asset_versions_status_profiles = {} def process(self, instance): self.log.debug("instance {}".format(instance)) @@ -80,6 +83,8 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): if instance_fps is None: instance_fps = instance.context.data["fps"] + status_name = self._get_asset_version_status_name(instance) + # Base of component item data # - create a copy of this object when want to use it base_component_item = { @@ -91,7 +96,8 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): }, "assetversion_data": { "version": version_number, - "comment": instance.context.data.get("comment") or "" + "comment": instance.context.data.get("comment") or "", + "status_name": status_name }, "component_overwrite": False, # This can be change optionally @@ -317,3 +323,24 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): ) )) instance.data["ftrackComponentsList"] = component_list + + def _get_asset_version_status_name(self, instance): + if not self.asset_versions_status_profiles: + return None + + # Prepare filtering data for new asset version status + anatomy_data = instance.data["anatomyData"] + task_type = anatomy_data.get("task", {}).get("type") + filtering_criteria = { + "families": instance.data["family"], + "hosts": instance.context.data["hostName"], + "task_types": task_type + } + matching_profile = filter_profiles( + self.asset_versions_status_profiles, + filtering_criteria + ) + if not matching_profile: + return None + + return matching_profile["status"] or None From b0dd4d51530a5a345bad9fae2c3d9fac2ee6ef19 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 27 Apr 2022 19:10:53 +0200 Subject: [PATCH 116/583] implemented logic which change status of asset version --- .../plugins/publish/integrate_ftrack_api.py | 44 ++++++++++++++++--- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_api.py b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_api.py index e60d00c7c3..64af8cb208 100644 --- a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_api.py +++ b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_api.py @@ -66,7 +66,19 @@ class IntegrateFtrackApi(pyblish.api.InstancePlugin): default_asset_name = parent_entity["name"] # Change status on task - self._set_task_status(instance, task_entity, session) + asset_version_status_ids_by_name = {} + project_entity = instance.context.data.get("ftrackProject") + if project_entity: + project_schema = project_entity["project_schema"] + asset_version_statuses = ( + project_schema.get_statuses("AssetVersion") + ) + asset_version_status_ids_by_name = { + status["name"].lower(): status["id"] + for status in asset_version_statuses + } + + self._set_task_status(instance, project_entity, task_entity, session) # Prepare AssetTypes asset_types_by_short = self._ensure_asset_types_exists( @@ -97,7 +109,11 @@ class IntegrateFtrackApi(pyblish.api.InstancePlugin): # Asset Version asset_version_data = data.get("assetversion_data") or {} asset_version_entity = self._ensure_asset_version_exists( - session, asset_version_data, asset_entity["id"], task_entity + session, + asset_version_data, + asset_entity["id"], + task_entity, + asset_version_status_ids_by_name ) # Component @@ -132,8 +148,7 @@ class IntegrateFtrackApi(pyblish.api.InstancePlugin): if asset_version not in instance.data[asset_versions_key]: instance.data[asset_versions_key].append(asset_version) - def _set_task_status(self, instance, task_entity, session): - project_entity = instance.context.data.get("ftrackProject") + def _set_task_status(self, instance, project_entity, task_entity, session): if not project_entity: self.log.info("Task status won't be set, project is not known.") return @@ -277,12 +292,19 @@ class IntegrateFtrackApi(pyblish.api.InstancePlugin): ).first() def _ensure_asset_version_exists( - self, session, asset_version_data, asset_id, task_entity + self, + session, + asset_version_data, + asset_id, + task_entity, + status_ids_by_name ): task_id = None if task_entity: task_id = task_entity["id"] + status_name = asset_version_data.pop("status_name", None) + # Try query asset version by criteria (asset id and version) version = asset_version_data.get("version") or 0 asset_version_entity = self._query_asset_version( @@ -324,6 +346,18 @@ class IntegrateFtrackApi(pyblish.api.InstancePlugin): session, version, asset_id ) + if status_name: + status_id = status_ids_by_name.get(status_name.lower()) + if not status_id: + self.log.info(( + "Ftrack status with name \"{}\"" + " for AssetVersion was not found." + ).format(status_name)) + + elif asset_version_entity["status_id"] != status_id: + asset_version_entity["status_id"] = status_id + session.commit() + # Set custom attributes if there were any set custom_attrs = asset_version_data.get("custom_attributes") or {} for attr_key, attr_value in custom_attrs.items(): From eea8f906a2a2b4031b672d021db1052ba0afdd0d Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 27 Apr 2022 19:16:10 +0200 Subject: [PATCH 117/583] added settings schemas and defaults for new attribute --- .../defaults/project_settings/ftrack.json | 3 +- .../schema_project_ftrack.json | 37 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/openpype/settings/defaults/project_settings/ftrack.json b/openpype/settings/defaults/project_settings/ftrack.json index a846a596c2..f9d16d6476 100644 --- a/openpype/settings/defaults/project_settings/ftrack.json +++ b/openpype/settings/defaults/project_settings/ftrack.json @@ -418,7 +418,8 @@ "redshiftproxy": "cache", "usd": "usd" }, - "keep_first_subset_name_for_review": true + "keep_first_subset_name_for_review": true, + "asset_versions_status_profiles": [] } } } \ No newline at end of file diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_ftrack.json b/openpype/settings/entities/schemas/projects_schema/schema_project_ftrack.json index 47effb3dbd..7db490b114 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_ftrack.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_ftrack.json @@ -858,6 +858,43 @@ "key": "keep_first_subset_name_for_review", "label": "Make subset name as first asset name", "default": true + }, + { + "type": "list", + "collapsible": true, + "key": "asset_versions_status_profiles", + "label": "AssetVersion status on publish", + "use_label_wrap": true, + "object_type": { + "type": "dict", + "children": [ + { + "key": "hosts", + "label": "Host names", + "type": "hosts-enum", + "multiselection": true + }, + { + "key": "task_types", + "label": "Task types", + "type": "task-types-enum" + }, + { + "key": "family", + "label": "Family", + "type": "list", + "object_type": "text" + }, + { + "type": "separator" + }, + { + "key": "status", + "label": "Status name", + "type": "text" + } + ] + } } ] } From c2e0c84034b94a876eca830e6c4ae5188b4c000a Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 28 Apr 2022 10:36:52 +0200 Subject: [PATCH 118/583] replaced renderlayer with render_layer and renderpass with render_pass --- .../plugins/create/create_render_layer.py | 13 ++++++++++--- .../plugins/create/create_render_pass.py | 13 ++++++++++--- .../plugins/publish/collect_instances.py | 18 +++++++++++++++++- .../plugins/publish/collect_scene_render.py | 10 ++++++++-- 4 files changed, 45 insertions(+), 9 deletions(-) diff --git a/openpype/hosts/tvpaint/plugins/create/create_render_layer.py b/openpype/hosts/tvpaint/plugins/create/create_render_layer.py index c1af9632b1..3b5bd47189 100644 --- a/openpype/hosts/tvpaint/plugins/create/create_render_layer.py +++ b/openpype/hosts/tvpaint/plugins/create/create_render_layer.py @@ -24,7 +24,9 @@ class CreateRenderlayer(plugin.Creator): " {clip_id} {group_id} {r} {g} {b} \"{name}\"" ) - dynamic_subset_keys = ["render_pass", "render_layer", "group"] + dynamic_subset_keys = [ + "renderpass", "renderlayer", "render_pass", "render_layer", "group" + ] @classmethod def get_dynamic_data( @@ -34,12 +36,17 @@ class CreateRenderlayer(plugin.Creator): variant, task_name, asset_id, project_name, host_name ) # Use render pass name from creator's plugin - dynamic_data["render_pass"] = cls.render_pass + dynamic_data["renderpass"] = cls.render_pass # Add variant to render layer - dynamic_data["render_layer"] = variant + dynamic_data["renderlayer"] = variant # Change family for subset name fill dynamic_data["family"] = "render" + # TODO remove - Backwards compatibility for old subset name templates + # - added 2022/04/28 + dynamic_data["render_pass"] = dynamic_data["renderpass"] + dynamic_data["render_layer"] = dynamic_data["renderlayer"] + return dynamic_data @classmethod diff --git a/openpype/hosts/tvpaint/plugins/create/create_render_pass.py b/openpype/hosts/tvpaint/plugins/create/create_render_pass.py index a7f717ccec..1c9f31e656 100644 --- a/openpype/hosts/tvpaint/plugins/create/create_render_pass.py +++ b/openpype/hosts/tvpaint/plugins/create/create_render_pass.py @@ -20,7 +20,9 @@ class CreateRenderPass(plugin.Creator): icon = "cube" defaults = ["Main"] - dynamic_subset_keys = ["render_pass", "render_layer"] + dynamic_subset_keys = [ + "renderpass", "renderlayer", "render_pass", "render_layer" + ] @classmethod def get_dynamic_data( @@ -29,9 +31,13 @@ class CreateRenderPass(plugin.Creator): dynamic_data = super(CreateRenderPass, cls).get_dynamic_data( variant, task_name, asset_id, project_name, host_name ) - dynamic_data["render_pass"] = variant + dynamic_data["renderpass"] = variant dynamic_data["family"] = "render" + # TODO remove - Backwards compatibility for old subset name templates + # - added 2022/04/28 + dynamic_data["renderpass"] = dynamic_data["render_pass"] + return dynamic_data @classmethod @@ -115,6 +121,7 @@ class CreateRenderPass(plugin.Creator): else: render_layer = beauty_instance["variant"] + subset_name_fill_data["renderlayer"] = render_layer subset_name_fill_data["render_layer"] = render_layer # Format dynamic keys in subset name @@ -129,7 +136,7 @@ class CreateRenderPass(plugin.Creator): self.data["group_id"] = group_id self.data["pass"] = variant - self.data["render_layer"] = render_layer + self.data["renderlayer"] = render_layer # Collect selected layer ids to be stored into instance layer_names = [layer["name"] for layer in selected_layers] diff --git a/openpype/hosts/tvpaint/plugins/publish/collect_instances.py b/openpype/hosts/tvpaint/plugins/publish/collect_instances.py index 188aa8c41a..9b51f88cae 100644 --- a/openpype/hosts/tvpaint/plugins/publish/collect_instances.py +++ b/openpype/hosts/tvpaint/plugins/publish/collect_instances.py @@ -45,6 +45,22 @@ class CollectInstances(pyblish.api.ContextPlugin): for instance_data in filtered_instance_data: instance_data["fps"] = context.data["sceneFps"] + # Conversion from older instances + # - change 'render_layer' to 'renderlayer' + # and 'render_pass' to 'renderpass' + + if ( + "renderlayer" not in instance_data + and "render_layer" in instance_data + ): + instance_data["renderlayer"] = instance_data["render_layer"] + + if ( + "renderpass" not in instance_data + and "render_pass" in instance_data + ): + instance_data["renderpass"] = instance_data["render_pass"] + # Store workfile instance data to instance data instance_data["originData"] = copy.deepcopy(instance_data) # Global instance data modifications @@ -191,7 +207,7 @@ class CollectInstances(pyblish.api.ContextPlugin): "Creating render pass instance. \"{}\"".format(pass_name) ) # Change label - render_layer = instance_data["render_layer"] + render_layer = instance_data["renderlayer"] # Backwards compatibility # - subset names were not stored as final subset names during creation diff --git a/openpype/hosts/tvpaint/plugins/publish/collect_scene_render.py b/openpype/hosts/tvpaint/plugins/publish/collect_scene_render.py index 1c042a62fb..02e0575a2c 100644 --- a/openpype/hosts/tvpaint/plugins/publish/collect_scene_render.py +++ b/openpype/hosts/tvpaint/plugins/publish/collect_scene_render.py @@ -69,9 +69,13 @@ class CollectRenderScene(pyblish.api.ContextPlugin): # Variant is using render pass name variant = self.render_layer dynamic_data = { - "render_layer": self.render_layer, - "render_pass": self.render_pass + "renderlayer": self.render_layer, + "renderpass": self.render_pass, } + # TODO remove - Backwards compatibility for old subset name templates + # - added 2022/04/28 + dynamic_data["render_layer"] = dynamic_data["renderlayer"] + dynamic_data["render_pass"] = dynamic_data["renderpass"] task_name = workfile_context["task"] subset_name = get_subset_name_with_asset_doc( @@ -102,6 +106,8 @@ class CollectRenderScene(pyblish.api.ContextPlugin): "asset": asset_name, "task": task_name } + # Add 'renderlayer' and 'renderpass' to data + instance_data.update(dynamic_data) instance = context.create_instance(**instance_data) From e7d244cd585dcd4171cc50f8cb0ac537fda360ff Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 28 Apr 2022 10:48:43 +0200 Subject: [PATCH 119/583] changed default settings of TVPaint --- openpype/settings/defaults/project_settings/global.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/settings/defaults/project_settings/global.json b/openpype/settings/defaults/project_settings/global.json index 7317a3da1c..7b223798f1 100644 --- a/openpype/settings/defaults/project_settings/global.json +++ b/openpype/settings/defaults/project_settings/global.json @@ -307,7 +307,7 @@ ], "task_types": [], "tasks": [], - "template": "{family}{Task}_{Render_layer}_{Render_pass}" + "template": "{family}{Task}_{Renderlayer}_{Renderpass}" }, { "families": [ From ed9771392db1475a6cb74e0a4417079e4505b248 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 28 Apr 2022 11:22:11 +0200 Subject: [PATCH 120/583] Nuke: anatomy node overrides plus UX improvements --- .../schemas/schema_anatomy_imageio.json | 96 +++++++++++-------- 1 file changed, 57 insertions(+), 39 deletions(-) diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_imageio.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_imageio.json index 9f142bad09..434f474f6e 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_imageio.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_imageio.json @@ -253,7 +253,7 @@ { "key": "requiredNodes", "type": "list", - "label": "Required Nodes", + "label": "Plugin required", "object_type": { "type": "dict", "children": [ @@ -272,35 +272,43 @@ "label": "Nuke Node Class" }, { - "type": "splitter" - }, - { - "key": "knobs", + "type": "collapsible-wrap", "label": "Knobs", - "type": "list", - "object_type": { - "type": "dict", - "children": [ - { - "type": "text", - "key": "name", - "label": "Name" - }, - { - "type": "text", - "key": "value", - "label": "Value" + "collapsible": true, + "collapsed": true, + "children": [ + { + "key": "knobs", + "type": "list", + "object_type": { + "type": "dict", + "children": [ + { + "type": "text", + "key": "name", + "label": "Name" + }, + { + "type": "text", + "key": "value", + "label": "Value" + } + ] } - ] - } + } + ] } + ] } }, + { + "type": "splitter" + }, { "type": "list", - "key": "customNodes", - "label": "Custom Nodes", + "key": "overrideNodes", + "label": "Plugin's node overrides", "object_type": { "type": "dict", "children": [ @@ -319,27 +327,37 @@ "label": "Nuke Node Class" }, { - "type": "splitter" + "key": "sebsets", + "label": "Subsets", + "type": "list", + "object_type": "text" }, { - "key": "knobs", + "type": "collapsible-wrap", "label": "Knobs", - "type": "list", - "object_type": { - "type": "dict", - "children": [ - { - "type": "text", - "key": "name", - "label": "Name" - }, - { - "type": "text", - "key": "value", - "label": "Value" + "collapsible": true, + "collapsed": true, + "children": [ + { + "key": "knobs", + "type": "list", + "object_type": { + "type": "dict", + "children": [ + { + "type": "text", + "key": "name", + "label": "Name" + }, + { + "type": "text", + "key": "value", + "label": "Value" + } + ] } - ] - } + } + ] } ] } From e0fba3583a75f832135f77c36bb6bb8690b8898b Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 28 Apr 2022 12:13:37 +0200 Subject: [PATCH 121/583] swapr keys --- openpype/hosts/tvpaint/plugins/create/create_render_pass.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/tvpaint/plugins/create/create_render_pass.py b/openpype/hosts/tvpaint/plugins/create/create_render_pass.py index 1c9f31e656..26fa8ac51a 100644 --- a/openpype/hosts/tvpaint/plugins/create/create_render_pass.py +++ b/openpype/hosts/tvpaint/plugins/create/create_render_pass.py @@ -36,7 +36,7 @@ class CreateRenderPass(plugin.Creator): # TODO remove - Backwards compatibility for old subset name templates # - added 2022/04/28 - dynamic_data["renderpass"] = dynamic_data["render_pass"] + dynamic_data["render_pass"] = dynamic_data["renderpass"] return dynamic_data From e4b5ee107b77c88833428161188d65474f3d7e84 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 28 Apr 2022 12:17:59 +0200 Subject: [PATCH 122/583] nuke: ux improvement --- .../schemas/projects_schema/schemas/schema_anatomy_imageio.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_imageio.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_imageio.json index 434f474f6e..141b51da0a 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_imageio.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_imageio.json @@ -334,7 +334,7 @@ }, { "type": "collapsible-wrap", - "label": "Knobs", + "label": "Knobs overrides", "collapsible": true, "collapsed": true, "children": [ From ca803331e595bb6bb8d2ff740d33fdfe25793aeb Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 28 Apr 2022 14:00:02 +0200 Subject: [PATCH 123/583] make sure keys are as list instead of 'dict_keys' in py2 --- openpype/lib/avalon_context.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/lib/avalon_context.py b/openpype/lib/avalon_context.py index 3fcddef745..9d8a92cfe9 100644 --- a/openpype/lib/avalon_context.py +++ b/openpype/lib/avalon_context.py @@ -1532,13 +1532,13 @@ class BuildWorkfile: subsets = list(legacy_io.find({ "type": "subset", - "parent": {"$in": asset_entity_by_ids.keys()} + "parent": {"$in": list(asset_entity_by_ids.keys())} })) subset_entity_by_ids = {subset["_id"]: subset for subset in subsets} sorted_versions = list(legacy_io.find({ "type": "version", - "parent": {"$in": subset_entity_by_ids.keys()} + "parent": {"$in": list(subset_entity_by_ids.keys())} }).sort("name", -1)) subset_id_with_latest_version = [] @@ -1552,7 +1552,7 @@ class BuildWorkfile: repres = legacy_io.find({ "type": "representation", - "parent": {"$in": last_versions_by_id.keys()} + "parent": {"$in": list(last_versions_by_id.keys())} }) output = {} From ba537e263d756034d70b3d5f08ead851684c9f8e Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Thu, 28 Apr 2022 15:09:12 +0100 Subject: [PATCH 124/583] Fix old avalon-core import --- openpype/hosts/unreal/plugins/create/create_render.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/unreal/plugins/create/create_render.py b/openpype/hosts/unreal/plugins/create/create_render.py index d81f7c7aab..1e6f5fb4d1 100644 --- a/openpype/hosts/unreal/plugins/create/create_render.py +++ b/openpype/hosts/unreal/plugins/create/create_render.py @@ -1,6 +1,6 @@ import unreal -from avalon import io +from openpype.pipeline import legacy_io from openpype.hosts.unreal.api import pipeline from openpype.hosts.unreal.api.plugin import Creator @@ -70,14 +70,14 @@ class CreateRender(Creator): # Get frame range. We need to go through the hierarchy and check # the frame range for the children. - asset_data = io.find_one({ + asset_data = legacy_io.find_one({ "type": "asset", "name": asset_name }) id = asset_data.get('_id') elements = list( - io.find({"type": "asset", "data.visualParent": id})) + legacy_io.find({"type": "asset", "data.visualParent": id})) if elements: start_frames = [] @@ -86,7 +86,7 @@ class CreateRender(Creator): start_frames.append(e.get('data').get('clipIn')) end_frames.append(e.get('data').get('clipOut')) - elements.extend(io.find({ + elements.extend(legacy_io.find({ "type": "asset", "data.visualParent": e.get('_id') })) From ab0980895fdac8ec1a83b777b5f41045202647c9 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 28 Apr 2022 16:43:37 +0200 Subject: [PATCH 125/583] nuke: adding key to default settings --- openpype/settings/defaults/project_anatomy/imageio.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/settings/defaults/project_anatomy/imageio.json b/openpype/settings/defaults/project_anatomy/imageio.json index 7a3f49452e..fedae994bf 100644 --- a/openpype/settings/defaults/project_anatomy/imageio.json +++ b/openpype/settings/defaults/project_anatomy/imageio.json @@ -165,7 +165,7 @@ ] } ], - "customNodes": [] + "overrideNodes": [] }, "regexInputs": { "inputs": [ From 45a5538a5c3eec03316e9fc24c0f4feb21be3742 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 28 Apr 2022 16:50:05 +0200 Subject: [PATCH 126/583] removed backwards compatibility --- start.py | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/start.py b/start.py index 38eb9e9bf4..4d4801c1e5 100644 --- a/start.py +++ b/start.py @@ -266,18 +266,9 @@ def set_openpype_global_environments() -> None: """Set global OpenPype's environments.""" import acre - try: - from openpype.settings import get_general_environments + from openpype.settings import get_general_environments - general_env = get_general_environments() - - except Exception: - # Backwards compatibility for OpenPype versions where - # `get_general_environments` does not exists yet - from openpype.settings import get_environments - - all_env = get_environments() - general_env = all_env["global"] + general_env = get_general_environments() merged_env = acre.merge( acre.parse(general_env), From 0ca5f9304e7cb8e9a6fe5961bd41152cad73d58a Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Thu, 28 Apr 2022 18:09:43 +0100 Subject: [PATCH 127/583] Layout loading will load assets in a different directory --- .../hosts/unreal/plugins/load/load_layout.py | 2 +- .../hosts/unreal/plugins/load/load_rig.py | 71 ++++++++++--------- 2 files changed, 37 insertions(+), 36 deletions(-) diff --git a/openpype/hosts/unreal/plugins/load/load_layout.py b/openpype/hosts/unreal/plugins/load/load_layout.py index e09255d88c..fc80859261 100644 --- a/openpype/hosts/unreal/plugins/load/load_layout.py +++ b/openpype/hosts/unreal/plugins/load/load_layout.py @@ -360,7 +360,7 @@ class LayoutLoader(plugin.Loader): continue options = { - "asset_dir": asset_dir + # "asset_dir": asset_dir } assets = load_container( diff --git a/openpype/hosts/unreal/plugins/load/load_rig.py b/openpype/hosts/unreal/plugins/load/load_rig.py index ff844a5e94..c27bd23aaf 100644 --- a/openpype/hosts/unreal/plugins/load/load_rig.py +++ b/openpype/hosts/unreal/plugins/load/load_rig.py @@ -52,54 +52,55 @@ class SkeletalMeshFBXLoader(plugin.Loader): asset_name = "{}_{}".format(asset, name) else: asset_name = "{}".format(name) + version = context.get('version').get('name') tools = unreal.AssetToolsHelpers().get_asset_tools() asset_dir, container_name = tools.create_unique_asset_name( - "{}/{}/{}".format(root, asset, name), suffix="") + f"{root}/{asset}/{name}_v{version:03d}", suffix="") container_name += suffix - unreal.EditorAssetLibrary.make_directory(asset_dir) + if not unreal.EditorAssetLibrary.does_directory_exist(asset_dir): + unreal.EditorAssetLibrary.make_directory(asset_dir) - task = unreal.AssetImportTask() + task = unreal.AssetImportTask() - task.set_editor_property('filename', self.fname) - task.set_editor_property('destination_path', asset_dir) - task.set_editor_property('destination_name', asset_name) - task.set_editor_property('replace_existing', False) - task.set_editor_property('automated', True) - task.set_editor_property('save', False) + task.set_editor_property('filename', self.fname) + task.set_editor_property('destination_path', asset_dir) + task.set_editor_property('destination_name', asset_name) + task.set_editor_property('replace_existing', False) + task.set_editor_property('automated', True) + task.set_editor_property('save', False) - # set import options here - options = unreal.FbxImportUI() - options.set_editor_property('import_as_skeletal', True) - options.set_editor_property('import_animations', False) - options.set_editor_property('import_mesh', True) - options.set_editor_property('import_materials', True) - options.set_editor_property('import_textures', True) - options.set_editor_property('skeleton', None) - options.set_editor_property('create_physics_asset', False) + # set import options here + options = unreal.FbxImportUI() + options.set_editor_property('import_as_skeletal', True) + options.set_editor_property('import_animations', False) + options.set_editor_property('import_mesh', True) + options.set_editor_property('import_materials', False) + options.set_editor_property('import_textures', False) + options.set_editor_property('skeleton', None) + options.set_editor_property('create_physics_asset', False) - options.set_editor_property('mesh_type_to_import', - unreal.FBXImportType.FBXIT_SKELETAL_MESH) + options.set_editor_property( + 'mesh_type_to_import', + unreal.FBXImportType.FBXIT_SKELETAL_MESH) - options.skeletal_mesh_import_data.set_editor_property( - 'import_content_type', - unreal.FBXImportContentType.FBXICT_ALL - ) - # set to import normals, otherwise Unreal will compute them - # and it will take a long time, depending on the size of the mesh - options.skeletal_mesh_import_data.set_editor_property( - 'normal_import_method', - unreal.FBXNormalImportMethod.FBXNIM_IMPORT_NORMALS - ) + options.skeletal_mesh_import_data.set_editor_property( + 'import_content_type', + unreal.FBXImportContentType.FBXICT_ALL) + # set to import normals, otherwise Unreal will compute them + # and it will take a long time, depending on the size of the mesh + options.skeletal_mesh_import_data.set_editor_property( + 'normal_import_method', + unreal.FBXNormalImportMethod.FBXNIM_IMPORT_NORMALS) - task.options = options - unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) # noqa: E501 + task.options = options + unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) # noqa: E501 - # Create Asset Container - unreal_pipeline.create_container( - container=container_name, path=asset_dir) + # Create Asset Container + unreal_pipeline.create_container( + container=container_name, path=asset_dir) data = { "schema": "openpype:container-2.0", From 337df2d605020e7fc7c0c45794ea9d3294d0374b Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Thu, 28 Apr 2022 18:13:46 +0100 Subject: [PATCH 128/583] Reworked update and remove operation for layouts --- .../hosts/unreal/plugins/load/load_layout.py | 526 ++++++++++-------- 1 file changed, 303 insertions(+), 223 deletions(-) diff --git a/openpype/hosts/unreal/plugins/load/load_layout.py b/openpype/hosts/unreal/plugins/load/load_layout.py index fc80859261..163dd1a8af 100644 --- a/openpype/hosts/unreal/plugins/load/load_layout.py +++ b/openpype/hosts/unreal/plugins/load/load_layout.py @@ -208,7 +208,14 @@ class LayoutLoader(plugin.Loader): actors.append(actor) - binding = sequence.add_possessable(actor) + binding = None + for p in sequence.get_possessables(): + if p.get_name() == actor.get_name(): + binding = p + break + + if not binding: + binding = sequence.add_possessable(actor) bindings.append(binding) @@ -299,15 +306,101 @@ class LayoutLoader(plugin.Loader): # Add animation to the sequencer bindings = bindings_dict.get(instance_name) + ar = unreal.AssetRegistryHelpers.get_asset_registry() + for binding in bindings: - binding.add_track(unreal.MovieSceneSkeletalAnimationTrack) - for track in binding.get_tracks(): + tracks = binding.get_tracks() + track = None + if not tracks: + track = binding.add_track( + unreal.MovieSceneSkeletalAnimationTrack) + else: + track = tracks[0] + + sections = track.get_sections() + section = None + if not sections: section = track.add_section() - section.set_range( - sequence.get_playback_start(), - sequence.get_playback_end()) + else: + section = sections[0] + sec_params = section.get_editor_property('params') - sec_params.set_editor_property('animation', animation) + curr_anim = sec_params.get_editor_property('animation') + + if curr_anim: + # Checks if the animation path has a container. + # If it does, it means that the animation is already + # in the sequencer. + anim_path = str(Path( + curr_anim.get_path_name()).parent + ).replace('\\', '/') + + filter = unreal.ARFilter( + class_names=["AssetContainer"], + package_paths=[anim_path], + recursive_paths=False) + containers = ar.get_assets(filter) + + if len(containers) > 0: + return + + section.set_range( + sequence.get_playback_start(), + sequence.get_playback_end()) + sec_params = section.get_editor_property('params') + sec_params.set_editor_property('animation', animation) + + def _generate_sequence(self, h, h_dir): + tools = unreal.AssetToolsHelpers().get_asset_tools() + + sequence = tools.create_asset( + asset_name=h, + package_path=h_dir, + asset_class=unreal.LevelSequence, + factory=unreal.LevelSequenceFactoryNew() + ) + + asset_data = legacy_io.find_one({ + "type": "asset", + "name": h_dir.split('/')[-1] + }) + + id = asset_data.get('_id') + + start_frames = [] + end_frames = [] + + elements = list( + legacy_io.find({"type": "asset", "data.visualParent": id})) + for e in elements: + start_frames.append(e.get('data').get('clipIn')) + end_frames.append(e.get('data').get('clipOut')) + + elements.extend(legacy_io.find({ + "type": "asset", + "data.visualParent": e.get('_id') + })) + + min_frame = min(start_frames) + max_frame = max(end_frames) + + sequence.set_display_rate( + unreal.FrameRate(asset_data.get('data').get("fps"), 1.0)) + sequence.set_playback_start(min_frame) + sequence.set_playback_end(max_frame) + + tracks = sequence.get_master_tracks() + track = None + for t in tracks: + if (t.get_class() == + unreal.MovieSceneCameraCutTrack.static_class()): + track = t + break + if not track: + track = sequence.add_master_track( + unreal.MovieSceneCameraCutTrack) + + return sequence, (min_frame, max_frame) def _process(self, lib_path, asset_dir, sequence, loaded=None): ar = unreal.AssetRegistryHelpers.get_asset_registry() @@ -326,6 +419,8 @@ class LayoutLoader(plugin.Loader): actors_dict = {} bindings_dict = {} + loaded_assets = [] + for element in data: reference = None if element.get('reference_fbx'): @@ -370,6 +465,17 @@ class LayoutLoader(plugin.Loader): options=options ) + container = None + + for asset in assets: + obj = ar.get_asset_by_object_path(asset).get_asset() + if obj.get_class().get_name() == 'AssetContainer': + container = obj + if obj.get_class().get_name() == 'Skeleton': + skeleton = obj + + loaded_assets.append(container.get_path_name()) + instances = [ item for item in data if (item.get('reference_fbx') == reference or @@ -390,15 +496,6 @@ class LayoutLoader(plugin.Loader): actors_dict[inst] = actors bindings_dict[inst] = bindings - if family == 'rig': - # Finds skeleton among the imported assets - for asset in assets: - obj = ar.get_asset_by_object_path(asset).get_asset() - if obj.get_class().get_name() == 'Skeleton': - skeleton = obj - if skeleton: - break - if skeleton: skeleton_dict[reference] = skeleton else: @@ -411,6 +508,8 @@ class LayoutLoader(plugin.Loader): asset_dir, path, instance_name, skeleton, actors_dict, animation_file, bindings_dict, sequence) + return loaded_assets + @staticmethod def _remove_family(assets, components, class_name, prop_name): ar = unreal.AssetRegistryHelpers.get_asset_registry() @@ -478,10 +577,10 @@ class LayoutLoader(plugin.Loader): hierarchy = context.get('asset').get('data').get('parents') root = self.ASSET_ROOT hierarchy_dir = root - hierarchy_list = [] + hierarchy_dir_list = [] for h in hierarchy: hierarchy_dir = f"{hierarchy_dir}/{h}" - hierarchy_list.append(hierarchy_dir) + hierarchy_dir_list.append(hierarchy_dir) asset = context.get('asset').get('name') suffix = "_CON" if asset: @@ -499,43 +598,31 @@ class LayoutLoader(plugin.Loader): # Create map for the shot, and create hierarchy of map. If the maps # already exist, we will use them. - maps = [] - for h in hierarchy_list: - a = h.split('/')[-1] - map = f"{h}/{a}_map.{a}_map" - new = False - - if not EditorAssetLibrary.does_asset_exist(map): - EditorLevelLibrary.new_level(f"{h}/{a}_map") - new = True - - maps.append({"map": map, "new": new}) + h_dir = hierarchy_dir_list[0] + h_asset = hierarchy[0] + master_level = f"{h_dir}/{h_asset}_map.{h_asset}_map" + if not EditorAssetLibrary.does_asset_exist(master_level): + EditorLevelLibrary.new_level(f"{h_dir}/{h_asset}_map") + level = f"{asset_dir}/{asset}_map.{asset}_map" EditorLevelLibrary.new_level(f"{asset_dir}/{asset}_map") - maps.append( - {"map": f"{asset_dir}/{asset}_map.{asset}_map", "new": True}) - for i in range(0, len(maps) - 1): - for j in range(i + 1, len(maps)): - if maps[j].get('new'): - EditorLevelLibrary.load_level(maps[i].get('map')) - EditorLevelUtils.add_level_to_world( - EditorLevelLibrary.get_editor_world(), - maps[j].get('map'), - unreal.LevelStreamingDynamic - ) - EditorLevelLibrary.save_all_dirty_levels() - - EditorLevelLibrary.load_level(maps[-1].get('map')) + EditorLevelLibrary.load_level(master_level) + EditorLevelUtils.add_level_to_world( + EditorLevelLibrary.get_editor_world(), + level, + unreal.LevelStreamingDynamic + ) + EditorLevelLibrary.save_all_dirty_levels() + EditorLevelLibrary.load_level(level) # Get all the sequences in the hierarchy. It will create them, if # they don't exist. sequences = [] frame_ranges = [] - i = 0 - for h in hierarchy_list: + for (h_dir, h) in zip(hierarchy_dir_list, hierarchy): root_content = EditorAssetLibrary.list_assets( - h, recursive=False, include_folder=False) + h_dir, recursive=False, include_folder=False) existing_sequences = [ EditorAssetLibrary.find_asset_data(asset) @@ -545,55 +632,10 @@ class LayoutLoader(plugin.Loader): ] if not existing_sequences: - sequence = tools.create_asset( - asset_name=hierarchy[i], - package_path=h, - asset_class=unreal.LevelSequence, - factory=unreal.LevelSequenceFactoryNew() - ) - - asset_data = legacy_io.find_one({ - "type": "asset", - "name": h.split('/')[-1] - }) - - id = asset_data.get('_id') - - start_frames = [] - end_frames = [] - - elements = list( - legacy_io.find({"type": "asset", "data.visualParent": id})) - for e in elements: - start_frames.append(e.get('data').get('clipIn')) - end_frames.append(e.get('data').get('clipOut')) - - elements.extend(legacy_io.find({ - "type": "asset", - "data.visualParent": e.get('_id') - })) - - min_frame = min(start_frames) - max_frame = max(end_frames) - - sequence.set_display_rate( - unreal.FrameRate(asset_data.get('data').get("fps"), 1.0)) - sequence.set_playback_start(min_frame) - sequence.set_playback_end(max_frame) + sequence, frame_range = self._generate_sequence(h, h_dir) sequences.append(sequence) - frame_ranges.append((min_frame, max_frame)) - - tracks = sequence.get_master_tracks() - track = None - for t in tracks: - if (t.get_class() == - unreal.MovieSceneCameraCutTrack.static_class()): - track = t - break - if not track: - track = sequence.add_master_track( - unreal.MovieSceneCameraCutTrack) + frame_ranges.append(frame_range) else: for e in existing_sequences: sequences.append(e.get_asset()) @@ -601,8 +643,6 @@ class LayoutLoader(plugin.Loader): e.get_asset().get_playback_start(), e.get_asset().get_playback_end())) - i += 1 - shot = tools.create_asset( asset_name=asset, package_path=asset_dir, @@ -612,15 +652,11 @@ class LayoutLoader(plugin.Loader): # sequences and frame_ranges have the same length for i in range(0, len(sequences) - 1): - maps_to_add = [] - for j in range(i + 1, len(maps)): - maps_to_add.append(maps[j].get('map')) - self._set_sequence_hierarchy( sequences[i], sequences[i + 1], frame_ranges[i][1], frame_ranges[i + 1][0], frame_ranges[i + 1][1], - maps_to_add) + [level]) data = self._get_data(asset) shot.set_display_rate( @@ -631,11 +667,11 @@ class LayoutLoader(plugin.Loader): sequences[-1], shot, frame_ranges[-1][1], data.get('clipIn'), data.get('clipOut'), - [maps[-1].get('map')]) + [level]) - EditorLevelLibrary.load_level(maps[-1].get('map')) + EditorLevelLibrary.load_level(level) - self._process(self.fname, asset_dir, shot) + loaded_assets = self._process(self.fname, asset_dir, shot) for s in sequences: EditorAssetLibrary.save_asset(s.get_full_name()) @@ -656,7 +692,8 @@ class LayoutLoader(plugin.Loader): "loader": str(self.__class__.__name__), "representation": context["representation"]["_id"], "parent": context["representation"]["parent"], - "family": context["representation"]["context"]["family"] + "family": context["representation"]["context"]["family"], + "loaded_assets": loaded_assets } unreal_pipeline.imprint( "{}/{}".format(asset_dir, container_name), data) @@ -667,148 +704,191 @@ class LayoutLoader(plugin.Loader): for a in asset_content: EditorAssetLibrary.save_asset(a) - EditorLevelLibrary.load_level(maps[0].get('map')) + EditorLevelLibrary.load_level(master_level) return asset_content def update(self, container, representation): ar = unreal.AssetRegistryHelpers.get_asset_registry() + root = "/Game/OpenPype" + + asset_dir = container.get('namespace') + + context = representation.get("context") + + hierarchy = context.get('hierarchy').split("/") + h_dir = f"{root}/{hierarchy[0]}" + h_asset = hierarchy[0] + master_level = f"{h_dir}/{h_asset}_map.{h_asset}_map" + + # # Create a temporary level to delete the layout level. + # EditorLevelLibrary.save_all_dirty_levels() + # EditorAssetLibrary.make_directory(f"{root}/tmp") + # tmp_level = f"{root}/tmp/temp_map" + # if not EditorAssetLibrary.does_asset_exist(f"{tmp_level}.temp_map"): + # EditorLevelLibrary.new_level(tmp_level) + # else: + # EditorLevelLibrary.load_level(tmp_level) + + # Get layout level + filter = unreal.ARFilter( + class_names=["World"], + package_paths=[asset_dir], + recursive_paths=False) + levels = ar.get_assets(filter) + filter = unreal.ARFilter( + class_names=["LevelSequence"], + package_paths=[asset_dir], + recursive_paths=False) + sequences = ar.get_assets(filter) + + layout_level = levels[0].get_editor_property('object_path') + + EditorLevelLibrary.save_all_dirty_levels() + EditorLevelLibrary.load_level(layout_level) + + # Delete all the actors in the level + actors = unreal.EditorLevelLibrary.get_all_level_actors() + for actor in actors: + unreal.EditorLevelLibrary.destroy_actor(actor) + + EditorLevelLibrary.save_current_level() + + EditorAssetLibrary.delete_directory(f"{asset_dir}/animations/") + source_path = get_representation_path(representation) - destination_path = container["namespace"] - lib_path = Path(get_representation_path(representation)) - self._remove_actors(destination_path) + loaded_assets = self._process( + source_path, asset_dir, sequences[0].get_asset()) - # Delete old animations - anim_path = f"{destination_path}/animations/" - EditorAssetLibrary.delete_directory(anim_path) - - with open(source_path, "r") as fp: - data = json.load(fp) - - references = [e.get('reference_fbx') for e in data] - asset_containers = self._get_asset_containers(destination_path) - loaded = [] - - # Delete all the assets imported with the previous version of the - # layout, if they're not in the new layout. - for asset_container in asset_containers: - if asset_container.get_editor_property( - 'asset_name') == container["objectName"]: - continue - ref = EditorAssetLibrary.get_metadata_tag( - asset_container.get_asset(), 'representation') - ppath = asset_container.get_editor_property('package_path') - - if ref not in references: - # If the asset is not in the new layout, delete it. - # Also check if the parent directory is empty, and delete that - # as well, if it is. - EditorAssetLibrary.delete_directory(ppath) - - parent = os.path.dirname(str(ppath)) - parent_content = EditorAssetLibrary.list_assets( - parent, recursive=False, include_folder=True - ) - - if len(parent_content) == 0: - EditorAssetLibrary.delete_directory(parent) - else: - # If the asset is in the new layout, search the instances in - # the JSON file, and create actors for them. - - actors_dict = {} - skeleton_dict = {} - - for element in data: - reference = element.get('reference_fbx') - instance_name = element.get('instance_name') - - skeleton = None - - if reference == ref and ref not in loaded: - loaded.append(ref) - - family = element.get('family') - - assets = EditorAssetLibrary.list_assets( - ppath, recursive=True, include_folder=False) - - instances = [ - item for item in data - if item.get('reference_fbx') == reference] - - for instance in instances: - transform = instance.get('transform') - inst = instance.get('instance_name') - - actors = [] - - if family == 'model': - actors = self._process_family( - assets, 'StaticMesh', transform, inst) - elif family == 'rig': - actors = self._process_family( - assets, 'SkeletalMesh', transform, inst) - actors_dict[inst] = actors - - if family == 'rig': - # Finds skeleton among the imported assets - for asset in assets: - obj = ar.get_asset_by_object_path( - asset).get_asset() - if obj.get_class().get_name() == 'Skeleton': - skeleton = obj - if skeleton: - break - - if skeleton: - skeleton_dict[reference] = skeleton - else: - skeleton = skeleton_dict.get(reference) - - animation_file = element.get('animation') - - if animation_file and skeleton: - self._import_animation( - destination_path, lib_path, - instance_name, skeleton, - actors_dict, animation_file) - - self._process(source_path, destination_path, loaded) - - container_path = "{}/{}".format(container["namespace"], - container["objectName"]) - # update metadata + data = { + "representation": str(representation["_id"]), + "parent": str(representation["parent"]), + "loaded_assets": loaded_assets + } unreal_pipeline.imprint( - container_path, - { - "representation": str(representation["_id"]), - "parent": str(representation["parent"]) - }) + "{}/{}".format(asset_dir, container.get('container_name')), data) + + EditorLevelLibrary.save_current_level() asset_content = EditorAssetLibrary.list_assets( - destination_path, recursive=True, include_folder=False) + asset_dir, recursive=True, include_folder=False) for a in asset_content: EditorAssetLibrary.save_asset(a) + EditorLevelLibrary.load_level(master_level) + def remove(self, container): """ - First, destroy all actors of the assets to be removed. Then, deletes - the asset's directory. + Delete the layout. First, check if the assets loaded with the layout + are used by other layouts. If not, delete the assets. """ - path = container["namespace"] - parent_path = os.path.dirname(path) + path = Path(container.get("namespace")) - self._remove_actors(path) + containers = unreal_pipeline.ls() + layout_containers = [ + c for c in containers + if (c.get('asset_name') != container.get('asset_name') and + c.get('family') == "layout")] - EditorAssetLibrary.delete_directory(path) + # Check if the assets have been loaded by other layouts, and deletes + # them if they haven't. + for asset in container.get('loaded_assets'): + layouts = [ + lc for lc in layout_containers + if asset in lc.get('loaded_assets')] + if len(layouts) == 0: + EditorAssetLibrary.delete_directory(str(Path(asset).parent)) + + # Remove the Level Sequence from the parent. + # We need to traverse the hierarchy from the master sequence to find + # the level sequence. + root = "/Game/OpenPype" + namespace = container.get('namespace').replace(f"{root}/", "") + ms_asset = namespace.split('/')[0] + ar = unreal.AssetRegistryHelpers.get_asset_registry() + filter = unreal.ARFilter( + class_names=["LevelSequence"], + package_paths=[f"{root}/{ms_asset}"], + recursive_paths=False) + sequences = ar.get_assets(filter) + master_sequence = sequences[0].get_asset() + filter = unreal.ARFilter( + class_names=["World"], + package_paths=[f"{root}/{ms_asset}"], + recursive_paths=False) + levels = ar.get_assets(filter) + master_level = levels[0].get_editor_property('object_path') + + sequences = [master_sequence] + + parent = None + for s in sequences: + tracks = s.get_master_tracks() + subscene_track = None + visibility_track = None + for t in tracks: + if t.get_class() == unreal.MovieSceneSubTrack.static_class(): + subscene_track = t + if (t.get_class() == + unreal.MovieSceneLevelVisibilityTrack.static_class()): + visibility_track = t + if subscene_track: + sections = subscene_track.get_sections() + for ss in sections: + if ss.get_sequence().get_name() == container.get('asset'): + parent = s + subscene_track.remove_section(ss) + break + sequences.append(ss.get_sequence()) + # Update subscenes indexes. + i = 0 + for ss in sections: + ss.set_row_index(i) + i += 1 + + if visibility_track: + sections = visibility_track.get_sections() + for ss in sections: + if unreal.Name(f"{container.get('asset')}_map") in ss.get_level_names(): + visibility_track.remove_section(ss) + # Update visibility sections indexes. + i = -1 + prev_name = [] + for ss in sections: + if prev_name != ss.get_level_names(): + i += 1 + ss.set_row_index(i) + prev_name = ss.get_level_names() + if parent: + break + + assert parent, "Could not find the parent sequence" + + # Create a temporary level to delete the layout level. + EditorLevelLibrary.save_all_dirty_levels() + EditorAssetLibrary.make_directory(f"{root}/tmp") + tmp_level = f"{root}/tmp/temp_map" + if not EditorAssetLibrary.does_asset_exist(f"{tmp_level}.temp_map"): + EditorLevelLibrary.new_level(tmp_level) + else: + EditorLevelLibrary.load_level(tmp_level) + + # Delete the layout directory. + EditorAssetLibrary.delete_directory(str(path)) + + EditorLevelLibrary.load_level(master_level) + EditorAssetLibrary.delete_directory(f"{root}/tmp") + + EditorLevelLibrary.save_current_level() + + # Delete the parent folder if there aren't any more layouts in it. asset_content = EditorAssetLibrary.list_assets( - parent_path, recursive=False, include_folder=True + str(path.parent), recursive=False, include_folder=True ) if len(asset_content) == 0: - EditorAssetLibrary.delete_directory(parent_path) + EditorAssetLibrary.delete_directory(str(path.parent)) From 9c2a7253ea53f9ecaac176852f61fe2cc04e9612 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Fri, 22 Apr 2022 11:25:23 +0100 Subject: [PATCH 129/583] Fix naming problem when loading animation for the asset container --- openpype/hosts/unreal/plugins/load/load_animation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/unreal/plugins/load/load_animation.py b/openpype/hosts/unreal/plugins/load/load_animation.py index c7581e0cdd..dc1337ed5d 100644 --- a/openpype/hosts/unreal/plugins/load/load_animation.py +++ b/openpype/hosts/unreal/plugins/load/load_animation.py @@ -165,7 +165,7 @@ class AnimationFBXLoader(plugin.Loader): instance_name = data.get("instance_name") - animation = self._process(asset_dir, container_name, instance_name) + animation = self._process(asset_dir, asset_name, instance_name) asset_content = EditorAssetLibrary.list_assets( hierarchy_dir, recursive=True, include_folder=False) From f3ed8d8403104655aa216d71f53b71d4e9ca3802 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Fri, 22 Apr 2022 11:22:00 +0100 Subject: [PATCH 130/583] Use anatomy for the render path --- openpype/hosts/unreal/api/rendering.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/unreal/api/rendering.py b/openpype/hosts/unreal/api/rendering.py index 376e1b75ce..dadfe77f45 100644 --- a/openpype/hosts/unreal/api/rendering.py +++ b/openpype/hosts/unreal/api/rendering.py @@ -1,5 +1,8 @@ +import os + import unreal +from openpype.api import Anatomy from openpype.hosts.unreal.api import pipeline @@ -46,6 +49,15 @@ def start_rendering(): if data["family"] == "render": inst_data.append(data) + try: + project = os.environ.get("AVALON_PROJECT") + anatomy = Anatomy(project) + root = anatomy.roots['renders'] + except: + raise("Could not find render root in anatomy settings.") + + render_dir = f"{root}/{project}" + # subsystem = unreal.get_editor_subsystem( # unreal.MoviePipelineQueueSubsystem) # queue = subsystem.get_queue() @@ -105,7 +117,7 @@ def start_rendering(): settings.custom_end_frame = r.get("frame_range")[1] settings.use_custom_playback_range = True settings.file_name_format = "{sequence_name}.{frame_number}" - settings.output_directory.path += r.get('output') + settings.output_directory.path = f"{render_dir}/{r.get('output')}" renderPass = job.get_configuration().find_or_add_setting_by_class( unreal.MoviePipelineDeferredPassBase) From 7724f4b943527b5d3763836daaf5e797b9b784e6 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Fri, 22 Apr 2022 16:22:01 +0100 Subject: [PATCH 131/583] Fix remove for camera assets --- .../hosts/unreal/plugins/load/load_camera.py | 93 ++++++++++++++++++- 1 file changed, 89 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/unreal/plugins/load/load_camera.py b/openpype/hosts/unreal/plugins/load/load_camera.py index ea896f6c44..09b2dc01bf 100644 --- a/openpype/hosts/unreal/plugins/load/load_camera.py +++ b/openpype/hosts/unreal/plugins/load/load_camera.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- """Load camera from FBX.""" -import os +from pathlib import Path import unreal from unreal import EditorAssetLibrary @@ -325,11 +325,96 @@ class CameraLoader(plugin.Loader): ) def remove(self, container): - path = container["namespace"] - parent_path = os.path.dirname(path) + path = Path(container.get("namespace")) + parent_path = str(path.parent.as_posix()) - EditorAssetLibrary.delete_directory(path) + ar = unreal.AssetRegistryHelpers.get_asset_registry() + filter = unreal.ARFilter( + class_names=["LevelSequence"], + package_paths=[f"{str(path.as_posix())}"], + recursive_paths=False) + sequences = ar.get_assets(filter) + if not sequences: + raise("Could not find sequence.") + + world = ar.get_asset_by_object_path( + EditorLevelLibrary.get_editor_world().get_path_name()) + + filter = unreal.ARFilter( + class_names=["World"], + package_paths=[f"{parent_path}"], + recursive_paths=True) + maps = ar.get_assets(filter) + + # There should be only one map in the list + if not maps: + raise("Could not find map.") + + map = maps[0] + + EditorLevelLibrary.save_all_dirty_levels() + EditorLevelLibrary.load_level(map.get_full_name()) + + # Remove the camera from the level. + actors = EditorLevelLibrary.get_all_level_actors() + + for a in actors: + if a.__class__ == unreal.CineCameraActor: + EditorLevelLibrary.destroy_actor(a) + + EditorLevelLibrary.save_all_dirty_levels() + EditorLevelLibrary.load_level(world.get_full_name()) + + # There should be only one sequence in the path. + sequence_name = sequences[0].asset_name + + # Remove the Level Sequence from the parent. + # We need to traverse the hierarchy from the master sequence to find + # the level sequence. + root = "/Game/OpenPype" + namespace = container.get('namespace').replace(f"{root}/", "") + ms_asset = namespace.split('/')[0] + filter = unreal.ARFilter( + class_names=["LevelSequence"], + package_paths=[f"{root}/{ms_asset}"], + recursive_paths=False) + sequences = ar.get_assets(filter) + master_sequence = sequences[0].get_asset() + + sequences = [master_sequence] + + parent = None + for s in sequences: + tracks = s.get_master_tracks() + subscene_track = None + for t in tracks: + if t.get_class() == unreal.MovieSceneSubTrack.static_class(): + subscene_track = t + break + if subscene_track: + sections = subscene_track.get_sections() + for ss in sections: + if ss.get_sequence().get_name() == sequence_name: + parent = s + subscene_track.remove_section(ss) + break + sequences.append(ss.get_sequence()) + # Update subscenes indexes. + i = 0 + for ss in sections: + ss.set_row_index(i) + i += 1 + + if parent: + break + + assert parent, "Could not find the parent sequence" + + EditorAssetLibrary.delete_directory(str(path.as_posix())) + + # Check if there isn't any more assets in the parent folder, and + # delete it if not. asset_content = EditorAssetLibrary.list_assets( parent_path, recursive=False, include_folder=True ) From 8d062e8b59d6dbdafa8b92c7f853bb05dce6eee6 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Thu, 28 Apr 2022 18:18:39 +0100 Subject: [PATCH 132/583] Fixed camera update --- .../hosts/unreal/plugins/load/load_camera.py | 167 ++++++++++++++---- 1 file changed, 128 insertions(+), 39 deletions(-) diff --git a/openpype/hosts/unreal/plugins/load/load_camera.py b/openpype/hosts/unreal/plugins/load/load_camera.py index 09b2dc01bf..9ae6e6ec03 100644 --- a/openpype/hosts/unreal/plugins/load/load_camera.py +++ b/openpype/hosts/unreal/plugins/load/load_camera.py @@ -268,62 +268,151 @@ class CameraLoader(plugin.Loader): return asset_content def update(self, container, representation): - path = container["namespace"] - ar = unreal.AssetRegistryHelpers.get_asset_registry() - tools = unreal.AssetToolsHelpers().get_asset_tools() - asset_content = EditorAssetLibrary.list_assets( - path, recursive=False, include_folder=False - ) - asset_name = "" - for a in asset_content: - asset = ar.get_asset_by_object_path(a) - if a.endswith("_CON"): - loaded_asset = EditorAssetLibrary.load_asset(a) - EditorAssetLibrary.set_metadata_tag( - loaded_asset, "representation", str(representation["_id"]) - ) - EditorAssetLibrary.set_metadata_tag( - loaded_asset, "parent", str(representation["parent"]) - ) - asset_name = EditorAssetLibrary.get_metadata_tag( - loaded_asset, "asset_name" - ) - elif asset.asset_class == "LevelSequence": - EditorAssetLibrary.delete_asset(a) + root = "/Game/OpenPype" - sequence = tools.create_asset( - asset_name=asset_name, - package_path=path, - asset_class=unreal.LevelSequence, - factory=unreal.LevelSequenceFactoryNew() + asset_dir = container.get('namespace') + + context = representation.get("context") + + hierarchy = context.get('hierarchy').split("/") + h_dir = f"{root}/{hierarchy[0]}" + h_asset = hierarchy[0] + master_level = f"{h_dir}/{h_asset}_map.{h_asset}_map" + + EditorLevelLibrary.save_current_level() + + filter = unreal.ARFilter( + class_names=["LevelSequence"], + package_paths=[asset_dir], + recursive_paths=False) + sequences = ar.get_assets(filter) + filter = unreal.ARFilter( + class_names=["World"], + package_paths=[str(Path(asset_dir).parent.as_posix())], + recursive_paths=True) + maps = ar.get_assets(filter) + + # There should be only one map in the list + EditorLevelLibrary.load_level(maps[0].get_full_name()) + + level_sequence = sequences[0].get_asset() + + display_rate = level_sequence.get_display_rate() + playback_start = level_sequence.get_playback_start() + playback_end = level_sequence.get_playback_end() + + sequence_name = f"{container.get('asset')}_camera" + + # Get the actors in the level sequence. + objs = unreal.SequencerTools.get_bound_objects( + unreal.EditorLevelLibrary.get_editor_world(), + level_sequence, + level_sequence.get_bindings(), + unreal.SequencerScriptingRange( + has_start_value=True, + has_end_value=True, + inclusive_start=level_sequence.get_playback_start(), + exclusive_end=level_sequence.get_playback_end() + ) ) - io_asset = legacy_io.Session["AVALON_ASSET"] - asset_doc = legacy_io.find_one({ - "type": "asset", - "name": io_asset - }) + # Delete actors from the map + for o in objs: + if o.bound_objects[0].get_class().get_name() == "CineCameraActor": + actor_path = o.bound_objects[0].get_path_name().split(":")[-1] + actor = EditorLevelLibrary.get_actor_reference(actor_path) + EditorLevelLibrary.destroy_actor(actor) - data = asset_doc.get("data") + # Remove the Level Sequence from the parent. + # We need to traverse the hierarchy from the master sequence to find + # the level sequence. + root = "/Game/OpenPype" + namespace = container.get('namespace').replace(f"{root}/", "") + ms_asset = namespace.split('/')[0] + filter = unreal.ARFilter( + class_names=["LevelSequence"], + package_paths=[f"{root}/{ms_asset}"], + recursive_paths=False) + sequences = ar.get_assets(filter) + master_sequence = sequences[0].get_asset() - if data: - sequence.set_display_rate(unreal.FrameRate(data.get("fps"), 1.0)) - sequence.set_playback_start(data.get("frameStart")) - sequence.set_playback_end(data.get("frameEnd")) + sequences = [master_sequence] + + parent = None + sub_scene = None + for s in sequences: + tracks = s.get_master_tracks() + subscene_track = None + for t in tracks: + if t.get_class() == unreal.MovieSceneSubTrack.static_class(): + subscene_track = t + break + if subscene_track: + sections = subscene_track.get_sections() + for ss in sections: + if ss.get_sequence().get_name() == sequence_name: + parent = s + sub_scene = ss + # subscene_track.remove_section(ss) + break + sequences.append(ss.get_sequence()) + # Update subscenes indexes. + i = 0 + for ss in sections: + ss.set_row_index(i) + i += 1 + + if parent: + break + + assert parent, "Could not find the parent sequence" + + EditorAssetLibrary.delete_asset(level_sequence.get_path_name()) settings = unreal.MovieSceneUserImportFBXSettings() settings.set_editor_property('reduce_keys', False) + tools = unreal.AssetToolsHelpers().get_asset_tools() + new_sequence = tools.create_asset( + asset_name=sequence_name, + package_path=asset_dir, + asset_class=unreal.LevelSequence, + factory=unreal.LevelSequenceFactoryNew() + ) + + new_sequence.set_display_rate(display_rate) + new_sequence.set_playback_start(playback_start) + new_sequence.set_playback_end(playback_end) + + sub_scene.set_sequence(new_sequence) + unreal.SequencerTools.import_fbx( EditorLevelLibrary.get_editor_world(), - sequence, - sequence.get_bindings(), + new_sequence, + new_sequence.get_bindings(), settings, str(representation["data"]["path"]) ) + data = { + "representation": str(representation["_id"]), + "parent": str(representation["parent"]) + } + unreal_pipeline.imprint( + "{}/{}".format(asset_dir, container.get('container_name')), data) + + EditorLevelLibrary.save_current_level() + + asset_content = EditorAssetLibrary.list_assets( + asset_dir, recursive=True, include_folder=False) + + for a in asset_content: + EditorAssetLibrary.save_asset(a) + + EditorLevelLibrary.load_level(master_level) + def remove(self, container): path = Path(container.get("namespace")) parent_path = str(path.parent.as_posix()) From 2f7ca634a29947bc60e1bf925fae9d11836f2d4a Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Wed, 27 Apr 2022 12:27:11 +0100 Subject: [PATCH 133/583] Use anatomy for render path when collecting render instances --- .../plugins/publish/collect_render_instances.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/unreal/plugins/publish/collect_render_instances.py b/openpype/hosts/unreal/plugins/publish/collect_render_instances.py index 9d60b65d08..f6a63c7ba7 100644 --- a/openpype/hosts/unreal/plugins/publish/collect_render_instances.py +++ b/openpype/hosts/unreal/plugins/publish/collect_render_instances.py @@ -1,8 +1,11 @@ +import os from pathlib import Path + import unreal -import pyblish.api +from openpype.api import Anatomy from openpype.hosts.unreal.api import pipeline +import pyblish.api class CollectRenderInstances(pyblish.api.InstancePlugin): @@ -77,9 +80,14 @@ class CollectRenderInstances(pyblish.api.InstancePlugin): self.log.debug(f"new instance data: {new_data}") - project_dir = unreal.Paths.project_dir() - render_dir = (f"{project_dir}/Saved/MovieRenders/" - f"{s.get('output')}") + try: + project = os.environ.get("AVALON_PROJECT") + anatomy = Anatomy(project) + root = anatomy.roots['renders'] + except: + raise("Could not find render root in anatomy settings.") + + render_dir = f"{root}/{project}/{s.get('output')}" render_path = Path(render_dir) frames = [] From 6120efc591549b466f28ce4438a7686d60074a10 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Thu, 28 Apr 2022 10:36:42 +0100 Subject: [PATCH 134/583] Fix animation loading to find the correct skeleton --- .../unreal/plugins/load/load_animation.py | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/unreal/plugins/load/load_animation.py b/openpype/hosts/unreal/plugins/load/load_animation.py index dc1337ed5d..60c1526d3d 100644 --- a/openpype/hosts/unreal/plugins/load/load_animation.py +++ b/openpype/hosts/unreal/plugins/load/load_animation.py @@ -134,7 +134,6 @@ class AnimationFBXLoader(plugin.Loader): Returns: list(str): list of container content """ - # Create directory for asset and avalon container hierarchy = context.get('asset').get('data').get('parents') root = "/Game/OpenPype" @@ -149,11 +148,30 @@ class AnimationFBXLoader(plugin.Loader): asset_dir, container_name = tools.create_unique_asset_name( f"{root}/Animations/{asset}/{name}", suffix="") + ar = unreal.AssetRegistryHelpers.get_asset_registry() + + filter = unreal.ARFilter( + class_names=["World"], + package_paths=[f"{root}/{hierarchy[0]}"], + recursive_paths=False) + levels = ar.get_assets(filter) + master_level = levels[0].get_editor_property('object_path') + hierarchy_dir = root for h in hierarchy: hierarchy_dir = f"{hierarchy_dir}/{h}" hierarchy_dir = f"{hierarchy_dir}/{asset}" + filter = unreal.ARFilter( + class_names=["World"], + package_paths=[f"{hierarchy_dir}/"], + recursive_paths=True) + levels = ar.get_assets(filter) + level = levels[0].get_editor_property('object_path') + + unreal.EditorLevelLibrary.save_all_dirty_levels() + unreal.EditorLevelLibrary.load_level(level) + container_name += suffix EditorAssetLibrary.make_directory(asset_dir) @@ -224,6 +242,9 @@ class AnimationFBXLoader(plugin.Loader): for a in imported_content: EditorAssetLibrary.save_asset(a) + unreal.EditorLevelLibrary.save_current_level() + unreal.EditorLevelLibrary.load_level(master_level) + def update(self, container, representation): name = container["asset_name"] source_path = get_representation_path(representation) From af3c8d01fd60d3bec0e2ffdecfa062cd5f25b700 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Thu, 28 Apr 2022 10:59:34 +0100 Subject: [PATCH 135/583] Fix raise calls --- openpype/hosts/unreal/api/rendering.py | 2 +- openpype/hosts/unreal/plugins/load/load_camera.py | 4 ++-- .../hosts/unreal/plugins/publish/collect_render_instances.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/unreal/api/rendering.py b/openpype/hosts/unreal/api/rendering.py index dadfe77f45..6d78ba1e33 100644 --- a/openpype/hosts/unreal/api/rendering.py +++ b/openpype/hosts/unreal/api/rendering.py @@ -54,7 +54,7 @@ def start_rendering(): anatomy = Anatomy(project) root = anatomy.roots['renders'] except: - raise("Could not find render root in anatomy settings.") + raise Exception("Could not find render root in anatomy settings.") render_dir = f"{root}/{project}" diff --git a/openpype/hosts/unreal/plugins/load/load_camera.py b/openpype/hosts/unreal/plugins/load/load_camera.py index 9ae6e6ec03..b33e45b6e9 100644 --- a/openpype/hosts/unreal/plugins/load/load_camera.py +++ b/openpype/hosts/unreal/plugins/load/load_camera.py @@ -425,7 +425,7 @@ class CameraLoader(plugin.Loader): sequences = ar.get_assets(filter) if not sequences: - raise("Could not find sequence.") + raise Exception("Could not find sequence.") world = ar.get_asset_by_object_path( EditorLevelLibrary.get_editor_world().get_path_name()) @@ -438,7 +438,7 @@ class CameraLoader(plugin.Loader): # There should be only one map in the list if not maps: - raise("Could not find map.") + raise Exception("Could not find map.") map = maps[0] diff --git a/openpype/hosts/unreal/plugins/publish/collect_render_instances.py b/openpype/hosts/unreal/plugins/publish/collect_render_instances.py index f6a63c7ba7..c37d5a5c01 100644 --- a/openpype/hosts/unreal/plugins/publish/collect_render_instances.py +++ b/openpype/hosts/unreal/plugins/publish/collect_render_instances.py @@ -85,7 +85,7 @@ class CollectRenderInstances(pyblish.api.InstancePlugin): anatomy = Anatomy(project) root = anatomy.roots['renders'] except: - raise("Could not find render root in anatomy settings.") + raise Exception("Could not find render root in anatomy settings.") render_dir = f"{root}/{project}/{s.get('output')}" render_path = Path(render_dir) From 77bac5c735ec2aee0f101f0267dc72b8635ad4b2 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 28 Apr 2022 20:20:25 +0200 Subject: [PATCH 136/583] nuke: fix `read` and rename to `read_avalon_data` --- openpype/hosts/nuke/api/lib.py | 22 ++++++++++------------ openpype/hosts/nuke/api/pipeline.py | 4 ++-- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 4e38f811c9..bd39a1f0a8 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -400,7 +400,7 @@ def add_write_node(name, **kwarg): return w -def read(node): +def read_avalon_data(node): """Return user-defined knobs from given `node` Args: @@ -415,8 +415,6 @@ def read(node): return knob_name[len("avalon:"):] elif knob_name.startswith("ak:"): return knob_name[len("ak:"):] - else: - return knob_name data = dict() @@ -445,7 +443,8 @@ def read(node): (knob_type == 26 and value) ): key = compat_prefixed(knob_name) - data[key] = value + if key is not None: + data[key] = value if knob_name == first_user_knob: break @@ -567,7 +566,7 @@ def check_inventory_versions(): if container: node = nuke.toNode(container["objectName"]) - avalon_knob_data = read(node) + avalon_knob_data = read_avalon_data(node) # get representation from io representation = legacy_io.find_one({ @@ -623,7 +622,7 @@ def writes_version_sync(): if _NODE_TAB_NAME not in each.knobs(): continue - avalon_knob_data = read(each) + avalon_knob_data = read_avalon_data(each) try: if avalon_knob_data['families'] not in ["render"]: @@ -665,14 +664,14 @@ def check_subsetname_exists(nodes, subset_name): bool: True of False """ return next((True for n in nodes - if subset_name in read(n).get("subset", "")), + if subset_name in read_avalon_data(n).get("subset", "")), False) def get_render_path(node): ''' Generate Render path from presets regarding avalon knob data ''' - data = {'avalon': read(node)} + data = {'avalon': read_avalon_data(node)} data_preset = { "nodeclass": data['avalon']['family'], "families": [data['avalon']['families']], @@ -1293,7 +1292,7 @@ class WorkfileSettings(object): for node in nuke.allNodes(filter="Group"): # get data from avalon knob - avalon_knob_data = read(node) + avalon_knob_data = read_avalon_data(node) if not avalon_knob_data: continue @@ -1342,7 +1341,6 @@ class WorkfileSettings(object): write_node[knob["name"]].setValue(value) - def set_reads_colorspace(self, read_clrs_inputs): """ Setting colorspace to Read nodes @@ -1630,8 +1628,8 @@ def get_write_node_template_attr(node): ''' # get avalon data from node - data = dict() - data['avalon'] = read(node) + data = {"avalon": read_avalon_data(node)} + data_preset = { "nodeclass": data['avalon']['family'], "families": [data['avalon']['families']], diff --git a/openpype/hosts/nuke/api/pipeline.py b/openpype/hosts/nuke/api/pipeline.py index 0194acd196..2785eb65cd 100644 --- a/openpype/hosts/nuke/api/pipeline.py +++ b/openpype/hosts/nuke/api/pipeline.py @@ -32,7 +32,7 @@ from .lib import ( launch_workfiles_app, check_inventory_versions, set_avalon_knob_data, - read, + read_avalon_data, Context ) @@ -359,7 +359,7 @@ def parse_container(node): dict: The container schema data for this container node. """ - data = read(node) + data = read_avalon_data(node) # (TODO) Remove key validation when `ls` has re-implemented. # From 2d3a4541c99c083565d56bcf6533d3c24099dce8 Mon Sep 17 00:00:00 2001 From: Milan Kolar Date: Thu, 28 Apr 2022 20:29:28 +0200 Subject: [PATCH 137/583] remove submodules after accidentally merging them back with PR --- openpype/modules/default_modules/ftrack/python2_vendor/arrow | 1 - .../default_modules/ftrack/python2_vendor/ftrack-python-api | 1 - repos/avalon-core | 1 - repos/avalon-unreal-integration | 1 - 4 files changed, 4 deletions(-) delete mode 160000 openpype/modules/default_modules/ftrack/python2_vendor/arrow delete mode 160000 openpype/modules/default_modules/ftrack/python2_vendor/ftrack-python-api delete mode 160000 repos/avalon-core delete mode 160000 repos/avalon-unreal-integration diff --git a/openpype/modules/default_modules/ftrack/python2_vendor/arrow b/openpype/modules/default_modules/ftrack/python2_vendor/arrow deleted file mode 160000 index b746fedf72..0000000000 --- a/openpype/modules/default_modules/ftrack/python2_vendor/arrow +++ /dev/null @@ -1 +0,0 @@ -Subproject commit b746fedf7286c3755a46f07ab72f4c414cd41fc0 diff --git a/openpype/modules/default_modules/ftrack/python2_vendor/ftrack-python-api b/openpype/modules/default_modules/ftrack/python2_vendor/ftrack-python-api deleted file mode 160000 index d277f474ab..0000000000 --- a/openpype/modules/default_modules/ftrack/python2_vendor/ftrack-python-api +++ /dev/null @@ -1 +0,0 @@ -Subproject commit d277f474ab016e7b53479c36af87cb861d0cc53e diff --git a/repos/avalon-core b/repos/avalon-core deleted file mode 160000 index 64491fbbcf..0000000000 --- a/repos/avalon-core +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 64491fbbcf89ba2a0b3a20d67d7486c6142232b3 diff --git a/repos/avalon-unreal-integration b/repos/avalon-unreal-integration deleted file mode 160000 index 43f6ea9439..0000000000 --- a/repos/avalon-unreal-integration +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 43f6ea943980b29c02a170942b566ae11f2b7080 From fbdb06a9ac542831ed2da5e1cb5361acf62bc938 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 28 Apr 2022 20:34:57 +0200 Subject: [PATCH 138/583] nuke: code consistency - replacing single quotations with double - improving code --- openpype/hosts/nuke/api/lib.py | 86 ++++++++++++++++--------------- openpype/hosts/nuke/api/plugin.py | 2 - 2 files changed, 45 insertions(+), 43 deletions(-) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index bd39a1f0a8..77945c6fec 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -541,7 +541,7 @@ def get_imageio_input_colorspace(filename): def on_script_load(): ''' Callback for ffmpeg support ''' - if nuke.env['LINUX']: + if nuke.env["LINUX"]: nuke.tcl('load ffmpegReader') nuke.tcl('load ffmpegWriter') else: @@ -592,7 +592,7 @@ def check_inventory_versions(): versions = legacy_io.find({ "type": "version", "parent": version["parent"] - }).distinct('name') + }).distinct("name") max_version = max(versions) @@ -625,17 +625,17 @@ def writes_version_sync(): avalon_knob_data = read_avalon_data(each) try: - if avalon_knob_data['families'] not in ["render"]: - log.debug(avalon_knob_data['families']) + if avalon_knob_data["families"] not in ["render"]: + log.debug(avalon_knob_data["families"]) continue - node_file = each['file'].value() + node_file = each["file"].value() node_version = "v" + get_version_from_path(node_file) log.debug("node_version: {}".format(node_version)) node_new_file = node_file.replace(node_version, new_version) - each['file'].setValue(node_new_file) + each["file"].setValue(node_new_file) if not os.path.isdir(os.path.dirname(node_new_file)): log.warning("Path does not exist! I am creating it.") os.makedirs(os.path.dirname(node_new_file)) @@ -673,9 +673,9 @@ def get_render_path(node): ''' data = {'avalon': read_avalon_data(node)} data_preset = { - "nodeclass": data['avalon']['family'], - "families": [data['avalon']['families']], - "creator": data['avalon']['creator'] + "nodeclass": data["avalon"]["family"], + "families": [data["avalon"]["families"]], + "creator": data["avalon"]["creator"], } nuke_imageio_writes = get_created_node_imageio_setting(**data_preset) @@ -748,7 +748,7 @@ def format_anatomy(data): def script_name(): ''' Returns nuke script path ''' - return nuke.root().knob('name').value() + return nuke.root().knob("name").value() def add_button_write_to_read(node): @@ -843,7 +843,7 @@ def create_write_node(name, data, input=None, prenodes=None, # adding dataflow template log.debug("imageio_writes: `{}`".format(imageio_writes)) for knob in imageio_writes["knobs"]: - _data.update({knob["name"]: knob["value"]}) + _data[knob["name"]] = knob["value"] _data = fix_data_for_node_create(_data) @@ -1192,15 +1192,19 @@ class WorkfileSettings(object): erased_viewers = [] for v in nuke.allNodes(filter="Viewer"): - v['viewerProcess'].setValue(str(viewer_dict["viewerProcess"])) + # set viewProcess to preset from settings + v["viewerProcess"].setValue( + str(viewer_dict["viewerProcess"]) + ) + if str(viewer_dict["viewerProcess"]) \ - not in v['viewerProcess'].value(): + not in v["viewerProcess"].value(): copy_inputs = v.dependencies() copy_knobs = {k: v[k].value() for k in v.knobs() if k not in filter_knobs} # delete viewer with wrong settings - erased_viewers.append(v['name'].value()) + erased_viewers.append(v["name"].value()) nuke.delete(v) # create new viewer @@ -1216,7 +1220,7 @@ class WorkfileSettings(object): nv[k].setValue(v) # set viewerProcess - nv['viewerProcess'].setValue(str(viewer_dict["viewerProcess"])) + nv["viewerProcess"].setValue(str(viewer_dict["viewerProcess"])) if erased_viewers: log.warning( @@ -1308,7 +1312,7 @@ class WorkfileSettings(object): data_preset = { "nodeclass": avalon_knob_data["family"], "families": families, - "creator": avalon_knob_data['creator'] + "creator": avalon_knob_data["creator"], } nuke_imageio_writes = get_created_node_imageio_setting( @@ -1366,17 +1370,16 @@ class WorkfileSettings(object): current = n["colorspace"].value() future = str(preset_clrsp) if current != future: - changes.update({ - n.name(): { - "from": current, - "to": future - } - }) + changes[n.name()] = { + "from": current, + "to": future + } + log.debug(changes) if changes: msg = "Read nodes are not set to correct colospace:\n\n" for nname, knobs in changes.items(): - msg += str( + msg += ( " - node: '{0}' is now '{1}' but should be '{2}'\n" ).format(nname, knobs["from"], knobs["to"]) @@ -1608,17 +1611,17 @@ def get_hierarchical_attr(entity, attr, default=None): if not value: break - if value or entity['type'].lower() == 'project': + if value or entity["type"].lower() == "project": return value - parent_id = entity['parent'] + parent_id = entity["parent"] if ( - entity['type'].lower() == 'asset' - and entity.get('data', {}).get('visualParent') + entity["type"].lower() == "asset" + and entity.get("data", {}).get("visualParent") ): - parent_id = entity['data']['visualParent'] + parent_id = entity["data"]["visualParent"] - parent = legacy_io.find_one({'_id': parent_id}) + parent = legacy_io.find_one({"_id": parent_id}) return get_hierarchical_attr(parent, attr) @@ -1631,9 +1634,9 @@ def get_write_node_template_attr(node): data = {"avalon": read_avalon_data(node)} data_preset = { - "nodeclass": data['avalon']['family'], - "families": [data['avalon']['families']], - "creator": data['avalon']['creator'] + "nodeclass": data["avalon"]["family"], + "families": [data["avalon"]["families"]], + "creator": data["avalon"]["creator"], } # get template data @@ -1644,10 +1647,11 @@ def get_write_node_template_attr(node): "file": get_render_path(node) }) - # adding imageio template - {correct_data.update({k: v}) - for k, v in nuke_imageio_writes.items() - if k not in ["_id", "_previous"]} + # adding imageio knob presets + for k, v in nuke_imageio_writes.items(): + if k in ["_id", "_previous"]: + continue + correct_data[k] = v # fix badly encoded data return fix_data_for_node_create(correct_data) @@ -1763,8 +1767,8 @@ def maintained_selection(): Example: >>> with maintained_selection(): - ... node['selected'].setValue(True) - >>> print(node['selected'].value()) + ... node["selected"].setValue(True) + >>> print(node["selected"].value()) False """ previous_selection = nuke.selectedNodes() @@ -1772,11 +1776,11 @@ def maintained_selection(): yield finally: # unselect all selection in case there is some - current_seletion = nuke.selectedNodes() - [n['selected'].setValue(False) for n in current_seletion] + reset_selection() + # and select all previously selected nodes if previous_selection: - [n['selected'].setValue(True) for n in previous_selection] + select_nodes(previous_selection) def reset_selection(): diff --git a/openpype/hosts/nuke/api/plugin.py b/openpype/hosts/nuke/api/plugin.py index eaf0ab6911..9c22edf63d 100644 --- a/openpype/hosts/nuke/api/plugin.py +++ b/openpype/hosts/nuke/api/plugin.py @@ -260,8 +260,6 @@ class ExporterReview(object): return nuke_imageio["viewer"]["viewerProcess"] - - class ExporterReviewLut(ExporterReview): """ Generator object for review lut from Nuke From 162b21b61e88b26a7a82830bc5db7c8c696f0249 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 28 Apr 2022 20:36:18 +0200 Subject: [PATCH 139/583] nuke: typo in subset (sebset) --- openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py | 2 +- openpype/settings/defaults/project_settings/nuke.json | 2 +- .../schemas/projects_schema/schemas/schema_anatomy_imageio.json | 2 +- .../schemas/projects_schema/schemas/schema_nuke_publish.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py b/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py index 2e8843d2e0..2a79d600ba 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py +++ b/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py @@ -52,7 +52,7 @@ class ExtractReviewDataMov(openpype.api.Extractor): for o_name, o_data in self.outputs.items(): f_families = o_data["filter"]["families"] f_task_types = o_data["filter"]["task_types"] - f_subsets = o_data["filter"]["sebsets"] + f_subsets = o_data["filter"]["subsets"] self.log.debug( "f_families `{}` > families: {}".format( diff --git a/openpype/settings/defaults/project_settings/nuke.json b/openpype/settings/defaults/project_settings/nuke.json index ab015271ff..ddf996b5f2 100644 --- a/openpype/settings/defaults/project_settings/nuke.json +++ b/openpype/settings/defaults/project_settings/nuke.json @@ -120,7 +120,7 @@ "filter": { "task_types": [], "families": [], - "sebsets": [] + "subsets": [] }, "read_raw": false, "viewer_process_override": "", diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_imageio.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_imageio.json index 141b51da0a..c90eeef787 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_imageio.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_imageio.json @@ -327,7 +327,7 @@ "label": "Nuke Node Class" }, { - "key": "sebsets", + "key": "subsets", "label": "Subsets", "type": "list", "object_type": "text" diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json index 4a796f1933..d67fb309bd 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json @@ -212,7 +212,7 @@ "object_type": "text" }, { - "key": "sebsets", + "key": "subsets", "label": "Subsets", "type": "list", "object_type": "text" From 5546ea2fa7bce5db07b7cb27a430d503bf62762c Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 28 Apr 2022 20:37:34 +0200 Subject: [PATCH 140/583] nuke: including `overrideNodes` keys also adding subset to filtering kwargs --- openpype/hosts/nuke/api/lib.py | 63 ++++++++++++++++++++++++++++--- openpype/hosts/nuke/api/plugin.py | 3 +- 2 files changed, 59 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 77945c6fec..3745bd7be7 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -506,20 +506,68 @@ def get_created_node_imageio_setting(**kwarg): log.debug(kwarg) nodeclass = kwarg.get("nodeclass", None) creator = kwarg.get("creator", None) + subset = kwarg.get("subset", None) assert any([creator, nodeclass]), nuke.message( "`{}`: Missing mandatory kwargs `host`, `cls`".format(__file__)) - imageio_nodes = get_nuke_imageio_settings()["nodes"]["requiredNodes"] + imageio_nodes = get_nuke_imageio_settings()["nodes"] + required_nodes = imageio_nodes["requiredNodes"] + override_nodes = imageio_nodes["overrideNodes"] imageio_node = None - for node in imageio_nodes: + for node in required_nodes: log.info(node) - if (nodeclass in node["nukeNodeClass"]) and ( - creator in node["plugins"]): + if ( + nodeclass in node["nukeNodeClass"] + and creator in node["plugins"] + ): imageio_node = node break + log.debug("__ imageio_node: {}".format(imageio_node)) + + # find matching override node + override_imageio_node = None + for onode in override_nodes: + log.info(onode) + if nodeclass not in node["nukeNodeClass"]: + continue + + if creator not in node["plugins"]: + continue + + if ( + onode["subsets"] + and not any(re.search(s, subset) for s in onode["subsets"]) + ): + continue + + override_imageio_node = onode + break + + log.debug("__ override_imageio_node: {}".format(override_imageio_node)) + # add overrides to imageio_node + if override_imageio_node: + # get all knob names in imageio_node + knob_names = [k["name"] for k in imageio_node["knobs"]] + + for oknob in override_imageio_node["knobs"]: + for knob in imageio_node["knobs"]: + # override matching knob name + if oknob["name"] == knob["name"]: + log.debug( + "_ overriding knob: `{}` > `{}`".format( + knob, oknob + )) + knob["value"] = oknob["value"] + # add missing knobs into imageio_node + if oknob["name"] not in knob_names: + log.debug( + "_ adding knob: `{}`".format(oknob)) + imageio_node["knobs"].append(oknob) + knob_names.append(oknob["name"]) + log.info("ImageIO node: {}".format(imageio_node)) return imageio_node @@ -676,6 +724,7 @@ def get_render_path(node): "nodeclass": data["avalon"]["family"], "families": [data["avalon"]["families"]], "creator": data["avalon"]["creator"], + "subset": data["avalon"]["subset"] } nuke_imageio_writes = get_created_node_imageio_setting(**data_preset) @@ -1298,10 +1347,10 @@ class WorkfileSettings(object): # get data from avalon knob avalon_knob_data = read_avalon_data(node) - if not avalon_knob_data: + if avalon_knob_data.get("id") != "pyblish.avalon.instance": continue - if avalon_knob_data["id"] != "pyblish.avalon.instance": + if "creator" not in avalon_knob_data: continue # establish families @@ -1313,6 +1362,7 @@ class WorkfileSettings(object): "nodeclass": avalon_knob_data["family"], "families": families, "creator": avalon_knob_data["creator"], + "subset": avalon_knob_data["subset"] } nuke_imageio_writes = get_created_node_imageio_setting( @@ -1637,6 +1687,7 @@ def get_write_node_template_attr(node): "nodeclass": data["avalon"]["family"], "families": [data["avalon"]["families"]], "creator": data["avalon"]["creator"], + "subset": data["avalon"]["subset"] } # get template data diff --git a/openpype/hosts/nuke/api/plugin.py b/openpype/hosts/nuke/api/plugin.py index 9c22edf63d..fdb5930cb2 100644 --- a/openpype/hosts/nuke/api/plugin.py +++ b/openpype/hosts/nuke/api/plugin.py @@ -671,7 +671,8 @@ class AbstractWriteRender(OpenPypeCreator): write_data = { "nodeclass": self.n_class, "families": [self.family], - "avalon": self.data + "avalon": self.data, + "subset": self.data["subset"] } # add creator data From 1765f25ecd3410fffd6ae9a35da96374ecb67abb Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 28 Apr 2022 20:48:46 +0200 Subject: [PATCH 141/583] nuke: removing knob preset if no value in override --- openpype/hosts/nuke/api/lib.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 3745bd7be7..3223feaec7 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -560,7 +560,13 @@ def get_created_node_imageio_setting(**kwarg): "_ overriding knob: `{}` > `{}`".format( knob, oknob )) - knob["value"] = oknob["value"] + if not oknob["value"]: + # remove original knob if no value found in oknob + imageio_node["knobs"].remove(knob) + else: + # override knob value with oknob's + knob["value"] = oknob["value"] + # add missing knobs into imageio_node if oknob["name"] not in knob_names: log.debug( From 6d411d2617273de9c27d69efab0426c386d91316 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 29 Apr 2022 10:09:09 +0200 Subject: [PATCH 142/583] added missing detailed description --- openpype/hosts/traypublisher/api/plugin.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/hosts/traypublisher/api/plugin.py b/openpype/hosts/traypublisher/api/plugin.py index 731bf7918a..813641a7d2 100644 --- a/openpype/hosts/traypublisher/api/plugin.py +++ b/openpype/hosts/traypublisher/api/plugin.py @@ -96,6 +96,7 @@ class SettingsCreator(TrayPublishCreator): "label": item_data["label"].strip(), "icon": item_data["icon"], "description": item_data["description"], + "detailed_description": item_data["detailed_description"], "enable_review": item_data["enable_review"], "extensions": item_data["extensions"], "allow_sequences": item_data["allow_sequences"], From 7a6602a4d5c84aa0b996fe1bcdd9d516b42bbebd Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 29 Apr 2022 10:11:09 +0200 Subject: [PATCH 143/583] added tray publisher to extract review/burnin plugins --- openpype/plugins/publish/extract_burnin.py | 1 + openpype/plugins/publish/extract_review.py | 1 + 2 files changed, 2 insertions(+) diff --git a/openpype/plugins/publish/extract_burnin.py b/openpype/plugins/publish/extract_burnin.py index 544c763b52..88093fb92f 100644 --- a/openpype/plugins/publish/extract_burnin.py +++ b/openpype/plugins/publish/extract_burnin.py @@ -41,6 +41,7 @@ class ExtractBurnin(openpype.api.Extractor): "shell", "hiero", "premiere", + "traypublisher", "standalonepublisher", "harmony", "fusion", diff --git a/openpype/plugins/publish/extract_review.py b/openpype/plugins/publish/extract_review.py index 9ee57c5a67..879125dac3 100644 --- a/openpype/plugins/publish/extract_review.py +++ b/openpype/plugins/publish/extract_review.py @@ -45,6 +45,7 @@ class ExtractReview(pyblish.api.InstancePlugin): "hiero", "premiere", "harmony", + "traypublisher", "standalonepublisher", "fusion", "tvpaint", From b2b4b3cb9cbe1beefbec4defd2b526650a71abdc Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 29 Apr 2022 10:16:13 +0200 Subject: [PATCH 144/583] fixed drop of multiple files for single file item input --- openpype/widgets/attribute_defs/files_widget.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/openpype/widgets/attribute_defs/files_widget.py b/openpype/widgets/attribute_defs/files_widget.py index c76474d957..a3ee370bd3 100644 --- a/openpype/widgets/attribute_defs/files_widget.py +++ b/openpype/widgets/attribute_defs/files_widget.py @@ -205,6 +205,18 @@ class FilesProxyModel(QtCore.QSortFilterProxyModel): return True return False + def filter_valid_files(self, filepaths): + filtered_paths = [] + for filepath in filepaths: + if os.path.isfile(filepath): + _, ext = os.path.splitext(filepath) + if ext in self._allowed_extensions: + filtered_paths.append(filepath) + + elif self._allow_folders: + filtered_paths.append(filepath) + return filtered_paths + def filterAcceptsRow(self, row, parent_index): # Skip filtering if multivalue is set if self._multivalue: @@ -617,6 +629,9 @@ class FilesWidget(QtWidgets.QFrame): filepath = url.toLocalFile() if os.path.exists(filepath): filepaths.append(filepath) + + # Filter filepaths before passing it to model + filepaths = self._files_proxy_model.filter_valid_files(filepaths) if filepaths: self._add_filepaths(filepaths) event.accept() From fe128ff4728f7a675d6e15700fd2faeecf5b0647 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 29 Apr 2022 10:27:16 +0200 Subject: [PATCH 145/583] added tooltip to disabled create button --- openpype/tools/publisher/widgets/create_dialog.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/openpype/tools/publisher/widgets/create_dialog.py b/openpype/tools/publisher/widgets/create_dialog.py index 971799a35a..243540f243 100644 --- a/openpype/tools/publisher/widgets/create_dialog.py +++ b/openpype/tools/publisher/widgets/create_dialog.py @@ -467,12 +467,15 @@ class CreateDialog(QtWidgets.QDialog): def _on_prereq_timer(self): prereq_available = True + creator_btn_tooltips = [] if self.creators_model.rowCount() < 1: prereq_available = False + creator_btn_tooltips.append("Creator is not selected") if self._asset_doc is None: # QUESTION how to handle invalid asset? prereq_available = False + creator_btn_tooltips.append("Context is not selected") if prereq_available != self._prereq_available: self._prereq_available = prereq_available @@ -481,6 +484,12 @@ class CreateDialog(QtWidgets.QDialog): self.creators_view.setEnabled(prereq_available) self.variant_input.setEnabled(prereq_available) self.variant_hints_btn.setEnabled(prereq_available) + + tooltip = "" + if creator_btn_tooltips: + tooltip = "\n".join(creator_btn_tooltips) + self.create_btn.setToolTip(tooltip) + self._on_variant_change() def _refresh_asset(self): From 42399403810f4ddabc28ce4c1c82a7e1a402aa80 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 29 Apr 2022 10:38:51 +0200 Subject: [PATCH 146/583] fixed import --- openpype/lib/__init__.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/openpype/lib/__init__.py b/openpype/lib/__init__.py index 29719b63bd..d19dacaff8 100644 --- a/openpype/lib/__init__.py +++ b/openpype/lib/__init__.py @@ -47,7 +47,6 @@ from .attribute_definitions import ( from .env_tools import ( env_value_to_bool, get_paths_from_environ, - get_global_environments ) from .terminal import Terminal @@ -248,7 +247,6 @@ __all__ = [ "env_value_to_bool", "get_paths_from_environ", - "get_global_environments", "get_vendor_bin_path", "get_oiio_tools_path", From 523a52c92f273a0ec24c256a5f2afaf911c86fd2 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Fri, 29 Apr 2022 09:43:50 +0100 Subject: [PATCH 147/583] Hound fixes --- openpype/hosts/unreal/api/rendering.py | 2 +- openpype/hosts/unreal/plugins/load/load_layout.py | 3 ++- .../hosts/unreal/plugins/publish/collect_render_instances.py | 5 +++-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/unreal/api/rendering.py b/openpype/hosts/unreal/api/rendering.py index 6d78ba1e33..b2732506fc 100644 --- a/openpype/hosts/unreal/api/rendering.py +++ b/openpype/hosts/unreal/api/rendering.py @@ -53,7 +53,7 @@ def start_rendering(): project = os.environ.get("AVALON_PROJECT") anatomy = Anatomy(project) root = anatomy.roots['renders'] - except: + except Exception: raise Exception("Could not find render root in anatomy settings.") render_dir = f"{root}/{project}" diff --git a/openpype/hosts/unreal/plugins/load/load_layout.py b/openpype/hosts/unreal/plugins/load/load_layout.py index 163dd1a8af..412f77e3a9 100644 --- a/openpype/hosts/unreal/plugins/load/load_layout.py +++ b/openpype/hosts/unreal/plugins/load/load_layout.py @@ -853,7 +853,8 @@ class LayoutLoader(plugin.Loader): if visibility_track: sections = visibility_track.get_sections() for ss in sections: - if unreal.Name(f"{container.get('asset')}_map") in ss.get_level_names(): + if (unreal.Name(f"{container.get('asset')}_map") + in ss.get_level_names()): visibility_track.remove_section(ss) # Update visibility sections indexes. i = -1 diff --git a/openpype/hosts/unreal/plugins/publish/collect_render_instances.py b/openpype/hosts/unreal/plugins/publish/collect_render_instances.py index c37d5a5c01..9fb45ea7a7 100644 --- a/openpype/hosts/unreal/plugins/publish/collect_render_instances.py +++ b/openpype/hosts/unreal/plugins/publish/collect_render_instances.py @@ -84,8 +84,9 @@ class CollectRenderInstances(pyblish.api.InstancePlugin): project = os.environ.get("AVALON_PROJECT") anatomy = Anatomy(project) root = anatomy.roots['renders'] - except: - raise Exception("Could not find render root in anatomy settings.") + except Exception: + raise Exception( + "Could not find render root in anatomy settings.") render_dir = f"{root}/{project}/{s.get('output')}" render_path = Path(render_dir) From 5f7524bb1cd542d49ccd60c538d78925596cf111 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 29 Apr 2022 10:46:12 +0200 Subject: [PATCH 148/583] hiero: better subset name --- .../hosts/hiero/plugins/publish/collect_frame_tag_instances.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/hiero/plugins/publish/collect_frame_tag_instances.py b/openpype/hosts/hiero/plugins/publish/collect_frame_tag_instances.py index 80a54ba2c5..982a34efd6 100644 --- a/openpype/hosts/hiero/plugins/publish/collect_frame_tag_instances.py +++ b/openpype/hosts/hiero/plugins/publish/collect_frame_tag_instances.py @@ -129,7 +129,7 @@ class CollectFrameTagInstances(pyblish.api.ContextPlugin): "family": "image", "families": ["frame"], "asset": subset_data["asset"], - "subset": subset_name, + "subset": name, "format": subset_data["format"], "frames": subset_data["frames"] } From 05cc463ed9064ab81a993f2dd0a078a0fff67b2f Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 29 Apr 2022 11:25:23 +0200 Subject: [PATCH 149/583] flame: addressing pr comment --- .../hosts/flame/plugins/publish/extract_subset_resources.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py index 4598405923..fd0ece2590 100644 --- a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py +++ b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py @@ -236,8 +236,8 @@ class ExtractSubsetResources(openpype.api.Extractor): # define kwargs based on preset type if "thumbnail" in unique_name: - export_kwargs["thumb_frame_number"] = in_mark + ( - source_duration_handles / 2) + export_kwargs["thumb_frame_number"] = int(in_mark + ( + source_duration_handles / 2)) else: export_kwargs.update({ "in_mark": in_mark, From fb2327ad3b6a71278def4632de287b16560b7142 Mon Sep 17 00:00:00 2001 From: jrsndlr Date: Fri, 29 Apr 2022 13:25:23 +0200 Subject: [PATCH 150/583] concat timecode fix --- .../plugins/publish/extract_review_slate.py | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/openpype/plugins/publish/extract_review_slate.py b/openpype/plugins/publish/extract_review_slate.py index 13526ece66..71a1fccf53 100644 --- a/openpype/plugins/publish/extract_review_slate.py +++ b/openpype/plugins/publish/extract_review_slate.py @@ -213,7 +213,9 @@ class ExtractReviewSlate(openpype.api.Extractor): input_args.extend(["-r {}".format(input_frame_rate)]) input_args.extend(["-frames:v 1"]) # add timecode from source to the slate, substract one frame + offset_timecode = "00:00:00:00" if input_timecode: + offset_timecode = str(input_timecode) offset_timecode = self._tc_offset( str(input_timecode), framerate=fps, @@ -222,11 +224,7 @@ class ExtractReviewSlate(openpype.api.Extractor): self.log.debug("Slate Timecode: `{}`".format( offset_timecode )) - if offset_timecode: - input_args.extend(["-timecode {}".format(offset_timecode)]) - else: - # fall back to input timecode if offset fails - input_args.extend(["-timecode {}".format(input_timecode)]) + input_args.extend(["-timecode {}".format(offset_timecode)]) if use_legacy_code: codec_args = repre["_profile"].get('codec', []) output_args.extend(codec_args) @@ -283,11 +281,12 @@ class ExtractReviewSlate(openpype.api.Extractor): width_scale, height_scale, to_width, to_height, width_half_pad, height_half_pad ) - # add output frame rate as a filter, just in case - scaling_arg += ",fps={}".format(input_frame_rate) - vf_back = self.add_video_filter_args(output_args, scaling_arg) - # add it to output_args - output_args.insert(0, vf_back) + # add output frame rate as a filter, just in case + scaling_arg += ",fps={}".format(input_frame_rate) + + vf_back = self.add_video_filter_args(output_args, scaling_arg) + # add it to output_args + output_args.insert(0, vf_back) # overrides output file output_args.append("-y") @@ -336,7 +335,8 @@ class ExtractReviewSlate(openpype.api.Extractor): "-f", "concat", "-safe", "0", "-i", conc_text_path, - "-c:v", "copy", + "-c", "copy", + "-timecode", offset_timecode, output_path ] if not input_audio: From 5d8cea50461e9190ca1838fca55779235010333a Mon Sep 17 00:00:00 2001 From: jrsndlr Date: Fri, 29 Apr 2022 13:27:14 +0200 Subject: [PATCH 151/583] hound --- openpype/plugins/publish/extract_review_slate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/plugins/publish/extract_review_slate.py b/openpype/plugins/publish/extract_review_slate.py index 71a1fccf53..46bd4f3a7b 100644 --- a/openpype/plugins/publish/extract_review_slate.py +++ b/openpype/plugins/publish/extract_review_slate.py @@ -336,7 +336,7 @@ class ExtractReviewSlate(openpype.api.Extractor): "-safe", "0", "-i", conc_text_path, "-c", "copy", - "-timecode", offset_timecode, + "-timecode", offset_timecode, output_path ] if not input_audio: From 10fd26e086a2b4f6ede4e1a7fbd901d7fdb34cf3 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 29 Apr 2022 14:11:47 +0200 Subject: [PATCH 152/583] moved enable review from creator into publish collector --- openpype/hosts/traypublisher/api/plugin.py | 29 ++++++----------- .../plugins/publish/collect_review_family.py | 31 +++++++++++++++++++ .../publish/collect_simple_instances.py | 9 +++--- .../project_settings/traypublisher.json | 1 - .../schema_project_traypublisher.json | 6 ---- 5 files changed, 46 insertions(+), 30 deletions(-) create mode 100644 openpype/hosts/traypublisher/plugins/publish/collect_review_family.py diff --git a/openpype/hosts/traypublisher/api/plugin.py b/openpype/hosts/traypublisher/api/plugin.py index 813641a7d2..603f34ee29 100644 --- a/openpype/hosts/traypublisher/api/plugin.py +++ b/openpype/hosts/traypublisher/api/plugin.py @@ -2,10 +2,7 @@ from openpype.pipeline import ( Creator, CreatedInstance ) -from openpype.lib import ( - FileDef, - BoolDef, -) +from openpype.lib import FileDef from .pipeline import ( list_instances, @@ -43,7 +40,6 @@ class TrayPublishCreator(Creator): class SettingsCreator(TrayPublishCreator): create_allow_context_change = True - enable_review = False extensions = [] def collect_instances(self): @@ -67,19 +63,15 @@ class SettingsCreator(TrayPublishCreator): self._add_instance_to_context(new_instance) def get_instance_attr_defs(self): - output = [] - - file_def = FileDef( - "filepath", - folders=False, - extensions=self.extensions, - allow_sequences=self.allow_sequences, - label="Filepath", - ) - output.append(file_def) - if self.enable_review: - output.append(BoolDef("review", label="Review")) - return output + return [ + FileDef( + "filepath", + folders=False, + extensions=self.extensions, + allow_sequences=self.allow_sequences, + label="Filepath", + ) + ] @classmethod def from_settings(cls, item_data): @@ -97,7 +89,6 @@ class SettingsCreator(TrayPublishCreator): "icon": item_data["icon"], "description": item_data["description"], "detailed_description": item_data["detailed_description"], - "enable_review": item_data["enable_review"], "extensions": item_data["extensions"], "allow_sequences": item_data["allow_sequences"], "default_variants": item_data["default_variants"] diff --git a/openpype/hosts/traypublisher/plugins/publish/collect_review_family.py b/openpype/hosts/traypublisher/plugins/publish/collect_review_family.py new file mode 100644 index 0000000000..965e251527 --- /dev/null +++ b/openpype/hosts/traypublisher/plugins/publish/collect_review_family.py @@ -0,0 +1,31 @@ +import pyblish.api +from openpype.lib import BoolDef +from openpype.pipeline import OpenPypePyblishPluginMixin + + +class CollectReviewFamily( + pyblish.api.InstancePlugin, OpenPypePyblishPluginMixin +): + """Add review family.""" + + label = "Collect Review Family" + order = pyblish.api.CollectorOrder - 0.49 + + hosts = ["traypublisher"] + families = [ + "image", + "render", + "plate", + "review" + ] + + def process(self, instance): + values = self.get_attr_values_from_data(instance.data) + if values.get("add_review_family"): + instance.data["families"].append("review") + + @classmethod + def get_attribute_defs(cls): + return [ + BoolDef("add_review_family", label="Review", default=True) + ] diff --git a/openpype/hosts/traypublisher/plugins/publish/collect_simple_instances.py b/openpype/hosts/traypublisher/plugins/publish/collect_simple_instances.py index 5fc66084d6..9facd90a48 100644 --- a/openpype/hosts/traypublisher/plugins/publish/collect_simple_instances.py +++ b/openpype/hosts/traypublisher/plugins/publish/collect_simple_instances.py @@ -22,10 +22,6 @@ class CollectSettingsSimpleInstances(pyblish.api.InstancePlugin): repres = instance.data["representations"] creator_attributes = instance.data["creator_attributes"] - - if creator_attributes.get("review"): - instance.data["families"].append("review") - filepath_item = creator_attributes["filepath"] self.log.info(filepath_item) filepaths = [ @@ -34,6 +30,7 @@ class CollectSettingsSimpleInstances(pyblish.api.InstancePlugin): ] instance.data["sourceFilepaths"] = filepaths + instance.data["stagingDir"] = filepath_item["directory"] filenames = filepath_item["filenames"] ext = os.path.splitext(filenames[0])[-1] @@ -46,3 +43,7 @@ class CollectSettingsSimpleInstances(pyblish.api.InstancePlugin): "stagingDir": filepath_item["directory"], "files": filenames }) + + self.log.debug("Created Simple Settings instance {}".format( + instance.data + )) diff --git a/openpype/settings/defaults/project_settings/traypublisher.json b/openpype/settings/defaults/project_settings/traypublisher.json index 1b0ad67abb..0b54cfd39e 100644 --- a/openpype/settings/defaults/project_settings/traypublisher.json +++ b/openpype/settings/defaults/project_settings/traypublisher.json @@ -8,7 +8,6 @@ "default_variants": [ "Main" ], - "enable_review": false, "description": "Publish workfile backup", "detailed_description": "", "allow_sequences": true, diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_traypublisher.json b/openpype/settings/entities/schemas/projects_schema/schema_project_traypublisher.json index 59c675d411..55c1b7b7d7 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_traypublisher.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_traypublisher.json @@ -45,12 +45,6 @@ "type": "text" } }, - { - "type": "boolean", - "key": "enable_review", - "label": "Enable review", - "tooltip": "Allow to create review from source file/s.\nFiles must be supported to be able create review." - }, { "type": "separator" }, From 1ea991f4f3d277e611587200bd93c007ae0fd660 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 29 Apr 2022 14:30:33 +0200 Subject: [PATCH 153/583] added right click callback for menu --- .../widgets/attribute_defs/files_widget.py | 69 ++++++++++++++----- 1 file changed, 50 insertions(+), 19 deletions(-) diff --git a/openpype/widgets/attribute_defs/files_widget.py b/openpype/widgets/attribute_defs/files_widget.py index a3ee370bd3..5fe0302507 100644 --- a/openpype/widgets/attribute_defs/files_widget.py +++ b/openpype/widgets/attribute_defs/files_widget.py @@ -251,7 +251,7 @@ class FilesProxyModel(QtCore.QSortFilterProxyModel): class ItemWidget(QtWidgets.QWidget): - split_requested = QtCore.Signal(str) + context_menu_requested = QtCore.Signal(QtCore.QPoint) def __init__( self, item_id, label, pixmap_icon, is_sequence, multivalue, parent=None @@ -316,19 +316,9 @@ class ItemWidget(QtWidgets.QWidget): self._update_btn_size() def _on_actions_clicked(self): - menu = QtWidgets.QMenu(self._split_btn) - - action = QtWidgets.QAction("Split sequence", menu) - action.triggered.connect(self._on_split_sequence) - - menu.addAction(action) - pos = self._split_btn.rect().bottomLeft() point = self._split_btn.mapToGlobal(pos) - menu.popup(point) - - def _on_split_sequence(self): - self.split_requested.emit(self._item_id) + self.context_menu_requested.emit(point) class InViewButton(IconButton): @@ -339,6 +329,7 @@ class FilesView(QtWidgets.QListView): """View showing instances and their groups.""" remove_requested = QtCore.Signal() + context_menu_requested = QtCore.Signal(QtCore.QPoint) def __init__(self, *args, **kwargs): super(FilesView, self).__init__(*args, **kwargs) @@ -347,6 +338,7 @@ class FilesView(QtWidgets.QListView): self.setSelectionMode( QtWidgets.QAbstractItemView.ExtendedSelection ) + self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) remove_btn = InViewButton(self) pix_enabled = paint_image_with_color( @@ -361,6 +353,7 @@ class FilesView(QtWidgets.QListView): remove_btn.setEnabled(False) remove_btn.clicked.connect(self._on_remove_clicked) + self.customContextMenuRequested.connect(self._on_context_menu_request) self._remove_btn = remove_btn @@ -397,6 +390,12 @@ class FilesView(QtWidgets.QListView): selected_item_ids.add(instance_id) return selected_item_ids + def has_selected_sequence(self): + for index in self.selectionModel().selectedIndexes(): + if index.data(IS_SEQUENCE_ROLE): + return True + return False + def event(self, event): if event.type() == QtCore.QEvent.KeyPress: if ( @@ -408,6 +407,12 @@ class FilesView(QtWidgets.QListView): return super(FilesView, self).event(event) + def _on_context_menu_request(self, pos): + index = self.indexAt(pos) + if index.isValid(): + point = self.mapToGlobal(pos) + self.context_menu_requested.emit(point) + def _on_selection_change(self): self._remove_btn.setEnabled(self.has_selected_item_ids()) @@ -456,6 +461,9 @@ class FilesWidget(QtWidgets.QFrame): files_proxy_model.rowsInserted.connect(self._on_rows_inserted) files_proxy_model.rowsRemoved.connect(self._on_rows_removed) files_view.remove_requested.connect(self._on_remove_requested) + files_view.context_menu_requested.connect( + self._on_context_menu_requested + ) self._in_set_value = False self._single_item = single_item self._multivalue = False @@ -527,7 +535,9 @@ class FilesWidget(QtWidgets.QFrame): is_sequence, self._multivalue ) - widget.split_requested.connect(self._on_split_request) + widget.context_menu_requested.connect( + self._on_context_menu_requested + ) self._files_view.setIndexWidget(index, widget) self._files_proxy_model.setData( index, widget.sizeHint(), QtCore.Qt.SizeHintRole @@ -559,17 +569,22 @@ class FilesWidget(QtWidgets.QFrame): if not self._in_set_value: self.value_changed.emit() - def _on_split_request(self, item_id): + def _on_split_request(self): if self._multivalue: return - file_item = self._files_model.get_file_item_by_id(item_id) - if not file_item: + item_ids = self._files_view.get_selected_item_ids() + if not item_ids: return - new_items = file_item.split_sequence() - self._remove_item_by_ids([item_id]) - self._add_filepaths(new_items) + for item_id in item_ids: + file_item = self._files_model.get_file_item_by_id(item_id) + if not file_item: + return + + new_items = file_item.split_sequence() + self._add_filepaths(new_items) + self._remove_item_by_ids(item_ids) def _on_remove_requested(self): if self._multivalue: @@ -579,6 +594,22 @@ class FilesWidget(QtWidgets.QFrame): if items_to_delete: self._remove_item_by_ids(items_to_delete) + def _on_context_menu_requested(self, pos): + if self._multivalue: + return + + menu = QtWidgets.QMenu(self._files_view) + + remove_action = QtWidgets.QAction("Remove", menu) + remove_action.triggered.connect(self._on_remove_requested) + menu.addAction(remove_action) + + if self._files_view.has_selected_sequence(): + remove_action = QtWidgets.QAction("Split sequence", menu) + remove_action.triggered.connect(self._on_split_request) + + menu.popup(pos) + def sizeHint(self): # Get size hints of widget and visible widgets result = super(FilesWidget, self).sizeHint() From b74b309de19a2f7de6cb06fc6b6afa1ed4d5a624 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 29 Apr 2022 14:34:12 +0200 Subject: [PATCH 154/583] fixed split sequence action --- openpype/widgets/attribute_defs/files_widget.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/openpype/widgets/attribute_defs/files_widget.py b/openpype/widgets/attribute_defs/files_widget.py index 5fe0302507..924dbf7fe5 100644 --- a/openpype/widgets/attribute_defs/files_widget.py +++ b/openpype/widgets/attribute_defs/files_widget.py @@ -410,7 +410,7 @@ class FilesView(QtWidgets.QListView): def _on_context_menu_request(self, pos): index = self.indexAt(pos) if index.isValid(): - point = self.mapToGlobal(pos) + point = self.viewport().mapToGlobal(pos) self.context_menu_requested.emit(point) def _on_selection_change(self): @@ -600,14 +600,15 @@ class FilesWidget(QtWidgets.QFrame): menu = QtWidgets.QMenu(self._files_view) + if self._files_view.has_selected_sequence(): + split_action = QtWidgets.QAction("Split sequence", menu) + split_action.triggered.connect(self._on_split_request) + menu.addAction(split_action) + remove_action = QtWidgets.QAction("Remove", menu) remove_action.triggered.connect(self._on_remove_requested) menu.addAction(remove_action) - if self._files_view.has_selected_sequence(): - remove_action = QtWidgets.QAction("Split sequence", menu) - remove_action.triggered.connect(self._on_split_request) - menu.popup(pos) def sizeHint(self): From 0647a5dcf294e7a7c88f691c7b5114c49927b5fc Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 29 Apr 2022 14:44:25 +0200 Subject: [PATCH 155/583] moved create button to right side --- openpype/tools/publisher/widgets/create_dialog.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/openpype/tools/publisher/widgets/create_dialog.py b/openpype/tools/publisher/widgets/create_dialog.py index 243540f243..dd1e00b79b 100644 --- a/openpype/tools/publisher/widgets/create_dialog.py +++ b/openpype/tools/publisher/widgets/create_dialog.py @@ -282,9 +282,6 @@ class CreateDialog(QtWidgets.QDialog): subset_name_input = QtWidgets.QLineEdit(self) subset_name_input.setEnabled(False) - create_btn = QtWidgets.QPushButton("Create", self) - create_btn.setEnabled(False) - form_layout = QtWidgets.QFormLayout() form_layout.addRow("Variant:", variant_widget) form_layout.addRow("Subset:", subset_name_input) @@ -295,7 +292,6 @@ class CreateDialog(QtWidgets.QDialog): mid_layout.addWidget(QtWidgets.QLabel("Choose family:", self)) mid_layout.addWidget(creators_view, 1) mid_layout.addLayout(form_layout, 0) - mid_layout.addWidget(create_btn, 0) # ------------ # --- Creator short info and attr defs --- @@ -313,11 +309,22 @@ class CreateDialog(QtWidgets.QDialog): # Precreate attributes widget pre_create_widget = PreCreateWidget(creator_attrs_widget) + # Create button + create_btn_wrapper = QtWidgets.QWidget(creator_attrs_widget) + create_btn = QtWidgets.QPushButton("Create", create_btn_wrapper) + create_btn.setEnabled(False) + + create_btn_wrap_layout = QtWidgets.QHBoxLayout(create_btn_wrapper) + create_btn_wrap_layout.setContentsMargins(0, 0, 0, 0) + create_btn_wrap_layout.addStretch(1) + create_btn_wrap_layout.addWidget(create_btn, 0) + creator_attrs_layout = QtWidgets.QVBoxLayout(creator_attrs_widget) creator_attrs_layout.setContentsMargins(0, 0, 0, 0) creator_attrs_layout.addWidget(creator_short_desc_widget, 0) creator_attrs_layout.addWidget(separator_widget, 0) creator_attrs_layout.addWidget(pre_create_widget, 1) + creator_attrs_layout.addWidget(create_btn_wrapper, 0) # ------------------------------------- # --- Detailed information about creator --- From 7924affca56d4f68cc5b88ccc6af0509c169b5c7 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 29 Apr 2022 14:45:55 +0200 Subject: [PATCH 156/583] make FileDef with vertical label as default --- openpype/lib/attribute_definitions.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/openpype/lib/attribute_definitions.py b/openpype/lib/attribute_definitions.py index bfac9da5ce..4ea691ca08 100644 --- a/openpype/lib/attribute_definitions.py +++ b/openpype/lib/attribute_definitions.py @@ -560,11 +560,7 @@ class FileDef(AbtractAttrDef): # Change horizontal label is_label_horizontal = kwargs.get("is_label_horizontal") if is_label_horizontal is None: - if single_item: - is_label_horizontal = True - else: - is_label_horizontal = False - kwargs["is_label_horizontal"] = is_label_horizontal + kwargs["is_label_horizontal"] = False self.single_item = single_item self.folders = folders From 889f17d5ee3ee46dd702da1e6a989bf61556a6ab Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 29 Apr 2022 15:22:24 +0200 Subject: [PATCH 157/583] wrapped header widgets into single widget --- openpype/tools/utils/assets_widget.py | 55 +++++++++++++++++++++++---- 1 file changed, 48 insertions(+), 7 deletions(-) diff --git a/openpype/tools/utils/assets_widget.py b/openpype/tools/utils/assets_widget.py index 3d4efcdd4d..90e688073d 100644 --- a/openpype/tools/utils/assets_widget.py +++ b/openpype/tools/utils/assets_widget.py @@ -589,10 +589,12 @@ class AssetsWidget(QtWidgets.QWidget): view = AssetsView(self) view.setModel(proxy) + header_widget = QtWidgets.QWidget(self) + current_asset_icon = qtawesome.icon( "fa.arrow-down", color=get_default_tools_icon_color() ) - current_asset_btn = QtWidgets.QPushButton(self) + current_asset_btn = QtWidgets.QPushButton(header_widget) current_asset_btn.setIcon(current_asset_icon) current_asset_btn.setToolTip("Go to Asset from current Session") # Hide by default @@ -601,15 +603,16 @@ class AssetsWidget(QtWidgets.QWidget): refresh_icon = qtawesome.icon( "fa.refresh", color=get_default_tools_icon_color() ) - refresh_btn = QtWidgets.QPushButton(self) + refresh_btn = QtWidgets.QPushButton(header_widget) refresh_btn.setIcon(refresh_icon) refresh_btn.setToolTip("Refresh items") - filter_input = PlaceholderLineEdit(self) + filter_input = PlaceholderLineEdit(header_widget) filter_input.setPlaceholderText("Filter assets..") # Header - header_layout = QtWidgets.QHBoxLayout() + header_layout = QtWidgets.QHBoxLayout(header_widget) + header_layout.setContentsMargins(0, 0, 0, 0) header_layout.addWidget(filter_input) header_layout.addWidget(current_asset_btn) header_layout.addWidget(refresh_btn) @@ -617,9 +620,8 @@ class AssetsWidget(QtWidgets.QWidget): # Layout layout = QtWidgets.QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(4) - layout.addLayout(header_layout) - layout.addWidget(view) + layout.addWidget(header_widget, 0) + layout.addWidget(view, 1) # Signals/Slots filter_input.textChanged.connect(self._on_filter_text_change) @@ -630,6 +632,8 @@ class AssetsWidget(QtWidgets.QWidget): current_asset_btn.clicked.connect(self._on_current_asset_click) view.doubleClicked.connect(self.double_clicked) + self._header_widget = header_widget + self._filter_input = filter_input self._refresh_btn = refresh_btn self._current_asset_btn = current_asset_btn self._model = model @@ -637,8 +641,40 @@ class AssetsWidget(QtWidgets.QWidget): self._view = view self._last_project_name = None + self._last_btns_height = None + self.model_selection = {} + @property + def header_widget(self): + return self._header_widget + + def _check_btns_height(self): + """Make buttons to have same height as filter input field. + + There is not handled case when buttons are bigger then filter input. + """ + if self._filter_input.height() == self._last_btns_height: + return + + height = self._filter_input.height() + self._last_btns_height = height + + for widget in ( + self._refresh_btn, + self._current_asset_btn + ): + widget.setMinimumHeight(height) + widget.setMaximumHeight(height) + + def resizeEvent(self, event): + super(AssetsWidget, self).resizeEvent(event) + self._check_btns_height() + + def showEvent(self, event): + super(AssetsWidget, self).showEvent(event) + self._check_btns_height() + def _create_source_model(self): model = AssetModel(dbcon=self.dbcon, parent=self) model.refreshed.connect(self._on_model_refresh) @@ -669,6 +705,7 @@ class AssetsWidget(QtWidgets.QWidget): This separation gives ability to override this method and use it in differnt way. """ + self.set_current_session_asset() def set_current_session_asset(self): @@ -681,6 +718,7 @@ class AssetsWidget(QtWidgets.QWidget): Some tools may have their global refresh button or do not support refresh at all. """ + if visible is None: visible = not self._refresh_btn.isVisible() self._refresh_btn.setVisible(visible) @@ -690,6 +728,7 @@ class AssetsWidget(QtWidgets.QWidget): Not all tools support using of current context asset. """ + if visible is None: visible = not self._current_asset_btn.isVisible() self._current_asset_btn.setVisible(visible) @@ -723,6 +762,7 @@ class AssetsWidget(QtWidgets.QWidget): so if you're modifying model keep in mind that this method should be called when refresh is done. """ + self._proxy.sort(0) self._set_loading_state(loading=False, empty=not has_item) self.refreshed.emit() @@ -767,6 +807,7 @@ class SingleSelectAssetsWidget(AssetsWidget): Contain single selection specific api methods. """ + def get_selected_asset_id(self): """Currently selected asset id.""" selection_model = self._view.selectionModel() From b27ba52aaf27772a18e6c948a108925906f14c13 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 29 Apr 2022 15:27:42 +0200 Subject: [PATCH 158/583] nuke: fix regex default --- openpype/settings/defaults/project_anatomy/imageio.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/settings/defaults/project_anatomy/imageio.json b/openpype/settings/defaults/project_anatomy/imageio.json index fedae994bf..09a5d98f46 100644 --- a/openpype/settings/defaults/project_anatomy/imageio.json +++ b/openpype/settings/defaults/project_anatomy/imageio.json @@ -170,7 +170,7 @@ "regexInputs": { "inputs": [ { - "regex": "[^-a-zA-Z0-9]beauty[^-a-zA-Z0-9]", + "regex": "(beauty).*(?=.exr)", "colorspace": "linear" } ] From 518c60de0b63746b20d80ebbd4467bc72655afbd Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 29 Apr 2022 15:31:05 +0200 Subject: [PATCH 159/583] fixed renderlayer key access --- .../plugins/publish/collect_instances.py | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/openpype/hosts/tvpaint/plugins/publish/collect_instances.py b/openpype/hosts/tvpaint/plugins/publish/collect_instances.py index 9b51f88cae..782907b65d 100644 --- a/openpype/hosts/tvpaint/plugins/publish/collect_instances.py +++ b/openpype/hosts/tvpaint/plugins/publish/collect_instances.py @@ -47,19 +47,18 @@ class CollectInstances(pyblish.api.ContextPlugin): # Conversion from older instances # - change 'render_layer' to 'renderlayer' - # and 'render_pass' to 'renderpass' + render_layer = instance_data.get("instance_data") + if not render_layer: + # Render Layer has only variant + if instance_data["family"] == "renderLayer": + render_layer = instance_data.get("variant") - if ( - "renderlayer" not in instance_data - and "render_layer" in instance_data - ): - instance_data["renderlayer"] = instance_data["render_layer"] + # Backwards compatibility for renderPasses + elif "render_layer" in instance_data: + render_layer = instance_data["render_layer"] - if ( - "renderpass" not in instance_data - and "render_pass" in instance_data - ): - instance_data["renderpass"] = instance_data["render_pass"] + if render_layer: + instance_data["renderlayer"] = render_layer # Store workfile instance data to instance data instance_data["originData"] = copy.deepcopy(instance_data) From f3726d0c95c35ee1e5fd3f480c751dd12fae0046 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 29 Apr 2022 15:35:25 +0200 Subject: [PATCH 160/583] modified collect scene instance to add only 'renderlayer' --- .../hosts/tvpaint/plugins/publish/collect_scene_render.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/tvpaint/plugins/publish/collect_scene_render.py b/openpype/hosts/tvpaint/plugins/publish/collect_scene_render.py index 02e0575a2c..2b8dbdc5b4 100644 --- a/openpype/hosts/tvpaint/plugins/publish/collect_scene_render.py +++ b/openpype/hosts/tvpaint/plugins/publish/collect_scene_render.py @@ -104,10 +104,10 @@ class CollectRenderScene(pyblish.api.ContextPlugin): "representations": [], "layers": copy.deepcopy(context.data["layersData"]), "asset": asset_name, - "task": task_name + "task": task_name, + # Add render layer to instance data + "renderlayer": self.render_layer } - # Add 'renderlayer' and 'renderpass' to data - instance_data.update(dynamic_data) instance = context.create_instance(**instance_data) From b17a71fe1466c84d39b74228c066a0819a9b32c2 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 29 Apr 2022 15:43:06 +0200 Subject: [PATCH 161/583] simplified btns height in assets widget --- openpype/tools/utils/assets_widget.py | 36 ++++++++------------------- 1 file changed, 10 insertions(+), 26 deletions(-) diff --git a/openpype/tools/utils/assets_widget.py b/openpype/tools/utils/assets_widget.py index 90e688073d..d1df1193d2 100644 --- a/openpype/tools/utils/assets_widget.py +++ b/openpype/tools/utils/assets_widget.py @@ -617,6 +617,16 @@ class AssetsWidget(QtWidgets.QWidget): header_layout.addWidget(current_asset_btn) header_layout.addWidget(refresh_btn) + # Make header widgets expand vertically if there is a place + for widget in ( + current_asset_btn, + refresh_btn, + filter_input, + ): + size_policy = widget.sizePolicy() + size_policy.setVerticalPolicy(size_policy.MinimumExpanding) + widget.setSizePolicy(size_policy) + # Layout layout = QtWidgets.QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) @@ -649,32 +659,6 @@ class AssetsWidget(QtWidgets.QWidget): def header_widget(self): return self._header_widget - def _check_btns_height(self): - """Make buttons to have same height as filter input field. - - There is not handled case when buttons are bigger then filter input. - """ - if self._filter_input.height() == self._last_btns_height: - return - - height = self._filter_input.height() - self._last_btns_height = height - - for widget in ( - self._refresh_btn, - self._current_asset_btn - ): - widget.setMinimumHeight(height) - widget.setMaximumHeight(height) - - def resizeEvent(self, event): - super(AssetsWidget, self).resizeEvent(event) - self._check_btns_height() - - def showEvent(self, event): - super(AssetsWidget, self).showEvent(event) - self._check_btns_height() - def _create_source_model(self): model = AssetModel(dbcon=self.dbcon, parent=self) model.refreshed.connect(self._on_model_refresh) From 7504f273806b59c8192dcf4bf7137340aa04d0ba Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 29 Apr 2022 15:53:58 +0200 Subject: [PATCH 162/583] Nuke: fixing default settings for workfile builder loaders --- openpype/settings/defaults/project_settings/nuke.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/openpype/settings/defaults/project_settings/nuke.json b/openpype/settings/defaults/project_settings/nuke.json index ddf996b5f2..0b03a00187 100644 --- a/openpype/settings/defaults/project_settings/nuke.json +++ b/openpype/settings/defaults/project_settings/nuke.json @@ -220,11 +220,12 @@ "repre_names": [ "exr", "dpx", - "mov" + "mov", + "mp4", + "h264" ], "loaders": [ - "LoadSequence", - "LoadMov" + "LoadClip" ] } ], From 1bc4d6c66a554b2902372546178ddce1220f8638 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 29 Apr 2022 16:14:50 +0200 Subject: [PATCH 163/583] keep creators view label at same height as asset filtering --- .../tools/publisher/widgets/assets_widget.py | 24 +++++++++++++++++++ .../tools/publisher/widgets/create_dialog.py | 19 ++++++++++++++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/openpype/tools/publisher/widgets/assets_widget.py b/openpype/tools/publisher/widgets/assets_widget.py index 984da59c77..c4d3cf8b1a 100644 --- a/openpype/tools/publisher/widgets/assets_widget.py +++ b/openpype/tools/publisher/widgets/assets_widget.py @@ -15,6 +15,7 @@ from openpype.tools.utils.assets_widget import ( class CreateDialogAssetsWidget(SingleSelectAssetsWidget): current_context_required = QtCore.Signal() + header_height_changed = QtCore.Signal(int) def __init__(self, controller, parent): self._controller = controller @@ -27,6 +28,27 @@ class CreateDialogAssetsWidget(SingleSelectAssetsWidget): self._last_selection = None self._enabled = None + self._last_filter_height = None + + def _check_header_height(self): + """Catch header height changes. + + Label on top of creaters should have same height so Creators view has + same offset. + """ + height = self.header_widget.height() + if height != self._last_filter_height: + self._last_filter_height = height + self.header_height_changed.emit(height) + + def resizeEvent(self, event): + super(CreateDialogAssetsWidget, self).resizeEvent(event) + self._check_header_height() + + def showEvent(self, event): + super(CreateDialogAssetsWidget, self).showEvent(event) + self._check_header_height() + def _on_current_asset_click(self): self.current_context_required.emit() @@ -71,6 +93,7 @@ class AssetsHierarchyModel(QtGui.QStandardItemModel): Uses controller to load asset hierarchy. All asset documents are stored by their parents. """ + def __init__(self, controller): super(AssetsHierarchyModel, self).__init__() self._controller = controller @@ -143,6 +166,7 @@ class AssetsHierarchyModel(QtGui.QStandardItemModel): class AssetsDialog(QtWidgets.QDialog): """Dialog to select asset for a context of instance.""" + def __init__(self, controller, parent): super(AssetsDialog, self).__init__(parent) self.setWindowTitle("Select asset") diff --git a/openpype/tools/publisher/widgets/create_dialog.py b/openpype/tools/publisher/widgets/create_dialog.py index dd1e00b79b..00a9ac785d 100644 --- a/openpype/tools/publisher/widgets/create_dialog.py +++ b/openpype/tools/publisher/widgets/create_dialog.py @@ -255,6 +255,14 @@ class CreateDialog(QtWidgets.QDialog): context_layout.addWidget(tasks_widget, 1) # --- Creators view --- + creators_header_widget = QtWidgets.QWidget(self) + header_label_widget = QtWidgets.QLabel( + "Choose family:", creators_header_widget + ) + creators_header_layout = QtWidgets.QHBoxLayout(creators_header_widget) + creators_header_layout.setContentsMargins(0, 0, 0, 0) + creators_header_layout.addWidget(header_label_widget, 1) + creators_view = QtWidgets.QListView(self) creators_model = QtGui.QStandardItemModel() creators_view.setModel(creators_model) @@ -289,7 +297,7 @@ class CreateDialog(QtWidgets.QDialog): mid_widget = QtWidgets.QWidget(self) mid_layout = QtWidgets.QVBoxLayout(mid_widget) mid_layout.setContentsMargins(0, 0, 0, 0) - mid_layout.addWidget(QtWidgets.QLabel("Choose family:", self)) + mid_layout.addWidget(creators_header_widget, 0) mid_layout.addWidget(creators_view, 1) mid_layout.addLayout(form_layout, 0) # ------------ @@ -362,6 +370,10 @@ class CreateDialog(QtWidgets.QDialog): help_btn.clicked.connect(self._on_help_btn) help_btn.resized.connect(self._on_help_btn_resize) + assets_widget.header_height_changed.connect( + self._on_asset_filter_height_change + ) + create_btn.clicked.connect(self._on_create) variant_widget.resized.connect(self._on_variant_widget_resize) variant_input.returnPressed.connect(self._on_create) @@ -394,6 +406,7 @@ class CreateDialog(QtWidgets.QDialog): self.variant_hints_menu = variant_hints_menu self.variant_hints_group = variant_hints_group + self._creators_header_widget = creators_header_widget self.creators_model = creators_model self.creators_view = creators_view self.create_btn = create_btn @@ -472,6 +485,10 @@ class CreateDialog(QtWidgets.QDialog): def _invalidate_prereq(self): self._prereq_timer.start() + def _on_asset_filter_height_change(self, height): + self._creators_header_widget.setMinimumHeight(height) + self._creators_header_widget.setMaximumHeight(height) + def _on_prereq_timer(self): prereq_available = True creator_btn_tooltips = [] From 48e887f2398a70ee9797599118c934b9dbe523f7 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 29 Apr 2022 16:39:58 +0200 Subject: [PATCH 164/583] modified description heights --- .../tools/publisher/widgets/create_dialog.py | 78 +++++++++++++++---- 1 file changed, 65 insertions(+), 13 deletions(-) diff --git a/openpype/tools/publisher/widgets/create_dialog.py b/openpype/tools/publisher/widgets/create_dialog.py index 00a9ac785d..cc47e9f175 100644 --- a/openpype/tools/publisher/widgets/create_dialog.py +++ b/openpype/tools/publisher/widgets/create_dialog.py @@ -114,6 +114,8 @@ class CreateErrorMessageBox(ErrorMessageBox): # TODO add creator identifier/label to details class CreatorShortDescWidget(QtWidgets.QWidget): + height_changed = QtCore.Signal(int) + def __init__(self, parent=None): super(CreatorShortDescWidget, self).__init__(parent=parent) @@ -152,6 +154,22 @@ class CreatorShortDescWidget(QtWidgets.QWidget): self._family_label = family_label self._description_label = description_label + self._last_height = None + + def _check_height_change(self): + height = self.height() + if height != self._last_height: + self._last_height = height + self.height_changed.emit(height) + + def showEvent(self, event): + super(CreatorShortDescWidget, self).showEvent(event) + self._check_height_change() + + def resizeEvent(self, event): + super(CreatorShortDescWidget, self).resizeEvent(event) + self._check_height_change() + def set_plugin(self, plugin=None): if not plugin: self._icon_widget.set_icon_def(None) @@ -309,10 +327,10 @@ class CreateDialog(QtWidgets.QDialog): creator_attrs_widget ) - separator_widget = QtWidgets.QWidget(self) - separator_widget.setObjectName("Separator") - separator_widget.setMinimumHeight(2) - separator_widget.setMaximumHeight(2) + attr_separator_widget = QtWidgets.QWidget(self) + attr_separator_widget.setObjectName("Separator") + attr_separator_widget.setMinimumHeight(2) + attr_separator_widget.setMaximumHeight(2) # Precreate attributes widget pre_create_widget = PreCreateWidget(creator_attrs_widget) @@ -330,21 +348,41 @@ class CreateDialog(QtWidgets.QDialog): creator_attrs_layout = QtWidgets.QVBoxLayout(creator_attrs_widget) creator_attrs_layout.setContentsMargins(0, 0, 0, 0) creator_attrs_layout.addWidget(creator_short_desc_widget, 0) - creator_attrs_layout.addWidget(separator_widget, 0) + creator_attrs_layout.addWidget(attr_separator_widget, 0) creator_attrs_layout.addWidget(pre_create_widget, 1) creator_attrs_layout.addWidget(create_btn_wrapper, 0) # ------------------------------------- # --- Detailed information about creator --- # Detailed description of creator - detail_description_widget = QtWidgets.QTextEdit(self) - detail_description_widget.setObjectName("InfoText") - detail_description_widget.setTextInteractionFlags( + detail_description_widget = QtWidgets.QWidget(self) + + detail_placoholder_widget = QtWidgets.QWidget( + detail_description_widget + ) + detail_placoholder_widget.setAttribute( + QtCore.Qt.WA_TranslucentBackground + ) + + detail_description_input = QtWidgets.QTextEdit( + detail_description_widget + ) + detail_description_input.setObjectName("InfoText") + detail_description_input.setTextInteractionFlags( QtCore.Qt.TextBrowserInteraction ) - detail_description_widget.setVisible(False) - # ------------------------------------------- + detail_description_layout = QtWidgets.QVBoxLayout( + detail_description_widget + ) + detail_description_layout.setContentsMargins(0, 0, 0, 0) + detail_description_layout.setSpacing(0) + detail_description_layout.addWidget(detail_placoholder_widget, 0) + detail_description_layout.addWidget(detail_description_input, 1) + + detail_description_widget.setVisible(False) + + # ------------------------------------------- splitter_widget = QtWidgets.QSplitter(self) splitter_widget.addWidget(context_widget) splitter_widget.addWidget(mid_widget) @@ -359,6 +397,7 @@ class CreateDialog(QtWidgets.QDialog): layout.addWidget(splitter_widget, 1) # Floating help button + # - Create this button as last to be fully visible help_btn = HelpButton(self) prereq_timer = QtCore.QTimer() @@ -388,6 +427,9 @@ class CreateDialog(QtWidgets.QDialog): self._on_current_session_context_request ) tasks_widget.task_changed.connect(self._on_task_change) + creator_short_desc_widget.height_changed.connect( + self._on_description_height_change + ) controller.add_plugins_refresh_callback(self._on_plugins_refresh) @@ -413,7 +455,11 @@ class CreateDialog(QtWidgets.QDialog): self._creator_short_desc_widget = creator_short_desc_widget self._pre_create_widget = pre_create_widget + self._attr_separator_widget = attr_separator_widget + + self._detail_placoholder_widget = detail_placoholder_widget self._detail_description_widget = detail_description_widget + self._detail_description_input = detail_description_input self._help_btn = help_btn self._prereq_timer = prereq_timer @@ -619,6 +665,12 @@ class CreateDialog(QtWidgets.QDialog): if self._task_name: self._tasks_widget.select_task_name(self._task_name) + def _on_description_height_change(self): + # Use separator's 'y' position as height + height = self._attr_separator_widget.y() + self._detail_placoholder_widget.setMinimumHeight(height) + self._detail_placoholder_widget.setMaximumHeight(height) + def _on_creator_item_change(self, new_index, _old_index): identifier = None if new_index.isValid(): @@ -666,14 +718,14 @@ class CreateDialog(QtWidgets.QDialog): def _set_creator_detailed_text(self, creator): if not creator: - self._detail_description_widget.setPlainText("") + self._detail_description_input.setPlainText("") return detailed_description = creator.get_detail_description() or "" if commonmark: html = commonmark.commonmark(detailed_description) - self._detail_description_widget.setHtml(html) + self._detail_description_input.setHtml(html) else: - self._detail_description_widget.setMarkdown(detailed_description) + self._detail_description_input.setMarkdown(detailed_description) def _set_creator_by_identifier(self, identifier): creator = self.controller.manual_creators.get(identifier) From 542efb57bc5df0a6e966c3fa6ac265bce693a599 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 29 Apr 2022 16:51:19 +0200 Subject: [PATCH 165/583] change style of detailed description --- openpype/style/style.css | 8 ++++++++ openpype/tools/publisher/widgets/create_dialog.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/openpype/style/style.css b/openpype/style/style.css index ae04a433fb..4032732a78 100644 --- a/openpype/style/style.css +++ b/openpype/style/style.css @@ -856,6 +856,14 @@ QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { } /* New Create/Publish UI */ +#CreatorDetailedDescription { + padding-left: 5px; + padding-right: 5px; + padding-top: 5px; + background: transparent; + border: 1px solid {color:border}; +} + #CreateDialogHelpButton { background: rgba(255, 255, 255, 31); border-top-right-radius: 0; diff --git a/openpype/tools/publisher/widgets/create_dialog.py b/openpype/tools/publisher/widgets/create_dialog.py index cc47e9f175..8e1ab3502d 100644 --- a/openpype/tools/publisher/widgets/create_dialog.py +++ b/openpype/tools/publisher/widgets/create_dialog.py @@ -367,7 +367,7 @@ class CreateDialog(QtWidgets.QDialog): detail_description_input = QtWidgets.QTextEdit( detail_description_widget ) - detail_description_input.setObjectName("InfoText") + detail_description_input.setObjectName("CreatorDetailedDescription") detail_description_input.setTextInteractionFlags( QtCore.Qt.TextBrowserInteraction ) From 49725781f4e8afc97f43f711dc3effb8d3ded7e0 Mon Sep 17 00:00:00 2001 From: jrsndlr Date: Fri, 29 Apr 2022 17:43:02 +0200 Subject: [PATCH 166/583] Fix concatenating metadata Slate concatenation didn't pass metadata to output --- .../plugins/publish/extract_review_slate.py | 19 +++++++++++++++++-- openpype/scripts/otio_burnin.py | 1 + 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/openpype/plugins/publish/extract_review_slate.py b/openpype/plugins/publish/extract_review_slate.py index 46bd4f3a7b..d5741273a8 100644 --- a/openpype/plugins/publish/extract_review_slate.py +++ b/openpype/plugins/publish/extract_review_slate.py @@ -77,7 +77,7 @@ class ExtractReviewSlate(openpype.api.Extractor): streams = get_ffprobe_streams( input_path, self.log ) - # get video metadata + # Get video metadata for stream in streams: input_timecode = None input_width = None @@ -337,8 +337,23 @@ class ExtractReviewSlate(openpype.api.Extractor): "-i", conc_text_path, "-c", "copy", "-timecode", offset_timecode, - output_path ] + # Use arguments from ffmpeg preset + source_ffmpeg_cmd = repre.get("ffmpeg_cmd") + if source_ffmpeg_cmd: + copy_args = ( + "-metadata", + "-metadata:s:v:0", + ) + args = source_ffmpeg_cmd.split(" ") + for indx, arg in enumerate(args): + if arg in copy_args: + concat_args.append(arg) + # assumes arg has one parameter + concat_args.append(args[indx + 1]) + # add output + concat_args.append(output_path) + if not input_audio: # ffmpeg concat subprocess self.log.debug( diff --git a/openpype/scripts/otio_burnin.py b/openpype/scripts/otio_burnin.py index 1f57891b84..4c3a5de2ec 100644 --- a/openpype/scripts/otio_burnin.py +++ b/openpype/scripts/otio_burnin.py @@ -568,6 +568,7 @@ def burnins_from_data( if source_ffmpeg_cmd: copy_args = ( "-metadata", + "-metadata:s:v:0", ) args = source_ffmpeg_cmd.split(" ") for idx, arg in enumerate(args): From a80d37847dad918a0b8f401a0432a9c9f5faf1b5 Mon Sep 17 00:00:00 2001 From: jrsndlr Date: Fri, 29 Apr 2022 17:46:34 +0200 Subject: [PATCH 167/583] hound --- openpype/plugins/publish/extract_review_slate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/plugins/publish/extract_review_slate.py b/openpype/plugins/publish/extract_review_slate.py index d5741273a8..59150e9d4a 100644 --- a/openpype/plugins/publish/extract_review_slate.py +++ b/openpype/plugins/publish/extract_review_slate.py @@ -353,7 +353,7 @@ class ExtractReviewSlate(openpype.api.Extractor): concat_args.append(args[indx + 1]) # add output concat_args.append(output_path) - + if not input_audio: # ffmpeg concat subprocess self.log.debug( From 99096269b52db11ad2bb5f9040a221cafbee97d7 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 29 Apr 2022 19:29:36 +0200 Subject: [PATCH 168/583] change separator size --- openpype/tools/publisher/widgets/create_dialog.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/tools/publisher/widgets/create_dialog.py b/openpype/tools/publisher/widgets/create_dialog.py index 8e1ab3502d..8c4f9d52a1 100644 --- a/openpype/tools/publisher/widgets/create_dialog.py +++ b/openpype/tools/publisher/widgets/create_dialog.py @@ -329,8 +329,8 @@ class CreateDialog(QtWidgets.QDialog): attr_separator_widget = QtWidgets.QWidget(self) attr_separator_widget.setObjectName("Separator") - attr_separator_widget.setMinimumHeight(2) - attr_separator_widget.setMaximumHeight(2) + attr_separator_widget.setMinimumHeight(1) + attr_separator_widget.setMaximumHeight(1) # Precreate attributes widget pre_create_widget = PreCreateWidget(creator_attrs_widget) From 833027f163c6a313db9ff26417b39ab3276fb600 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 29 Apr 2022 19:29:58 +0200 Subject: [PATCH 169/583] animate description showing --- .../tools/publisher/widgets/create_dialog.py | 137 ++++++++++++++++-- 1 file changed, 128 insertions(+), 9 deletions(-) diff --git a/openpype/tools/publisher/widgets/create_dialog.py b/openpype/tools/publisher/widgets/create_dialog.py index 8c4f9d52a1..10be47c517 100644 --- a/openpype/tools/publisher/widgets/create_dialog.py +++ b/openpype/tools/publisher/widgets/create_dialog.py @@ -404,8 +404,13 @@ class CreateDialog(QtWidgets.QDialog): prereq_timer.setInterval(50) prereq_timer.setSingleShot(True) + desc_width_anim_timer = QtCore.QTimer() + desc_width_anim_timer.setInterval(10) + prereq_timer.timeout.connect(self._on_prereq_timer) + desc_width_anim_timer.timeout.connect(self._on_desc_animation) + help_btn.clicked.connect(self._on_help_btn) help_btn.resized.connect(self._on_help_btn_resize) @@ -465,6 +470,15 @@ class CreateDialog(QtWidgets.QDialog): self._prereq_timer = prereq_timer self._first_show = True + # Description animation + self._description_size_policy = detail_description_widget.sizePolicy() + self._desc_width_anim_timer = desc_width_anim_timer + self._desc_widget_step = 0 + self._last_description_width = None + self._last_full_width = 0 + self._expected_description_width = 0 + self._other_widgets_widths = [] + def _emit_message(self, message): self._overlay_object.add_message(message) @@ -688,34 +702,139 @@ class CreateDialog(QtWidgets.QDialog): self._update_help_btn() def _on_help_btn(self): + if self._desc_width_anim_timer.isActive(): + return + final_size = self.size() cur_sizes = self._splitter_widget.sizes() - spacing = self._splitter_widget.handleWidth() + + if self._desc_widget_step == 0: + now_visible = self._detail_description_widget.isVisible() + else: + now_visible = self._desc_widget_step > 0 sizes = [] for idx, value in enumerate(cur_sizes): if idx < 3: sizes.append(value) - now_visible = self._detail_description_widget.isVisible() + self._last_full_width = final_size.width() + self._other_widgets_widths = list(sizes) + if now_visible: - width = final_size.width() - ( - spacing + self._detail_description_widget.width() - ) + cur_desc_width = self._detail_description_widget.width() + if cur_desc_width < 1: + cur_desc_width = 2 + step_size = int(cur_desc_width / 5) + if step_size < 1: + step_size = 1 + + step_size *= -1 + expected_width = 0 + desc_width = cur_desc_width - 1 + width = final_size.width() - 1 + min_max = desc_width + self._last_description_width = cur_desc_width else: - last_size = self._detail_description_widget.sizeHint().width() - width = final_size.width() + spacing + last_size - sizes.append(last_size) + self._detail_description_widget.setVisible(True) + handle = self._splitter_widget.handle(3) + desc_width = handle.sizeHint().width() + if self._last_description_width: + expected_width = self._last_description_width + else: + hint = self._detail_description_widget.sizeHint() + expected_width = hint.width() + + width = final_size.width() + desc_width + step_size = int(expected_width / 5) + if step_size < 1: + step_size = 1 + min_max = 0 + + self._detail_description_widget.setMinimumWidth(min_max) + self._detail_description_widget.setMaximumWidth(min_max) + self._expected_description_width = expected_width + self._desc_widget_step = step_size + + self._desc_width_anim_timer.start() + + sizes.append(desc_width) final_size.setWidth(width) - self._detail_description_widget.setVisible(not now_visible) self._splitter_widget.setSizes(sizes) self.resize(final_size) self._help_btn.set_expanded(not now_visible) + def _on_desc_animation(self): + current_width = self._detail_description_widget.width() + + desc_width = None + last_step = False + growing = self._desc_widget_step > 0 + + # Growing + if growing: + if current_width < self._expected_description_width: + desc_width = current_width + self._desc_widget_step + if desc_width >= self._expected_description_width: + desc_width = self._expected_description_width + last_step = True + + # Decreasing + elif self._desc_widget_step < 0: + if current_width > self._expected_description_width: + desc_width = current_width + self._desc_widget_step + if desc_width <= self._expected_description_width: + desc_width = self._expected_description_width + last_step = True + + if desc_width is None: + self._desc_widget_step = 0 + self._desc_width_anim_timer.stop() + return + + if last_step and not growing: + self._detail_description_widget.setVisible(False) + QtWidgets.QApplication.processEvents() + + width = self._last_full_width + handle_width = self._splitter_widget.handle(3).width() + if growing: + width += (handle_width + desc_width) + else: + width -= self._last_description_width + if last_step: + width -= handle_width + else: + width += desc_width + + if not last_step or growing: + self._detail_description_widget.setMaximumWidth(desc_width) + self._detail_description_widget.setMinimumWidth(desc_width) + + window_size = self.size() + window_size.setWidth(width) + self.resize(window_size) + if not last_step: + return + + self._desc_widget_step = 0 + self._desc_width_anim_timer.stop() + + if not growing: + return + + self._detail_description_widget.setSizePolicy( + self._description_size_policy + ) + + sizes = list(self._other_widgets_widths) + sizes.append(desc_width) + self._splitter_widget.setSizes(sizes) + def _set_creator_detailed_text(self, creator): if not creator: self._detail_description_input.setPlainText("") From b53ed826b4aa120d133f8f795007389177cff511 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 2 May 2022 12:24:00 +0200 Subject: [PATCH 170/583] nuke: creator default knob to settings --- .../defaults/project_settings/nuke.json | 6 +- .../projects_schema/schema_project_nuke.json | 12 +- .../schemas/schema_nuke_knob_inputs.json | 151 ++++++++++++++++++ 3 files changed, 165 insertions(+), 4 deletions(-) create mode 100644 openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_knob_inputs.json diff --git a/openpype/settings/defaults/project_settings/nuke.json b/openpype/settings/defaults/project_settings/nuke.json index ddf996b5f2..36daa92485 100644 --- a/openpype/settings/defaults/project_settings/nuke.json +++ b/openpype/settings/defaults/project_settings/nuke.json @@ -21,7 +21,8 @@ "defaults": [ "Main", "Mask" - ] + ], + "knobs": [] }, "CreateWritePrerender": { "fpath_template": "{work}/prerenders/nuke/{subset}/{subset}.{frame}.{ext}", @@ -33,7 +34,8 @@ "Branch01", "Part01" ], - "reviewable": false + "reviewable": false, + "knobs": [] } }, "publish": { diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_nuke.json b/openpype/settings/entities/schemas/projects_schema/schema_project_nuke.json index 9ab5fc65fb..dfd3306b2e 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_nuke.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_nuke.json @@ -87,7 +87,7 @@ "children": [ { "type": "dict", - "collapsible": false, + "collapsible": true, "key": "CreateWriteRender", "label": "CreateWriteRender", "is_group": true, @@ -104,12 +104,16 @@ "object_type": { "type": "text" } + }, + { + "type": "schema", + "name": "schema_nuke_knob_inputs" } ] }, { "type": "dict", - "collapsible": false, + "collapsible": true, "key": "CreateWritePrerender", "label": "CreateWritePrerender", "is_group": true, @@ -136,6 +140,10 @@ "type": "boolean", "key": "reviewable", "label": "Add reviewable toggle" + }, + { + "type": "schema", + "name": "schema_nuke_knob_inputs" } ] } diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_knob_inputs.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_knob_inputs.json new file mode 100644 index 0000000000..0d03b89288 --- /dev/null +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_knob_inputs.json @@ -0,0 +1,151 @@ +{ + "type": "collapsible-wrap", + "label": "Knob defaults", + "collapsible": true, + "collapsed": true, + "children": [{ + "type": "list", + "key": "knobs", + "object_type": { + "type": "dict-conditional", + "enum_key": "type", + "enum_label": "Type", + "enum_children": [ + { + "key": "string", + "label": "String", + "children": [ + { + "type": "text", + "key": "name", + "label": "Name" + }, + { + "type": "text", + "key": "value", + "label": "Value" + } + ] + }, + { + "key": "bool", + "label": "Boolean", + "children": [ + { + "type": "text", + "key": "name", + "label": "Name" + }, + { + "type": "boolean", + "key": "value", + "label": "Value" + } + ] + }, + { + "key": "number", + "label": "Number", + "children": [ + { + "type": "text", + "key": "name", + "label": "Name" + }, + { + "type": "number", + "key": "value", + "default": 1, + "decimal": 0 + } + + ] + }, + { + "key": "decimal_number", + "label": "Decimal number", + "children": [ + { + "type": "text", + "key": "name", + "label": "Name" + }, + { + "type": "number", + "key": "value", + "default": 1, + "decimal": 4 + } + + ] + }, + { + "key": "2d_vector", + "label": "2D vector", + "children": [ + { + "type": "text", + "key": "name", + "label": "Name" + }, + { + "type": "list-strict", + "key": "value", + "label": "Value", + "object_types": [ + { + "type": "number", + "key": "x", + "default": 1, + "decimal": 4 + }, + { + "type": "number", + "key": "y", + "default": 1, + "decimal": 4 + } + ] + } + ] + }, + { + "key": "3d_vector", + "label": "3D vector", + "children": [ + { + "type": "text", + "key": "name", + "label": "Name" + }, + { + "type": "list-strict", + "key": "value", + "label": "Value", + "object_types": [ + { + "type": "number", + "key": "x", + "default": 1, + "decimal": 4 + }, + { + "type": "number", + "key": "y", + "default": 1, + "decimal": 4 + }, + { + "type": "number", + "key": "y", + "default": 1, + "decimal": 4 + } + ] + } + ] + } + ] + } + }] +} From 8e4dc740e8dea2b87218754816b9501998284461 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 2 May 2022 12:24:20 +0200 Subject: [PATCH 171/583] nuke: adding default knobs to created node --- openpype/hosts/nuke/api/lib.py | 25 +++++++++++++++++++ openpype/hosts/nuke/api/plugin.py | 4 ++- .../plugins/create/create_write_render.py | 8 +++--- 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 3223feaec7..065fe9beb2 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -858,6 +858,7 @@ def create_write_node(name, data, input=None, prenodes=None, Return: node (obj): group node with avalon data as Knobs ''' + knob_overrides = data.get("knobs", []) imageio_writes = get_created_node_imageio_setting(**data) for knob in imageio_writes["knobs"]: @@ -1061,6 +1062,30 @@ def create_write_node(name, data, input=None, prenodes=None, tile_color = _data.get("tile_color", "0xff0000ff") GN["tile_color"].setValue(tile_color) + # overrie knob values from settings + for knob in knob_overrides: + knob_type = knob["type"] + knob_name = knob["name"] + knob_value = knob["value"] + if knob_name not in GN.knobs(): + continue + if not knob_value: + continue + + # set correctly knob types + if knob_type == "string": + knob_value = str(knob_value) + if knob_type == "number": + knob_value = int(knob_value) + if knob_type == "decimal_number": + knob_value = float(knob_value) + if knob_type == "bool": + knob_value = bool(knob_value) + if knob_type in ["2d_vector", "3d_vector"]: + knob_value = list(knob_value) + + GN[knob_name].setValue(knob_value) + return GN diff --git a/openpype/hosts/nuke/api/plugin.py b/openpype/hosts/nuke/api/plugin.py index fdb5930cb2..37c3633d2c 100644 --- a/openpype/hosts/nuke/api/plugin.py +++ b/openpype/hosts/nuke/api/plugin.py @@ -605,6 +605,7 @@ class AbstractWriteRender(OpenPypeCreator): family = "render" icon = "sign-out" defaults = ["Main", "Mask"] + knobs = [] def __init__(self, *args, **kwargs): super(AbstractWriteRender, self).__init__(*args, **kwargs) @@ -672,7 +673,8 @@ class AbstractWriteRender(OpenPypeCreator): "nodeclass": self.n_class, "families": [self.family], "avalon": self.data, - "subset": self.data["subset"] + "subset": self.data["subset"], + "knobs": self.knobs } # add creator data diff --git a/openpype/hosts/nuke/plugins/create/create_write_render.py b/openpype/hosts/nuke/plugins/create/create_write_render.py index 18a101546f..36a7b5c33f 100644 --- a/openpype/hosts/nuke/plugins/create/create_write_render.py +++ b/openpype/hosts/nuke/plugins/create/create_write_render.py @@ -13,6 +13,7 @@ class CreateWriteRender(plugin.AbstractWriteRender): family = "render" icon = "sign-out" defaults = ["Main", "Mask"] + knobs = [] def __init__(self, *args, **kwargs): super(CreateWriteRender, self).__init__(*args, **kwargs) @@ -38,13 +39,12 @@ class CreateWriteRender(plugin.AbstractWriteRender): } ] - write_node = create_write_node( + return create_write_node( self.data["subset"], write_data, input=selected_node, - prenodes=_prenodes) - - return write_node + prenodes=_prenodes + ) def _modify_write_node(self, write_node): return write_node From d0b3cf73c4c5e6967a3f2431626b6c02771eab66 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 2 May 2022 17:11:12 +0200 Subject: [PATCH 172/583] nuke: extracting method for knob types --- openpype/hosts/nuke/api/lib.py | 36 ++++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 065fe9beb2..a002e02ea3 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -1062,31 +1062,43 @@ def create_write_node(name, data, input=None, prenodes=None, tile_color = _data.get("tile_color", "0xff0000ff") GN["tile_color"].setValue(tile_color) - # overrie knob values from settings - for knob in knob_overrides: + # finally add knob overrides + set_node_knobs_from_settings(GN, knob_overrides) + + return GN + + +def set_node_knobs_from_settings(node, knob_settings): + """ Overriding knob values from settings + + Using `schema_nuke_knob_inputs` for knob type definitions. + + Args: + node (nuke.Node): nuke node + knob_settings (list): list of dict. Keys are `type`, `name`, `value` + """ + for knob in knob_settings: knob_type = knob["type"] knob_name = knob["name"] knob_value = knob["value"] - if knob_name not in GN.knobs(): + if knob_name not in node.knobs(): continue if not knob_value: continue # set correctly knob types - if knob_type == "string": - knob_value = str(knob_value) - if knob_type == "number": - knob_value = int(knob_value) - if knob_type == "decimal_number": - knob_value = float(knob_value) if knob_type == "bool": knob_value = bool(knob_value) + elif knob_type == "decimal_number": + knob_value = float(knob_value) + elif knob_type == "number": + knob_value = int(knob_value) + elif knob_type == "string": + knob_value = str(knob_value) if knob_type in ["2d_vector", "3d_vector"]: knob_value = list(knob_value) - GN[knob_name].setValue(knob_value) - - return GN + node[knob_name].setValue(knob_value) def add_rendering_knobs(node, farm=True): From 4b7f17c77eca959fe4337e148311f6dbd878c136 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 2 May 2022 18:01:31 +0200 Subject: [PATCH 173/583] nuke: adding hex and color types to nuke knob schema --- .../schemas/schema_nuke_knob_inputs.json | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_knob_inputs.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_knob_inputs.json index 0d03b89288..03eea039d6 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_knob_inputs.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_knob_inputs.json @@ -27,6 +27,22 @@ } ] }, + { + "key": "hex", + "label": "Hexadecimal (0x)", + "children": [ + { + "type": "text", + "key": "name", + "label": "Name" + }, + { + "type": "text", + "key": "value", + "label": "Value" + } + ] + }, { "key": "bool", "label": "Boolean", @@ -144,6 +160,48 @@ ] } ] + }, + { + "key": "color", + "label": "Color", + "children": [ + { + "type": "text", + "key": "name", + "label": "Name" + }, + { + "type": "list-strict", + "key": "value", + "label": "Value", + "object_types": [ + { + "type": "number", + "key": "x", + "default": 1, + "decimal": 4 + }, + { + "type": "number", + "key": "x", + "default": 1, + "decimal": 4 + }, + { + "type": "number", + "key": "y", + "default": 1, + "decimal": 4 + }, + { + "type": "number", + "key": "y", + "default": 1, + "decimal": 4 + } + ] + } + ] } ] } From a72aaecc0d1a640416e321ba5d4fe51a03b7fe93 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 2 May 2022 18:02:03 +0200 Subject: [PATCH 174/583] nuke: solving hex and color knob types --- openpype/hosts/nuke/api/lib.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index a002e02ea3..82c92f06f9 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -1,4 +1,5 @@ import os +from pprint import pformat import re import six import platform @@ -1086,6 +1087,11 @@ def set_node_knobs_from_settings(node, knob_settings): if not knob_value: continue + # first convert string types to string + # just to ditch unicode + if isinstance(knob_value, six.text_type): + knob_value = str(knob_value) + # set correctly knob types if knob_type == "bool": knob_value = bool(knob_value) @@ -1094,9 +1100,16 @@ def set_node_knobs_from_settings(node, knob_settings): elif knob_type == "number": knob_value = int(knob_value) elif knob_type == "string": - knob_value = str(knob_value) - if knob_type in ["2d_vector", "3d_vector"]: - knob_value = list(knob_value) + knob_value = knob_value + elif knob_type == "hex": + if not knob_value.startswith("0x"): + raise ValueError( + "Check your settings! Input Hexa is wrong! \n{}".format( + pformat(knob_settings) + )) + knob_value = int(knob_value, 16) + if knob_type in ["2d_vector", "3d_vector", "color"]: + knob_value = [float(v) for v in knob_value] node[knob_name].setValue(knob_value) From e6326570f8770251658b531ff3b3bd64c37a99f2 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 2 May 2022 18:35:58 +0200 Subject: [PATCH 175/583] help button is resized and has more content --- openpype/style/style.css | 7 +- .../tools/publisher/widgets/create_dialog.py | 115 +++++++++++++++--- 2 files changed, 101 insertions(+), 21 deletions(-) diff --git a/openpype/style/style.css b/openpype/style/style.css index 4032732a78..8eeae1a58a 100644 --- a/openpype/style/style.css +++ b/openpype/style/style.css @@ -866,16 +866,21 @@ QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { #CreateDialogHelpButton { background: rgba(255, 255, 255, 31); + border-top-left-radius: 0.2em; + border-bottom-left-radius: 0.2em; border-top-right-radius: 0; border-bottom-right-radius: 0; font-size: 10pt; font-weight: bold; - padding: 3px 3px 3px 3px; + padding: 0px; } #CreateDialogHelpButton:hover { background: rgba(255, 255, 255, 63); } +#CreateDialogHelpButton QWidget { + background: transparent; +} #PublishLogConsole { font-family: "Noto Sans Mono"; diff --git a/openpype/tools/publisher/widgets/create_dialog.py b/openpype/tools/publisher/widgets/create_dialog.py index 10be47c517..8b2710b165 100644 --- a/openpype/tools/publisher/widgets/create_dialog.py +++ b/openpype/tools/publisher/widgets/create_dialog.py @@ -3,6 +3,7 @@ import re import traceback import copy +import qtawesome try: import commonmark except Exception: @@ -15,7 +16,8 @@ from openpype.pipeline.create import ( ) from openpype.tools.utils import ( ErrorMessageBox, - MessageOverlayObject + MessageOverlayObject, + ClickableFrame, ) from .widgets import IconValuePixmapLabel @@ -186,13 +188,43 @@ class CreatorShortDescWidget(QtWidgets.QWidget): self._description_label.setText(description) -class HelpButton(QtWidgets.QPushButton): - resized = QtCore.Signal() +class HelpButton(ClickableFrame): + resized = QtCore.Signal(int) + question_mark_icon_name = "fa.question" + help_icon_name = "fa.question-circle" + hide_icon_name = "fa.angle-left" def __init__(self, *args, **kwargs): super(HelpButton, self).__init__(*args, **kwargs) self.setObjectName("CreateDialogHelpButton") + question_mark_label = QtWidgets.QLabel(self) + help_widget = QtWidgets.QWidget(self) + + help_question = QtWidgets.QLabel(help_widget) + help_label = QtWidgets.QLabel("Help", help_widget) + hide_icon = QtWidgets.QLabel(help_widget) + + help_layout = QtWidgets.QHBoxLayout(help_widget) + help_layout.setContentsMargins(0, 0, 5, 0) + help_layout.addWidget(help_question, 0) + help_layout.addWidget(help_label, 0) + help_layout.addStretch(1) + help_layout.addWidget(hide_icon, 0) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + layout.addWidget(question_mark_label, 0) + layout.addWidget(help_widget, 1) + + help_widget.setVisible(False) + + self._question_mark_label = question_mark_label + self._help_widget = help_widget + self._help_question = help_question + self._hide_icon = hide_icon + self._expanded = None self.set_expanded() @@ -202,27 +234,52 @@ class HelpButton(QtWidgets.QPushButton): return expanded = False self._expanded = expanded - if expanded: - text = "<" + self._help_widget.setVisible(expanded) + self._update_content() + + def _update_content(self): + width = self.get_icon_width() + if self._expanded: + question_mark_pix = QtGui.QPixmap(width, width) + question_mark_pix.fill(QtCore.Qt.transparent) + else: - text = "?" - self.setText(text) + question_mark_icon = qtawesome.icon( + self.question_mark_icon_name, color=QtCore.Qt.white + ) + question_mark_pix = question_mark_icon.pixmap(width, width) - self._update_size() + hide_icon = qtawesome.icon( + self.hide_icon_name, color=QtCore.Qt.white + ) + help_question_icon = qtawesome.icon( + self.help_icon_name, color=QtCore.Qt.white + ) + self._question_mark_label.setPixmap(question_mark_pix) + self._question_mark_label.setMaximumWidth(width) + self._hide_icon.setPixmap(hide_icon.pixmap(width, width)) + self._help_question.setPixmap(help_question_icon.pixmap(width, width)) - def _update_size(self): - new_size = self.minimumSizeHint() - if self.size() != new_size: - self.resize(new_size) - self.resized.emit() + def get_icon_width(self): + metrics = self.fontMetrics() + return metrics.height() + + def set_pos_and_size(self, pos_x, pos_y, width, height): + update_icon = self.height() != height + self.move(pos_x, pos_y) + self.resize(width, height) + + if update_icon: + self._update_content() + self.updateGeometry() def showEvent(self, event): super(HelpButton, self).showEvent(event) - self._update_size() + self.resized.emit(self.height()) def resizeEvent(self, event): super(HelpButton, self).resizeEvent(event) - self._update_size() + self.resized.emit(self.height()) class CreateDialog(QtWidgets.QDialog): @@ -692,14 +749,32 @@ class CreateDialog(QtWidgets.QDialog): self._set_creator_by_identifier(identifier) def _update_help_btn(self): - pos_x = self.width() - self._help_btn.width() - point = self._creator_short_desc_widget.rect().topRight() + short_desc_rect = self._creator_short_desc_widget.rect() + height = short_desc_rect.height() + + point = short_desc_rect.topRight() mapped_point = self._creator_short_desc_widget.mapTo(self, point) pos_y = mapped_point.y() - self._help_btn.move(max(0, pos_x), max(0, pos_y)) - def _on_help_btn_resize(self): - self._update_help_btn() + icon_width = self._help_btn.get_icon_width() + + pos_x = self.width() - icon_width + if self._detail_placoholder_widget.isVisible(): + pos_x -= ( + self._detail_placoholder_widget.width() + + self._splitter_widget.handle(3).width() + ) + + width = self.width() - pos_x + + self._help_btn.set_pos_and_size( + max(0, pos_x), max(0, pos_y), + width, height + ) + + def _on_help_btn_resize(self, height): + if self._creator_short_desc_widget.height() != height: + self._update_help_btn() def _on_help_btn(self): if self._desc_width_anim_timer.isActive(): From b7f859c0b00c41cb511f256160b4d17d7164e616 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 2 May 2022 19:27:27 +0200 Subject: [PATCH 176/583] fixed description min/max sizes --- .../tools/publisher/widgets/create_dialog.py | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/openpype/tools/publisher/widgets/create_dialog.py b/openpype/tools/publisher/widgets/create_dialog.py index 8b2710b165..bcf87dea6a 100644 --- a/openpype/tools/publisher/widgets/create_dialog.py +++ b/openpype/tools/publisher/widgets/create_dialog.py @@ -492,6 +492,7 @@ class CreateDialog(QtWidgets.QDialog): creator_short_desc_widget.height_changed.connect( self._on_description_height_change ) + splitter_widget.splitterMoved.connect(self._on_splitter_move) controller.add_plugins_refresh_callback(self._on_plugins_refresh) @@ -534,6 +535,7 @@ class CreateDialog(QtWidgets.QDialog): self._last_description_width = None self._last_full_width = 0 self._expected_description_width = 0 + self._last_desc_max_width = None self._other_widgets_widths = [] def _emit_message(self, message): @@ -750,14 +752,18 @@ class CreateDialog(QtWidgets.QDialog): def _update_help_btn(self): short_desc_rect = self._creator_short_desc_widget.rect() - height = short_desc_rect.height() - point = short_desc_rect.topRight() + # point = short_desc_rect.topRight() + point = short_desc_rect.center() mapped_point = self._creator_short_desc_widget.mapTo(self, point) - pos_y = mapped_point.y() - + # pos_y = mapped_point.y() + center_pos_y = mapped_point.y() icon_width = self._help_btn.get_icon_width() + _height = int(icon_width * 2.5) + height = min(_height, short_desc_rect.height()) + pos_y = center_pos_y - int(height / 2) + pos_x = self.width() - icon_width if self._detail_placoholder_widget.isVisible(): pos_x -= ( @@ -776,6 +782,9 @@ class CreateDialog(QtWidgets.QDialog): if self._creator_short_desc_widget.height() != height: self._update_help_btn() + def _on_splitter_move(self, *args): + self._update_help_btn() + def _on_help_btn(self): if self._desc_width_anim_timer.isActive(): return @@ -827,6 +836,10 @@ class CreateDialog(QtWidgets.QDialog): step_size = 1 min_max = 0 + if self._last_desc_max_width is None: + self._last_desc_max_width = ( + self._detail_description_widget.maximumWidth() + ) self._detail_description_widget.setMinimumWidth(min_max) self._detail_description_widget.setMaximumWidth(min_max) self._expected_description_width = expected_width @@ -902,6 +915,10 @@ class CreateDialog(QtWidgets.QDialog): if not growing: return + self._detail_description_widget.setMinimumWidth(0) + self._detail_description_widget.setMaximumWidth( + self._last_desc_max_width + ) self._detail_description_widget.setSizePolicy( self._description_size_policy ) From c608eeb2623bf97ccf65d876404d69c7e8b9988d Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 2 May 2022 21:35:09 +0200 Subject: [PATCH 177/583] Remove remaining imports from avalon --- openpype/hosts/blender/api/plugin.py | 2 +- openpype/hosts/fusion/api/pipeline.py | 3 ++- openpype/hosts/harmony/api/lib.py | 2 +- openpype/hosts/hiero/api/lib.py | 11 +++++------ openpype/hosts/houdini/plugins/load/show_usdview.py | 11 +++++------ openpype/hosts/maya/plugins/load/_load_animation.py | 2 +- openpype/hosts/nuke/api/lib.py | 2 +- openpype/widgets/project_settings.py | 2 +- tests/lib/testing_classes.py | 2 +- 9 files changed, 18 insertions(+), 19 deletions(-) diff --git a/openpype/hosts/blender/api/plugin.py b/openpype/hosts/blender/api/plugin.py index 3207f543b7..c59be8d7ff 100644 --- a/openpype/hosts/blender/api/plugin.py +++ b/openpype/hosts/blender/api/plugin.py @@ -266,7 +266,7 @@ class AssetLoader(LoaderPlugin): # Only containerise if it's not already a collection from a .blend file. # representation = context["representation"]["name"] # if representation != "blend": - # from avalon.blender.pipeline import containerise + # from openpype.hosts.blender.api.pipeline import containerise # return containerise( # name=name, # namespace=namespace, diff --git a/openpype/hosts/fusion/api/pipeline.py b/openpype/hosts/fusion/api/pipeline.py index 0867b464d5..54002f9f51 100644 --- a/openpype/hosts/fusion/api/pipeline.py +++ b/openpype/hosts/fusion/api/pipeline.py @@ -45,7 +45,8 @@ def install(): This is where you install menus and register families, data and loaders into fusion. - It is called automatically when installing via `api.install(avalon.fusion)` + It is called automatically when installing via + `openpype.pipeline.install_host(openpype.hosts.fusion.api)` See the Maya equivalent for inspiration on how to implement this. diff --git a/openpype/hosts/harmony/api/lib.py b/openpype/hosts/harmony/api/lib.py index 53fd0f07dd..e5e7ad1b7e 100644 --- a/openpype/hosts/harmony/api/lib.py +++ b/openpype/hosts/harmony/api/lib.py @@ -463,7 +463,7 @@ def imprint(node_id, data, remove=False): remove (bool): Removes the data from the scene. Example: - >>> from avalon.harmony import lib + >>> from openpype.hosts.harmony.api import lib >>> node = "Top/Display" >>> data = {"str": "someting", "int": 1, "float": 0.32, "bool": True} >>> lib.imprint(layer, data) diff --git a/openpype/hosts/hiero/api/lib.py b/openpype/hosts/hiero/api/lib.py index 0e64ddcaf5..2a4cd03b76 100644 --- a/openpype/hosts/hiero/api/lib.py +++ b/openpype/hosts/hiero/api/lib.py @@ -553,10 +553,10 @@ class PublishAction(QtWidgets.QAction): # # ''' # import hiero.core -# from avalon.nuke import imprint -# from pype.hosts.nuke import ( -# lib as nklib -# ) +# from openpype.hosts.nuke.api.lib import ( +# BuildWorkfile, +# imprint +# ) # # # check if the file exists if does then Raise "File exists!" # if os.path.exists(filepath): @@ -583,8 +583,7 @@ class PublishAction(QtWidgets.QAction): # # nuke_script.addNode(root_node) # -# # here to call pype.hosts.nuke.lib.BuildWorkfile -# script_builder = nklib.BuildWorkfile( +# script_builder = BuildWorkfile( # root_node=root_node, # root_path=root_path, # nodes=nuke_script.getNodes(), diff --git a/openpype/hosts/houdini/plugins/load/show_usdview.py b/openpype/hosts/houdini/plugins/load/show_usdview.py index 8066615181..2737bc40fa 100644 --- a/openpype/hosts/houdini/plugins/load/show_usdview.py +++ b/openpype/hosts/houdini/plugins/load/show_usdview.py @@ -1,3 +1,7 @@ +import os +import subprocess + +from openpype.lib.vendor_bin_utils import find_executable from openpype.pipeline import load @@ -14,12 +18,7 @@ class ShowInUsdview(load.LoaderPlugin): def load(self, context, name=None, namespace=None, data=None): - import os - import subprocess - - import avalon.lib as lib - - usdview = lib.which("usdview") + usdview = find_executable("usdview") filepath = os.path.normpath(self.fname) filepath = filepath.replace("\\", "/") diff --git a/openpype/hosts/maya/plugins/load/_load_animation.py b/openpype/hosts/maya/plugins/load/_load_animation.py index bce1f0fc67..9c37e498ef 100644 --- a/openpype/hosts/maya/plugins/load/_load_animation.py +++ b/openpype/hosts/maya/plugins/load/_load_animation.py @@ -2,7 +2,7 @@ import openpype.hosts.maya.api.plugin class AbcLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): - """Specific loader of Alembic for the avalon.animation family""" + """Loader to reference an Alembic file""" families = ["animation", "camera", diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 3223feaec7..f0af20289c 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -193,7 +193,7 @@ def imprint(node, data, tab=None): Examples: ``` import nuke - from avalon.nuke import lib + from openpype.hosts.nuke.api import lib node = nuke.createNode("NoOp") data = { diff --git a/openpype/widgets/project_settings.py b/openpype/widgets/project_settings.py index 43ff9f2789..687e17b3bf 100644 --- a/openpype/widgets/project_settings.py +++ b/openpype/widgets/project_settings.py @@ -4,7 +4,7 @@ import platform from Qt import QtCore, QtGui, QtWidgets -from avalon import style +from openpype import style import ftrack_api diff --git a/tests/lib/testing_classes.py b/tests/lib/testing_classes.py index 7dfbf6fd0d..f991f02227 100644 --- a/tests/lib/testing_classes.py +++ b/tests/lib/testing_classes.py @@ -153,7 +153,7 @@ class ModuleUnitTest(BaseTest): Database prepared from dumps with 'db_setup' fixture. """ - from avalon.api import AvalonMongoDB + from openpype.pipeline import AvalonMongoDB dbcon = AvalonMongoDB() dbcon.Session["AVALON_PROJECT"] = self.TEST_PROJECT_NAME yield dbcon From 9e2f7f328b5746aa9c2b617520c85e672233dcef Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 2 May 2022 21:43:01 +0200 Subject: [PATCH 178/583] Cleanup some Loader docstrings --- openpype/hosts/fusion/plugins/load/actions.py | 4 ++-- openpype/hosts/houdini/plugins/load/actions.py | 4 ++-- openpype/hosts/houdini/plugins/load/load_alembic.py | 2 +- openpype/hosts/houdini/plugins/load/load_camera.py | 2 +- openpype/hosts/houdini/plugins/load/load_image.py | 4 ++-- openpype/hosts/houdini/plugins/load/load_vdb.py | 2 +- openpype/hosts/maya/plugins/load/_load_animation.py | 2 +- openpype/hosts/maya/plugins/load/actions.py | 4 ++-- openpype/hosts/maya/plugins/load/load_ass.py | 2 +- openpype/hosts/maya/plugins/load/load_gpucache.py | 2 +- openpype/hosts/maya/plugins/load/load_reference.py | 2 +- openpype/hosts/maya/plugins/load/load_vdb_to_vray.py | 1 + openpype/hosts/nuke/plugins/load/actions.py | 4 ++-- 13 files changed, 18 insertions(+), 17 deletions(-) diff --git a/openpype/hosts/fusion/plugins/load/actions.py b/openpype/hosts/fusion/plugins/load/actions.py index bc59cec77f..819c9272fd 100644 --- a/openpype/hosts/fusion/plugins/load/actions.py +++ b/openpype/hosts/fusion/plugins/load/actions.py @@ -6,7 +6,7 @@ from openpype.pipeline import load class FusionSetFrameRangeLoader(load.LoaderPlugin): - """Specific loader of Alembic for the avalon.animation family""" + """Set frame range excluding pre- and post-handles""" families = ["animation", "camera", @@ -40,7 +40,7 @@ class FusionSetFrameRangeLoader(load.LoaderPlugin): class FusionSetFrameRangeWithHandlesLoader(load.LoaderPlugin): - """Specific loader of Alembic for the avalon.animation family""" + """Set frame range including pre- and post-handles""" families = ["animation", "camera", diff --git a/openpype/hosts/houdini/plugins/load/actions.py b/openpype/hosts/houdini/plugins/load/actions.py index 63d74c39a5..637be1513d 100644 --- a/openpype/hosts/houdini/plugins/load/actions.py +++ b/openpype/hosts/houdini/plugins/load/actions.py @@ -6,7 +6,7 @@ from openpype.pipeline import load class SetFrameRangeLoader(load.LoaderPlugin): - """Set Houdini frame range""" + """Set frame range excluding pre- and post-handles""" families = [ "animation", @@ -44,7 +44,7 @@ class SetFrameRangeLoader(load.LoaderPlugin): class SetFrameRangeWithHandlesLoader(load.LoaderPlugin): - """Set Maya frame range including pre- and post-handles""" + """Set frame range including pre- and post-handles""" families = [ "animation", diff --git a/openpype/hosts/houdini/plugins/load/load_alembic.py b/openpype/hosts/houdini/plugins/load/load_alembic.py index 0214229d5a..96e666b255 100644 --- a/openpype/hosts/houdini/plugins/load/load_alembic.py +++ b/openpype/hosts/houdini/plugins/load/load_alembic.py @@ -7,7 +7,7 @@ from openpype.hosts.houdini.api import pipeline class AbcLoader(load.LoaderPlugin): - """Specific loader of Alembic for the avalon.animation family""" + """Load Alembic""" families = ["model", "animation", "pointcache", "gpuCache"] label = "Load Alembic" diff --git a/openpype/hosts/houdini/plugins/load/load_camera.py b/openpype/hosts/houdini/plugins/load/load_camera.py index ef57d115da..059ad11a76 100644 --- a/openpype/hosts/houdini/plugins/load/load_camera.py +++ b/openpype/hosts/houdini/plugins/load/load_camera.py @@ -78,7 +78,7 @@ def transfer_non_default_values(src, dest, ignore=None): class CameraLoader(load.LoaderPlugin): - """Specific loader of Alembic for the avalon.animation family""" + """Load camera from an Alembic file""" families = ["camera"] label = "Load Camera (abc)" diff --git a/openpype/hosts/houdini/plugins/load/load_image.py b/openpype/hosts/houdini/plugins/load/load_image.py index 671f08f18f..928c2ee734 100644 --- a/openpype/hosts/houdini/plugins/load/load_image.py +++ b/openpype/hosts/houdini/plugins/load/load_image.py @@ -42,9 +42,9 @@ def get_image_avalon_container(): class ImageLoader(load.LoaderPlugin): - """Specific loader of Alembic for the avalon.animation family""" + """Load images into COP2""" - families = ["colorbleed.imagesequence"] + families = ["imagesequence"] label = "Load Image (COP2)" representations = ["*"] order = -10 diff --git a/openpype/hosts/houdini/plugins/load/load_vdb.py b/openpype/hosts/houdini/plugins/load/load_vdb.py index 06bb9e45e4..bff0f8b0bf 100644 --- a/openpype/hosts/houdini/plugins/load/load_vdb.py +++ b/openpype/hosts/houdini/plugins/load/load_vdb.py @@ -9,7 +9,7 @@ from openpype.hosts.houdini.api import pipeline class VdbLoader(load.LoaderPlugin): - """Specific loader of Alembic for the avalon.animation family""" + """Load VDB""" families = ["vdbcache"] label = "Load VDB" diff --git a/openpype/hosts/maya/plugins/load/_load_animation.py b/openpype/hosts/maya/plugins/load/_load_animation.py index bce1f0fc67..9c37e498ef 100644 --- a/openpype/hosts/maya/plugins/load/_load_animation.py +++ b/openpype/hosts/maya/plugins/load/_load_animation.py @@ -2,7 +2,7 @@ import openpype.hosts.maya.api.plugin class AbcLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): - """Specific loader of Alembic for the avalon.animation family""" + """Loader to reference an Alembic file""" families = ["animation", "camera", diff --git a/openpype/hosts/maya/plugins/load/actions.py b/openpype/hosts/maya/plugins/load/actions.py index 483ad32402..4b7871a40c 100644 --- a/openpype/hosts/maya/plugins/load/actions.py +++ b/openpype/hosts/maya/plugins/load/actions.py @@ -10,7 +10,7 @@ from openpype.hosts.maya.api.lib import ( class SetFrameRangeLoader(load.LoaderPlugin): - """Specific loader of Alembic for the avalon.animation family""" + """Set frame range excluding pre- and post-handles""" families = ["animation", "camera", @@ -44,7 +44,7 @@ class SetFrameRangeLoader(load.LoaderPlugin): class SetFrameRangeWithHandlesLoader(load.LoaderPlugin): - """Specific loader of Alembic for the avalon.animation family""" + """Set frame range including pre- and post-handles""" families = ["animation", "camera", diff --git a/openpype/hosts/maya/plugins/load/load_ass.py b/openpype/hosts/maya/plugins/load/load_ass.py index 18de4df3b1..a284b7ec1f 100644 --- a/openpype/hosts/maya/plugins/load/load_ass.py +++ b/openpype/hosts/maya/plugins/load/load_ass.py @@ -16,7 +16,7 @@ from openpype.hosts.maya.api.pipeline import containerise class AssProxyLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): - """Load the Proxy""" + """Load Arnold Proxy as reference""" families = ["ass"] representations = ["ass"] diff --git a/openpype/hosts/maya/plugins/load/load_gpucache.py b/openpype/hosts/maya/plugins/load/load_gpucache.py index 591e568e4c..6d5e945508 100644 --- a/openpype/hosts/maya/plugins/load/load_gpucache.py +++ b/openpype/hosts/maya/plugins/load/load_gpucache.py @@ -8,7 +8,7 @@ from openpype.api import get_project_settings class GpuCacheLoader(load.LoaderPlugin): - """Load model Alembic as gpuCache""" + """Load Alembic as gpuCache""" families = ["model"] representations = ["abc"] diff --git a/openpype/hosts/maya/plugins/load/load_reference.py b/openpype/hosts/maya/plugins/load/load_reference.py index a8875cf216..d65b5a2c1e 100644 --- a/openpype/hosts/maya/plugins/load/load_reference.py +++ b/openpype/hosts/maya/plugins/load/load_reference.py @@ -12,7 +12,7 @@ from openpype.hosts.maya.api.lib import maintained_selection class ReferenceLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): - """Load the model""" + """Reference file""" families = ["model", "pointcache", diff --git a/openpype/hosts/maya/plugins/load/load_vdb_to_vray.py b/openpype/hosts/maya/plugins/load/load_vdb_to_vray.py index 4f14235bfb..3a16264ec0 100644 --- a/openpype/hosts/maya/plugins/load/load_vdb_to_vray.py +++ b/openpype/hosts/maya/plugins/load/load_vdb_to_vray.py @@ -74,6 +74,7 @@ def _fix_duplicate_vvg_callbacks(): class LoadVDBtoVRay(load.LoaderPlugin): + """Load OpenVDB in a V-Ray Volume Grid""" families = ["vdbcache"] representations = ["vdb"] diff --git a/openpype/hosts/nuke/plugins/load/actions.py b/openpype/hosts/nuke/plugins/load/actions.py index 81840b3a38..d364a4f3a1 100644 --- a/openpype/hosts/nuke/plugins/load/actions.py +++ b/openpype/hosts/nuke/plugins/load/actions.py @@ -9,7 +9,7 @@ log = Logger().get_logger(__name__) class SetFrameRangeLoader(load.LoaderPlugin): - """Specific loader of Alembic for the avalon.animation family""" + """Set frame range excluding pre- and post-handles""" families = ["animation", "camera", @@ -43,7 +43,7 @@ class SetFrameRangeLoader(load.LoaderPlugin): class SetFrameRangeWithHandlesLoader(load.LoaderPlugin): - """Specific loader of Alembic for the avalon.animation family""" + """Set frame range including pre- and post-handles""" families = ["animation", "camera", From 80494c91c145a01c7aa2cf5017af8c89ecb6a4c8 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 2 May 2022 22:29:38 +0200 Subject: [PATCH 179/583] Fix typo --- openpype/modules/base.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/modules/base.py b/openpype/modules/base.py index 23c908299f..58ad3a8d2f 100644 --- a/openpype/modules/base.py +++ b/openpype/modules/base.py @@ -310,7 +310,7 @@ def _load_modules(): init_path = os.path.join(fullpath, "__init__.py") if not os.path.exists(init_path): log.debug(( - "Module directory does not contan __init__.py file {}" + "Module directory does not contain __init__.py file {}" ).format(fullpath)) continue @@ -357,7 +357,7 @@ def _load_modules(): init_path = os.path.join(fullpath, "__init__.py") if not os.path.exists(init_path): log.debug(( - "Module directory does not contan __init__.py file {}" + "Module directory does not contain __init__.py file {}" ).format(fullpath)) continue From 4fc8617bd1037b1b77b34cb903c85ddbb651a1d4 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 2 May 2022 22:32:23 +0200 Subject: [PATCH 180/583] Fix typo in comment --- openpype/modules/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/base.py b/openpype/modules/base.py index 58ad3a8d2f..e280589548 100644 --- a/openpype/modules/base.py +++ b/openpype/modules/base.py @@ -353,7 +353,7 @@ def _load_modules(): basename, ext = os.path.splitext(filename) if os.path.isdir(fullpath): - # Check existence of init fil + # Check existence of init file init_path = os.path.join(fullpath, "__init__.py") if not os.path.exists(init_path): log.debug(( From 445fc679f1752def211f75a298ceb3a8af80f889 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 2 May 2022 22:32:47 +0200 Subject: [PATCH 181/583] And fix the other typo in comment --- openpype/modules/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/base.py b/openpype/modules/base.py index e280589548..5ad1fc71c4 100644 --- a/openpype/modules/base.py +++ b/openpype/modules/base.py @@ -306,7 +306,7 @@ def _load_modules(): basename, ext = os.path.splitext(filename) if os.path.isdir(fullpath): - # Check existence of init fil + # Check existence of init file init_path = os.path.join(fullpath, "__init__.py") if not os.path.exists(init_path): log.debug(( From ef7798d5028573a4384ebf38db33eae7cf47b98e Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 2 May 2022 23:04:33 +0200 Subject: [PATCH 182/583] Fix coloring of TrayModuleManager output --- openpype/lib/terminal.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/lib/terminal.py b/openpype/lib/terminal.py index 5121b6ec26..f6072ed209 100644 --- a/openpype/lib/terminal.py +++ b/openpype/lib/terminal.py @@ -98,7 +98,7 @@ class Terminal: r"\*\*\* WRN": _SB + _LY + r"*** WRN" + _RST, r" \- ": _SB + _LY + r" - " + _RST, r"\[ ": _SB + _LG + r"[ " + _RST, - r"\]": _SB + _LG + r"]" + _RST, + r" \]": _SB + _LG + r" ]" + _RST, r"{": _LG + r"{", r"}": r"}" + _RST, r"\(": _LY + r"(", From 4f3cbeb9a94a237da4f12858bb4ae95f7cc581d1 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 3 May 2022 11:29:57 +0200 Subject: [PATCH 183/583] fix compositing order --- openpype/hosts/tvpaint/lib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/tvpaint/lib.py b/openpype/hosts/tvpaint/lib.py index 715ebb4a9d..c67ab1e4fb 100644 --- a/openpype/hosts/tvpaint/lib.py +++ b/openpype/hosts/tvpaint/lib.py @@ -573,7 +573,7 @@ def composite_rendered_layers( layer_ids_by_position[layer_position] = layer["layer_id"] # Sort layer positions - sorted_positions = tuple(sorted(layer_ids_by_position.keys())) + sorted_positions = tuple(reversed(sorted(layer_ids_by_position.keys()))) # Prepare variable where filepaths without any rendered content # - transparent will be created transparent_filepaths = set() From 518ab19a0b58dbe78c7051aa1f69fc4e28f1e310 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 3 May 2022 12:11:23 +0200 Subject: [PATCH 184/583] fix missing openpype_versions variable for headless mode --- start.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/start.py b/start.py index 4d4801c1e5..750552399f 100644 --- a/start.py +++ b/start.py @@ -1029,6 +1029,9 @@ def boot(): message = str(exc) _print(message) if os.environ.get("OPENPYPE_HEADLESS_MODE") == "1": + openpype_versions = bootstrap.find_openpype( + include_zips=True, staging=use_staging + ) list_versions(openpype_versions, local_version) else: igniter.show_message_dialog("Version not found", message) @@ -1053,6 +1056,9 @@ def boot(): message = str(exc) _print(message) if os.environ.get("OPENPYPE_HEADLESS_MODE") == "1": + openpype_versions = bootstrap.find_openpype( + include_zips=True, staging=use_staging + ) list_versions(openpype_versions, local_version) else: igniter.show_message_dialog("Version not found", message) From 8e7b27cff14cba8330b7f8c9148b64130c21f2f0 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 3 May 2022 12:16:19 +0200 Subject: [PATCH 185/583] nuke: change `string` type to `text` --- openpype/hosts/nuke/api/lib.py | 2 +- openpype/settings/defaults/project_settings/nuke.json | 6 +++--- .../projects_schema/schemas/schema_nuke_knob_inputs.json | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 82c92f06f9..e6e61844ba 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -1099,7 +1099,7 @@ def set_node_knobs_from_settings(node, knob_settings): knob_value = float(knob_value) elif knob_type == "number": knob_value = int(knob_value) - elif knob_type == "string": + elif knob_type == "text": knob_value = knob_value elif knob_type == "hex": if not knob_value.startswith("0x"): diff --git a/openpype/settings/defaults/project_settings/nuke.json b/openpype/settings/defaults/project_settings/nuke.json index c952d72bb1..4fc6caa57e 100644 --- a/openpype/settings/defaults/project_settings/nuke.json +++ b/openpype/settings/defaults/project_settings/nuke.json @@ -131,17 +131,17 @@ "reformat_node_add": false, "reformat_node_config": [ { - "type": "string", + "type": "text", "name": "type", "value": "to format" }, { - "type": "string", + "type": "text", "name": "format", "value": "HD_1080" }, { - "type": "string", + "type": "text", "name": "filter", "value": "Lanczos6" }, diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_knob_inputs.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_knob_inputs.json index 03eea039d6..95913f5335 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_knob_inputs.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_knob_inputs.json @@ -12,8 +12,8 @@ "enum_label": "Type", "enum_children": [ { - "key": "string", - "label": "String", + "key": "text", + "label": "Text", "children": [ { "type": "text", From 712d4c72abf7f40d26e7a781f06c69b0eb6ca214 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 3 May 2022 12:23:36 +0200 Subject: [PATCH 186/583] moved print and validate versions logic to separated functions --- start.py | 86 +++++++++++++++++++++++++++++++------------------------- 1 file changed, 47 insertions(+), 39 deletions(-) diff --git a/start.py b/start.py index 750552399f..064b828744 100644 --- a/start.py +++ b/start.py @@ -897,6 +897,51 @@ def _bootstrap_from_code(use_version, use_staging): return version_path +def _boot_validate_versions(use_version, local_version): + _print(f">>> Validating version [ {use_version} ]") + openpype_versions = bootstrap.find_openpype(include_zips=True, + staging=True) + openpype_versions += bootstrap.find_openpype(include_zips=True, + staging=False) + v: OpenPypeVersion + found = [v for v in openpype_versions if str(v) == use_version] + if not found: + _print(f"!!! Version [ {use_version} ] not found.") + list_versions(openpype_versions, local_version) + sys.exit(1) + + # print result + result = bootstrap.validate_openpype_version( + bootstrap.get_version_path_from_list( + use_version, openpype_versions)) + + _print("{}{}".format( + ">>> " if result[0] else "!!! ", + bootstrap.validate_openpype_version( + bootstrap.get_version_path_from_list( + use_version, openpype_versions) + )[1]) + ) + + +def _boot_print_versions(use_staging, local_version, openpype_root): + if not use_staging: + _print("--- This will list only non-staging versions detected.") + _print(" To see staging versions, use --use-staging argument.") + else: + _print("--- This will list only staging versions detected.") + _print(" To see other version, omit --use-staging argument.") + _openpype_root = OPENPYPE_ROOT + openpype_versions = bootstrap.find_openpype(include_zips=True, + staging=use_staging) + if getattr(sys, 'frozen', False): + local_version = bootstrap.get_version(Path(_openpype_root)) + else: + local_version = OpenPypeVersion.get_installed_version_str() + + list_versions(openpype_versions, local_version) + + def boot(): """Bootstrap OpenPype.""" @@ -966,30 +1011,7 @@ def boot(): local_version = OpenPypeVersion.get_installed_version_str() if "validate" in commands: - _print(f">>> Validating version [ {use_version} ]") - openpype_versions = bootstrap.find_openpype(include_zips=True, - staging=True) - openpype_versions += bootstrap.find_openpype(include_zips=True, - staging=False) - v: OpenPypeVersion - found = [v for v in openpype_versions if str(v) == use_version] - if not found: - _print(f"!!! Version [ {use_version} ] not found.") - list_versions(openpype_versions, local_version) - sys.exit(1) - - # print result - result = bootstrap.validate_openpype_version( - bootstrap.get_version_path_from_list( - use_version, openpype_versions)) - - _print("{}{}".format( - ">>> " if result[0] else "!!! ", - bootstrap.validate_openpype_version( - bootstrap.get_version_path_from_list( - use_version, openpype_versions) - )[1]) - ) + _boot_validate_versions(use_version, local_version) sys.exit(1) if not openpype_path: @@ -999,21 +1021,7 @@ def boot(): os.environ["OPENPYPE_PATH"] = openpype_path if "print_versions" in commands: - if not use_staging: - _print("--- This will list only non-staging versions detected.") - _print(" To see staging versions, use --use-staging argument.") - else: - _print("--- This will list only staging versions detected.") - _print(" To see other version, omit --use-staging argument.") - _openpype_root = OPENPYPE_ROOT - openpype_versions = bootstrap.find_openpype(include_zips=True, - staging=use_staging) - if getattr(sys, 'frozen', False): - local_version = bootstrap.get_version(Path(_openpype_root)) - else: - local_version = OpenPypeVersion.get_installed_version_str() - - list_versions(openpype_versions, local_version) + _boot_print_versions(use_staging, local_version, OPENPYPE_ROOT) sys.exit(1) # ------------------------------------------------------------------------ From 80c7d177a6c3144fb7f8cb4464503ab53ec295cb Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 3 May 2022 12:33:26 +0200 Subject: [PATCH 187/583] simplified modules file validations and imports --- openpype/modules/base.py | 103 +++++++++++++++------------------------ 1 file changed, 38 insertions(+), 65 deletions(-) diff --git a/openpype/modules/base.py b/openpype/modules/base.py index 5ad1fc71c4..b48de59fa0 100644 --- a/openpype/modules/base.py +++ b/openpype/modules/base.py @@ -290,49 +290,16 @@ def _load_modules(): log = PypeLogger.get_logger("ModulesLoader") - current_dir = os.path.abspath(os.path.dirname(__file__)) - processed_paths = set() - processed_paths.add(current_dir) - # Import default modules imported from 'openpype.modules' - for filename in os.listdir(current_dir): - # Ignore filenames - if ( - filename in IGNORED_FILENAMES - or filename in IGNORED_DEFAULT_FILENAMES - ): - continue - - fullpath = os.path.join(current_dir, filename) - basename, ext = os.path.splitext(filename) - - if os.path.isdir(fullpath): - # Check existence of init file - init_path = os.path.join(fullpath, "__init__.py") - if not os.path.exists(init_path): - log.debug(( - "Module directory does not contain __init__.py file {}" - ).format(fullpath)) - continue - - elif ext not in (".py", ): - continue - - try: - import_str = "openpype.modules.{}".format(basename) - new_import_str = "{}.{}".format(modules_key, basename) - default_module = __import__(import_str, fromlist=("", )) - sys.modules[new_import_str] = default_module - setattr(openpype_modules, basename, default_module) - - except Exception: - msg = ( - "Failed to import default module '{}'." - ).format(basename) - log.error(msg, exc_info=True) - # Look for OpenPype modules in paths defined with `get_module_dirs` # - dynamically imported OpenPype modules and addons - for dirpath in get_module_dirs(): + module_dirs = get_module_dirs() + # Add current directory at first place + # - has small differences in import logic + current_dir = os.path.abspath(os.path.dirname(__file__)) + module_dirs.insert(0, current_dir) + + processed_paths = set() + for dirpath in module_dirs: # Skip already processed paths if dirpath in processed_paths: continue @@ -344,39 +311,42 @@ def _load_modules(): ).format(dirpath)) continue + is_in_current_dir = dirpath == current_dir for filename in os.listdir(dirpath): # Ignore filenames if filename in IGNORED_FILENAMES: continue + if ( + is_in_current_dir + and filename in IGNORED_DEFAULT_FILENAMES + ): + continue + fullpath = os.path.join(dirpath, filename) basename, ext = os.path.splitext(filename) - if os.path.isdir(fullpath): - # Check existence of init file - init_path = os.path.join(fullpath, "__init__.py") - if not os.path.exists(init_path): - log.debug(( - "Module directory does not contain __init__.py file {}" - ).format(fullpath)) - continue - - elif ext not in (".py", ): - continue - # TODO add more logic how to define if folder is module or not # - check manifest and content of manifest try: - if os.path.isdir(fullpath): - # Module without init file can't be used as OpenPype module - # because the module class could not be imported - init_file = os.path.join(fullpath, "__init__.py") - if not os.path.exists(init_file): - log.info(( - "Skipping module directory because of" - " missing \"__init__.py\" file. \"{}\"" + if is_in_current_dir: + # Don't import dynamically + import_str = "openpype.modules.{}".format(basename) + new_import_str = "{}.{}".format(modules_key, basename) + default_module = __import__(import_str, fromlist=("", )) + sys.modules[new_import_str] = default_module + setattr(openpype_modules, basename, default_module) + + elif os.path.isdir(fullpath): + # Check existence of init file + init_path = os.path.join(fullpath, "__init__.py") + if not os.path.exists(init_path): + log.debug(( + "Module directory does not contan __init__.py" + " file {}" ).format(fullpath)) continue + import_module_from_dirpath(dirpath, filename, modules_key) elif ext in (".py", ): @@ -384,10 +354,13 @@ def _load_modules(): setattr(openpype_modules, basename, module) except Exception: - log.error( - "Failed to import '{}'.".format(fullpath), - exc_info=True - ) + if is_in_current_dir: + msg = "Failed to import default module '{}'.".format( + basename + ) + else: + msg = "Failed to import '{}'.".format(fullpath) + log.error(msg, exc_info=True) class _OpenPypeInterfaceMeta(ABCMeta): From 16af1a2347af8ae4f87f2095a1f63379312724dd Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 3 May 2022 12:41:17 +0200 Subject: [PATCH 188/583] moved validation much earlier --- openpype/modules/base.py | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/openpype/modules/base.py b/openpype/modules/base.py index b48de59fa0..a85bedac31 100644 --- a/openpype/modules/base.py +++ b/openpype/modules/base.py @@ -326,11 +326,25 @@ def _load_modules(): fullpath = os.path.join(dirpath, filename) basename, ext = os.path.splitext(filename) + # Validations + if os.path.isdir(fullpath): + # Check existence of init file + init_path = os.path.join(fullpath, "__init__.py") + if not os.path.exists(init_path): + log.debug(( + "Module directory does not contan __init__.py" + " file {}" + ).format(fullpath)) + continue + + elif ext not in (".py", ): + continue + # TODO add more logic how to define if folder is module or not # - check manifest and content of manifest try: + # Don't import dynamically current directory modules if is_in_current_dir: - # Don't import dynamically import_str = "openpype.modules.{}".format(basename) new_import_str = "{}.{}".format(modules_key, basename) default_module = __import__(import_str, fromlist=("", )) @@ -338,18 +352,9 @@ def _load_modules(): setattr(openpype_modules, basename, default_module) elif os.path.isdir(fullpath): - # Check existence of init file - init_path = os.path.join(fullpath, "__init__.py") - if not os.path.exists(init_path): - log.debug(( - "Module directory does not contan __init__.py" - " file {}" - ).format(fullpath)) - continue - import_module_from_dirpath(dirpath, filename, modules_key) - elif ext in (".py", ): + else: module = import_filepath(fullpath) setattr(openpype_modules, basename, module) From 313382f2fb7d6957f3fe5a11688ac5c0d8e18783 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 3 May 2022 12:46:40 +0200 Subject: [PATCH 189/583] fix OPENPYPE_ROOT usage --- start.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/start.py b/start.py index 064b828744..cd1d95dd8f 100644 --- a/start.py +++ b/start.py @@ -931,11 +931,11 @@ def _boot_print_versions(use_staging, local_version, openpype_root): else: _print("--- This will list only staging versions detected.") _print(" To see other version, omit --use-staging argument.") - _openpype_root = OPENPYPE_ROOT + openpype_versions = bootstrap.find_openpype(include_zips=True, staging=use_staging) if getattr(sys, 'frozen', False): - local_version = bootstrap.get_version(Path(_openpype_root)) + local_version = bootstrap.get_version(Path(openpype_root)) else: local_version = OpenPypeVersion.get_installed_version_str() From 7903437ba76f6ddf0a0679d25a7169a56c669bad Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 3 May 2022 12:50:52 +0200 Subject: [PATCH 190/583] nuke: converting schema knobs to template schema - refactoring imageio nuke to the template - refactoring nuke publish bake move to the template --- .../defaults/project_anatomy/imageio.json | 33 ++- .../projects_schema/schema_project_nuke.json | 20 +- .../schemas/schema_anatomy_imageio.json | 54 +---- .../schemas/schema_nuke_knob_inputs.json | 209 ----------------- .../schemas/schema_nuke_publish.json | 106 +-------- .../schemas/template_nuke_knob_inputs.json | 222 ++++++++++++++++++ 6 files changed, 281 insertions(+), 363 deletions(-) delete mode 100644 openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_knob_inputs.json create mode 100644 openpype/settings/entities/schemas/projects_schema/schemas/template_nuke_knob_inputs.json diff --git a/openpype/settings/defaults/project_anatomy/imageio.json b/openpype/settings/defaults/project_anatomy/imageio.json index fedae994bf..2023dae23c 100644 --- a/openpype/settings/defaults/project_anatomy/imageio.json +++ b/openpype/settings/defaults/project_anatomy/imageio.json @@ -55,36 +55,44 @@ "nukeNodeClass": "Write", "knobs": [ { + "type": "text", "name": "file_type", "value": "exr" }, { + "type": "text", "name": "datatype", "value": "16 bit half" }, { + "type": "text", "name": "compression", "value": "Zip (1 scanline)" }, { + "type": "bool", "name": "autocrop", - "value": "True" + "value": true }, { + "type": "hex", "name": "tile_color", "value": "0xff0000ff" }, { + "type": "text", "name": "channels", "value": "rgb" }, { + "type": "text", "name": "colorspace", "value": "linear" }, { + "type": "bool", "name": "create_directories", - "value": "True" + "value": true } ] }, @@ -95,36 +103,44 @@ "nukeNodeClass": "Write", "knobs": [ { + "type": "text", "name": "file_type", "value": "exr" }, { + "type": "text", "name": "datatype", "value": "16 bit half" }, { + "type": "text", "name": "compression", "value": "Zip (1 scanline)" }, { + "type": "bool", "name": "autocrop", - "value": "False" + "value": true }, { + "type": "hex", "name": "tile_color", "value": "0xadab1dff" }, { + "type": "text", "name": "channels", "value": "rgb" }, { + "type": "text", "name": "colorspace", "value": "linear" }, { + "type": "bool", "name": "create_directories", - "value": "True" + "value": true } ] }, @@ -135,32 +151,39 @@ "nukeNodeClass": "Write", "knobs": [ { + "type": "text", "name": "file_type", "value": "tiff" }, { + "type": "text", "name": "datatype", "value": "16 bit" }, { + "type": "text", "name": "compression", "value": "Deflate" }, { + "type": "hex", "name": "tile_color", "value": "0x23ff00ff" }, { + "type": "text", "name": "channels", "value": "rgb" }, { + "type": "text", "name": "colorspace", "value": "sRGB" }, { + "type": "bool", "name": "create_directories", - "value": "True" + "value": true } ] } diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_nuke.json b/openpype/settings/entities/schemas/projects_schema/schema_project_nuke.json index dfd3306b2e..29ad8e3c6c 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_nuke.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_nuke.json @@ -106,8 +106,14 @@ } }, { - "type": "schema", - "name": "schema_nuke_knob_inputs" + "type": "schema_template", + "name": "template_nuke_knob_inputs", + "template_data": [ + { + "label": "Node knobs", + "key": "knobs" + } + ] } ] }, @@ -142,8 +148,14 @@ "label": "Add reviewable toggle" }, { - "type": "schema", - "name": "schema_nuke_knob_inputs" + "type": "schema_template", + "name": "template_nuke_knob_inputs", + "template_data": [ + { + "label": "Node knobs", + "key": "knobs" + } + ] } ] } diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_imageio.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_imageio.json index 819f7121c4..ef8c907dda 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_imageio.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_imageio.json @@ -272,29 +272,12 @@ "label": "Nuke Node Class" }, { - "type": "collapsible-wrap", - "label": "Knobs", - "collapsible": true, - "collapsed": true, - "children": [ + "type": "schema_template", + "name": "template_nuke_knob_inputs", + "template_data": [ { - "key": "knobs", - "type": "list", - "object_type": { - "type": "dict", - "children": [ - { - "type": "text", - "key": "name", - "label": "Name" - }, - { - "type": "text", - "key": "value", - "label": "Value" - } - ] - } + "label": "Knobs", + "key": "knobs" } ] } @@ -333,29 +316,12 @@ "object_type": "text" }, { - "type": "collapsible-wrap", - "label": "Knobs overrides", - "collapsible": true, - "collapsed": true, - "children": [ + "type": "schema_template", + "name": "template_nuke_knob_inputs", + "template_data": [ { - "key": "knobs", - "type": "list", - "object_type": { - "type": "dict", - "children": [ - { - "type": "text", - "key": "name", - "label": "Name" - }, - { - "type": "text", - "key": "value", - "label": "Value" - } - ] - } + "label": "Knobs overrides", + "key": "knobs" } ] } diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_knob_inputs.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_knob_inputs.json deleted file mode 100644 index 95913f5335..0000000000 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_knob_inputs.json +++ /dev/null @@ -1,209 +0,0 @@ -{ - "type": "collapsible-wrap", - "label": "Knob defaults", - "collapsible": true, - "collapsed": true, - "children": [{ - "type": "list", - "key": "knobs", - "object_type": { - "type": "dict-conditional", - "enum_key": "type", - "enum_label": "Type", - "enum_children": [ - { - "key": "text", - "label": "Text", - "children": [ - { - "type": "text", - "key": "name", - "label": "Name" - }, - { - "type": "text", - "key": "value", - "label": "Value" - } - ] - }, - { - "key": "hex", - "label": "Hexadecimal (0x)", - "children": [ - { - "type": "text", - "key": "name", - "label": "Name" - }, - { - "type": "text", - "key": "value", - "label": "Value" - } - ] - }, - { - "key": "bool", - "label": "Boolean", - "children": [ - { - "type": "text", - "key": "name", - "label": "Name" - }, - { - "type": "boolean", - "key": "value", - "label": "Value" - } - ] - }, - { - "key": "number", - "label": "Number", - "children": [ - { - "type": "text", - "key": "name", - "label": "Name" - }, - { - "type": "number", - "key": "value", - "default": 1, - "decimal": 0 - } - - ] - }, - { - "key": "decimal_number", - "label": "Decimal number", - "children": [ - { - "type": "text", - "key": "name", - "label": "Name" - }, - { - "type": "number", - "key": "value", - "default": 1, - "decimal": 4 - } - - ] - }, - { - "key": "2d_vector", - "label": "2D vector", - "children": [ - { - "type": "text", - "key": "name", - "label": "Name" - }, - { - "type": "list-strict", - "key": "value", - "label": "Value", - "object_types": [ - { - "type": "number", - "key": "x", - "default": 1, - "decimal": 4 - }, - { - "type": "number", - "key": "y", - "default": 1, - "decimal": 4 - } - ] - } - ] - }, - { - "key": "3d_vector", - "label": "3D vector", - "children": [ - { - "type": "text", - "key": "name", - "label": "Name" - }, - { - "type": "list-strict", - "key": "value", - "label": "Value", - "object_types": [ - { - "type": "number", - "key": "x", - "default": 1, - "decimal": 4 - }, - { - "type": "number", - "key": "y", - "default": 1, - "decimal": 4 - }, - { - "type": "number", - "key": "y", - "default": 1, - "decimal": 4 - } - ] - } - ] - }, - { - "key": "color", - "label": "Color", - "children": [ - { - "type": "text", - "key": "name", - "label": "Name" - }, - { - "type": "list-strict", - "key": "value", - "label": "Value", - "object_types": [ - { - "type": "number", - "key": "x", - "default": 1, - "decimal": 4 - }, - { - "type": "number", - "key": "x", - "default": 1, - "decimal": 4 - }, - { - "type": "number", - "key": "y", - "default": 1, - "decimal": 4 - }, - { - "type": "number", - "key": "y", - "default": 1, - "decimal": 4 - } - ] - } - ] - } - ] - } - }] -} diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json index d67fb309bd..94b52bba13 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json @@ -253,108 +253,12 @@ "default": false }, { - "type": "collapsible-wrap", - "label": "Reformat Node Knobs", - "collapsible": true, - "collapsed": true, - "children": [ + "type": "schema_template", + "name": "template_nuke_knob_inputs", + "template_data": [ { - "type": "list", - "key": "reformat_node_config", - "object_type": { - "type": "dict-conditional", - "enum_key": "type", - "enum_label": "Type", - "enum_children": [ - { - "key": "string", - "label": "String", - "children": [ - { - "type": "text", - "key": "name", - "label": "Name" - }, - { - "type": "text", - "key": "value", - "label": "Value" - } - ] - }, - { - "key": "bool", - "label": "Boolean", - "children": [ - { - "type": "text", - "key": "name", - "label": "Name" - }, - { - "type": "boolean", - "key": "value", - "label": "Value" - } - ] - }, - { - "key": "number", - "label": "Number", - "children": [ - { - "type": "text", - "key": "name", - "label": "Name" - }, - { - "type": "list-strict", - "key": "value", - "label": "Value", - "object_types": [ - { - "type": "number", - "key": "number", - "default": 1, - "decimal": 4 - } - ] - } - - ] - }, - { - "key": "list_numbers", - "label": "2 Numbers", - "children": [ - { - "type": "text", - "key": "name", - "label": "Name" - }, - { - "type": "list-strict", - "key": "value", - "label": "Value", - "object_types": [ - { - "type": "number", - "key": "x", - "default": 1, - "decimal": 4 - }, - { - "type": "number", - "key": "y", - "default": 1, - "decimal": 4 - } - ] - } - ] - } - ] - } + "label": "Reformat Node Knobs", + "key": "reformat_node_config" } ] }, diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/template_nuke_knob_inputs.json b/openpype/settings/entities/schemas/projects_schema/schemas/template_nuke_knob_inputs.json new file mode 100644 index 0000000000..957c684013 --- /dev/null +++ b/openpype/settings/entities/schemas/projects_schema/schemas/template_nuke_knob_inputs.json @@ -0,0 +1,222 @@ +[ + { + "type": "collapsible-wrap", + "label": "{label}", + "collapsible": true, + "collapsed": true, + "children": [{ + "type": "list", + "key": "{key}", + "object_type": { + "type": "dict-conditional", + "enum_key": "type", + "enum_label": "Type", + "enum_children": [ + { + "key": "text", + "label": "Text", + "children": [ + { + "type": "text", + "key": "name", + "label": "Name" + }, + { + "type": "text", + "key": "value", + "label": "Value" + } + ] + }, + { + "key": "hex", + "label": "Hexadecimal (0x)", + "children": [ + { + "type": "text", + "key": "name", + "label": "Name" + }, + { + "type": "text", + "key": "value", + "label": "Value" + } + ] + }, + { + "key": "bool", + "label": "Boolean", + "children": [ + { + "type": "text", + "key": "name", + "label": "Name" + }, + { + "type": "boolean", + "key": "value", + "label": "Value" + } + ] + }, + { + "key": "number", + "label": "Number", + "children": [ + { + "type": "text", + "key": "name", + "label": "Name" + }, + { + "type": "number", + "key": "value", + "default": 1, + "decimal": 0, + "maximum": 99999999 + } + + ] + }, + { + "key": "decimal_number", + "label": "Decimal number", + "children": [ + { + "type": "text", + "key": "name", + "label": "Name" + }, + { + "type": "number", + "key": "value", + "default": 1, + "decimal": 4, + "maximum": 99999999 + } + + ] + }, + { + "key": "2d_vector", + "label": "2D vector", + "children": [ + { + "type": "text", + "key": "name", + "label": "Name" + }, + { + "type": "list-strict", + "key": "value", + "label": "Value", + "object_types": [ + { + "type": "number", + "key": "x", + "default": 1, + "decimal": 4, + "maximum": 99999999 + }, + { + "type": "number", + "key": "y", + "default": 1, + "decimal": 4, + "maximum": 99999999 + } + ] + } + ] + }, + { + "key": "3d_vector", + "label": "3D vector", + "children": [ + { + "type": "text", + "key": "name", + "label": "Name" + }, + { + "type": "list-strict", + "key": "value", + "label": "Value", + "object_types": [ + { + "type": "number", + "key": "x", + "default": 1, + "decimal": 4, + "maximum": 99999999 + }, + { + "type": "number", + "key": "y", + "default": 1, + "decimal": 4, + "maximum": 99999999 + }, + { + "type": "number", + "key": "y", + "default": 1, + "decimal": 4, + "maximum": 99999999 + } + ] + } + ] + }, + { + "key": "color", + "label": "Color", + "children": [ + { + "type": "text", + "key": "name", + "label": "Name" + }, + { + "type": "list-strict", + "key": "value", + "label": "Value", + "object_types": [ + { + "type": "number", + "key": "x", + "default": 1, + "decimal": 4, + "maximum": 99999999 + }, + { + "type": "number", + "key": "x", + "default": 1, + "decimal": 4, + "maximum": 99999999 + }, + { + "type": "number", + "key": "y", + "default": 1, + "decimal": 4, + "maximum": 99999999 + }, + { + "type": "number", + "key": "y", + "default": 1, + "decimal": 4, + "maximum": 99999999 + } + ] + } + ] + } + ] + } + }] + } +] From 317c27eeaf06f393122aec10508baf9f1b413f71 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 3 May 2022 14:04:40 +0200 Subject: [PATCH 191/583] modified log message Co-authored-by: Roy Nieterau --- openpype/modules/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/base.py b/openpype/modules/base.py index a85bedac31..629a2fa689 100644 --- a/openpype/modules/base.py +++ b/openpype/modules/base.py @@ -364,7 +364,7 @@ def _load_modules(): basename ) else: - msg = "Failed to import '{}'.".format(fullpath) + msg = "Failed to import module '{}'.".format(fullpath) log.error(msg, exc_info=True) From 0b306e2e77bed1dd939619baa9917075d9b3902d Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 3 May 2022 14:04:49 +0200 Subject: [PATCH 192/583] fix typo Co-authored-by: Roy Nieterau --- openpype/modules/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/base.py b/openpype/modules/base.py index 629a2fa689..0dd512ee8b 100644 --- a/openpype/modules/base.py +++ b/openpype/modules/base.py @@ -332,7 +332,7 @@ def _load_modules(): init_path = os.path.join(fullpath, "__init__.py") if not os.path.exists(init_path): log.debug(( - "Module directory does not contan __init__.py" + "Module directory does not contain __init__.py" " file {}" ).format(fullpath)) continue From dc94809957803a7ab57c027ff2b82b504fe78f38 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 3 May 2022 14:35:45 +0200 Subject: [PATCH 193/583] added icon to asset input dialog --- openpype/style/style.css | 19 ++++++++++++++- openpype/tools/publisher/widgets/widgets.py | 26 +++++++++++++++++---- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/openpype/style/style.css b/openpype/style/style.css index 8eeae1a58a..93b13916c1 100644 --- a/openpype/style/style.css +++ b/openpype/style/style.css @@ -1027,7 +1027,24 @@ VariantInputsWidget QToolButton { border-left: 1px solid {color:border}; } -#TasksCombobox[state="invalid"], #AssetNameInput[state="invalid"] { +#AssetNameInputButton { + background: {color:bg-inputs}; + border: 1px solid {color:border}; + border-bottom-left-radius: 0px; + border-top-left-radius: 0px; + padding: 0px; + qproperty-iconSize: 11px 11px; +} +#AssetNameInputButton:disabled { + background: {color:bg-inputs-disabled}; +} +#AssetNameInput { + border-bottom-right-radius: 0px; + border-top-right-radius: 0px; + border-right: none; +} + +#TasksCombobox[state="invalid"], #AssetNameInput[state="invalid"], #AssetNameInputButton[state="invalid"] { border-color: {color:publisher:error}; } diff --git a/openpype/tools/publisher/widgets/widgets.py b/openpype/tools/publisher/widgets/widgets.py index 5ced469b59..1569e1a4c1 100644 --- a/openpype/tools/publisher/widgets/widgets.py +++ b/openpype/tools/publisher/widgets/widgets.py @@ -14,7 +14,8 @@ from openpype.tools.utils import ( PlaceholderLineEdit, IconButton, PixmapLabel, - BaseClickableFrame + BaseClickableFrame, + set_style_property, ) from openpype.pipeline.create import SUBSET_NAME_ALLOWED_SYMBOLS from .assets_widget import AssetsDialog @@ -350,15 +351,32 @@ class AssetsField(BaseClickableFrame): name_input = ClickableLineEdit(self) name_input.setObjectName("AssetNameInput") + icon_name = "fa.window-maximize" + icon = qtawesome.icon(icon_name, color="white") + icon_btn = QtWidgets.QPushButton(self) + icon_btn.setIcon(icon) + icon_btn.setObjectName("AssetNameInputButton") + layout = QtWidgets.QHBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) layout.addWidget(name_input, 1) + layout.addWidget(icon_btn, 0) + for widget in ( + name_input, + icon_btn + ): + size_policy = widget.sizePolicy() + size_policy.setVerticalPolicy(size_policy.MinimumExpanding) + widget.setSizePolicy(size_policy) name_input.clicked.connect(self._mouse_release_callback) + icon_btn.clicked.connect(self._mouse_release_callback) dialog.finished.connect(self._on_dialog_finish) self._dialog = dialog self._name_input = name_input + self._icon_btn = icon_btn self._origin_value = [] self._origin_selection = [] @@ -406,10 +424,8 @@ class AssetsField(BaseClickableFrame): self._set_state_property(state) def _set_state_property(self, state): - current_value = self._name_input.property("state") - if current_value != state: - self._name_input.setProperty("state", state) - self._name_input.style().polish(self._name_input) + set_style_property(self._name_input, "state", state) + set_style_property(self._icon_btn, "state", state) def is_valid(self): """Is asset valid.""" From b17d9d23497691a4cea6174c323a2bb3cf27e870 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 3 May 2022 15:22:32 +0200 Subject: [PATCH 194/583] fixed empty files def --- openpype/lib/attribute_definitions.py | 14 +++++++++++++- openpype/widgets/attribute_defs/files_widget.py | 6 ++++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/openpype/lib/attribute_definitions.py b/openpype/lib/attribute_definitions.py index 4ea691ca08..a1f7c1e0f4 100644 --- a/openpype/lib/attribute_definitions.py +++ b/openpype/lib/attribute_definitions.py @@ -316,6 +316,7 @@ class FileDefItem(object): self.is_sequence = False self.template = None self.frames = [] + self.is_empty = True self.set_filenames(filenames, frames, template) @@ -323,7 +324,9 @@ class FileDefItem(object): return json.dumps(self.to_dict()) def __repr__(self): - if self.is_sequence: + if self.is_empty: + filename = "< empty >" + elif self.is_sequence: filename = self.template else: filename = self.filenames[0] @@ -335,6 +338,9 @@ class FileDefItem(object): @property def label(self): + if self.is_empty: + return None + if not self.is_sequence: return self.filenames[0] @@ -386,6 +392,8 @@ class FileDefItem(object): @property def ext(self): + if self.is_empty: + return None _, ext = os.path.splitext(self.filenames[0]) if ext: return ext @@ -393,6 +401,9 @@ class FileDefItem(object): @property def is_dir(self): + if self.is_empty: + return False + # QUESTION a better way how to define folder (in init argument?) if self.ext: return False @@ -411,6 +422,7 @@ class FileDefItem(object): if is_sequence and not template: raise ValueError("Missing template for sequence") + self.is_empty = len(filenames) == 0 self.filenames = filenames self.template = template self.frames = frames diff --git a/openpype/widgets/attribute_defs/files_widget.py b/openpype/widgets/attribute_defs/files_widget.py index 924dbf7fe5..23cf8342b1 100644 --- a/openpype/widgets/attribute_defs/files_widget.py +++ b/openpype/widgets/attribute_defs/files_widget.py @@ -151,7 +151,7 @@ class FilesModel(QtGui.QStandardItemModel): item = QtGui.QStandardItem() item_id = str(uuid.uuid4()) item.setData(item_id, ITEM_ID_ROLE) - item.setData(file_item.label, ITEM_LABEL_ROLE) + item.setData(file_item.label or "< empty >", ITEM_LABEL_ROLE) item.setData(file_item.filenames, FILENAMES_ROLE) item.setData(file_item.directory, DIRPATH_ROLE) item.setData(icon_pixmap, ITEM_ICON_ROLE) @@ -512,7 +512,9 @@ class FilesWidget(QtWidgets.QFrame): return file_items if file_items: return file_items[0] - return FileDefItem.create_empty_item() + + empty_item = FileDefItem.create_empty_item() + return empty_item.to_dict() def set_filters(self, folders_allowed, exts_filter): self._files_proxy_model.set_allow_folders(folders_allowed) From 33c3d1bc4c8278690c522f1b74b1fe5a2705ed5d Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 3 May 2022 15:23:22 +0200 Subject: [PATCH 195/583] resize properly families and subset names --- openpype/tools/publisher/widgets/widgets.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/openpype/tools/publisher/widgets/widgets.py b/openpype/tools/publisher/widgets/widgets.py index 1569e1a4c1..45c42e6558 100644 --- a/openpype/tools/publisher/widgets/widgets.py +++ b/openpype/tools/publisher/widgets/widgets.py @@ -908,11 +908,23 @@ class MultipleItemWidget(QtWidgets.QWidget): layout.setContentsMargins(0, 0, 0, 0) layout.addWidget(view) + model.rowsInserted.connect(self._on_insert) + self._view = view self._model = model self._value = [] + def _on_insert(self): + self._update_size() + + def _update_size(self): + model = self._view.model() + if model.rowCount() == 0: + return + height = self._view.sizeHintForRow(0) + self.setMaximumHeight(height + (2 * self._view.spacing())) + def showEvent(self, event): super(MultipleItemWidget, self).showEvent(event) tmp_item = None @@ -920,13 +932,15 @@ class MultipleItemWidget(QtWidgets.QWidget): # Add temp item to be able calculate maximum height of widget tmp_item = QtGui.QStandardItem("tmp") self._model.appendRow(tmp_item) - - height = self._view.sizeHintForRow(0) - self.setMaximumHeight(height + (2 * self._view.spacing())) + self._update_size() if tmp_item is not None: self._model.clear() + def resizeEvent(self, event): + super(MultipleItemWidget, self).resizeEvent(event) + self._update_size() + def set_value(self, value=None): """Set value/s of currently selected instance.""" if value is None: From 1c2b6408c4d3af491a886aa3d61c5dc7ee53a7f1 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 3 May 2022 15:28:36 +0200 Subject: [PATCH 196/583] nuke: refectory imageio settings for plugin nodes --- openpype/hosts/nuke/api/lib.py | 117 +++++++++++++++------------------ 1 file changed, 52 insertions(+), 65 deletions(-) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index e6e61844ba..0fa5633b90 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -375,23 +375,18 @@ def add_write_node(name, **kwarg): Returns: node (obj): nuke write node """ + file_path = kwarg["path"] + knobs = kwarg["knobs"] frame_range = kwarg.get("frame_range", None) w = nuke.createNode( "Write", "name {}".format(name)) - w["file"].setValue(kwarg["file"]) + w["file"].setValue(file_path) - for k, v in kwarg.items(): - if "frame_range" in k: - continue - log.info([k, v]) - try: - w[k].setValue(v) - except KeyError as e: - log.debug(e) - continue + # finally add knob overrides + set_node_knobs_from_settings(w, knobs) if frame_range: w["use_limit"].setValue(True) @@ -501,17 +496,9 @@ def get_nuke_imageio_settings(): return get_anatomy_settings(Context.project_name)["imageio"]["nuke"] -def get_created_node_imageio_setting(**kwarg): +def get_imageio_node_setting(node_class, plugin_name, subset): ''' Get preset data for dataflow (fileType, compression, bitDepth) ''' - log.debug(kwarg) - nodeclass = kwarg.get("nodeclass", None) - creator = kwarg.get("creator", None) - subset = kwarg.get("subset", None) - - assert any([creator, nodeclass]), nuke.message( - "`{}`: Missing mandatory kwargs `host`, `cls`".format(__file__)) - imageio_nodes = get_nuke_imageio_settings()["nodes"] required_nodes = imageio_nodes["requiredNodes"] override_nodes = imageio_nodes["overrideNodes"] @@ -520,8 +507,8 @@ def get_created_node_imageio_setting(**kwarg): for node in required_nodes: log.info(node) if ( - nodeclass in node["nukeNodeClass"] - and creator in node["plugins"] + node_class in node["nukeNodeClass"] + and plugin_name in node["plugins"] ): imageio_node = node break @@ -532,10 +519,10 @@ def get_created_node_imageio_setting(**kwarg): override_imageio_node = None for onode in override_nodes: log.info(onode) - if nodeclass not in node["nukeNodeClass"]: + if node_class not in node["nukeNodeClass"]: continue - if creator not in node["plugins"]: + if plugin_name not in node["plugins"]: continue if ( @@ -726,15 +713,14 @@ def check_subsetname_exists(nodes, subset_name): def get_render_path(node): ''' Generate Render path from presets regarding avalon knob data ''' - data = {'avalon': read_avalon_data(node)} - data_preset = { - "nodeclass": data["avalon"]["family"], - "families": [data["avalon"]["families"]], - "creator": data["avalon"]["creator"], - "subset": data["avalon"]["subset"] - } + avalon_knob_data = read_avalon_data(node) + data = {'avalon': avalon_knob_data} - nuke_imageio_writes = get_created_node_imageio_setting(**data_preset) + nuke_imageio_writes = get_imageio_node_setting( + node_class=avalon_knob_data["family"], + plugin_name=avalon_knob_data["creator"], + subset=avalon_knob_data["subset"] + ) host_name = os.environ.get("AVALON_APP") data.update({ @@ -859,9 +845,20 @@ def create_write_node(name, data, input=None, prenodes=None, Return: node (obj): group node with avalon data as Knobs ''' + # group node knob overrides knob_overrides = data.get("knobs", []) - imageio_writes = get_created_node_imageio_setting(**data) + # filtering variables + plugin_name = data["creator"], + subset = data["subset"] + + # get knob settings for write node + imageio_writes = get_imageio_node_setting( + node_class=data["nodeclass"], + plugin_name=plugin_name, + subset=subset + ) + for knob in imageio_writes["knobs"]: if knob["name"] == "file_type": representation = knob["value"] @@ -893,22 +890,20 @@ def create_write_node(name, data, input=None, prenodes=None, log.warning("Path does not exist! I am creating it.") os.makedirs(os.path.dirname(fpath)) - _data = OrderedDict({ - "file": fpath - }) + _wn_props = { + "path": fpath, + "knobs": imageio_writes["knobs"] + } # adding dataflow template - log.debug("imageio_writes: `{}`".format(imageio_writes)) - for knob in imageio_writes["knobs"]: - _data[knob["name"]] = knob["value"] - - _data = fix_data_for_node_create(_data) - - log.debug("_data: `{}`".format(_data)) + log.debug("__ _wn_props: `{}`".format( + pformat(_wn_props) + )) if "frame_range" in data.keys(): - _data["frame_range"] = data.get("frame_range", None) - log.debug("_data[frame_range]: `{}`".format(_data["frame_range"])) + _wn_props["frame_range"] = data.get("frame_range", None) + log.debug("_wn_props[frame_range]: `{}`".format( + _wn_props["frame_range"])) GN = nuke.createNode("Group", "name {}".format(name)) @@ -985,7 +980,7 @@ def create_write_node(name, data, input=None, prenodes=None, # creating write node write_node = now_node = add_write_node( "inside_{}".format(name), - **_data + **_wn_props ) write_node.hideControlPanel() # connect to previous node @@ -1060,7 +1055,7 @@ def create_write_node(name, data, input=None, prenodes=None, GN[_NODE_TAB_NAME].setFlag(0) # set tile color - tile_color = _data.get("tile_color", "0xff0000ff") + tile_color = _wn_props["knobs"].get("tile_color", "0xff0000ff") GN["tile_color"].setValue(tile_color) # finally add knob overrides @@ -1414,15 +1409,11 @@ class WorkfileSettings(object): if avalon_knob_data.get("families"): families.append(avalon_knob_data.get("families")) - data_preset = { - "nodeclass": avalon_knob_data["family"], - "families": families, - "creator": avalon_knob_data["creator"], - "subset": avalon_knob_data["subset"] - } - - nuke_imageio_writes = get_created_node_imageio_setting( - **data_preset) + nuke_imageio_writes = get_imageio_node_setting( + node_class=avalon_knob_data["family"], + plugin_name=avalon_knob_data["creator"], + subset=avalon_knob_data["subset"] + ) log.debug("nuke_imageio_writes: `{}`".format(nuke_imageio_writes)) @@ -1737,17 +1728,13 @@ def get_write_node_template_attr(node): ''' # get avalon data from node - data = {"avalon": read_avalon_data(node)} - - data_preset = { - "nodeclass": data["avalon"]["family"], - "families": [data["avalon"]["families"]], - "creator": data["avalon"]["creator"], - "subset": data["avalon"]["subset"] - } - + avalon_knob_data = read_avalon_data(node) # get template data - nuke_imageio_writes = get_created_node_imageio_setting(**data_preset) + nuke_imageio_writes = get_imageio_node_setting( + node_class=avalon_knob_data["family"], + plugin_name=avalon_knob_data["creator"], + subset=avalon_knob_data["subset"] + ) # collecting correct data correct_data = OrderedDict({ From 9eb9c62318f0a00b4ceb5c21bd42c839bedaa9be Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 3 May 2022 15:55:38 +0200 Subject: [PATCH 197/583] different asset dialog size --- .../tools/publisher/widgets/assets_widget.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/openpype/tools/publisher/widgets/assets_widget.py b/openpype/tools/publisher/widgets/assets_widget.py index c4d3cf8b1a..46fdcc6526 100644 --- a/openpype/tools/publisher/widgets/assets_widget.py +++ b/openpype/tools/publisher/widgets/assets_widget.py @@ -220,9 +220,26 @@ class AssetsDialog(QtWidgets.QDialog): # - adds ability to call reset on multiple places without repeating self._soft_reset_enabled = True + self._first_show = True + self._default_height = 500 + + def _on_first_show(self): + center = self.rect().center() + size = self.size() + size.setHeight(self._default_height) + + self.resize(size) + new_pos = self.mapToGlobal(center) + new_pos.setX(new_pos.x() - int(self.width() / 2)) + new_pos.setY(new_pos.y() - int(self.height() / 2)) + self.move(new_pos) + def showEvent(self, event): """Refresh asset model on show.""" super(AssetsDialog, self).showEvent(event) + if self._first_show: + self._first_show = False + self._on_first_show() # Refresh on show self.reset(False) From 5ca7b53c11e9b9fe66bd05947d04edadd8efb0d4 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 3 May 2022 15:56:01 +0200 Subject: [PATCH 198/583] fix styles --- openpype/style/style.css | 32 +++++++++++++++++---- openpype/tools/publisher/widgets/widgets.py | 6 +++- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/openpype/style/style.css b/openpype/style/style.css index 93b13916c1..d76d833be1 100644 --- a/openpype/style/style.css +++ b/openpype/style/style.css @@ -1027,24 +1027,44 @@ VariantInputsWidget QToolButton { border-left: 1px solid {color:border}; } -#AssetNameInputButton { +#AssetNameInputWidget { background: {color:bg-inputs}; border: 1px solid {color:border}; + border-radius: 0.3em; +} + +#AssetNameInputWidget QWidget { + background: transparent; +} + +#AssetNameInputButton { border-bottom-left-radius: 0px; border-top-left-radius: 0px; padding: 0px; qproperty-iconSize: 11px 11px; + border-left: 1px solid {color:border}; + border-right: none; + border-top: none; + border-bottom: none; } -#AssetNameInputButton:disabled { - background: {color:bg-inputs-disabled}; -} + #AssetNameInput { border-bottom-right-radius: 0px; border-top-right-radius: 0px; - border-right: none; + border: none; } -#TasksCombobox[state="invalid"], #AssetNameInput[state="invalid"], #AssetNameInputButton[state="invalid"] { +#AssetNameInputWidget:hover { + border-color: {color:border-hover}; +} +#AssetNameInputWidget:focus{ + border-color: {color:border-focus}; +} +#AssetNameInputWidget:disabled { + background: {color:bg-inputs-disabled}; +} + +#TasksCombobox[state="invalid"], #AssetNameInputWidget[state="invalid"], #AssetNameInputButton[state="invalid"] { border-color: {color:publisher:error}; } diff --git a/openpype/tools/publisher/widgets/widgets.py b/openpype/tools/publisher/widgets/widgets.py index 45c42e6558..6c09128857 100644 --- a/openpype/tools/publisher/widgets/widgets.py +++ b/openpype/tools/publisher/widgets/widgets.py @@ -345,8 +345,11 @@ class AssetsField(BaseClickableFrame): def __init__(self, controller, parent): super(AssetsField, self).__init__(parent) + self.setObjectName("AssetNameInputWidget") - dialog = AssetsDialog(controller, self) + # Don't use 'self' for parent! + # - this widget has specific styles + dialog = AssetsDialog(controller, parent) name_input = ClickableLineEdit(self) name_input.setObjectName("AssetNameInput") @@ -363,6 +366,7 @@ class AssetsField(BaseClickableFrame): layout.addWidget(name_input, 1) layout.addWidget(icon_btn, 0) + # Make sure all widgets are vertically extended to highest widget for widget in ( name_input, icon_btn From c23b55f714614671a1c002ad2c5280a07d413676 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 3 May 2022 16:09:29 +0200 Subject: [PATCH 199/583] changed default sizes of create dialog --- .../tools/publisher/widgets/create_dialog.py | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/openpype/tools/publisher/widgets/create_dialog.py b/openpype/tools/publisher/widgets/create_dialog.py index bcf87dea6a..09b2b9cb30 100644 --- a/openpype/tools/publisher/widgets/create_dialog.py +++ b/openpype/tools/publisher/widgets/create_dialog.py @@ -283,7 +283,7 @@ class HelpButton(ClickableFrame): class CreateDialog(QtWidgets.QDialog): - default_size = (900, 500) + default_size = (1000, 560) def __init__( self, controller, asset_name=None, task_name=None, parent=None @@ -354,7 +354,6 @@ class CreateDialog(QtWidgets.QDialog): variant_hints_menu = QtWidgets.QMenu(variant_widget) variant_hints_group = QtWidgets.QActionGroup(variant_hints_menu) - # variant_hints_btn.setMenu(variant_hints_menu) variant_layout = QtWidgets.QHBoxLayout(variant_widget) variant_layout.setContentsMargins(0, 0, 0, 0) @@ -1093,6 +1092,21 @@ class CreateDialog(QtWidgets.QDialog): self.variant_input.setProperty("state", state) self.variant_input.style().polish(self.variant_input) + def _on_first_show(self): + center = self.rect().center() + + width, height = self.default_size + self.resize(width, height) + part = int(width / 7) + self._splitter_widget.setSizes( + [part * 2, part * 2, width - (part * 4)] + ) + + new_pos = self.mapToGlobal(center) + new_pos.setX(new_pos.x() - int(self.width() / 2)) + new_pos.setY(new_pos.y() - int(self.height() / 2)) + self.move(new_pos) + def moveEvent(self, event): super(CreateDialog, self).moveEvent(event) self._last_pos = self.pos() @@ -1101,13 +1115,7 @@ class CreateDialog(QtWidgets.QDialog): super(CreateDialog, self).showEvent(event) if self._first_show: self._first_show = False - width, height = self.default_size - self.resize(width, height) - - third_size = int(width / 3) - self._splitter_widget.setSizes( - [third_size, third_size, width - (2 * third_size)] - ) + self._on_first_show() if self._last_pos is not None: self.move(self._last_pos) From d82480b1ee9d8558fd4281649b1a4c8a363fa516 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 3 May 2022 16:17:30 +0200 Subject: [PATCH 200/583] fix variant value changes --- openpype/tools/publisher/widgets/widgets.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openpype/tools/publisher/widgets/widgets.py b/openpype/tools/publisher/widgets/widgets.py index 6c09128857..169da717f7 100644 --- a/openpype/tools/publisher/widgets/widgets.py +++ b/openpype/tools/publisher/widgets/widgets.py @@ -862,6 +862,8 @@ class VariantInputWidget(PlaceholderLineEdit): self._ignore_value_change = True + self._has_value_changed = False + self._origin_value = list(variants) self._current_value = list(variants) From 06a1e484d61274a6646e6b3667e76d3525f2497e Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 3 May 2022 16:53:59 +0200 Subject: [PATCH 201/583] fix property changes --- openpype/tools/publisher/widgets/widgets.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/tools/publisher/widgets/widgets.py b/openpype/tools/publisher/widgets/widgets.py index 169da717f7..63dd0ad198 100644 --- a/openpype/tools/publisher/widgets/widgets.py +++ b/openpype/tools/publisher/widgets/widgets.py @@ -428,6 +428,7 @@ class AssetsField(BaseClickableFrame): self._set_state_property(state) def _set_state_property(self, state): + set_style_property(self, "state", state) set_style_property(self._name_input, "state", state) set_style_property(self._icon_btn, "state", state) From 6c838de7ffecf6a49df97fb76b0ae95a56fc3062 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 3 May 2022 16:55:58 +0200 Subject: [PATCH 202/583] Add Houdini loader to load Alembic through Alembic Archive node --- .../plugins/load/load_alembic_archive.py | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 openpype/hosts/houdini/plugins/load/load_alembic_archive.py diff --git a/openpype/hosts/houdini/plugins/load/load_alembic_archive.py b/openpype/hosts/houdini/plugins/load/load_alembic_archive.py new file mode 100644 index 0000000000..b960073e12 --- /dev/null +++ b/openpype/hosts/houdini/plugins/load/load_alembic_archive.py @@ -0,0 +1,75 @@ +import os +from openpype.pipeline import ( + load, + get_representation_path, +) +from openpype.hosts.houdini.api import pipeline + + +class AbcArchiveLoader(load.LoaderPlugin): + """Load Alembic as full geometry network hierarchy """ + + families = ["model", "animation", "pointcache", "gpuCache"] + label = "Load Alembic as Archive" + representations = ["abc"] + order = -5 + icon = "code-fork" + color = "orange" + + def load(self, context, name=None, namespace=None, data=None): + + import hou + + # Format file name, Houdini only wants forward slashes + file_path = os.path.normpath(self.fname) + file_path = file_path.replace("\\", "/") + + # Get the root node + obj = hou.node("/obj") + + # Define node name + namespace = namespace if namespace else context["asset"]["name"] + node_name = "{}_{}".format(namespace, name) if namespace else name + + # Create an Alembic archive node + node = obj.createNode("alembicarchive", node_name=node_name) + node.moveToGoodPosition() + + # TODO: add FPS of project / asset + node.setParms({"fileName": file_path, + "channelRef": True}) + + # Apply some magic + node.parm("buildHierarchy").pressButton() + node.moveToGoodPosition() + + nodes = [node] + + self[:] = nodes + + return pipeline.containerise(node_name, + namespace, + nodes, + context, + self.__class__.__name__, + suffix="") + + def update(self, container, representation): + + node = container["node"] + + # Update the file path + file_path = get_representation_path(representation) + file_path = file_path.replace("\\", "/") + + # Update attributes + node.setParms({"fileName": file_path, + "representation": str(representation["_id"])}) + + # Rebuild + node.parm("buildHierarchy").pressButton() + + def remove(self, container): + + node = container["node"] + node.destroy() From 50e19bbfc075dac637e1cddc775d7cb092f54c8c Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 3 May 2022 17:20:42 +0200 Subject: [PATCH 203/583] create attributes use same grid layout logic --- openpype/tools/publisher/widgets/widgets.py | 30 ++++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/openpype/tools/publisher/widgets/widgets.py b/openpype/tools/publisher/widgets/widgets.py index 63dd0ad198..7096b9fb50 100644 --- a/openpype/tools/publisher/widgets/widgets.py +++ b/openpype/tools/publisher/widgets/widgets.py @@ -1272,7 +1272,11 @@ class CreatorAttrsWidget(QtWidgets.QWidget): ) content_widget = QtWidgets.QWidget(self._scroll_area) - content_layout = QtWidgets.QFormLayout(content_widget) + content_layout = QtWidgets.QGridLayout(content_widget) + content_layout.setColumnStretch(0, 0) + content_layout.setColumnStretch(1, 1) + + row = 0 for attr_def, attr_instances, values in result: widget = create_widget_for_attr_def(attr_def, content_widget) if attr_def.is_value_def: @@ -1283,10 +1287,28 @@ class CreatorAttrsWidget(QtWidgets.QWidget): else: widget.set_value(values, True) - label = attr_def.label or attr_def.key - content_layout.addRow(label, widget) - widget.value_changed.connect(self._input_value_changed) + expand_cols = 2 + if attr_def.is_value_def and attr_def.is_label_horizontal: + expand_cols = 1 + col_num = 2 - expand_cols + + label = attr_def.label or attr_def.key + if label: + label_widget = QtWidgets.QLabel(label, self) + content_layout.addWidget( + label_widget, row, 0, 1, expand_cols + ) + if not attr_def.is_label_horizontal: + row += 1 + + content_layout.addWidget( + widget, row, col_num, 1, expand_cols + ) + + row += 1 + + widget.value_changed.connect(self._input_value_changed) self._attr_def_id_to_instances[attr_def.id] = attr_instances self._attr_def_id_to_attr_def[attr_def.id] = attr_def From 25fd05df9ea82f4698cc03d113b3b149d9bf2536 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Tue, 3 May 2022 16:29:45 +0100 Subject: [PATCH 204/583] Updated plugin for UE5 --- .../Source/OpenPype/OpenPype.Build.cs | 2 + .../Source/OpenPype/Private/OpenPype.cpp | 110 ++++++++---------- .../OpenPype/Private/OpenPypeCommands.cpp | 13 +++ .../Source/OpenPype/Private/OpenPypeStyle.cpp | 53 ++++----- .../Source/OpenPype/Public/OpenPype.h | 8 +- .../Source/OpenPype/Public/OpenPypeCommands.h | 24 ++++ .../Source/OpenPype/Public/OpenPypeStyle.h | 12 +- 7 files changed, 116 insertions(+), 106 deletions(-) create mode 100644 openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypeCommands.cpp create mode 100644 openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypeCommands.h diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/OpenPype.Build.cs b/openpype/hosts/unreal/integration/Source/OpenPype/OpenPype.Build.cs index c30835b63d..fcfd268234 100644 --- a/openpype/hosts/unreal/integration/Source/OpenPype/OpenPype.Build.cs +++ b/openpype/hosts/unreal/integration/Source/OpenPype/OpenPype.Build.cs @@ -36,7 +36,9 @@ public class OpenPype : ModuleRules { "Projects", "InputCore", + "EditorFramework", "UnrealEd", + "ToolMenus", "LevelEditor", "CoreUObject", "Engine", diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPype.cpp b/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPype.cpp index 15c46b3862..b3bd9a81b3 100644 --- a/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPype.cpp +++ b/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPype.cpp @@ -1,7 +1,10 @@ #include "OpenPype.h" -#include "LevelEditor.h" -#include "OpenPypePythonBridge.h" #include "OpenPypeStyle.h" +#include "OpenPypeCommands.h" +#include "OpenPypePythonBridge.h" +#include "LevelEditor.h" +#include "Misc/MessageDialog.h" +#include "ToolMenus.h" static const FName OpenPypeTabName("OpenPype"); @@ -11,82 +14,61 @@ static const FName OpenPypeTabName("OpenPype"); // This function is triggered when the plugin is staring up void FOpenPypeModule::StartupModule() { - FOpenPypeStyle::Initialize(); - FOpenPypeStyle::SetIcon("Logo", "openpype40"); + FOpenPypeStyle::ReloadTextures(); + FOpenPypeCommands::Register(); - // Create the Extender that will add content to the menu - FLevelEditorModule& LevelEditorModule = FModuleManager::LoadModuleChecked("LevelEditor"); - - TSharedPtr MenuExtender = MakeShareable(new FExtender()); - TSharedPtr ToolbarExtender = MakeShareable(new FExtender()); + PluginCommands = MakeShareable(new FUICommandList); - MenuExtender->AddMenuExtension( - "LevelEditor", - EExtensionHook::After, - NULL, - FMenuExtensionDelegate::CreateRaw(this, &FOpenPypeModule::AddMenuEntry) - ); - ToolbarExtender->AddToolBarExtension( - "Settings", - EExtensionHook::After, - NULL, - FToolBarExtensionDelegate::CreateRaw(this, &FOpenPypeModule::AddToobarEntry)); - - - LevelEditorModule.GetMenuExtensibilityManager()->AddExtender(MenuExtender); - LevelEditorModule.GetToolBarExtensibilityManager()->AddExtender(ToolbarExtender); + PluginCommands->MapAction( + FOpenPypeCommands::Get().OpenPypeTools, + FExecuteAction::CreateRaw(this, &FOpenPypeModule::MenuPopup), + FCanExecuteAction()); + PluginCommands->MapAction( + FOpenPypeCommands::Get().OpenPypeToolsDialog, + FExecuteAction::CreateRaw(this, &FOpenPypeModule::MenuDialog), + FCanExecuteAction()); + UToolMenus::RegisterStartupCallback(FSimpleMulticastDelegate::FDelegate::CreateRaw(this, &FOpenPypeModule::RegisterMenus)); } void FOpenPypeModule::ShutdownModule() { + UToolMenus::UnRegisterStartupCallback(this); + + UToolMenus::UnregisterOwner(this); + FOpenPypeStyle::Shutdown(); + + FOpenPypeCommands::Unregister(); } - -void FOpenPypeModule::AddMenuEntry(FMenuBuilder& MenuBuilder) +void FOpenPypeModule::RegisterMenus() { - // Create Section - MenuBuilder.BeginSection("OpenPype", TAttribute(FText::FromString("OpenPype"))); + // Owner will be used for cleanup in call to UToolMenus::UnregisterOwner + FToolMenuOwnerScoped OwnerScoped(this); + { - // Create a Submenu inside of the Section - MenuBuilder.AddMenuEntry( - FText::FromString("Tools..."), - FText::FromString("Pipeline tools"), - FSlateIcon(FOpenPypeStyle::GetStyleSetName(), "OpenPype.Logo"), - FUIAction(FExecuteAction::CreateRaw(this, &FOpenPypeModule::MenuPopup)) - ); - - MenuBuilder.AddMenuEntry( - FText::FromString("Tools dialog..."), - FText::FromString("Pipeline tools dialog"), - FSlateIcon(FOpenPypeStyle::GetStyleSetName(), "OpenPype.Logo"), - FUIAction(FExecuteAction::CreateRaw(this, &FOpenPypeModule::MenuDialog)) - ); - + UToolMenu* Menu = UToolMenus::Get()->ExtendMenu("LevelEditor.MainMenu.Tools"); + { + // FToolMenuSection& Section = Menu->FindOrAddSection("OpenPype"); + FToolMenuSection& Section = Menu->AddSection( + "OpenPype", + TAttribute(FText::FromString("OpenPype")), + FToolMenuInsert("Programming", EToolMenuInsertType::Before) + ); + Section.AddMenuEntryWithCommandList(FOpenPypeCommands::Get().OpenPypeTools, PluginCommands); + Section.AddMenuEntryWithCommandList(FOpenPypeCommands::Get().OpenPypeToolsDialog, PluginCommands); + } + UToolMenu* ToolbarMenu = UToolMenus::Get()->ExtendMenu("LevelEditor.LevelEditorToolBar.PlayToolBar"); + { + FToolMenuSection& Section = ToolbarMenu->FindOrAddSection("PluginTools"); + { + FToolMenuEntry& Entry = Section.AddEntry(FToolMenuEntry::InitToolBarButton(FOpenPypeCommands::Get().OpenPypeTools)); + Entry.SetCommandList(PluginCommands); + } + } } - MenuBuilder.EndSection(); -} - -void FOpenPypeModule::AddToobarEntry(FToolBarBuilder& ToolbarBuilder) -{ - ToolbarBuilder.BeginSection(TEXT("OpenPype")); - { - ToolbarBuilder.AddToolBarButton( - FUIAction( - FExecuteAction::CreateRaw(this, &FOpenPypeModule::MenuPopup), - NULL, - FIsActionChecked() - - ), - NAME_None, - LOCTEXT("OpenPype_label", "OpenPype"), - LOCTEXT("OpenPype_tooltip", "OpenPype Tools"), - FSlateIcon(FOpenPypeStyle::GetStyleSetName(), "OpenPype.Logo") - ); - } - ToolbarBuilder.EndSection(); } diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypeCommands.cpp b/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypeCommands.cpp new file mode 100644 index 0000000000..6187bd7c7e --- /dev/null +++ b/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypeCommands.cpp @@ -0,0 +1,13 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#include "OpenPypeCommands.h" + +#define LOCTEXT_NAMESPACE "FOpenPypeModule" + +void FOpenPypeCommands::RegisterCommands() +{ + UI_COMMAND(OpenPypeTools, "OpenPype Tools", "Pipeline tools", EUserInterfaceActionType::Button, FInputChord()); + UI_COMMAND(OpenPypeToolsDialog, "OpenPype Tools Dialog", "Pipeline tools dialog", EUserInterfaceActionType::Button, FInputChord()); +} + +#undef LOCTEXT_NAMESPACE diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypeStyle.cpp b/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypeStyle.cpp index a51c2d6aa5..4a53af26b5 100644 --- a/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypeStyle.cpp +++ b/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypeStyle.cpp @@ -1,10 +1,14 @@ +#include "OpenPype.h" #include "OpenPypeStyle.h" #include "Framework/Application/SlateApplication.h" -#include "Styling/SlateStyle.h" #include "Styling/SlateStyleRegistry.h" +#include "Slate/SlateGameResources.h" +#include "Interfaces/IPluginManager.h" +#include "Styling/SlateStyleMacros.h" +#define RootToContentDir Style->RootToContentDir -TUniquePtr< FSlateStyleSet > FOpenPypeStyle::OpenPypeStyleInstance = nullptr; +TSharedPtr FOpenPypeStyle::OpenPypeStyleInstance = nullptr; void FOpenPypeStyle::Initialize() { @@ -17,11 +21,9 @@ void FOpenPypeStyle::Initialize() void FOpenPypeStyle::Shutdown() { - if (OpenPypeStyleInstance.IsValid()) - { - FSlateStyleRegistry::UnRegisterSlateStyle(*OpenPypeStyleInstance); - OpenPypeStyleInstance.Reset(); - } + FSlateStyleRegistry::UnRegisterSlateStyle(*OpenPypeStyleInstance); + ensure(OpenPypeStyleInstance.IsUnique()); + OpenPypeStyleInstance.Reset(); } FName FOpenPypeStyle::GetStyleSetName() @@ -30,41 +32,30 @@ FName FOpenPypeStyle::GetStyleSetName() return StyleSetName; } -FName FOpenPypeStyle::GetContextName() -{ - static FName ContextName(TEXT("OpenPype")); - return ContextName; -} - -#define IMAGE_BRUSH(RelativePath, ...) FSlateImageBrush( Style->RootToContentDir( RelativePath, TEXT(".png") ), __VA_ARGS__ ) - +const FVector2D Icon16x16(16.0f, 16.0f); +const FVector2D Icon20x20(20.0f, 20.0f); const FVector2D Icon40x40(40.0f, 40.0f); -TUniquePtr< FSlateStyleSet > FOpenPypeStyle::Create() +TSharedRef< FSlateStyleSet > FOpenPypeStyle::Create() { - TUniquePtr< FSlateStyleSet > Style = MakeUnique(GetStyleSetName()); - Style->SetContentRoot(FPaths::ProjectPluginsDir() / TEXT("OpenPype/Resources")); + TSharedRef< FSlateStyleSet > Style = MakeShareable(new FSlateStyleSet("OpenPypeStyle")); + Style->SetContentRoot(IPluginManager::Get().FindPlugin("OpenPype")->GetBaseDir() / TEXT("Resources")); + + Style->Set("OpenPype.OpenPypeTools", new IMAGE_BRUSH(TEXT("openpype40"), Icon40x40)); + Style->Set("OpenPype.OpenPypeToolsDialog", new IMAGE_BRUSH(TEXT("openpype40"), Icon40x40)); return Style; } -void FOpenPypeStyle::SetIcon(const FString& StyleName, const FString& ResourcePath) +void FOpenPypeStyle::ReloadTextures() { - FSlateStyleSet* Style = OpenPypeStyleInstance.Get(); - - FString Name(GetContextName().ToString()); - Name = Name + "." + StyleName; - Style->Set(*Name, new FSlateImageBrush(Style->RootToContentDir(ResourcePath, TEXT(".png")), Icon40x40)); - - - FSlateApplication::Get().GetRenderer()->ReloadTextureResources(); + if (FSlateApplication::IsInitialized()) + { + FSlateApplication::Get().GetRenderer()->ReloadTextureResources(); + } } -#undef IMAGE_BRUSH - const ISlateStyle& FOpenPypeStyle::Get() { - check(OpenPypeStyleInstance); - return *OpenPypeStyleInstance; return *OpenPypeStyleInstance; } diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPype.h b/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPype.h index db3f299354..3ee5eaa65f 100644 --- a/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPype.h +++ b/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPype.h @@ -2,7 +2,8 @@ #pragma once -#include "Engine.h" +#include "CoreMinimal.h" +#include "Modules/ModuleManager.h" class FOpenPypeModule : public IModuleInterface @@ -12,10 +13,11 @@ public: virtual void ShutdownModule() override; private: + void RegisterMenus(); - void AddMenuEntry(FMenuBuilder& MenuBuilder); - void AddToobarEntry(FToolBarBuilder& ToolbarBuilder); void MenuPopup(); void MenuDialog(); +private: + TSharedPtr PluginCommands; }; diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypeCommands.h b/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypeCommands.h new file mode 100644 index 0000000000..62ffb8de33 --- /dev/null +++ b/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypeCommands.h @@ -0,0 +1,24 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#pragma once + +#include "CoreMinimal.h" +#include "Framework/Commands/Commands.h" +#include "OpenPypeStyle.h" + +class FOpenPypeCommands : public TCommands +{ +public: + + FOpenPypeCommands() + : TCommands(TEXT("OpenPype"), NSLOCTEXT("Contexts", "OpenPype", "OpenPype Tools"), NAME_None, FOpenPypeStyle::GetStyleSetName()) + { + } + + // TCommands<> interface + virtual void RegisterCommands() override; + +public: + TSharedPtr< FUICommandInfo > OpenPypeTools; + TSharedPtr< FUICommandInfo > OpenPypeToolsDialog; +}; diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypeStyle.h b/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypeStyle.h index fbc8bcdd5b..ae704251e1 100644 --- a/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypeStyle.h +++ b/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypeStyle.h @@ -1,22 +1,18 @@ #pragma once #include "CoreMinimal.h" - -class FSlateStyleSet; -class ISlateStyle; - +#include "Styling/SlateStyle.h" class FOpenPypeStyle { public: static void Initialize(); static void Shutdown(); + static void ReloadTextures(); static const ISlateStyle& Get(); static FName GetStyleSetName(); - static FName GetContextName(); - static void SetIcon(const FString& StyleName, const FString& ResourcePath); private: - static TUniquePtr< FSlateStyleSet > Create(); - static TUniquePtr< FSlateStyleSet > OpenPypeStyleInstance; + static TSharedRef< class FSlateStyleSet > Create(); + static TSharedPtr< class FSlateStyleSet > OpenPypeStyleInstance; }; \ No newline at end of file From 8db65d56cc16bce55d6588a627ef5656c23a71fe Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 3 May 2022 17:31:51 +0200 Subject: [PATCH 205/583] hound fix --- openpype/tools/publisher/widgets/create_dialog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/tools/publisher/widgets/create_dialog.py b/openpype/tools/publisher/widgets/create_dialog.py index 09b2b9cb30..9e357f3a56 100644 --- a/openpype/tools/publisher/widgets/create_dialog.py +++ b/openpype/tools/publisher/widgets/create_dialog.py @@ -774,7 +774,7 @@ class CreateDialog(QtWidgets.QDialog): self._help_btn.set_pos_and_size( max(0, pos_x), max(0, pos_y), - width, height + width, height ) def _on_help_btn_resize(self, height): From 289e35d68d9c7d5bd6a95a07100f7e82fcc59201 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 3 May 2022 17:33:08 +0200 Subject: [PATCH 206/583] added stretches --- openpype/widgets/attribute_defs/widgets.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openpype/widgets/attribute_defs/widgets.py b/openpype/widgets/attribute_defs/widgets.py index 875b69acb4..b6493b80a8 100644 --- a/openpype/widgets/attribute_defs/widgets.py +++ b/openpype/widgets/attribute_defs/widgets.py @@ -91,6 +91,8 @@ class AttributeDefinitionsWidget(QtWidgets.QWidget): layout.deleteLater() new_layout = QtWidgets.QGridLayout() + new_layout.setColumnStretch(0, 0) + new_layout.setColumnStretch(1, 1) self.setLayout(new_layout) def set_attr_defs(self, attr_defs): From 0dd91e8736b222651e3a158cc9d9dc266b052bd7 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 3 May 2022 22:22:49 +0200 Subject: [PATCH 207/583] Nuke: adding `formatable` to node knob keys --- openpype/hosts/nuke/api/lib.py | 33 ++++++++++++++--- .../schemas/template_nuke_knob_inputs.json | 36 +++++++++++++++++++ 2 files changed, 65 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 0fa5633b90..475eb006ff 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -1059,12 +1059,12 @@ def create_write_node(name, data, input=None, prenodes=None, GN["tile_color"].setValue(tile_color) # finally add knob overrides - set_node_knobs_from_settings(GN, knob_overrides) + set_node_knobs_from_settings(GN, knob_overrides, **kwargs) return GN -def set_node_knobs_from_settings(node, knob_settings): +def set_node_knobs_from_settings(node, knob_settings, **kwargs): """ Overriding knob values from settings Using `schema_nuke_knob_inputs` for knob type definitions. @@ -1072,13 +1072,37 @@ def set_node_knobs_from_settings(node, knob_settings): Args: node (nuke.Node): nuke node knob_settings (list): list of dict. Keys are `type`, `name`, `value` + kwargs (dict)[optional]: keys for formatable knob settings """ for knob in knob_settings: knob_type = knob["type"] knob_name = knob["name"] - knob_value = knob["value"] + if knob_name not in node.knobs(): continue + + # first deal with formatable knob settings + if knob_type == "formatable": + template = knob["template"] + to_type = knob["to_type"] + try: + _knob_value = template.format( + **kwargs + ) + except KeyError as msg: + raise KeyError(msg) + + # convert value to correct type + if to_type == "2d_vector": + knob_value = _knob_value.split(";").split(",") + else: + knob_value = _knob_value + + knob_type = to_type + + else: + knob_value = knob["value"] + if not knob_value: continue @@ -1103,9 +1127,10 @@ def set_node_knobs_from_settings(node, knob_settings): pformat(knob_settings) )) knob_value = int(knob_value, 16) - if knob_type in ["2d_vector", "3d_vector", "color"]: + elif knob_type in ["2d_vector", "3d_vector", "color"]: knob_value = [float(v) for v in knob_value] + node[knob_name].setValue(knob_value) diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/template_nuke_knob_inputs.json b/openpype/settings/entities/schemas/projects_schema/schemas/template_nuke_knob_inputs.json index 957c684013..e6ca1e7fd4 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/template_nuke_knob_inputs.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/template_nuke_knob_inputs.json @@ -28,6 +28,42 @@ } ] }, + { + "key": "formatable", + "label": "Formate from template", + "children": [ + { + "type": "text", + "key": "name", + "label": "Name" + }, + { + "type": "text", + "key": "template", + "label": "Template", + "placeholder": "{{key}} or {{key}};{{key}}" + }, + { + "type": "enum", + "key": "to_type", + "label": "Knob type", + "enum_items": [ + { + "text": "Text" + }, + { + "number": "Number" + }, + { + "decimal_number": "Decimal number" + }, + { + "2d_vector": "2D vector" + } + ] + } + ] + }, { "key": "hex", "label": "Hexadecimal (0x)", From 3ddb46463c67025d4a46a70e4f3ee545ab99ac8f Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 3 May 2022 22:26:52 +0200 Subject: [PATCH 208/583] nuke: updating create_write_node inputs and docs --- openpype/hosts/nuke/api/lib.py | 62 ++++++++++++++++++++++------------ 1 file changed, 40 insertions(+), 22 deletions(-) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 475eb006ff..4170eaf1e7 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -812,39 +812,54 @@ def add_button_clear_rendered(node, path): node.addKnob(knob) -def create_write_node(name, data, input=None, prenodes=None, - review=True, linked_knobs=None, farm=True): +def create_write_node( + name, + data, + input=None, + prenodes=None, + review=True, + farm=True, + linked_knobs=None, + **kwargs +): ''' Creating write node which is group node Arguments: name (str): name of node - data (dict): data to be imprinted - input (node): selected node to connect to - prenodes (list, optional): list of lists, definitions for nodes - to be created before write - review (bool): adding review knob + data (dict): creator write instance data + input (node)[optional]: selected node to connect to + prenodes (dict)[optional]: + nodes to be created before write with dependency + review (bool)[optional]: adding review knob + farm (bool)[optional]: rendering workflow target + kwargs (dict)[optional]: additional key arguments for formating Example: - prenodes = [ - { - "nodeName": { - "class": "" # string - "knobs": [ - ("knobName": value), - ... - ], - "dependent": [ - following_node_01, - ... - ] - } + prenodes = { + "nodeName": { + "nodeclass": "Reformat", + "dependent": [ + following_node_01, + ... + ], + "knobs": [ + { + "type": "text", + "name": "knobname", + "value": "knob value" + }, + ... + ] }, ... - ] + } + Return: node (obj): group node with avalon data as Knobs ''' + prenodes = prenodes or {} + # group node knob overrides knob_overrides = data.get("knobs", []) @@ -880,7 +895,9 @@ def create_write_node(name, data, input=None, prenodes=None, # build file path to workfiles fdir = str(anatomy_filled["work"]["folder"]).replace("\\", "/") fpath = data["fpath_template"].format( - work=fdir, version=data["version"], subset=data["subset"], + work=fdir, + version=data["version"], + subset=data["subset"], frame=data["frame"], ext=representation ) @@ -919,6 +936,7 @@ def create_write_node(name, data, input=None, prenodes=None, prev_node = nuke.createNode( "Input", "name {}".format("rgba")) prev_node.hideControlPanel() + # creating pre-write nodes `prenodes` if prenodes: for node in prenodes: From 3fd5a534c6747f3b9ceb5b333112c58a7762b7d0 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 3 May 2022 22:28:05 +0200 Subject: [PATCH 209/583] nuke: adding prenodes to settings - also adding write still plugin to settings --- .../defaults/project_settings/nuke.json | 48 ++++++- .../projects_schema/schema_project_nuke.json | 125 ++++++++++++++++++ 2 files changed, 170 insertions(+), 3 deletions(-) diff --git a/openpype/settings/defaults/project_settings/nuke.json b/openpype/settings/defaults/project_settings/nuke.json index 4fc6caa57e..057d950b54 100644 --- a/openpype/settings/defaults/project_settings/nuke.json +++ b/openpype/settings/defaults/project_settings/nuke.json @@ -22,10 +22,28 @@ "Main", "Mask" ], - "knobs": [] + "knobs": [], + "prenodes": { + "Reformat01": { + "nodeclass": "Reformat", + "dependent": "", + "knobs": [ + { + "type": "text", + "name": "resize", + "value": "none" + }, + { + "type": "bool", + "name": "black_outside", + "value": true + } + ] + } + } }, "CreateWritePrerender": { - "fpath_template": "{work}/prerenders/nuke/{subset}/{subset}.{frame}.{ext}", + "fpath_template": "{work}/renders/nuke/{subset}/{subset}.{frame}.{ext}", "use_range_limit": true, "defaults": [ "Key01", @@ -35,7 +53,31 @@ "Part01" ], "reviewable": false, - "knobs": [] + "knobs": [], + "prenodes": {} + }, + "CreateWriteStill": { + "fpath_template": "{work}/renders/nuke/{subset}/{subset}.{ext}", + "defaults": [ + "ImageFrame{frame:0>4}", + "MPFrame{frame:0>4}", + "LayoutFrame{frame:0>4}" + ], + "knobs": [], + "prenodes": { + "FrameHold01": { + "nodeclass": "FrameHold", + "dependent": "", + "knobs": [ + { + "type": "formatable", + "name": "FrameHold", + "template": "{frame}", + "to_type": "number" + } + ] + } + } } }, "publish": { diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_nuke.json b/openpype/settings/entities/schemas/projects_schema/schema_project_nuke.json index 29ad8e3c6c..bc572cbdc8 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_nuke.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_nuke.json @@ -114,6 +114,37 @@ "key": "knobs" } ] + }, + { + "key": "prenodes", + "label": "Pre write nodes", + "type": "dict-modifiable", + "highlight_content": true, + "object_type": { + "type": "dict", + "children": [ + { + "key": "nodeclass", + "label": "Node class", + "type": "text" + }, + { + "key": "dependent", + "label": "Outside node dependency", + "type": "text" + }, + { + "type": "schema_template", + "name": "template_nuke_knob_inputs", + "template_data": [ + { + "label": "Node knobs", + "key": "knobs" + } + ] + } + ] + } } ] }, @@ -156,6 +187,100 @@ "key": "knobs" } ] + }, + { + "key": "prenodes", + "label": "Pre write nodes", + "type": "dict-modifiable", + "highlight_content": true, + "object_type": { + "type": "dict", + "children": [ + { + "key": "nodeclass", + "label": "Node class", + "type": "text" + }, + { + "key": "dependent", + "label": "Outside node dependency", + "type": "text" + }, + { + "type": "schema_template", + "name": "template_nuke_knob_inputs", + "template_data": [ + { + "label": "Node knobs", + "key": "knobs" + } + ] + } + ] + } + } + ] + }, + { + "type": "dict", + "collapsible": true, + "key": "CreateWriteStill", + "label": "CreateWriteStill", + "is_group": true, + "children": [ + { + "type": "text", + "key": "fpath_template", + "label": "Path template" + }, + { + "type": "list", + "key": "defaults", + "label": "Subset name defaults", + "object_type": { + "type": "text" + } + }, + { + "type": "schema_template", + "name": "template_nuke_knob_inputs", + "template_data": [ + { + "label": "Node knobs", + "key": "knobs" + } + ] + }, + { + "key": "prenodes", + "label": "Pre write nodes", + "type": "dict-modifiable", + "highlight_content": true, + "object_type": { + "type": "dict", + "children": [ + { + "key": "nodeclass", + "label": "Node class", + "type": "text" + }, + { + "key": "dependent", + "label": "Outside node dependency", + "type": "text" + }, + { + "type": "schema_template", + "name": "template_nuke_knob_inputs", + "template_data": [ + { + "label": "Node knobs", + "key": "knobs" + } + ] + } + ] + } } ] } From 890e10836825968b61cd1f524d7c399c394173e0 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 3 May 2022 22:28:51 +0200 Subject: [PATCH 210/583] nuke: updating plugins with new settings with prenodes removing self.preset references --- openpype/hosts/nuke/api/plugin.py | 25 +++------ .../plugins/create/create_write_prerender.py | 21 ++++--- .../plugins/create/create_write_render.py | 43 +++++++++----- .../nuke/plugins/create/create_write_still.py | 56 +++++++++++-------- 4 files changed, 82 insertions(+), 63 deletions(-) diff --git a/openpype/hosts/nuke/api/plugin.py b/openpype/hosts/nuke/api/plugin.py index 37c3633d2c..1eaccef795 100644 --- a/openpype/hosts/nuke/api/plugin.py +++ b/openpype/hosts/nuke/api/plugin.py @@ -27,9 +27,6 @@ class OpenPypeCreator(LegacyCreator): def __init__(self, *args, **kwargs): super(OpenPypeCreator, self).__init__(*args, **kwargs) - self.presets = get_current_project_settings()["nuke"]["create"].get( - self.__class__.__name__, {} - ) if check_subsetname_exists( nuke.allNodes(), self.data["subset"]): @@ -606,6 +603,7 @@ class AbstractWriteRender(OpenPypeCreator): icon = "sign-out" defaults = ["Main", "Mask"] knobs = [] + prenodes = {} def __init__(self, *args, **kwargs): super(AbstractWriteRender, self).__init__(*args, **kwargs) @@ -682,21 +680,12 @@ class AbstractWriteRender(OpenPypeCreator): self.data.update(creator_data) write_data.update(creator_data) - if self.presets.get('fpath_template'): - self.log.info("Adding template path from preset") - write_data.update( - {"fpath_template": self.presets["fpath_template"]} - ) - else: - self.log.info("Adding template path from plugin") - write_data.update({ - "fpath_template": - ("{work}/" + self.family + "s/nuke/{subset}" - "/{subset}.{frame}.{ext}")}) - - write_node = self._create_write_node(selected_node, - inputs, outputs, - write_data) + write_node = self._create_write_node( + selected_node, + inputs, + outputs, + write_data + ) # relinking to collected connections for i, input in enumerate(inputs): diff --git a/openpype/hosts/nuke/plugins/create/create_write_prerender.py b/openpype/hosts/nuke/plugins/create/create_write_prerender.py index 7297f74c13..f86ed7b89e 100644 --- a/openpype/hosts/nuke/plugins/create/create_write_prerender.py +++ b/openpype/hosts/nuke/plugins/create/create_write_prerender.py @@ -12,22 +12,27 @@ class CreateWritePrerender(plugin.AbstractWriteRender): n_class = "Write" family = "prerender" icon = "sign-out" + + # settings + fpath_template = "{work}/render/nuke/{subset}/{subset}.{frame}.{ext}" defaults = ["Key01", "Bg01", "Fg01", "Branch01", "Part01"] + reviewable = False + use_range_limit = True def __init__(self, *args, **kwargs): super(CreateWritePrerender, self).__init__(*args, **kwargs) def _create_write_node(self, selected_node, inputs, outputs, write_data): - reviewable = self.presets.get("reviewable") - write_node = create_write_node( + # add fpath_template + write_data["fpath_template"] = self.fpath_template + + return create_write_node( self.data["subset"], write_data, input=selected_node, - prenodes=[], - review=reviewable, - linked_knobs=["channels", "___", "first", "last", "use_limit"]) - - return write_node + review=self.reviewable, + linked_knobs=["channels", "___", "first", "last", "use_limit"] + ) def _modify_write_node(self, write_node): # open group node @@ -38,7 +43,7 @@ class CreateWritePrerender(plugin.AbstractWriteRender): w_node = n write_node.end() - if self.presets.get("use_range_limit"): + if self.use_range_limit: w_node["use_limit"].setValue(True) w_node["first"].setValue(nuke.root()["first_frame"].value()) w_node["last"].setValue(nuke.root()["last_frame"].value()) diff --git a/openpype/hosts/nuke/plugins/create/create_write_render.py b/openpype/hosts/nuke/plugins/create/create_write_render.py index 36a7b5c33f..43b1a9dcb4 100644 --- a/openpype/hosts/nuke/plugins/create/create_write_render.py +++ b/openpype/hosts/nuke/plugins/create/create_write_render.py @@ -12,13 +12,36 @@ class CreateWriteRender(plugin.AbstractWriteRender): n_class = "Write" family = "render" icon = "sign-out" + + # settings + fpath_template = "{work}/render/nuke/{subset}/{subset}.{frame}.{ext}" defaults = ["Main", "Mask"] - knobs = [] + prenodes = { + "Reformat01": { + "nodeclass": "Reformat", + "dependent": None, + "knobs": [ + { + "type": "text", + "name": "resize", + "value": "none" + }, + { + "type": "bool", + "name": "black_outside", + "value": True + } + ] + } + } def __init__(self, *args, **kwargs): super(CreateWriteRender, self).__init__(*args, **kwargs) def _create_write_node(self, selected_node, inputs, outputs, write_data): + # add fpath_template + write_data["fpath_template"] = self.fpath_template + # add reformat node to cut off all outside of format bounding box # get width and height try: @@ -27,23 +50,15 @@ class CreateWriteRender(plugin.AbstractWriteRender): actual_format = nuke.root().knob('format').value() width, height = (actual_format.width(), actual_format.height()) - _prenodes = [ - { - "name": "Reformat01", - "class": "Reformat", - "knobs": [ - ("resize", 0), - ("black_outside", 1), - ], - "dependent": None - } - ] - return create_write_node( self.data["subset"], write_data, input=selected_node, - prenodes=_prenodes + prenodes=self.prenodes, + **{ + "width": width, + "height": height + } ) def _modify_write_node(self, write_node): diff --git a/openpype/hosts/nuke/plugins/create/create_write_still.py b/openpype/hosts/nuke/plugins/create/create_write_still.py index d22b5eab3f..4966344b82 100644 --- a/openpype/hosts/nuke/plugins/create/create_write_still.py +++ b/openpype/hosts/nuke/plugins/create/create_write_still.py @@ -12,42 +12,52 @@ class CreateWriteStill(plugin.AbstractWriteRender): n_class = "Write" family = "still" icon = "image" + + # settings + fpath_template = "{work}/render/nuke/{subset}/{subset}.{ext}" defaults = [ - "ImageFrame{:0>4}".format(nuke.frame()), - "MPFrame{:0>4}".format(nuke.frame()), - "LayoutFrame{:0>4}".format(nuke.frame()) + "ImageFrame{frame:0>4}", + "MPFrame{frame:0>4}", + "LayoutFrame{frame:0>4}" ] + prenodes = { + "FrameHold01": { + "nodeclass": "FrameHold", + "dependent": None, + "knobs": [ + { + "type": "formatable", + "name": "first_frame", + "template": "{frame}", + "to_type": "number" + } + ] + } + } def __init__(self, *args, **kwargs): + # format defaults + new_defaults = [] + for _d in self.defaults: + new_d = _d.format(frame=nuke.frame()) + new_defaults.append(new_d) + self.defaults = new_defaults + super(CreateWriteStill, self).__init__(*args, **kwargs) def _create_write_node(self, selected_node, inputs, outputs, write_data): - # explicitly reset template to 'renders', not same as other 2 writes - write_data.update({ - "fpath_template": ( - "{work}/renders/nuke/{subset}/{subset}.{ext}")}) + # add fpath_template + write_data["fpath_template"] = self.fpath_template - _prenodes = [ - { - "name": "FrameHold01", - "class": "FrameHold", - "knobs": [ - ("first_frame", nuke.frame()) - ], - "dependent": None - } - ] - - write_node = create_write_node( + return create_write_node( self.name, write_data, input=selected_node, review=False, - prenodes=_prenodes, + prenodes=self.prenodes, farm=False, - linked_knobs=["channels", "___", "first", "last", "use_limit"]) - - return write_node + linked_knobs=["channels", "___", "first", "last", "use_limit"] + ) def _modify_write_node(self, write_node): write_node.begin() From 71e3c979768cbf24b803ef6b7995372ca4e2134a Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 4 May 2022 10:45:15 +0200 Subject: [PATCH 211/583] added new session schema --- openpype/pipeline/legacy_io.py | 4 +- schema/session-3.0.json | 127 +++++++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 schema/session-3.0.json diff --git a/openpype/pipeline/legacy_io.py b/openpype/pipeline/legacy_io.py index c41406b208..b0da68f2f5 100644 --- a/openpype/pipeline/legacy_io.py +++ b/openpype/pipeline/legacy_io.py @@ -25,7 +25,7 @@ def install(): session = session_data_from_environment(context_keys=True) - session["schema"] = "openpype:session-2.0" + session["schema"] = "openpype:session-3.0" try: schema.validate(session) except schema.ValidationError as e: @@ -55,7 +55,7 @@ def uninstall(): def requires_install(func): @functools.wraps(func) def decorated(*args, **kwargs): - if not module._is_installed: + if not _is_installed: install() return func(*args, **kwargs) return decorated diff --git a/schema/session-3.0.json b/schema/session-3.0.json new file mode 100644 index 0000000000..4a89403592 --- /dev/null +++ b/schema/session-3.0.json @@ -0,0 +1,127 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + + "title": "openpype:session-3.0", + "description": "The Avalon environment", + + "type": "object", + + "additionalProperties": true, + + "required": [ + "AVALON_PROJECT", + "AVALON_ASSET" + ], + + "properties": { + "AVALON_PROJECTS": { + "description": "Absolute path to root of project directories", + "type": "string", + "example": "/nas/projects" + }, + "AVALON_PROJECT": { + "description": "Name of project", + "type": "string", + "pattern": "^\\w*$", + "example": "Hulk" + }, + "AVALON_ASSET": { + "description": "Name of asset", + "type": "string", + "pattern": "^\\w*$", + "example": "Bruce" + }, + "AVALON_SILO": { + "description": "Name of asset group or container", + "type": "string", + "pattern": "^\\w*$", + "example": "assets" + }, + "AVALON_TASK": { + "description": "Name of task", + "type": "string", + "pattern": "^\\w*$", + "example": "modeling" + }, + "AVALON_APP": { + "description": "Name of application", + "type": "string", + "pattern": "^\\w*$", + "example": "maya2016" + }, + "AVALON_DB": { + "description": "Name of database", + "type": "string", + "pattern": "^\\w*$", + "example": "avalon", + "default": "avalon" + }, + "AVALON_LABEL": { + "description": "Nice name of Avalon, used in e.g. graphical user interfaces", + "type": "string", + "example": "Mindbender", + "default": "Avalon" + }, + "AVALON_SENTRY": { + "description": "Address to Sentry", + "type": "string", + "pattern": "^http[\\w/@:.]*$", + "example": "https://5b872b280de742919b115bdc8da076a5:8d278266fe764361b8fa6024af004a9c@logs.mindbender.com/2", + "default": null + }, + "AVALON_DEADLINE": { + "description": "Address to Deadline", + "type": "string", + "pattern": "^http[\\w/@:.]*$", + "example": "http://192.168.99.101", + "default": null + }, + "AVALON_TIMEOUT": { + "description": "Wherever there is a need for a timeout, this is the default value.", + "type": "string", + "pattern": "^[0-9]*$", + "default": "1000", + "example": "1000" + }, + "AVALON_UPLOAD": { + "description": "Boolean of whether to upload published material to central asset repository", + "type": "string", + "default": null, + "example": "True" + }, + "AVALON_USERNAME": { + "description": "Generic username", + "type": "string", + "pattern": "^\\w*$", + "default": "avalon", + "example": "myself" + }, + "AVALON_PASSWORD": { + "description": "Generic password", + "type": "string", + "pattern": "^\\w*$", + "default": "secret", + "example": "abc123" + }, + "AVALON_INSTANCE_ID": { + "description": "Unique identifier for instances in a working file", + "type": "string", + "pattern": "^[\\w.]*$", + "default": "avalon.instance", + "example": "avalon.instance" + }, + "AVALON_CONTAINER_ID": { + "description": "Unique identifier for a loaded representation in a working file", + "type": "string", + "pattern": "^[\\w.]*$", + "default": "avalon.container", + "example": "avalon.container" + }, + "AVALON_DEBUG": { + "description": "Enable debugging mode. Some applications may use this for e.g. extended verbosity or mock plug-ins.", + "type": "string", + "default": null, + "example": "True" + } + } +} From 37785895972b648cdec86efddf7acf95a921f464 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 4 May 2022 10:46:05 +0200 Subject: [PATCH 212/583] reduced session keys --- schema/session-3.0.json | 48 +---------------------------------------- 1 file changed, 1 insertion(+), 47 deletions(-) diff --git a/schema/session-3.0.json b/schema/session-3.0.json index 4a89403592..9f785939e4 100644 --- a/schema/session-3.0.json +++ b/schema/session-3.0.json @@ -31,12 +31,6 @@ "pattern": "^\\w*$", "example": "Bruce" }, - "AVALON_SILO": { - "description": "Name of asset group or container", - "type": "string", - "pattern": "^\\w*$", - "example": "assets" - }, "AVALON_TASK": { "description": "Name of task", "type": "string", @@ -44,7 +38,7 @@ "example": "modeling" }, "AVALON_APP": { - "description": "Name of application", + "description": "Name of host", "type": "string", "pattern": "^\\w*$", "example": "maya2016" @@ -62,20 +56,6 @@ "example": "Mindbender", "default": "Avalon" }, - "AVALON_SENTRY": { - "description": "Address to Sentry", - "type": "string", - "pattern": "^http[\\w/@:.]*$", - "example": "https://5b872b280de742919b115bdc8da076a5:8d278266fe764361b8fa6024af004a9c@logs.mindbender.com/2", - "default": null - }, - "AVALON_DEADLINE": { - "description": "Address to Deadline", - "type": "string", - "pattern": "^http[\\w/@:.]*$", - "example": "http://192.168.99.101", - "default": null - }, "AVALON_TIMEOUT": { "description": "Wherever there is a need for a timeout, this is the default value.", "type": "string", @@ -83,26 +63,6 @@ "default": "1000", "example": "1000" }, - "AVALON_UPLOAD": { - "description": "Boolean of whether to upload published material to central asset repository", - "type": "string", - "default": null, - "example": "True" - }, - "AVALON_USERNAME": { - "description": "Generic username", - "type": "string", - "pattern": "^\\w*$", - "default": "avalon", - "example": "myself" - }, - "AVALON_PASSWORD": { - "description": "Generic password", - "type": "string", - "pattern": "^\\w*$", - "default": "secret", - "example": "abc123" - }, "AVALON_INSTANCE_ID": { "description": "Unique identifier for instances in a working file", "type": "string", @@ -116,12 +76,6 @@ "pattern": "^[\\w.]*$", "default": "avalon.container", "example": "avalon.container" - }, - "AVALON_DEBUG": { - "description": "Enable debugging mode. Some applications may use this for e.g. extended verbosity or mock plug-ins.", - "type": "string", - "default": null, - "example": "True" } } } From c18abc195b781dc756cec17ccf9543325c05bbb9 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 4 May 2022 12:08:56 +0200 Subject: [PATCH 213/583] nuke: extracting prenodes creation and fixing `dependency` linking --- openpype/hosts/nuke/api/lib.py | 98 +++++++++++++++------------------- 1 file changed, 42 insertions(+), 56 deletions(-) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 4170eaf1e7..fc1ec7ec3d 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -812,6 +812,47 @@ def add_button_clear_rendered(node, path): node.addKnob(knob) +def create_prenodes(prev_node, nodes_setting, **kwargs): + last_node = None + for_dependency = {} + for name, node in nodes_setting.items(): + # get attributes + nodeclass = node["nodeclass"] + knobs = node["knobs"] + + # create node + now_node = nuke.createNode( + nodeclass, "name {}".format(name)) + now_node.hideControlPanel() + + # add for dependency linking + for_dependency[name] = { + "node": now_node, + "dependent": node["dependent"] + } + + # add data to knob + set_node_knobs_from_settings(now_node, knobs, **kwargs) + + # switch actual node to previous + last_node = now_node + + for _node_name, node_prop in for_dependency.items(): + if not node_prop["dependent"]: + node_prop["node"].setInput( + 0, prev_node) + elif node_prop["dependent"] in for_dependency: + _prev_node = for_dependency[node_prop["dependent"]] + node_prop["node"].setInput( + 0, _prev_node) + else: + log.warning("Dependency has wrong name of node: {}".format( + node_prop + )) + + return last_node + + def create_write_node( name, data, @@ -938,62 +979,7 @@ def create_write_node( prev_node.hideControlPanel() # creating pre-write nodes `prenodes` - if prenodes: - for node in prenodes: - # get attributes - pre_node_name = node["name"] - klass = node["class"] - knobs = node["knobs"] - dependent = node["dependent"] - - # create node - now_node = nuke.createNode( - klass, "name {}".format(pre_node_name)) - now_node.hideControlPanel() - - # add data to knob - for _knob in knobs: - knob, value = _knob - try: - now_node[knob].value() - except NameError: - log.warning( - "knob `{}` does not exist on node `{}`".format( - knob, now_node["name"].value() - )) - continue - - if not knob and not value: - continue - - log.info((knob, value)) - - if isinstance(value, str): - if "[" in value: - now_node[knob].setExpression(value) - else: - now_node[knob].setValue(value) - - # connect to previous node - if dependent: - if isinstance(dependent, (tuple or list)): - for i, node_name in enumerate(dependent): - input_node = nuke.createNode( - "Input", "name {}".format(node_name)) - input_node.hideControlPanel() - now_node.setInput(1, input_node) - - elif isinstance(dependent, str): - input_node = nuke.createNode( - "Input", "name {}".format(node_name)) - input_node.hideControlPanel() - now_node.setInput(0, input_node) - - else: - now_node.setInput(0, prev_node) - - # switch actual node to previous - prev_node = now_node + create_prenodes(prev_node, prenodes, **kwargs) # creating write node write_node = now_node = add_write_node( From bc07da3de252167ebf7c604ef7dd2fd1245414d7 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 4 May 2022 17:02:51 +0200 Subject: [PATCH 214/583] nuke: fixing create write still --- .../nuke/plugins/create/create_write_still.py | 22 +++++++++---------- .../defaults/project_settings/nuke.json | 8 +++---- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/openpype/hosts/nuke/plugins/create/create_write_still.py b/openpype/hosts/nuke/plugins/create/create_write_still.py index 4966344b82..0b1a942e0e 100644 --- a/openpype/hosts/nuke/plugins/create/create_write_still.py +++ b/openpype/hosts/nuke/plugins/create/create_write_still.py @@ -2,6 +2,10 @@ import nuke from openpype.hosts.nuke.api import plugin from openpype.hosts.nuke.api.lib import create_write_node +from openpype.api import ( + Logger +) +log = Logger.get_logger(__name__) class CreateWriteStill(plugin.AbstractWriteRender): @@ -16,9 +20,9 @@ class CreateWriteStill(plugin.AbstractWriteRender): # settings fpath_template = "{work}/render/nuke/{subset}/{subset}.{ext}" defaults = [ - "ImageFrame{frame:0>4}", - "MPFrame{frame:0>4}", - "LayoutFrame{frame:0>4}" + "ImageFrame", + "MPFrame", + "LayoutFrame" ] prenodes = { "FrameHold01": { @@ -36,13 +40,6 @@ class CreateWriteStill(plugin.AbstractWriteRender): } def __init__(self, *args, **kwargs): - # format defaults - new_defaults = [] - for _d in self.defaults: - new_d = _d.format(frame=nuke.frame()) - new_defaults.append(new_d) - self.defaults = new_defaults - super(CreateWriteStill, self).__init__(*args, **kwargs) def _create_write_node(self, selected_node, inputs, outputs, write_data): @@ -56,7 +53,10 @@ class CreateWriteStill(plugin.AbstractWriteRender): review=False, prenodes=self.prenodes, farm=False, - linked_knobs=["channels", "___", "first", "last", "use_limit"] + linked_knobs=["channels", "___", "first", "last", "use_limit"], + **{ + "frame": nuke.frame() + } ) def _modify_write_node(self, write_node): diff --git a/openpype/settings/defaults/project_settings/nuke.json b/openpype/settings/defaults/project_settings/nuke.json index 057d950b54..128d440732 100644 --- a/openpype/settings/defaults/project_settings/nuke.json +++ b/openpype/settings/defaults/project_settings/nuke.json @@ -59,9 +59,9 @@ "CreateWriteStill": { "fpath_template": "{work}/renders/nuke/{subset}/{subset}.{ext}", "defaults": [ - "ImageFrame{frame:0>4}", - "MPFrame{frame:0>4}", - "LayoutFrame{frame:0>4}" + "ImageFrame", + "MPFrame", + "LayoutFrame" ], "knobs": [], "prenodes": { @@ -71,7 +71,7 @@ "knobs": [ { "type": "formatable", - "name": "FrameHold", + "name": "first_frame", "template": "{frame}", "to_type": "number" } From a6ff0174b0a8ad0ecec5f230d860d02bd914f903 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 4 May 2022 17:03:46 +0200 Subject: [PATCH 215/583] Nuke: fixing lib functions for prenodes and others --- openpype/hosts/nuke/api/lib.py | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index fc1ec7ec3d..6d6a988b44 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -842,7 +842,7 @@ def create_prenodes(prev_node, nodes_setting, **kwargs): node_prop["node"].setInput( 0, prev_node) elif node_prop["dependent"] in for_dependency: - _prev_node = for_dependency[node_prop["dependent"]] + _prev_node = for_dependency[node_prop["dependent"]]["node"] node_prop["node"].setInput( 0, _prev_node) else: @@ -905,7 +905,7 @@ def create_write_node( knob_overrides = data.get("knobs", []) # filtering variables - plugin_name = data["creator"], + plugin_name = data["creator"] subset = data["subset"] # get knob settings for write node @@ -979,7 +979,9 @@ def create_write_node( prev_node.hideControlPanel() # creating pre-write nodes `prenodes` - create_prenodes(prev_node, prenodes, **kwargs) + last_prenode = create_prenodes(prev_node, prenodes, **kwargs) + if last_prenode: + prev_node = last_prenode # creating write node write_node = now_node = add_write_node( @@ -1059,8 +1061,14 @@ def create_write_node( GN[_NODE_TAB_NAME].setFlag(0) # set tile color - tile_color = _wn_props["knobs"].get("tile_color", "0xff0000ff") - GN["tile_color"].setValue(tile_color) + tile_color = next( + iter( + k["value"] for k in _wn_props["knobs"] + if "tile_color" in k["name"] + ), "0xff0000ff" + ) + GN["tile_color"].setValue( + int(tile_color, 16)) # finally add knob overrides set_node_knobs_from_settings(GN, knob_overrides, **kwargs) @@ -1079,6 +1087,7 @@ def set_node_knobs_from_settings(node, knob_settings, **kwargs): kwargs (dict)[optional]: keys for formatable knob settings """ for knob in knob_settings: + log.debug("__ knob: {}".format(pformat(knob))) knob_type = knob["type"] knob_name = knob["name"] @@ -1093,7 +1102,9 @@ def set_node_knobs_from_settings(node, knob_settings, **kwargs): _knob_value = template.format( **kwargs ) + log.debug("__ knob_value0: {}".format(_knob_value)) except KeyError as msg: + log.warning("__ msg: {}".format(msg)) raise KeyError(msg) # convert value to correct type From cb0ca1c220a231b117c3f6d35b30a8f9fb22c71b Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 4 May 2022 17:38:17 +0200 Subject: [PATCH 216/583] '_is_installed' is accessed from 'module' --- openpype/pipeline/legacy_io.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/pipeline/legacy_io.py b/openpype/pipeline/legacy_io.py index b0da68f2f5..c8e7e79600 100644 --- a/openpype/pipeline/legacy_io.py +++ b/openpype/pipeline/legacy_io.py @@ -55,7 +55,7 @@ def uninstall(): def requires_install(func): @functools.wraps(func) def decorated(*args, **kwargs): - if not _is_installed: + if not module._is_installed: install() return func(*args, **kwargs) return decorated From 2ec9bcfca17dcfdc8ce435c86e798475283b8f1b Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 4 May 2022 17:43:17 +0200 Subject: [PATCH 217/583] reduced duplicated code when OP version is not allowed --- start.py | 31 +++++++++++++------------------ 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/start.py b/start.py index cd1d95dd8f..89c5c98d27 100644 --- a/start.py +++ b/start.py @@ -942,6 +942,17 @@ def _boot_print_versions(use_staging, local_version, openpype_root): list_versions(openpype_versions, local_version) +def _boot_handle_missing_version(local_version, use_staging, message): + _print(message) + if os.environ.get("OPENPYPE_HEADLESS_MODE") == "1": + openpype_versions = bootstrap.find_openpype( + include_zips=True, staging=use_staging + ) + list_versions(openpype_versions, local_version) + else: + igniter.show_message_dialog("Version not found", message) + + def boot(): """Bootstrap OpenPype.""" @@ -1034,15 +1045,7 @@ def boot(): try: version_path = _find_frozen_openpype(use_version, use_staging) except OpenPypeVersionNotFound as exc: - message = str(exc) - _print(message) - if os.environ.get("OPENPYPE_HEADLESS_MODE") == "1": - openpype_versions = bootstrap.find_openpype( - include_zips=True, staging=use_staging - ) - list_versions(openpype_versions, local_version) - else: - igniter.show_message_dialog("Version not found", message) + _boot_handle_missing_version(local_version, use_staging, str(exc)) sys.exit(1) except RuntimeError as e: @@ -1061,15 +1064,7 @@ def boot(): version_path = _bootstrap_from_code(use_version, use_staging) except OpenPypeVersionNotFound as exc: - message = str(exc) - _print(message) - if os.environ.get("OPENPYPE_HEADLESS_MODE") == "1": - openpype_versions = bootstrap.find_openpype( - include_zips=True, staging=use_staging - ) - list_versions(openpype_versions, local_version) - else: - igniter.show_message_dialog("Version not found", message) + _boot_handle_missing_version(local_version, use_staging, str(exc)) sys.exit(1) # set this to point either to `python` from venv in case of live code From f3cf21ebb4c4486098a2c9aea80d50656786e46b Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 4 May 2022 17:45:54 +0200 Subject: [PATCH 218/583] avoid duplicated calls --- start.py | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/start.py b/start.py index 89c5c98d27..6e339fabab 100644 --- a/start.py +++ b/start.py @@ -911,17 +911,11 @@ def _boot_validate_versions(use_version, local_version): sys.exit(1) # print result - result = bootstrap.validate_openpype_version( - bootstrap.get_version_path_from_list( - use_version, openpype_versions)) - - _print("{}{}".format( - ">>> " if result[0] else "!!! ", - bootstrap.validate_openpype_version( - bootstrap.get_version_path_from_list( - use_version, openpype_versions) - )[1]) + version_path = bootstrap.get_version_path_from_list( + use_version, openpype_versions ) + valid, message = bootstrap.validate_openpype_version(version_path) + _print("{}{}".format(">>> " if valid else "!!! ", message)) def _boot_print_versions(use_staging, local_version, openpype_root): From e2076c0f2fa264f4dc7ae6959c3292b60616ca91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20David?= Date: Thu, 3 Feb 2022 19:03:16 +0100 Subject: [PATCH 219/583] Module: Kitsu module --- .../modules/default_modules/kitsu/__init__.py | 15 ++ .../default_modules/kitsu/kitsu_module.py | 147 ++++++++++++++++++ .../kitsu/plugins/publish/example_plugin.py | 9 ++ .../schemas/project_schemas/main.json | 30 ++++ .../schemas/project_schemas/the_template.json | 30 ++++ .../modules/default_modules/kitsu/widgets.py | 31 ++++ .../defaults/project_settings/kitsu.json | 3 + .../defaults/system_settings/modules.json | 4 + .../schemas/projects_schema/schema_main.json | 4 + .../projects_schema/schema_project_kitsu.json | 17 ++ .../module_settings/schema_kitsu.json | 23 +++ .../schemas/system_schema/schema_modules.json | 4 + 12 files changed, 317 insertions(+) create mode 100644 openpype/modules/default_modules/kitsu/__init__.py create mode 100644 openpype/modules/default_modules/kitsu/kitsu_module.py create mode 100644 openpype/modules/default_modules/kitsu/plugins/publish/example_plugin.py create mode 100644 openpype/modules/default_modules/kitsu/settings/schemas/project_schemas/main.json create mode 100644 openpype/modules/default_modules/kitsu/settings/schemas/project_schemas/the_template.json create mode 100644 openpype/modules/default_modules/kitsu/widgets.py create mode 100644 openpype/settings/defaults/project_settings/kitsu.json create mode 100644 openpype/settings/entities/schemas/projects_schema/schema_project_kitsu.json create mode 100644 openpype/settings/entities/schemas/system_schema/module_settings/schema_kitsu.json diff --git a/openpype/modules/default_modules/kitsu/__init__.py b/openpype/modules/default_modules/kitsu/__init__.py new file mode 100644 index 0000000000..cd0c2ea8af --- /dev/null +++ b/openpype/modules/default_modules/kitsu/__init__.py @@ -0,0 +1,15 @@ +""" Addon class definition and Settings definition must be imported here. + +If addon class or settings definition won't be here their definition won't +be found by OpenPype discovery. +""" + +from .kitsu_module import ( + AddonSettingsDef, + KitsuModule +) + +__all__ = ( + "AddonSettingsDef", + "KitsuModule" +) diff --git a/openpype/modules/default_modules/kitsu/kitsu_module.py b/openpype/modules/default_modules/kitsu/kitsu_module.py new file mode 100644 index 0000000000..81d7e56a81 --- /dev/null +++ b/openpype/modules/default_modules/kitsu/kitsu_module.py @@ -0,0 +1,147 @@ +"""Addon definition is located here. + +Import of python packages that may not be available should not be imported +in global space here until are required or used. +- Qt related imports +- imports of Python 3 packages + - we still support Python 2 hosts where addon definition should available +""" + +import os +import click + +from openpype.modules import ( + JsonFilesSettingsDef, + OpenPypeModule, + ModulesManager +) +# Import interface defined by this addon to be able find other addons using it +from openpype_interfaces import ( + IPluginPaths, + ITrayAction +) + + +# Settings definition of this addon using `JsonFilesSettingsDef` +# - JsonFilesSettingsDef is prepared settings definition using json files +# to define settings and store default values +class AddonSettingsDef(JsonFilesSettingsDef): + # This will add prefixes to every schema and template from `schemas` + # subfolder. + # - it is not required to fill the prefix but it is highly + # recommended as schemas and templates may have name clashes across + # multiple addons + # - it is also recommended that prefix has addon name in it + schema_prefix = "kitsu" + + def get_settings_root_path(self): + """Implemented abstract class of JsonFilesSettingsDef. + + Return directory path where json files defying addon settings are + located. + """ + return os.path.join( + os.path.dirname(os.path.abspath(__file__)), + "settings" + ) + + +class KitsuModule(OpenPypeModule, IPluginPaths, ITrayAction): + """This Addon has defined it's settings and interface. + + This example has system settings with an enabled option. And use + few other interfaces: + - `IPluginPaths` to define custom plugin paths + - `ITrayAction` to be shown in tray tool + """ + label = "Kitsu" + name = "kitsu" + + def initialize(self, settings): + """Initialization of addon.""" + module_settings = settings[self.name] + # Enabled by settings + self.enabled = module_settings.get("enabled", False) + + # Prepare variables that can be used or set afterwards + self._connected_modules = None + # UI which must not be created at this time + self._dialog = None + + def tray_init(self): + """Implementation of abstract method for `ITrayAction`. + + We're definitely in tray tool so we can pre create dialog. + """ + + self._create_dialog() + + def _create_dialog(self): + # Don't recreate dialog if already exists + if self._dialog is not None: + return + + from .widgets import MyExampleDialog + + self._dialog = MyExampleDialog() + + def show_dialog(self): + """Show dialog with connected modules. + + This can be called from anywhere but can also crash in headless mode. + There is no way to prevent addon to do invalid operations if he's + not handling them. + """ + # Make sure dialog is created + self._create_dialog() + # Show dialog + self._dialog.open() + + def get_connected_modules(self): + """Custom implementation of addon.""" + names = set() + if self._connected_modules is not None: + for module in self._connected_modules: + names.add(module.name) + return names + + def on_action_trigger(self): + """Implementation of abstract method for `ITrayAction`.""" + self.show_dialog() + + def get_plugin_paths(self): + """Implementation of abstract method for `IPluginPaths`.""" + current_dir = os.path.dirname(os.path.abspath(__file__)) + + return { + "publish": [os.path.join(current_dir, "plugins", "publish")] + } + + def cli(self, click_group): + click_group.add_command(cli_main) + + +@click.group(KitsuModule.name, help="Kitsu dynamic cli commands.") +def cli_main(): + pass + + +@cli_main.command() +def nothing(): + """Does nothing but print a message.""" + print("You've triggered \"nothing\" command.") + + +@cli_main.command() +def show_dialog(): + """Show ExampleAddon dialog. + + We don't have access to addon directly through cli so we have to create + it again. + """ + from openpype.tools.utils.lib import qt_app_context + + manager = ModulesManager() + example_addon = manager.modules_by_name[KitsuModule.name] + with qt_app_context(): + example_addon.show_dialog() diff --git a/openpype/modules/default_modules/kitsu/plugins/publish/example_plugin.py b/openpype/modules/default_modules/kitsu/plugins/publish/example_plugin.py new file mode 100644 index 0000000000..61602f4e78 --- /dev/null +++ b/openpype/modules/default_modules/kitsu/plugins/publish/example_plugin.py @@ -0,0 +1,9 @@ +import pyblish.api + + +class CollectExampleAddon(pyblish.api.ContextPlugin): + order = pyblish.api.CollectorOrder + 0.4 + label = "Collect Kitsu" + + def process(self, context): + self.log.info("I'm in Kitsu's plugin!") diff --git a/openpype/modules/default_modules/kitsu/settings/schemas/project_schemas/main.json b/openpype/modules/default_modules/kitsu/settings/schemas/project_schemas/main.json new file mode 100644 index 0000000000..82e58ce9ab --- /dev/null +++ b/openpype/modules/default_modules/kitsu/settings/schemas/project_schemas/main.json @@ -0,0 +1,30 @@ +{ + "type": "dict", + "key": "kitsu", + "label": " Kitsu", + "collapsible": true, + "children": [ + { + "type": "number", + "key": "number", + "label": "This is your lucky number:", + "minimum": 7, + "maximum": 7, + "decimals": 0 + }, + { + "type": "template", + "name": "kitsu/the_template", + "template_data": [ + { + "name": "color_1", + "label": "Color 1" + }, + { + "name": "color_2", + "label": "Color 2" + } + ] + } + ] +} diff --git a/openpype/modules/default_modules/kitsu/settings/schemas/project_schemas/the_template.json b/openpype/modules/default_modules/kitsu/settings/schemas/project_schemas/the_template.json new file mode 100644 index 0000000000..af8fd9dae4 --- /dev/null +++ b/openpype/modules/default_modules/kitsu/settings/schemas/project_schemas/the_template.json @@ -0,0 +1,30 @@ +[ + { + "type": "list-strict", + "key": "{name}", + "label": "{label}", + "object_types": [ + { + "label": "Red", + "type": "number", + "minimum": 0, + "maximum": 1, + "decimal": 3 + }, + { + "label": "Green", + "type": "number", + "minimum": 0, + "maximum": 1, + "decimal": 3 + }, + { + "label": "Blue", + "type": "number", + "minimum": 0, + "maximum": 1, + "decimal": 3 + } + ] + } +] diff --git a/openpype/modules/default_modules/kitsu/widgets.py b/openpype/modules/default_modules/kitsu/widgets.py new file mode 100644 index 0000000000..de232113fe --- /dev/null +++ b/openpype/modules/default_modules/kitsu/widgets.py @@ -0,0 +1,31 @@ +from Qt import QtWidgets + +from openpype.style import load_stylesheet + + +class MyExampleDialog(QtWidgets.QDialog): + def __init__(self, parent=None): + super(MyExampleDialog, self).__init__(parent) + + self.setWindowTitle("Connected modules") + + msg = "This is example dialog of Kitsu." + label_widget = QtWidgets.QLabel(msg, self) + + ok_btn = QtWidgets.QPushButton("OK", self) + btns_layout = QtWidgets.QHBoxLayout() + btns_layout.addStretch(1) + btns_layout.addWidget(ok_btn) + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(label_widget) + layout.addLayout(btns_layout) + + ok_btn.clicked.connect(self._on_ok_clicked) + + self._label_widget = label_widget + + self.setStyleSheet(load_stylesheet()) + + def _on_ok_clicked(self): + self.done(1) diff --git a/openpype/settings/defaults/project_settings/kitsu.json b/openpype/settings/defaults/project_settings/kitsu.json new file mode 100644 index 0000000000..b4d2ccc611 --- /dev/null +++ b/openpype/settings/defaults/project_settings/kitsu.json @@ -0,0 +1,3 @@ +{ + "number": 0 +} \ No newline at end of file diff --git a/openpype/settings/defaults/system_settings/modules.json b/openpype/settings/defaults/system_settings/modules.json index d74269922f..9cfaddecbe 100644 --- a/openpype/settings/defaults/system_settings/modules.json +++ b/openpype/settings/defaults/system_settings/modules.json @@ -137,6 +137,10 @@ } } }, + "kitsu": { + "enabled": false, + "kitsu_server": "" + }, "timers_manager": { "enabled": true, "auto_stop": true, diff --git a/openpype/settings/entities/schemas/projects_schema/schema_main.json b/openpype/settings/entities/schemas/projects_schema/schema_main.json index dbddd18c80..6c07209de3 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_main.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_main.json @@ -62,6 +62,10 @@ "type": "schema", "name": "schema_project_ftrack" }, + { + "type": "schema", + "name": "schema_project_kitsu" + }, { "type": "schema", "name": "schema_project_deadline" diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_kitsu.json b/openpype/settings/entities/schemas/projects_schema/schema_project_kitsu.json new file mode 100644 index 0000000000..93976cc03b --- /dev/null +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_kitsu.json @@ -0,0 +1,17 @@ +{ + "type": "dict", + "key": "kitsu", + "label": "Kitsu", + "collapsible": true, + "is_file": true, + "children": [ + { + "type": "number", + "key": "number", + "label": "This is your lucky number:", + "minimum": 7, + "maximum": 7, + "decimals": 0 + } + ] +} diff --git a/openpype/settings/entities/schemas/system_schema/module_settings/schema_kitsu.json b/openpype/settings/entities/schemas/system_schema/module_settings/schema_kitsu.json new file mode 100644 index 0000000000..8e496dc783 --- /dev/null +++ b/openpype/settings/entities/schemas/system_schema/module_settings/schema_kitsu.json @@ -0,0 +1,23 @@ +{ + "type": "dict", + "key": "kitsu", + "label": "Kitsu", + "collapsible": true, + "require_restart": true, + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "text", + "key": "kitsu_server", + "label": "Server" + }, + { + "type": "splitter" + } + ] +} diff --git a/openpype/settings/entities/schemas/system_schema/schema_modules.json b/openpype/settings/entities/schemas/system_schema/schema_modules.json index 52595914ed..d22b9016a7 100644 --- a/openpype/settings/entities/schemas/system_schema/schema_modules.json +++ b/openpype/settings/entities/schemas/system_schema/schema_modules.json @@ -44,6 +44,10 @@ "type": "schema", "name": "schema_ftrack" }, + { + "type": "schema", + "name": "schema_kitsu" + }, { "type": "dict", "key": "timers_manager", From 70440290cafcf02753f8c499951cc164b011c132 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20David?= Date: Tue, 8 Feb 2022 15:38:02 +0100 Subject: [PATCH 220/583] Fist step to sync from Zou to local --- .../default_modules/kitsu/kitsu_module.py | 125 +++++++++++++++++- .../defaults/system_settings/modules.json | 4 +- .../module_settings/schema_kitsu.json | 12 +- 3 files changed, 135 insertions(+), 6 deletions(-) diff --git a/openpype/modules/default_modules/kitsu/kitsu_module.py b/openpype/modules/default_modules/kitsu/kitsu_module.py index 81d7e56a81..55e4640fa2 100644 --- a/openpype/modules/default_modules/kitsu/kitsu_module.py +++ b/openpype/modules/default_modules/kitsu/kitsu_module.py @@ -6,20 +6,27 @@ in global space here until are required or used. - imports of Python 3 packages - we still support Python 2 hosts where addon definition should available """ - +import collections import os import click +from avalon.api import AvalonMongoDB +import gazu +from openpype.api import get_project_basic_paths, create_project_folders +from openpype.lib import create_project +from openpype.lib.anatomy import Anatomy from openpype.modules import ( JsonFilesSettingsDef, OpenPypeModule, ModulesManager ) +from openpype.tools.project_manager.project_manager.model import AssetItem, ProjectItem, TaskItem # Import interface defined by this addon to be able find other addons using it from openpype_interfaces import ( IPluginPaths, ITrayAction ) +from pymongo import UpdateOne, DeleteOne # Settings definition of this addon using `JsonFilesSettingsDef` @@ -60,9 +67,27 @@ class KitsuModule(OpenPypeModule, IPluginPaths, ITrayAction): def initialize(self, settings): """Initialization of addon.""" module_settings = settings[self.name] + # Enabled by settings self.enabled = module_settings.get("enabled", False) + # Add API URL schema + kitsu_url = module_settings["server"].strip() + if kitsu_url: + # Ensure web url + if not kitsu_url.startswith("http"): + kitsu_url = "https://" + kitsu_url + + # Check for "/api" url validity + if not kitsu_url.endswith("api"): + kitsu_url = f"{kitsu_url}{'' if kitsu_url.endswith('/') else '/'}api" + + self.server_url = kitsu_url + + # Set credentials + self.script_login = module_settings["script_login"] + self.script_pwd = module_settings["script_pwd"] + # Prepare variables that can be used or set afterwards self._connected_modules = None # UI which must not be created at this time @@ -76,6 +101,14 @@ class KitsuModule(OpenPypeModule, IPluginPaths, ITrayAction): self._create_dialog() + def get_global_environments(self): + """Kitsu's global environments.""" + return { + "KITSU_SERVER": self.server_url, + "KITSU_LOGIN": self.script_login, + "KITSU_PWD": self.script_pwd + } + def _create_dialog(self): # Don't recreate dialog if already exists if self._dialog is not None: @@ -127,10 +160,94 @@ def cli_main(): @cli_main.command() -def nothing(): - """Does nothing but print a message.""" - print("You've triggered \"nothing\" command.") +def sync_local(): + """Synchronize local database from Zou sever database.""" + # Connect to server + gazu.client.set_host(os.environ["KITSU_SERVER"]) + + # Authenticate + gazu.log_in(os.environ["KITSU_LOGIN"], os.environ["KITSU_PWD"]) + + # Iterate projects + dbcon = AvalonMongoDB() + dbcon.install() + all_projects = gazu.project.all_projects() + for project in all_projects: + # Create project locally + # Try to find project document + project_name = project["name"] + project_code = project_name + dbcon.Session["AVALON_PROJECT"] = project_name + project_doc = dbcon.find_one({ + "type": "project" + }) + + # Get all assets from zou + all_assets = gazu.asset.all_assets_for_project(project) + + # Query all assets of the local project + project_col = dbcon.database[project_code] + asset_docs_zou_ids = { + asset_doc["data"]["zou_id"] + for asset_doc in project_col.find({"type": "asset"}) + } + + # Create project if is not available + # - creation is required to be able set project anatomy and attributes + if not project_doc: + print(f"Creating project '{project_name}'") + project_doc = create_project(project_name, project_code, dbcon=dbcon) + + # Create project item + insert_list = [] + + bulk_writes = [] + for zou_asset in all_assets: + doc_data = {"zou_id": zou_asset["id"]} + + # Create Asset + new_doc = { + "name": zou_asset["name"], + "type": "asset", + "schema": "openpype:asset-3.0", + "data": doc_data, + "parent": project_doc["_id"] + } + + if zou_asset["id"] not in asset_docs_zou_ids: # Item is new + insert_list.append(new_doc) + + # TODO tasks + + # elif item.data(REMOVED_ROLE): # TODO removal + # if item.data(HIERARCHY_CHANGE_ABLE_ROLE): + # bulk_writes.append(DeleteOne( + # {"_id": item.asset_id} + # )) + # else: + # bulk_writes.append(UpdateOne( + # {"_id": item.asset_id}, + # {"$set": {"type": "archived_asset"}} + # )) + + # else: TODO update data + # update_data = new_item.update_data() + # if update_data: + # bulk_writes.append(UpdateOne( + # {"_id": new_item.asset_id}, + # update_data + # )) + + # Insert new docs if created + if insert_list: + project_col.insert_many(insert_list) + + # Write into DB TODO + # if bulk_writes: + # project_col.bulk_write(bulk_writes) + + dbcon.uninstall() @cli_main.command() def show_dialog(): diff --git a/openpype/settings/defaults/system_settings/modules.json b/openpype/settings/defaults/system_settings/modules.json index 9cfaddecbe..ddb2edc360 100644 --- a/openpype/settings/defaults/system_settings/modules.json +++ b/openpype/settings/defaults/system_settings/modules.json @@ -139,7 +139,9 @@ }, "kitsu": { "enabled": false, - "kitsu_server": "" + "server": "", + "script_login": "", + "script_pwd": "" }, "timers_manager": { "enabled": true, diff --git a/openpype/settings/entities/schemas/system_schema/module_settings/schema_kitsu.json b/openpype/settings/entities/schemas/system_schema/module_settings/schema_kitsu.json index 8e496dc783..ae2b52df0d 100644 --- a/openpype/settings/entities/schemas/system_schema/module_settings/schema_kitsu.json +++ b/openpype/settings/entities/schemas/system_schema/module_settings/schema_kitsu.json @@ -13,9 +13,19 @@ }, { "type": "text", - "key": "kitsu_server", + "key": "server", "label": "Server" }, + { + "type": "text", + "key": "script_login", + "label": "Script Login" + }, + { + "type": "text", + "key": "script_pwd", + "label": "Script Password" + }, { "type": "splitter" } From 719afdcc8ae5f8710537d55da24040bd7ccfcd95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20David?= Date: Tue, 8 Feb 2022 15:41:02 +0100 Subject: [PATCH 221/583] black --- .../default_modules/kitsu/kitsu_module.py | 37 ++++++------------- 1 file changed, 11 insertions(+), 26 deletions(-) diff --git a/openpype/modules/default_modules/kitsu/kitsu_module.py b/openpype/modules/default_modules/kitsu/kitsu_module.py index 55e4640fa2..6eb37dfaed 100644 --- a/openpype/modules/default_modules/kitsu/kitsu_module.py +++ b/openpype/modules/default_modules/kitsu/kitsu_module.py @@ -6,27 +6,17 @@ in global space here until are required or used. - imports of Python 3 packages - we still support Python 2 hosts where addon definition should available """ -import collections import os import click from avalon.api import AvalonMongoDB import gazu -from openpype.api import get_project_basic_paths, create_project_folders from openpype.lib import create_project from openpype.lib.anatomy import Anatomy -from openpype.modules import ( - JsonFilesSettingsDef, - OpenPypeModule, - ModulesManager -) -from openpype.tools.project_manager.project_manager.model import AssetItem, ProjectItem, TaskItem +from openpype.modules import JsonFilesSettingsDef, OpenPypeModule, ModulesManager + # Import interface defined by this addon to be able find other addons using it -from openpype_interfaces import ( - IPluginPaths, - ITrayAction -) -from pymongo import UpdateOne, DeleteOne +from openpype_interfaces import IPluginPaths, ITrayAction # Settings definition of this addon using `JsonFilesSettingsDef` @@ -47,10 +37,7 @@ class AddonSettingsDef(JsonFilesSettingsDef): Return directory path where json files defying addon settings are located. """ - return os.path.join( - os.path.dirname(os.path.abspath(__file__)), - "settings" - ) + return os.path.join(os.path.dirname(os.path.abspath(__file__)), "settings") class KitsuModule(OpenPypeModule, IPluginPaths, ITrayAction): @@ -61,6 +48,7 @@ class KitsuModule(OpenPypeModule, IPluginPaths, ITrayAction): - `IPluginPaths` to define custom plugin paths - `ITrayAction` to be shown in tray tool """ + label = "Kitsu" name = "kitsu" @@ -106,7 +94,7 @@ class KitsuModule(OpenPypeModule, IPluginPaths, ITrayAction): return { "KITSU_SERVER": self.server_url, "KITSU_LOGIN": self.script_login, - "KITSU_PWD": self.script_pwd + "KITSU_PWD": self.script_pwd, } def _create_dialog(self): @@ -146,9 +134,7 @@ class KitsuModule(OpenPypeModule, IPluginPaths, ITrayAction): """Implementation of abstract method for `IPluginPaths`.""" current_dir = os.path.dirname(os.path.abspath(__file__)) - return { - "publish": [os.path.join(current_dir, "plugins", "publish")] - } + return {"publish": [os.path.join(current_dir, "plugins", "publish")]} def cli(self, click_group): click_group.add_command(cli_main) @@ -179,10 +165,8 @@ def sync_local(): project_name = project["name"] project_code = project_name dbcon.Session["AVALON_PROJECT"] = project_name - project_doc = dbcon.find_one({ - "type": "project" - }) - + project_doc = dbcon.find_one({"type": "project"}) + # Get all assets from zou all_assets = gazu.asset.all_assets_for_project(project) @@ -212,7 +196,7 @@ def sync_local(): "type": "asset", "schema": "openpype:asset-3.0", "data": doc_data, - "parent": project_doc["_id"] + "parent": project_doc["_id"], } if zou_asset["id"] not in asset_docs_zou_ids: # Item is new @@ -249,6 +233,7 @@ def sync_local(): dbcon.uninstall() + @cli_main.command() def show_dialog(): """Show ExampleAddon dialog. From 7c63d3a374637ee8630d293b8ac8dcc8a34ffb95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20David?= Date: Tue, 8 Feb 2022 16:28:06 +0100 Subject: [PATCH 222/583] Add tasks to asset --- .../default_modules/kitsu/kitsu_module.py | 37 +++++++++++++++---- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/openpype/modules/default_modules/kitsu/kitsu_module.py b/openpype/modules/default_modules/kitsu/kitsu_module.py index 6eb37dfaed..9730437ec2 100644 --- a/openpype/modules/default_modules/kitsu/kitsu_module.py +++ b/openpype/modules/default_modules/kitsu/kitsu_module.py @@ -187,22 +187,43 @@ def sync_local(): insert_list = [] bulk_writes = [] - for zou_asset in all_assets: - doc_data = {"zou_id": zou_asset["id"]} + for asset in all_assets: + asset_data = {"zou_id": asset["id"]} + + # Set tasks + asset_tasks = gazu.task.all_tasks_for_asset(asset) + asset_data["tasks"] = { + t["task_type_name"]: {"type": t["task_type_name"]} for t in asset_tasks + } # Create Asset - new_doc = { - "name": zou_asset["name"], + asset_doc = { + "name": asset["name"], "type": "asset", "schema": "openpype:asset-3.0", - "data": doc_data, + "data": asset_data, "parent": project_doc["_id"], } - if zou_asset["id"] not in asset_docs_zou_ids: # Item is new - insert_list.append(new_doc) + if asset["id"] not in asset_docs_zou_ids: # Item is new + insert_list.append(asset_doc) + else: + asset_doc = project_col.find_one({"data": {"zou_id": asset["id"]}}) - # TODO tasks + # TODO update + # for task in asset_tasks: + # # print(task) + # task_data = {"zou_id": task["id"]} + + # # Create Task + # task_doc = { + # "name": task["name"], + # "type": "task", + # "schema": "openpype:asset-3.0", + # "data": task_data, + # "parent": asset_doc["_id"], + # } + # insert_list.append(task_doc) # elif item.data(REMOVED_ROLE): # TODO removal # if item.data(HIERARCHY_CHANGE_ABLE_ROLE): From 9a1dd4fc0630f94cff1ef554fd31ede714b8ad49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20David?= Date: Tue, 8 Feb 2022 19:26:26 +0100 Subject: [PATCH 223/583] All bulk write. Updating assets --- .../default_modules/kitsu/kitsu_module.py | 68 ++++++++----------- 1 file changed, 29 insertions(+), 39 deletions(-) diff --git a/openpype/modules/default_modules/kitsu/kitsu_module.py b/openpype/modules/default_modules/kitsu/kitsu_module.py index 9730437ec2..1a71a05a7e 100644 --- a/openpype/modules/default_modules/kitsu/kitsu_module.py +++ b/openpype/modules/default_modules/kitsu/kitsu_module.py @@ -12,10 +12,8 @@ import click from avalon.api import AvalonMongoDB import gazu from openpype.lib import create_project -from openpype.lib.anatomy import Anatomy from openpype.modules import JsonFilesSettingsDef, OpenPypeModule, ModulesManager - -# Import interface defined by this addon to be able find other addons using it +from pymongo import DeleteOne, InsertOne, UpdateOne from openpype_interfaces import IPluginPaths, ITrayAction @@ -183,9 +181,6 @@ def sync_local(): print(f"Creating project '{project_name}'") project_doc = create_project(project_name, project_code, dbcon=dbcon) - # Create project item - insert_list = [] - bulk_writes = [] for asset in all_assets: asset_data = {"zou_id": asset["id"]} @@ -196,34 +191,33 @@ def sync_local(): t["task_type_name"]: {"type": t["task_type_name"]} for t in asset_tasks } - # Create Asset - asset_doc = { - "name": asset["name"], - "type": "asset", - "schema": "openpype:asset-3.0", - "data": asset_data, - "parent": project_doc["_id"], - } + # Update or create asset + if asset["id"] in asset_docs_zou_ids: # Update asset + asset_doc = project_col.find_one({"data.zou_id": asset["id"]}) - if asset["id"] not in asset_docs_zou_ids: # Item is new - insert_list.append(asset_doc) - else: - asset_doc = project_col.find_one({"data": {"zou_id": asset["id"]}}) + # Override all 'data' TODO filter data to update? + diff_data = { + k: asset_data[k] + for k in asset_data.keys() + if asset_doc["data"].get(k) != asset_data[k] + } + if diff_data: + bulk_writes.append( + UpdateOne( + {"_id": asset_doc["_id"]}, {"$set": {"data": asset_data}} + ) + ) + else: # Create + asset_doc = { + "name": asset["name"], + "type": "asset", + "schema": "openpype:asset-3.0", + "data": asset_data, + "parent": project_doc["_id"], + } - # TODO update - # for task in asset_tasks: - # # print(task) - # task_data = {"zou_id": task["id"]} - - # # Create Task - # task_doc = { - # "name": task["name"], - # "type": "task", - # "schema": "openpype:asset-3.0", - # "data": task_data, - # "parent": asset_doc["_id"], - # } - # insert_list.append(task_doc) + # Insert new doc + bulk_writes.append(InsertOne(asset_doc)) # elif item.data(REMOVED_ROLE): # TODO removal # if item.data(HIERARCHY_CHANGE_ABLE_ROLE): @@ -244,13 +238,9 @@ def sync_local(): # update_data # )) - # Insert new docs if created - if insert_list: - project_col.insert_many(insert_list) - - # Write into DB TODO - # if bulk_writes: - # project_col.bulk_write(bulk_writes) + # Write into DB + if bulk_writes: + project_col.bulk_write(bulk_writes) dbcon.uninstall() From 206bf9f3c18873a6f81734841779eeab4f8829d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20David?= Date: Wed, 9 Feb 2022 09:45:11 +0100 Subject: [PATCH 224/583] Delete assets --- .../default_modules/kitsu/kitsu_module.py | 40 +++++++++---------- 1 file changed, 18 insertions(+), 22 deletions(-) diff --git a/openpype/modules/default_modules/kitsu/kitsu_module.py b/openpype/modules/default_modules/kitsu/kitsu_module.py index 1a71a05a7e..1ce1bef6a2 100644 --- a/openpype/modules/default_modules/kitsu/kitsu_module.py +++ b/openpype/modules/default_modules/kitsu/kitsu_module.py @@ -170,10 +170,13 @@ def sync_local(): # Query all assets of the local project project_col = dbcon.database[project_code] - asset_docs_zou_ids = { - asset_doc["data"]["zou_id"] + asset_doc_ids = { + asset_doc["_id"]: asset_doc for asset_doc in project_col.find({"type": "asset"}) } + asset_docs_zou_ids = { + asset_doc["data"]["zou_id"] for asset_doc in asset_doc_ids.values() + } # Create project if is not available # - creation is required to be able set project anatomy and attributes @@ -182,6 +185,7 @@ def sync_local(): project_doc = create_project(project_name, project_code, dbcon=dbcon) bulk_writes = [] + sync_assets = set() for asset in all_assets: asset_data = {"zou_id": asset["id"]} @@ -195,13 +199,13 @@ def sync_local(): if asset["id"] in asset_docs_zou_ids: # Update asset asset_doc = project_col.find_one({"data.zou_id": asset["id"]}) - # Override all 'data' TODO filter data to update? - diff_data = { + # Override all 'data' + updated_data = { k: asset_data[k] for k in asset_data.keys() if asset_doc["data"].get(k) != asset_data[k] } - if diff_data: + if updated_data: bulk_writes.append( UpdateOne( {"_id": asset_doc["_id"]}, {"$set": {"data": asset_data}} @@ -219,24 +223,16 @@ def sync_local(): # Insert new doc bulk_writes.append(InsertOne(asset_doc)) - # elif item.data(REMOVED_ROLE): # TODO removal - # if item.data(HIERARCHY_CHANGE_ABLE_ROLE): - # bulk_writes.append(DeleteOne( - # {"_id": item.asset_id} - # )) - # else: - # bulk_writes.append(UpdateOne( - # {"_id": item.asset_id}, - # {"$set": {"type": "archived_asset"}} - # )) + # Keep synchronized asset for diff + sync_assets.add(asset_doc["_id"]) - # else: TODO update data - # update_data = new_item.update_data() - # if update_data: - # bulk_writes.append(UpdateOne( - # {"_id": new_item.asset_id}, - # update_data - # )) + # Delete from diff of assets in OP and synchronized assets to detect deleted assets + diff_assets = set(asset_doc_ids.keys()) - sync_assets + if diff_assets: + # Delete doc + bulk_writes.extend( + [DeleteOne(asset_doc_ids[asset_id]) for asset_id in diff_assets] + ) # Write into DB if bulk_writes: From e882de52f04d2ca79e30fb23801257cefd085e56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20David?= Date: Thu, 10 Feb 2022 17:35:03 +0100 Subject: [PATCH 225/583] Assets hierarchy --- .../default_modules/kitsu/kitsu_module.py | 165 ++++++++++++------ 1 file changed, 114 insertions(+), 51 deletions(-) diff --git a/openpype/modules/default_modules/kitsu/kitsu_module.py b/openpype/modules/default_modules/kitsu/kitsu_module.py index 1ce1bef6a2..92d724be67 100644 --- a/openpype/modules/default_modules/kitsu/kitsu_module.py +++ b/openpype/modules/default_modules/kitsu/kitsu_module.py @@ -7,13 +7,15 @@ in global space here until are required or used. - we still support Python 2 hosts where addon definition should available """ import os +from typing import Dict, List, Set import click from avalon.api import AvalonMongoDB import gazu from openpype.lib import create_project from openpype.modules import JsonFilesSettingsDef, OpenPypeModule, ModulesManager -from pymongo import DeleteOne, InsertOne, UpdateOne +from pymongo import DeleteOne, UpdateOne +from pymongo.collection import Collection from openpype_interfaces import IPluginPaths, ITrayAction @@ -144,8 +146,8 @@ def cli_main(): @cli_main.command() -def sync_local(): - """Synchronize local database from Zou sever database.""" +def sync_openpype(): + """Synchronize openpype database from Zou sever database.""" # Connect to server gazu.client.set_host(os.environ["KITSU_SERVER"]) @@ -167,69 +169,61 @@ def sync_local(): # Get all assets from zou all_assets = gazu.asset.all_assets_for_project(project) - - # Query all assets of the local project - project_col = dbcon.database[project_code] - asset_doc_ids = { - asset_doc["_id"]: asset_doc - for asset_doc in project_col.find({"type": "asset"}) - } - asset_docs_zou_ids = { - asset_doc["data"]["zou_id"] for asset_doc in asset_doc_ids.values() - } + all_episodes = gazu.shot.all_episodes_for_project(project) + all_seqs = gazu.shot.all_sequences_for_project(project) + all_shots = gazu.shot.all_shots_for_project(project) # Create project if is not available # - creation is required to be able set project anatomy and attributes + to_insert = [] if not project_doc: print(f"Creating project '{project_name}'") project_doc = create_project(project_name, project_code, dbcon=dbcon) - bulk_writes = [] - sync_assets = set() - for asset in all_assets: - asset_data = {"zou_id": asset["id"]} + # Query all assets of the local project + project_col = dbcon.database[project_code] + asset_doc_ids = { + asset_doc["data"]["zou_id"]: asset_doc + for asset_doc in project_col.find({"type": "asset"}) + if asset_doc["data"].get("zou_id") + } + asset_doc_ids[project["id"]] = project_doc - # Set tasks - asset_tasks = gazu.task.all_tasks_for_asset(asset) - asset_data["tasks"] = { - t["task_type_name"]: {"type": t["task_type_name"]} for t in asset_tasks - } - - # Update or create asset - if asset["id"] in asset_docs_zou_ids: # Update asset - asset_doc = project_col.find_one({"data.zou_id": asset["id"]}) - - # Override all 'data' - updated_data = { - k: asset_data[k] - for k in asset_data.keys() - if asset_doc["data"].get(k) != asset_data[k] - } - if updated_data: - bulk_writes.append( - UpdateOne( - {"_id": asset_doc["_id"]}, {"$set": {"data": asset_data}} - ) - ) - else: # Create - asset_doc = { - "name": asset["name"], + # Create + to_insert.extend( + [ + { + "name": item["name"], "type": "asset", "schema": "openpype:asset-3.0", - "data": asset_data, - "parent": project_doc["_id"], + "data": {"zou_id": item["id"], "tasks": {}}, } + for item in all_episodes + all_assets + all_seqs + all_shots + if item["id"] not in asset_doc_ids.keys() + ] + ) + if to_insert: + # Insert in doc + project_col.insert_many(to_insert) - # Insert new doc - bulk_writes.append(InsertOne(asset_doc)) + # Update existing docs + asset_doc_ids.update( + { + asset_doc["data"]["zou_id"]: asset_doc + for asset_doc in project_col.find({"type": "asset"}) + if asset_doc["data"].get("zou_id") + } + ) - # Keep synchronized asset for diff - sync_assets.add(asset_doc["_id"]) + # Update + all_entities = all_assets + all_episodes + all_seqs + all_shots + bulk_writes = update_op_assets(project_col, all_entities, asset_doc_ids) - # Delete from diff of assets in OP and synchronized assets to detect deleted assets - diff_assets = set(asset_doc_ids.keys()) - sync_assets + # Delete + diff_assets = set(asset_doc_ids.keys()) - { + e["id"] for e in all_entities + [project] + } if diff_assets: - # Delete doc bulk_writes.extend( [DeleteOne(asset_doc_ids[asset_id]) for asset_id in diff_assets] ) @@ -241,6 +235,75 @@ def sync_local(): dbcon.uninstall() +def update_op_assets( + project_col: Collection, items_list: List[dict], asset_doc_ids: Dict[str, dict] +) -> List[UpdateOne]: + """Update OpenPype assets. + Set 'data' and 'parent' fields. + + :param project_col: Project collection to query data from + :param items_list: List of zou items to update + :param asset_doc_ids: Dicts of [{zou_id: asset_doc}, ...] + :return: List of UpdateOne objects + """ + bulk_writes = [] + for item in items_list: + # Update asset + item_doc = project_col.find_one({"data.zou_id": item["id"]}) + item_data = item_doc["data"].copy() + + # Tasks + tasks_list = None + if item["type"] == "Asset": + tasks_list = gazu.task.all_tasks_for_asset(item) + elif item["type"] == "Shot": + tasks_list = gazu.task.all_tasks_for_shot(item) + if tasks_list: + item_data["tasks"] = { + t["task_type_name"]: {"type": t["task_type_name"]} for t in tasks_list + } + + # Visual parent for hierarchy + direct_parent_id = item["parent_id"] or item["source_id"] + if direct_parent_id: + visual_parent_doc = asset_doc_ids[direct_parent_id] + item_data["visualParent"] = visual_parent_doc["_id"] + + # Add parents for hierarchy + parent_zou_id = item["parent_id"] + item_data["parents"] = [] + while parent_zou_id is not None: + parent_doc = asset_doc_ids[parent_zou_id] + item_data["parents"].insert(0, parent_doc["name"]) + + parent_zou_id = next( + i for i in items_list if i["id"] == parent_doc["data"]["zou_id"] + )["parent_id"] + + # TODO create missing tasks before + + # Update 'data' different in zou DB + updated_data = { + k: item_data[k] + for k in item_data.keys() + if item_doc["data"].get(k) != item_data[k] + } + if updated_data or not item_doc.get("parent"): + bulk_writes.append( + UpdateOne( + {"_id": item_doc["_id"]}, + { + "$set": { + "data": item_data, + "parent": asset_doc_ids[item["project_id"]]["_id"], + } + }, + ) + ) + + return bulk_writes + + @cli_main.command() def show_dialog(): """Show ExampleAddon dialog. From ee281f740d58821ab4d5e190e6b4863af5c2ac44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20David?= Date: Thu, 10 Feb 2022 18:03:27 +0100 Subject: [PATCH 226/583] Project tasks --- .../default_modules/kitsu/kitsu_module.py | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/openpype/modules/default_modules/kitsu/kitsu_module.py b/openpype/modules/default_modules/kitsu/kitsu_module.py index 92d724be67..84709bc0a2 100644 --- a/openpype/modules/default_modules/kitsu/kitsu_module.py +++ b/openpype/modules/default_modules/kitsu/kitsu_module.py @@ -160,6 +160,8 @@ def sync_openpype(): dbcon.install() all_projects = gazu.project.all_projects() for project in all_projects: + bulk_writes = [] + # Create project locally # Try to find project document project_name = project["name"] @@ -180,6 +182,23 @@ def sync_openpype(): print(f"Creating project '{project_name}'") project_doc = create_project(project_name, project_code, dbcon=dbcon) + # Project tasks + bulk_writes.append( + UpdateOne( + {"_id": project_doc["_id"]}, + { + "$set": { + "config.tasks": { + t["name"]: { + "short_name": t.get("short_name", t["name"]) + } + for t in gazu.task.all_task_types_for_project(project) + } + } + }, + ) + ) + # Query all assets of the local project project_col = dbcon.database[project_code] asset_doc_ids = { @@ -217,7 +236,7 @@ def sync_openpype(): # Update all_entities = all_assets + all_episodes + all_seqs + all_shots - bulk_writes = update_op_assets(project_col, all_entities, asset_doc_ids) + bulk_writes.extend(update_op_assets(project_col, all_entities, asset_doc_ids)) # Delete diff_assets = set(asset_doc_ids.keys()) - { @@ -228,7 +247,7 @@ def sync_openpype(): [DeleteOne(asset_doc_ids[asset_id]) for asset_id in diff_assets] ) - # Write into DB + # Write into DB # TODO make it common for all projects if bulk_writes: project_col.bulk_write(bulk_writes) From d63c5fcae8ec1ee8c49c4a21dea86b3e9da157d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20David?= Date: Fri, 11 Feb 2022 09:29:08 +0100 Subject: [PATCH 227/583] Optim: bulkwrite and queries mutualization --- .../default_modules/kitsu/kitsu_module.py | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/openpype/modules/default_modules/kitsu/kitsu_module.py b/openpype/modules/default_modules/kitsu/kitsu_module.py index 84709bc0a2..89312a344c 100644 --- a/openpype/modules/default_modules/kitsu/kitsu_module.py +++ b/openpype/modules/default_modules/kitsu/kitsu_module.py @@ -159,9 +159,8 @@ def sync_openpype(): dbcon = AvalonMongoDB() dbcon.install() all_projects = gazu.project.all_projects() + bulk_writes = [] for project in all_projects: - bulk_writes = [] - # Create project locally # Try to find project document project_name = project["name"] @@ -236,7 +235,7 @@ def sync_openpype(): # Update all_entities = all_assets + all_episodes + all_seqs + all_shots - bulk_writes.extend(update_op_assets(project_col, all_entities, asset_doc_ids)) + bulk_writes.extend(update_op_assets(all_entities, asset_doc_ids)) # Delete diff_assets = set(asset_doc_ids.keys()) - { @@ -247,20 +246,19 @@ def sync_openpype(): [DeleteOne(asset_doc_ids[asset_id]) for asset_id in diff_assets] ) - # Write into DB # TODO make it common for all projects - if bulk_writes: - project_col.bulk_write(bulk_writes) + # Write into DB + if bulk_writes: + project_col.bulk_write(bulk_writes) dbcon.uninstall() def update_op_assets( - project_col: Collection, items_list: List[dict], asset_doc_ids: Dict[str, dict] + items_list: List[dict], asset_doc_ids: Dict[str, dict] ) -> List[UpdateOne]: """Update OpenPype assets. Set 'data' and 'parent' fields. - :param project_col: Project collection to query data from :param items_list: List of zou items to update :param asset_doc_ids: Dicts of [{zou_id: asset_doc}, ...] :return: List of UpdateOne objects @@ -268,7 +266,7 @@ def update_op_assets( bulk_writes = [] for item in items_list: # Update asset - item_doc = project_col.find_one({"data.zou_id": item["id"]}) + item_doc = asset_doc_ids[item["id"]] item_data = item_doc["data"].copy() # Tasks @@ -299,8 +297,6 @@ def update_op_assets( i for i in items_list if i["id"] == parent_doc["data"]["zou_id"] )["parent_id"] - # TODO create missing tasks before - # Update 'data' different in zou DB updated_data = { k: item_data[k] From 4e68bcf55fd2552f53904dcd0a6c47b56946c927 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20David?= Date: Fri, 11 Feb 2022 09:31:14 +0100 Subject: [PATCH 228/583] cleaning --- openpype/modules/default_modules/kitsu/kitsu_module.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/modules/default_modules/kitsu/kitsu_module.py b/openpype/modules/default_modules/kitsu/kitsu_module.py index 89312a344c..e52d18b84b 100644 --- a/openpype/modules/default_modules/kitsu/kitsu_module.py +++ b/openpype/modules/default_modules/kitsu/kitsu_module.py @@ -15,7 +15,6 @@ import gazu from openpype.lib import create_project from openpype.modules import JsonFilesSettingsDef, OpenPypeModule, ModulesManager from pymongo import DeleteOne, UpdateOne -from pymongo.collection import Collection from openpype_interfaces import IPluginPaths, ITrayAction From 294b93f65a97d17f503a82962f121c3202002a51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20David?= Date: Sat, 12 Feb 2022 15:06:22 +0100 Subject: [PATCH 229/583] Create episode --- .../default_modules/kitsu/kitsu_module.py | 226 +++++++++++++++++- .../defaults/project_settings/kitsu.json | 6 +- .../projects_schema/schema_project_kitsu.json | 26 +- 3 files changed, 243 insertions(+), 15 deletions(-) diff --git a/openpype/modules/default_modules/kitsu/kitsu_module.py b/openpype/modules/default_modules/kitsu/kitsu_module.py index e52d18b84b..c4f627d5ad 100644 --- a/openpype/modules/default_modules/kitsu/kitsu_module.py +++ b/openpype/modules/default_modules/kitsu/kitsu_module.py @@ -6,12 +6,14 @@ in global space here until are required or used. - imports of Python 3 packages - we still support Python 2 hosts where addon definition should available """ -import os -from typing import Dict, List, Set import click +import os +import re +from typing import Dict, List from avalon.api import AvalonMongoDB import gazu +from openpype.api import get_project_settings from openpype.lib import create_project from openpype.modules import JsonFilesSettingsDef, OpenPypeModule, ModulesManager from pymongo import DeleteOne, UpdateOne @@ -145,8 +147,8 @@ def cli_main(): @cli_main.command() -def sync_openpype(): - """Synchronize openpype database from Zou sever database.""" +def sync_zou(): + """Synchronize Zou server database (Kitsu backend) with openpype database.""" # Connect to server gazu.client.set_host(os.environ["KITSU_SERVER"]) @@ -157,8 +159,108 @@ def sync_openpype(): # Iterate projects dbcon = AvalonMongoDB() dbcon.install() - all_projects = gazu.project.all_projects() + + op_projects = [p for p in dbcon.projects()] bulk_writes = [] + for op_project in op_projects: + # Create project locally + # Try to find project document + project_name = op_project["name"] + project_code = op_project["data"]["code"] + dbcon.Session["AVALON_PROJECT"] = project_name + + # Get all entities from zou + zou_project = gazu.project.get_project_by_name(project_name) + + # Create project + if zou_project is None: + raise RuntimeError( + f"Project '{project_name}' doesn't exist in Zou database, please create it in Kitsu and add logged user to it before running synchronization." + ) + + # Update project settings and data + zou_project.update( + { + "code": op_project["data"]["code"], + "fps": op_project["data"]["fps"], + "resolution": f"{op_project['data']['resolutionWidth']}x{op_project['data']['resolutionHeight']}", + } + ) + gazu.project.update_project(zou_project) + gazu.project.update_project_data(zou_project, data=op_project["data"]) + + all_assets = gazu.asset.all_assets_for_project(zou_project) + all_episodes = gazu.shot.all_episodes_for_project(zou_project) + all_seqs = gazu.shot.all_sequences_for_project(zou_project) + all_shots = gazu.shot.all_shots_for_project(zou_project) + print(zou_project["name"]) + all_entities_ids = { + e["id"] for e in all_episodes + all_seqs + all_shots + all_assets + } + + project_module_settings = get_project_settings(project_name)["kitsu"] + + # Create new assets + # Query all assets of the local project + project_col = dbcon.database[project_name] + asset_docs = [asset_doc for asset_doc in project_col.find({"type": "asset"})] + + new_assets_docs = [ + doc + for doc in asset_docs + if doc["data"].get("zou_id") not in all_entities_ids + ] + naming_pattern = project_module_settings["entities_naming_pattern"] + regex_ep = re.compile( + r"({})|({})|({})".format( + naming_pattern["episode"].replace("#", "\d"), + naming_pattern["sequence"].replace("#", "\d"), + naming_pattern["shot"].replace("#", "\d"), + ), + re.IGNORECASE, + ) + for doc in new_assets_docs: + match = regex_ep.match(doc["name"]) + if not match: + # TODO asset + continue + + print(doc) + if match.group(1): # Episode + new_episode = gazu.shot.new_episode(zou_project, doc["name"]) + + # Update doc with zou id + bulk_writes.append( + UpdateOne( + {"_id": doc["_id"]}, + {"$set": {"data.zou_id": new_episode["id"]}}, + ) + ) + elif match.group(2): # Sequence + # TODO match zou episode + new_sequence = gazu.shot.new_sequence(zou_project, doc["name"]) + + # Update doc with zou id + bulk_writes.append( + UpdateOne( + {"_id": doc["_id"]}, + {"$set": {"data.zou_id": new_sequence["id"]}}, + ) + ) + elif match.group(3): # Shot + pass + + # Delete + # if gazu. + + # Write into DB + if bulk_writes: + project_col.bulk_write(bulk_writes) + + dbcon.uninstall() + + return + for project in all_projects: # Create project locally # Try to find project document @@ -245,9 +347,117 @@ def sync_openpype(): [DeleteOne(asset_doc_ids[asset_id]) for asset_id in diff_assets] ) - # Write into DB - if bulk_writes: - project_col.bulk_write(bulk_writes) + +@cli_main.command() +def sync_openpype(): + """Synchronize openpype database from Zou sever database.""" + + # Connect to server + gazu.client.set_host(os.environ["KITSU_SERVER"]) + + # Authenticate + gazu.log_in(os.environ["KITSU_LOGIN"], os.environ["KITSU_PWD"]) + + # Iterate projects + dbcon = AvalonMongoDB() + dbcon.install() + all_projects = gazu.project.all_projects() + bulk_writes = [] + for project in all_projects: + # Create project locally + # Try to find project document + project_name = project["name"] + project_code = project_name + dbcon.Session["AVALON_PROJECT"] = project_name + project_doc = dbcon.find_one({"type": "project"}) + + # Get all assets from zou + all_assets = gazu.asset.all_assets_for_project(project) + all_episodes = gazu.shot.all_episodes_for_project(project) + all_seqs = gazu.shot.all_sequences_for_project(project) + all_shots = gazu.shot.all_shots_for_project(project) + + # Create project if is not available + # - creation is required to be able set project anatomy and attributes + to_insert = [] + if not project_doc: + print(f"Creating project '{project_name}'") + project_doc = create_project(project_name, project_code, dbcon=dbcon) + + # Project data and tasks + bulk_writes.append( + UpdateOne( + {"_id": project_doc["_id"]}, + { + "$set": { + "config.tasks": { + t["name"]: {"short_name": t.get("short_name", t["name"])} + for t in gazu.task.all_task_types_for_project(project) + }, + "data": project["data"].update( + { + "code": project["code"], + "fps": project_code["fps"], + "resolutionWidth": project["resolution"].split("x")[0], + "resolutionHeight": project["resolution"].split("x")[1], + } + ), + } + }, + ) + ) + + # Query all assets of the local project + project_col = dbcon.database[project_code] + asset_doc_ids = { + asset_doc["data"]["zou_id"]: asset_doc + for asset_doc in project_col.find({"type": "asset"}) + if asset_doc["data"].get("zou_id") + } + asset_doc_ids[project["id"]] = project_doc + + # Create + to_insert.extend( + [ + { + "name": item["name"], + "type": "asset", + "schema": "openpype:asset-3.0", + "data": {"zou_id": item["id"], "tasks": {}}, + } + for item in all_episodes + all_assets + all_seqs + all_shots + if item["id"] not in asset_doc_ids.keys() + ] + ) + if to_insert: + # Insert in doc + project_col.insert_many(to_insert) + + # Update existing docs + asset_doc_ids.update( + { + asset_doc["data"]["zou_id"]: asset_doc + for asset_doc in project_col.find({"type": "asset"}) + if asset_doc["data"].get("zou_id") + } + ) + + # Update + all_entities = all_assets + all_episodes + all_seqs + all_shots + bulk_writes.extend(update_op_assets(all_entities, asset_doc_ids)) + + # Delete + diff_assets = set(asset_doc_ids.keys()) - { + e["id"] for e in all_entities + [project] + } + if diff_assets: + bulk_writes.extend( + [DeleteOne(asset_doc_ids[asset_id]) for asset_id in diff_assets] + ) + + # Write into DB + if bulk_writes: + project_col.bulk_write(bulk_writes) dbcon.uninstall() diff --git a/openpype/settings/defaults/project_settings/kitsu.json b/openpype/settings/defaults/project_settings/kitsu.json index b4d2ccc611..435814a9d1 100644 --- a/openpype/settings/defaults/project_settings/kitsu.json +++ b/openpype/settings/defaults/project_settings/kitsu.json @@ -1,3 +1,7 @@ { - "number": 0 + "entities_naming_pattern": { + "episode": "E##", + "sequence": "SQ##", + "shot": "SH##" + } } \ No newline at end of file diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_kitsu.json b/openpype/settings/entities/schemas/projects_schema/schema_project_kitsu.json index 93976cc03b..a504959001 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_kitsu.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_kitsu.json @@ -6,12 +6,26 @@ "is_file": true, "children": [ { - "type": "number", - "key": "number", - "label": "This is your lucky number:", - "minimum": 7, - "maximum": 7, - "decimals": 0 + "type": "dict", + "key": "entities_naming_pattern", + "label": "Entities naming pattern", + "children": [ + { + "type": "text", + "key": "episode", + "label": "Episode:" + }, + { + "type": "text", + "key": "sequence", + "label": "Sequence:" + }, + { + "type": "text", + "key": "shot", + "label": "Shot:" + } + ] } ] } From bad16651e1b2ed2d307496941ac95adb1224040a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20David?= Date: Mon, 14 Feb 2022 17:58:06 +0100 Subject: [PATCH 230/583] Create asset, ep, seq and shot in zou --- .../default_modules/kitsu/kitsu_module.py | 123 +++++++++++++----- 1 file changed, 93 insertions(+), 30 deletions(-) diff --git a/openpype/modules/default_modules/kitsu/kitsu_module.py b/openpype/modules/default_modules/kitsu/kitsu_module.py index c4f627d5ad..453d1c5315 100644 --- a/openpype/modules/default_modules/kitsu/kitsu_module.py +++ b/openpype/modules/default_modules/kitsu/kitsu_module.py @@ -189,6 +189,7 @@ def sync_zou(): gazu.project.update_project(zou_project) gazu.project.update_project_data(zou_project, data=op_project["data"]) + all_asset_types = gazu.asset.all_asset_types() all_assets = gazu.asset.all_assets_for_project(zou_project) all_episodes = gazu.shot.all_episodes_for_project(zou_project) all_seqs = gazu.shot.all_sequences_for_project(zou_project) @@ -199,15 +200,17 @@ def sync_zou(): } project_module_settings = get_project_settings(project_name)["kitsu"] + project_col = dbcon.database[project_name] + asset_docs = { + asset_doc["_id"]: asset_doc + for asset_doc in project_col.find({"type": "asset"}) + } # Create new assets # Query all assets of the local project - project_col = dbcon.database[project_name] - asset_docs = [asset_doc for asset_doc in project_col.find({"type": "asset"})] - new_assets_docs = [ doc - for doc in asset_docs + for doc in asset_docs.values() if doc["data"].get("zou_id") not in all_entities_ids ] naming_pattern = project_module_settings["entities_naming_pattern"] @@ -220,37 +223,95 @@ def sync_zou(): re.IGNORECASE, ) for doc in new_assets_docs: + visual_parent_id = doc["data"]["visualParent"] + match = regex_ep.match(doc["name"]) - if not match: - # TODO asset - continue - - print(doc) - if match.group(1): # Episode - new_episode = gazu.shot.new_episode(zou_project, doc["name"]) - - # Update doc with zou id - bulk_writes.append( - UpdateOne( - {"_id": doc["_id"]}, - {"$set": {"data.zou_id": new_episode["id"]}}, - ) + if not match: # Asset + new_entity = gazu.asset.new_asset( + zou_project, all_asset_types[0], doc["name"] ) + + elif match.group(1): # Episode + new_entity = gazu.shot.new_episode(zou_project, doc["name"]) + elif match.group(2): # Sequence - # TODO match zou episode - new_sequence = gazu.shot.new_sequence(zou_project, doc["name"]) - - # Update doc with zou id - bulk_writes.append( - UpdateOne( - {"_id": doc["_id"]}, - {"$set": {"data.zou_id": new_sequence["id"]}}, - ) + parent_doc = asset_docs[visual_parent_id] + new_entity = gazu.shot.new_sequence( + zou_project, doc["name"], episode=parent_doc["data"]["zou_id"] ) - elif match.group(3): # Shot - pass - # Delete + elif match.group(3): # Shot + # Match and check parent doc + parent_doc = asset_docs[visual_parent_id] + zou_parent_id = parent_doc["data"]["zou_id"] + if parent_doc["data"].get("zou", {}).get("type") != "Sequence": + # Warn + print( + f"Shot {doc['name']} must be parented to a Sequence in Kitsu. Creating automatically one substitute sequence..." + ) + + # Create new sequence + digits_padding = naming_pattern["sequence"].count("#") + substitute_sequence_name = f'{naming_pattern["sequence"].replace("#" * digits_padding, "1".zfill(digits_padding))}' + new_sequence = gazu.shot.new_sequence( + zou_project, substitute_sequence_name, episode=zou_parent_id + ) + + # Insert doc + inserted = project_col.insert_one( + { + "name": substitute_sequence_name, + "type": "asset", + "schema": "openpype:asset-3.0", + "data": { + "zou_id": new_sequence["id"], + "tasks": {}, + "parents": parent_doc["data"]["parents"] + + [parent_doc["name"]], + "visualParent": parent_doc["_id"], + }, + "parent": parent_doc["_id"], + } + ) + visual_parent_id = inserted.inserted_id + + # Update parent ID + zou_parent_id = new_sequence["id"] + + # Create shot + new_entity = gazu.shot.new_shot( + zou_project, + zou_parent_id, + doc["name"], + frame_in=doc["data"]["frameStart"], + frame_out=doc["data"]["frameEnd"], + nb_frames=doc["data"]["frameEnd"] - doc["data"]["frameStart"], + ) + + # Update doc with zou id + doc["data"].update( + { + "zou_id": new_entity["id"], + "visualParent": visual_parent_id, + "zou": new_entity, + } + ) + bulk_writes.append( + UpdateOne( + {"_id": doc["_id"]}, + { + "$set": { + "data.zou_id": new_entity["id"], + "data.visualParent": visual_parent_id, + "data.zou": new_entity, + } + }, + ) + ) + + # TODO update / tasks + + # Delete TODO # if gazu. # Write into DB @@ -477,6 +538,7 @@ def update_op_assets( # Update asset item_doc = asset_doc_ids[item["id"]] item_data = item_doc["data"].copy() + item_data["zou"] = item # Tasks tasks_list = None @@ -484,6 +546,7 @@ def update_op_assets( tasks_list = gazu.task.all_tasks_for_asset(item) elif item["type"] == "Shot": tasks_list = gazu.task.all_tasks_for_shot(item) + # TODO frame in and out if tasks_list: item_data["tasks"] = { t["task_type_name"]: {"type": t["task_type_name"]} for t in tasks_list From 9af6264d1e02f92244d678964da5c5902428016f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20David?= Date: Tue, 15 Feb 2022 18:21:26 +0100 Subject: [PATCH 231/583] Update and delete in zou --- .../default_modules/kitsu/kitsu_module.py | 146 +++++++----------- 1 file changed, 53 insertions(+), 93 deletions(-) diff --git a/openpype/modules/default_modules/kitsu/kitsu_module.py b/openpype/modules/default_modules/kitsu/kitsu_module.py index 453d1c5315..e42edbc52b 100644 --- a/openpype/modules/default_modules/kitsu/kitsu_module.py +++ b/openpype/modules/default_modules/kitsu/kitsu_module.py @@ -6,6 +6,8 @@ in global space here until are required or used. - imports of Python 3 packages - we still support Python 2 hosts where addon definition should available """ +from asyncio import all_tasks +from turtle import update import click import os import re @@ -170,6 +172,7 @@ def sync_zou(): dbcon.Session["AVALON_PROJECT"] = project_name # Get all entities from zou + print(f"Synchronizing {project_name}...") zou_project = gazu.project.get_project_by_name(project_name) # Create project @@ -194,11 +197,11 @@ def sync_zou(): all_episodes = gazu.shot.all_episodes_for_project(zou_project) all_seqs = gazu.shot.all_sequences_for_project(zou_project) all_shots = gazu.shot.all_shots_for_project(zou_project) - print(zou_project["name"]) all_entities_ids = { e["id"] for e in all_episodes + all_seqs + all_shots + all_assets } + # Query all assets of the local project project_module_settings = get_project_settings(project_name)["kitsu"] project_col = dbcon.database[project_name] asset_docs = { @@ -207,7 +210,6 @@ def sync_zou(): } # Create new assets - # Query all assets of the local project new_assets_docs = [ doc for doc in asset_docs.values() @@ -225,6 +227,7 @@ def sync_zou(): for doc in new_assets_docs: visual_parent_id = doc["data"]["visualParent"] + # Match asset type by it's name match = regex_ep.match(doc["name"]) if not match: # Asset new_entity = gazu.asset.new_asset( @@ -269,6 +272,7 @@ def sync_zou(): "parents": parent_doc["data"]["parents"] + [parent_doc["name"]], "visualParent": parent_doc["_id"], + "zou": new_sequence, }, "parent": parent_doc["_id"], } @@ -309,10 +313,54 @@ def sync_zou(): ) ) - # TODO update / tasks + # Update assets + all_tasks_types = {t["name"]: t for t in gazu.task.all_task_types()} + assets_docs_to_update = [ + doc + for doc in asset_docs.values() + if doc["data"].get("zou_id") in all_entities_ids + ] + for doc in assets_docs_to_update: + zou_id = doc["data"].get("zou", {}).get("id") + if zou_id: + # Data + entity_data = {} + frame_in = doc["data"].get("frameStart") + frame_out = doc["data"].get("frameEnd") + if frame_in or frame_out: + entity_data.update( + { + "data": {"frame_in": frame_in, "frame_out": frame_out}, + "nb_frames": frame_out - frame_in, + } + ) + entity = gazu.raw.update("entities", zou_id, entity_data) - # Delete TODO - # if gazu. + # Tasks + all_tasks_func = getattr( + gazu.task, f"all_tasks_for_{entity['type'].lower()}" + ) + entity_tasks = {t["name"] for t in all_tasks_func(entity)} + for task_name in doc["data"]["tasks"].keys(): + # Create only if new + if task_name not in entity_tasks: + task_type = all_tasks_types.get(task_name) + + # Create non existing task + if not task_type: + task_type = gazu.task.new_task_type(task_name) + all_tasks_types[task_name] = task_type + + # New task for entity + gazu.task.new_task(entity, task_type) + + # Delete + deleted_entities = all_entities_ids - { + asset_doc["data"].get("zou", {}).get("id") + for asset_doc in asset_docs.values() + } + for entity_id in deleted_entities: + gazu.raw.delete(f"data/entities/{entity_id}") # Write into DB if bulk_writes: @@ -320,94 +368,6 @@ def sync_zou(): dbcon.uninstall() - return - - for project in all_projects: - # Create project locally - # Try to find project document - project_name = project["name"] - project_code = project_name - dbcon.Session["AVALON_PROJECT"] = project_name - project_doc = dbcon.find_one({"type": "project"}) - - # Get all assets from zou - all_assets = gazu.asset.all_assets_for_project(project) - all_episodes = gazu.shot.all_episodes_for_project(project) - all_seqs = gazu.shot.all_sequences_for_project(project) - all_shots = gazu.shot.all_shots_for_project(project) - - # Create project if is not available - # - creation is required to be able set project anatomy and attributes - to_insert = [] - if not project_doc: - print(f"Creating project '{project_name}'") - project_doc = create_project(project_name, project_code, dbcon=dbcon) - - # Project tasks - bulk_writes.append( - UpdateOne( - {"_id": project_doc["_id"]}, - { - "$set": { - "config.tasks": { - t["name"]: { - "short_name": t.get("short_name", t["name"]) - } - for t in gazu.task.all_task_types_for_project(project) - } - } - }, - ) - ) - - # Query all assets of the local project - project_col = dbcon.database[project_code] - asset_doc_ids = { - asset_doc["data"]["zou_id"]: asset_doc - for asset_doc in project_col.find({"type": "asset"}) - if asset_doc["data"].get("zou_id") - } - asset_doc_ids[project["id"]] = project_doc - - # Create - to_insert.extend( - [ - { - "name": item["name"], - "type": "asset", - "schema": "openpype:asset-3.0", - "data": {"zou_id": item["id"], "tasks": {}}, - } - for item in all_episodes + all_assets + all_seqs + all_shots - if item["id"] not in asset_doc_ids.keys() - ] - ) - if to_insert: - # Insert in doc - project_col.insert_many(to_insert) - - # Update existing docs - asset_doc_ids.update( - { - asset_doc["data"]["zou_id"]: asset_doc - for asset_doc in project_col.find({"type": "asset"}) - if asset_doc["data"].get("zou_id") - } - ) - - # Update - all_entities = all_assets + all_episodes + all_seqs + all_shots - bulk_writes.extend(update_op_assets(all_entities, asset_doc_ids)) - - # Delete - diff_assets = set(asset_doc_ids.keys()) - { - e["id"] for e in all_entities + [project] - } - if diff_assets: - bulk_writes.extend( - [DeleteOne(asset_doc_ids[asset_id]) for asset_id in diff_assets] - ) - @cli_main.command() def sync_openpype(): From b2ce0ac07a04ebdc2e61b4289ec2c2fcde624c63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20David?= Date: Tue, 15 Feb 2022 18:29:43 +0100 Subject: [PATCH 232/583] Shot naming matching --- .../default_modules/kitsu/kitsu_module.py | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/openpype/modules/default_modules/kitsu/kitsu_module.py b/openpype/modules/default_modules/kitsu/kitsu_module.py index e42edbc52b..a67f0e2d58 100644 --- a/openpype/modules/default_modules/kitsu/kitsu_module.py +++ b/openpype/modules/default_modules/kitsu/kitsu_module.py @@ -233,16 +233,7 @@ def sync_zou(): new_entity = gazu.asset.new_asset( zou_project, all_asset_types[0], doc["name"] ) - - elif match.group(1): # Episode - new_entity = gazu.shot.new_episode(zou_project, doc["name"]) - - elif match.group(2): # Sequence - parent_doc = asset_docs[visual_parent_id] - new_entity = gazu.shot.new_sequence( - zou_project, doc["name"], episode=parent_doc["data"]["zou_id"] - ) - + # Match case in shot Date: Tue, 15 Feb 2022 18:30:06 +0100 Subject: [PATCH 233/583] cleaning --- openpype/modules/default_modules/kitsu/kitsu_module.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/openpype/modules/default_modules/kitsu/kitsu_module.py b/openpype/modules/default_modules/kitsu/kitsu_module.py index a67f0e2d58..47af25ce60 100644 --- a/openpype/modules/default_modules/kitsu/kitsu_module.py +++ b/openpype/modules/default_modules/kitsu/kitsu_module.py @@ -6,8 +6,6 @@ in global space here until are required or used. - imports of Python 3 packages - we still support Python 2 hosts where addon definition should available """ -from asyncio import all_tasks -from turtle import update import click import os import re From a8611c07a8d56942950a2294a95f06f07aa87d94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20David?= Date: Wed, 16 Feb 2022 15:42:23 +0100 Subject: [PATCH 234/583] Clean sync zou --- .../default_modules/kitsu/kitsu_module.py | 138 +++++++++--------- 1 file changed, 72 insertions(+), 66 deletions(-) diff --git a/openpype/modules/default_modules/kitsu/kitsu_module.py b/openpype/modules/default_modules/kitsu/kitsu_module.py index 47af25ce60..ce4c579607 100644 --- a/openpype/modules/default_modules/kitsu/kitsu_module.py +++ b/openpype/modules/default_modules/kitsu/kitsu_module.py @@ -13,6 +13,24 @@ from typing import Dict, List from avalon.api import AvalonMongoDB import gazu +from gazu.asset import all_assets_for_project, all_asset_types, new_asset +from gazu.shot import ( + all_episodes_for_project, + all_sequences_for_project, + all_shots_for_project, + new_episode, + new_sequence, + new_shot, + update_sequence, +) +from gazu.task import ( + all_tasks_for_asset, + all_tasks_for_shot, + all_task_types, + all_task_types_for_project, + new_task, + new_task_type, +) from openpype.api import get_project_settings from openpype.lib import create_project from openpype.modules import JsonFilesSettingsDef, OpenPypeModule, ModulesManager @@ -176,7 +194,7 @@ def sync_zou(): # Create project if zou_project is None: raise RuntimeError( - f"Project '{project_name}' doesn't exist in Zou database, please create it in Kitsu and add logged user to it before running synchronization." + f"Project '{project_name}' doesn't exist in Zou database, please create it in Kitsu and add OpenPype user to it before running synchronization." ) # Update project settings and data @@ -190,11 +208,11 @@ def sync_zou(): gazu.project.update_project(zou_project) gazu.project.update_project_data(zou_project, data=op_project["data"]) - all_asset_types = gazu.asset.all_asset_types() - all_assets = gazu.asset.all_assets_for_project(zou_project) - all_episodes = gazu.shot.all_episodes_for_project(zou_project) - all_seqs = gazu.shot.all_sequences_for_project(zou_project) - all_shots = gazu.shot.all_shots_for_project(zou_project) + asset_types = all_asset_types() + all_assets = all_assets_for_project(zou_project) + all_episodes = all_episodes_for_project(zou_project) + all_seqs = all_sequences_for_project(zou_project) + all_shots = all_shots_for_project(zou_project) all_entities_ids = { e["id"] for e in all_episodes + all_seqs + all_shots + all_assets } @@ -211,14 +229,14 @@ def sync_zou(): new_assets_docs = [ doc for doc in asset_docs.values() - if doc["data"].get("zou_id") not in all_entities_ids + if doc["data"].get("zou", {}).get("id") not in all_entities_ids ] naming_pattern = project_module_settings["entities_naming_pattern"] regex_ep = re.compile( - r"({})|({})|({})".format( - naming_pattern["episode"].replace("#", "\d"), - naming_pattern["sequence"].replace("#", "\d"), - naming_pattern["shot"].replace("#", "\d"), + r"(.*{}.*)|(.*{}.*)|(.*{}.*)".format( + naming_pattern["shot"].replace("#", ""), + naming_pattern["sequence"].replace("#", ""), + naming_pattern["episode"].replace("#", ""), ), re.IGNORECASE, ) @@ -228,51 +246,38 @@ def sync_zou(): # Match asset type by it's name match = regex_ep.match(doc["name"]) if not match: # Asset - new_entity = gazu.asset.new_asset( - zou_project, all_asset_types[0], doc["name"] - ) + new_entity = new_asset(zou_project, asset_types[0], doc["name"]) # Match case in shot Date: Wed, 16 Feb 2022 17:30:35 +0100 Subject: [PATCH 235/583] Fix sync --- .../default_modules/kitsu/kitsu_module.py | 36 +++++++++++-------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/openpype/modules/default_modules/kitsu/kitsu_module.py b/openpype/modules/default_modules/kitsu/kitsu_module.py index ce4c579607..266aaa9139 100644 --- a/openpype/modules/default_modules/kitsu/kitsu_module.py +++ b/openpype/modules/default_modules/kitsu/kitsu_module.py @@ -184,7 +184,6 @@ def sync_zou(): # Create project locally # Try to find project document project_name = op_project["name"] - project_code = op_project["data"]["code"] dbcon.Session["AVALON_PROJECT"] = project_name # Get all entities from zou @@ -198,15 +197,16 @@ def sync_zou(): ) # Update project settings and data - zou_project.update( - { - "code": op_project["data"]["code"], - "fps": op_project["data"]["fps"], - "resolution": f"{op_project['data']['resolutionWidth']}x{op_project['data']['resolutionHeight']}", - } - ) + if op_project["data"]: + zou_project.update( + { + "code": op_project["data"]["code"], + "fps": op_project["data"]["fps"], + "resolution": f"{op_project['data']['resolutionWidth']}x{op_project['data']['resolutionHeight']}", + } + ) + gazu.project.update_project_data(zou_project, data=op_project["data"]) gazu.project.update_project(zou_project) - gazu.project.update_project_data(zou_project, data=op_project["data"]) asset_types = all_asset_types() all_assets = all_assets_for_project(zou_project) @@ -270,8 +270,9 @@ def sync_zou(): created_sequence = new_sequence( zou_project, substitute_sequence_name, episode=zou_parent_id ) - created_sequence["is_substitute"] = True - update_sequence(created_sequence) + gazu.shot.update_sequence_data( + created_sequence, {"is_substitute": True} + ) # Update parent ID zou_parent_id = created_sequence["id"] @@ -395,11 +396,18 @@ def sync_openpype(): dbcon.Session["AVALON_PROJECT"] = project_name project_doc = dbcon.find_one({"type": "project"}) + print(f"Synchronizing {project_name}...") + # Get all assets from zou all_assets = all_assets_for_project(project) all_episodes = all_episodes_for_project(project) all_seqs = all_sequences_for_project(project) all_shots = all_shots_for_project(project) + all_entities = [ + e + for e in all_assets + all_episodes + all_seqs + all_shots + if not e["data"].get("is_substitute") + ] # Create project if is not available # - creation is required to be able set project anatomy and attributes @@ -421,7 +429,7 @@ def sync_openpype(): "data": project["data"].update( { "code": project["code"], - "fps": project_code["fps"], + "fps": project["fps"], "resolutionWidth": project["resolution"].split("x")[0], "resolutionHeight": project["resolution"].split("x")[1], } @@ -449,9 +457,8 @@ def sync_openpype(): "schema": "openpype:asset-3.0", "data": {"zou": item, "tasks": {}}, } - for item in all_episodes + all_assets + all_seqs + all_shots + for item in all_entities if item["id"] not in asset_doc_ids.keys() - and not item.get("is_substitute") ] ) if to_insert: @@ -468,7 +475,6 @@ def sync_openpype(): ) # Update - all_entities = all_assets + all_episodes + all_seqs + all_shots bulk_writes.extend(update_op_assets(all_entities, asset_doc_ids)) # Delete From 93fb1ac0f1331439852104189733671386366861 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20David?= Date: Thu, 17 Feb 2022 11:12:13 +0100 Subject: [PATCH 236/583] Use substitutes id --- .../default_modules/kitsu/kitsu_module.py | 27 +++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/openpype/modules/default_modules/kitsu/kitsu_module.py b/openpype/modules/default_modules/kitsu/kitsu_module.py index 266aaa9139..0529d38a0e 100644 --- a/openpype/modules/default_modules/kitsu/kitsu_module.py +++ b/openpype/modules/default_modules/kitsu/kitsu_module.py @@ -242,6 +242,7 @@ def sync_zou(): ) for doc in new_assets_docs: visual_parent_id = doc["data"]["visualParent"] + parent_substitutes = [] # Match asset type by it's name match = regex_ep.match(doc["name"]) @@ -273,6 +274,7 @@ def sync_zou(): gazu.shot.update_sequence_data( created_sequence, {"is_substitute": True} ) + parent_substitutes.append(created_sequence) # Update parent ID zou_parent_id = created_sequence["id"] @@ -310,6 +312,7 @@ def sync_zou(): "$set": { "data.visualParent": visual_parent_id, "data.zou": new_entity, + "data.parent_substitutes": parent_substitutes, } }, ) @@ -494,17 +497,17 @@ def sync_openpype(): def update_op_assets( - items_list: List[dict], asset_doc_ids: Dict[str, dict] + entities_list: List[dict], asset_doc_ids: Dict[str, dict] ) -> List[UpdateOne]: """Update OpenPype assets. Set 'data' and 'parent' fields. - :param items_list: List of zou items to update + :param entities_list: List of zou entities to update :param asset_doc_ids: Dicts of [{zou_id: asset_doc}, ...] :return: List of UpdateOne objects """ bulk_writes = [] - for item in items_list: + for item in entities_list: # Update asset item_doc = asset_doc_ids[item["id"]] item_data = item_doc["data"].copy() @@ -523,20 +526,28 @@ def update_op_assets( } # Visual parent for hierarchy - direct_parent_id = item["parent_id"] or item["source_id"] - if direct_parent_id: - visual_parent_doc = asset_doc_ids[direct_parent_id] + substitute_parent_item = ( + item_data["parent_substitutes"][0] + if item_data.get("parent_substitutes") + else None + ) + parent_zou_id = item["parent_id"] or item["source_id"] + if substitute_parent_item: + parent_zou_id = ( + substitute_parent_item["parent_id"] + or substitute_parent_item["source_id"] + ) + visual_parent_doc = asset_doc_ids[parent_zou_id] item_data["visualParent"] = visual_parent_doc["_id"] # Add parents for hierarchy - parent_zou_id = item["parent_id"] item_data["parents"] = [] while parent_zou_id is not None: parent_doc = asset_doc_ids[parent_zou_id] item_data["parents"].insert(0, parent_doc["name"]) parent_zou_id = next( - i for i in items_list if i["id"] == parent_doc["data"]["zou"]["id"] + i for i in entities_list if i["id"] == parent_doc["data"]["zou"]["id"] )["parent_id"] # Update 'data' different in zou DB From c058ba54c2763b4563f5912f8a8a8930cceafc1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20David?= Date: Tue, 22 Feb 2022 18:27:06 +0100 Subject: [PATCH 237/583] Project sync and code DRYing --- .../modules/default_modules/kitsu/__init__.py | 10 +-- .../default_modules/kitsu/kitsu_module.py | 52 ++++++---------- .../default_modules/kitsu/listeners.py | 61 +++++++++++++++++++ .../default_modules/kitsu/utils/__init__.py | 0 .../default_modules/kitsu/utils/openpype.py | 47 ++++++++++++++ 5 files changed, 128 insertions(+), 42 deletions(-) create mode 100644 openpype/modules/default_modules/kitsu/listeners.py create mode 100644 openpype/modules/default_modules/kitsu/utils/__init__.py create mode 100644 openpype/modules/default_modules/kitsu/utils/openpype.py diff --git a/openpype/modules/default_modules/kitsu/__init__.py b/openpype/modules/default_modules/kitsu/__init__.py index cd0c2ea8af..dc7c2dad50 100644 --- a/openpype/modules/default_modules/kitsu/__init__.py +++ b/openpype/modules/default_modules/kitsu/__init__.py @@ -4,12 +4,6 @@ If addon class or settings definition won't be here their definition won't be found by OpenPype discovery. """ -from .kitsu_module import ( - AddonSettingsDef, - KitsuModule -) +from .kitsu_module import AddonSettingsDef, KitsuModule -__all__ = ( - "AddonSettingsDef", - "KitsuModule" -) +__all__ = ("AddonSettingsDef", "KitsuModule") diff --git a/openpype/modules/default_modules/kitsu/kitsu_module.py b/openpype/modules/default_modules/kitsu/kitsu_module.py index 0529d38a0e..3fddc48ee9 100644 --- a/openpype/modules/default_modules/kitsu/kitsu_module.py +++ b/openpype/modules/default_modules/kitsu/kitsu_module.py @@ -11,7 +11,6 @@ import os import re from typing import Dict, List -from avalon.api import AvalonMongoDB import gazu from gazu.asset import all_assets_for_project, all_asset_types, new_asset from gazu.shot import ( @@ -21,7 +20,6 @@ from gazu.shot import ( new_episode, new_sequence, new_shot, - update_sequence, ) from gazu.task import ( all_tasks_for_asset, @@ -31,11 +29,15 @@ from gazu.task import ( new_task, new_task_type, ) +from pymongo import DeleteOne, UpdateOne + +from avalon.api import AvalonMongoDB from openpype.api import get_project_settings from openpype.lib import create_project from openpype.modules import JsonFilesSettingsDef, OpenPypeModule, ModulesManager -from pymongo import DeleteOne, UpdateOne +from openpype.modules.default_modules.kitsu.utils.openpype import sync_project from openpype_interfaces import IPluginPaths, ITrayAction +from .listeners import add_listeners # Settings definition of this addon using `JsonFilesSettingsDef` @@ -371,8 +373,6 @@ def sync_zou(): if bulk_writes: project_col.bulk_write(bulk_writes) - # TODO Create events daemons - dbcon.uninstall() @@ -412,35 +412,8 @@ def sync_openpype(): if not e["data"].get("is_substitute") ] - # Create project if is not available - # - creation is required to be able set project anatomy and attributes - to_insert = [] - if not project_doc: - print(f"Creating project '{project_name}'") - project_doc = create_project(project_name, project_code, dbcon=dbcon) - - # Project data and tasks - bulk_writes.append( - UpdateOne( - {"_id": project_doc["_id"]}, - { - "$set": { - "config.tasks": { - t["name"]: {"short_name": t.get("short_name", t["name"])} - for t in all_task_types_for_project(project) - }, - "data": project["data"].update( - { - "code": project["code"], - "fps": project["fps"], - "resolutionWidth": project["resolution"].split("x")[0], - "resolutionHeight": project["resolution"].split("x")[1], - } - ), - } - }, - ) - ) + # Sync project. Create if doesn't exist + bulk_writes.append(sync_project(project, dbcon)) # Query all assets of the local project project_col = dbcon.database[project_code] @@ -452,6 +425,7 @@ def sync_openpype(): asset_doc_ids[project["id"]] = project_doc # Create + to_insert = [] to_insert.extend( [ { @@ -572,6 +546,16 @@ def update_op_assets( return bulk_writes +@cli_main.command() +def listen(): + """Show ExampleAddon dialog. + + We don't have access to addon directly through cli so we have to create + it again. + """ + add_listeners() + + @cli_main.command() def show_dialog(): """Show ExampleAddon dialog. diff --git a/openpype/modules/default_modules/kitsu/listeners.py b/openpype/modules/default_modules/kitsu/listeners.py new file mode 100644 index 0000000000..5303933bce --- /dev/null +++ b/openpype/modules/default_modules/kitsu/listeners.py @@ -0,0 +1,61 @@ +from turtle import update +import gazu +import os + +from pymongo import DeleteOne, UpdateOne + +from avalon.api import AvalonMongoDB +from openpype.modules.default_modules.kitsu.utils.openpype import sync_project + + +def add_listeners(): + # Connect to server + gazu.client.set_host(os.environ["KITSU_SERVER"]) + + # Authenticate + gazu.log_in(os.environ["KITSU_LOGIN"], os.environ["KITSU_PWD"]) + gazu.set_event_host(os.environ["KITSU_SERVER"].replace("api", "socket.io")) + + # Connect to DB + dbcon = AvalonMongoDB() + dbcon.install() + + def new_project(data): + """Create new project into DB.""" + + # Use update process to avoid duplicating code + update_project(data) + + def update_project(data): + """Update project into DB.""" + # Get project entity + project = gazu.project.get_project(data["project_id"]) + project_name = project["name"] + dbcon.Session["AVALON_PROJECT"] = project_name + + update_project = sync_project(project, dbcon) + + # Write into DB + if update_project: + project_col = dbcon.database[project_name] + project_col.bulk_write([update_project]) + + def delete_project(data): + # Get project entity + print(data) # TODO check bugfix + project = gazu.project.get_project(data["project_id"]) + + # Delete project collection + project_col = dbcon.database[project["name"]] + project_col.drop() + + def new_asset(data): + print("Asset created %s" % data) + + event_client = gazu.events.init() + gazu.events.add_listener(event_client, "project:new", new_project) + gazu.events.add_listener(event_client, "project:update", update_project) + gazu.events.add_listener(event_client, "project:delete", delete_project) + gazu.events.add_listener(event_client, "asset:new", new_asset) + gazu.events.run_client(event_client) + print("ll") diff --git a/openpype/modules/default_modules/kitsu/utils/__init__.py b/openpype/modules/default_modules/kitsu/utils/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openpype/modules/default_modules/kitsu/utils/openpype.py b/openpype/modules/default_modules/kitsu/utils/openpype.py new file mode 100644 index 0000000000..9e795bb8ca --- /dev/null +++ b/openpype/modules/default_modules/kitsu/utils/openpype.py @@ -0,0 +1,47 @@ +import gazu + +from pymongo import DeleteOne, UpdateOne + +from avalon.api import AvalonMongoDB +from openpype.lib import create_project + + +def sync_project(project: dict, dbcon: AvalonMongoDB) -> UpdateOne: + """Sync project with database. + Create project if doesn't exist. + + :param project: Gazu project + :param dbcon: DB to create project in + :return: Update instance for the project + """ + project_name = project["name"] + project_doc = dbcon.find_one({"type": "project"}) + if not project_doc: + print(f"Creating project '{project_name}'") + project_doc = create_project(project_name, project_name, dbcon=dbcon) + + print(f"Synchronizing {project_name}...") + + # Project data and tasks + if not project["data"]: # Sentinel + project["data"] = {} + + return UpdateOne( + {"_id": project_doc["_id"]}, + { + "$set": { + "config.tasks": { + t["name"]: {"short_name": t.get("short_name", t["name"])} + for t in gazu.task.all_task_types_for_project(project) + }, + "data": project["data"].update( + { + "code": project["code"], + "fps": project["fps"], + "resolutionWidth": project["resolution"].split("x")[0], + "resolutionHeight": project["resolution"].split("x")[1], + } + ), + } + }, + ) From 0d38ccc5127bd00157aa9d19157f5d7c3ec3e20f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20David?= Date: Wed, 23 Feb 2022 15:14:38 +0100 Subject: [PATCH 238/583] Listen asset and DRY --- .../default_modules/kitsu/kitsu_module.py | 117 ++++-------------- .../default_modules/kitsu/listeners.py | 70 +++++++++-- .../default_modules/kitsu/utils/openpype.py | 114 +++++++++++++++++ 3 files changed, 198 insertions(+), 103 deletions(-) diff --git a/openpype/modules/default_modules/kitsu/kitsu_module.py b/openpype/modules/default_modules/kitsu/kitsu_module.py index 3fddc48ee9..0bdd4b12e6 100644 --- a/openpype/modules/default_modules/kitsu/kitsu_module.py +++ b/openpype/modules/default_modules/kitsu/kitsu_module.py @@ -22,10 +22,7 @@ from gazu.shot import ( new_shot, ) from gazu.task import ( - all_tasks_for_asset, - all_tasks_for_shot, all_task_types, - all_task_types_for_project, new_task, new_task_type, ) @@ -33,9 +30,12 @@ from pymongo import DeleteOne, UpdateOne from avalon.api import AvalonMongoDB from openpype.api import get_project_settings -from openpype.lib import create_project from openpype.modules import JsonFilesSettingsDef, OpenPypeModule, ModulesManager -from openpype.modules.default_modules.kitsu.utils.openpype import sync_project +from openpype.modules.default_modules.kitsu.utils.openpype import ( + create_op_asset, + sync_project, + update_op_assets, +) from openpype_interfaces import IPluginPaths, ITrayAction from .listeners import add_listeners @@ -417,33 +417,28 @@ def sync_openpype(): # Query all assets of the local project project_col = dbcon.database[project_code] - asset_doc_ids = { + zou_ids_and_asset_docs = { asset_doc["data"]["zou"]["id"]: asset_doc for asset_doc in project_col.find({"type": "asset"}) if asset_doc["data"].get("zou", {}).get("id") } - asset_doc_ids[project["id"]] = project_doc + zou_ids_and_asset_docs[project["id"]] = project_doc # Create to_insert = [] to_insert.extend( [ - { - "name": item["name"], - "type": "asset", - "schema": "openpype:asset-3.0", - "data": {"zou": item, "tasks": {}}, - } + create_op_asset(item) for item in all_entities - if item["id"] not in asset_doc_ids.keys() + if item["id"] not in zou_ids_and_asset_docs.keys() ] ) if to_insert: - # Insert in doc + # Insert doc in DB project_col.insert_many(to_insert) # Update existing docs - asset_doc_ids.update( + zou_ids_and_asset_docs.update( { asset_doc["data"]["zou"]["id"]: asset_doc for asset_doc in project_col.find({"type": "asset"}) @@ -452,15 +447,23 @@ def sync_openpype(): ) # Update - bulk_writes.extend(update_op_assets(all_entities, asset_doc_ids)) + bulk_writes.extend( + [ + UpdateOne({"_id": id}, update) + for id, update in update_op_assets(all_entities, zou_ids_and_asset_docs) + ] + ) # Delete - diff_assets = set(asset_doc_ids.keys()) - { + diff_assets = set(zou_ids_and_asset_docs.keys()) - { e["id"] for e in all_entities + [project] } if diff_assets: bulk_writes.extend( - [DeleteOne(asset_doc_ids[asset_id]) for asset_id in diff_assets] + [ + DeleteOne(zou_ids_and_asset_docs[asset_id]) + for asset_id in diff_assets + ] ) # Write into DB @@ -470,82 +473,6 @@ def sync_openpype(): dbcon.uninstall() -def update_op_assets( - entities_list: List[dict], asset_doc_ids: Dict[str, dict] -) -> List[UpdateOne]: - """Update OpenPype assets. - Set 'data' and 'parent' fields. - - :param entities_list: List of zou entities to update - :param asset_doc_ids: Dicts of [{zou_id: asset_doc}, ...] - :return: List of UpdateOne objects - """ - bulk_writes = [] - for item in entities_list: - # Update asset - item_doc = asset_doc_ids[item["id"]] - item_data = item_doc["data"].copy() - item_data["zou"] = item - - # Tasks - tasks_list = None - if item["type"] == "Asset": - tasks_list = all_tasks_for_asset(item) - elif item["type"] == "Shot": - tasks_list = all_tasks_for_shot(item) - # TODO frame in and out - if tasks_list: - item_data["tasks"] = { - t["task_type_name"]: {"type": t["task_type_name"]} for t in tasks_list - } - - # Visual parent for hierarchy - substitute_parent_item = ( - item_data["parent_substitutes"][0] - if item_data.get("parent_substitutes") - else None - ) - parent_zou_id = item["parent_id"] or item["source_id"] - if substitute_parent_item: - parent_zou_id = ( - substitute_parent_item["parent_id"] - or substitute_parent_item["source_id"] - ) - visual_parent_doc = asset_doc_ids[parent_zou_id] - item_data["visualParent"] = visual_parent_doc["_id"] - - # Add parents for hierarchy - item_data["parents"] = [] - while parent_zou_id is not None: - parent_doc = asset_doc_ids[parent_zou_id] - item_data["parents"].insert(0, parent_doc["name"]) - - parent_zou_id = next( - i for i in entities_list if i["id"] == parent_doc["data"]["zou"]["id"] - )["parent_id"] - - # Update 'data' different in zou DB - updated_data = { - k: item_data[k] - for k in item_data.keys() - if item_doc["data"].get(k) != item_data[k] - } - if updated_data or not item_doc.get("parent"): - bulk_writes.append( - UpdateOne( - {"_id": item_doc["_id"]}, - { - "$set": { - "data": item_data, - "parent": asset_doc_ids[item["project_id"]]["_id"], - } - }, - ) - ) - - return bulk_writes - - @cli_main.command() def listen(): """Show ExampleAddon dialog. diff --git a/openpype/modules/default_modules/kitsu/listeners.py b/openpype/modules/default_modules/kitsu/listeners.py index 5303933bce..fd4cf6dd3d 100644 --- a/openpype/modules/default_modules/kitsu/listeners.py +++ b/openpype/modules/default_modules/kitsu/listeners.py @@ -5,7 +5,12 @@ import os from pymongo import DeleteOne, UpdateOne from avalon.api import AvalonMongoDB -from openpype.modules.default_modules.kitsu.utils.openpype import sync_project +from openpype.modules.default_modules.kitsu.utils.openpype import ( + create_op_asset, + set_op_project, + sync_project, + update_op_assets, +) def add_listeners(): @@ -15,19 +20,22 @@ def add_listeners(): # Authenticate gazu.log_in(os.environ["KITSU_LOGIN"], os.environ["KITSU_PWD"]) gazu.set_event_host(os.environ["KITSU_SERVER"].replace("api", "socket.io")) + event_client = gazu.events.init() # Connect to DB dbcon = AvalonMongoDB() dbcon.install() + # == Project == + def new_project(data): - """Create new project into DB.""" + """Create new project into OP DB.""" # Use update process to avoid duplicating code update_project(data) def update_project(data): - """Update project into DB.""" + """Update project into OP DB.""" # Get project entity project = gazu.project.get_project(data["project_id"]) project_name = project["name"] @@ -41,6 +49,7 @@ def add_listeners(): project_col.bulk_write([update_project]) def delete_project(data): + """Delete project.""" # Get project entity print(data) # TODO check bugfix project = gazu.project.get_project(data["project_id"]) @@ -49,13 +58,58 @@ def add_listeners(): project_col = dbcon.database[project["name"]] project_col.drop() - def new_asset(data): - print("Asset created %s" % data) - - event_client = gazu.events.init() gazu.events.add_listener(event_client, "project:new", new_project) gazu.events.add_listener(event_client, "project:update", update_project) gazu.events.add_listener(event_client, "project:delete", delete_project) + + # == Asset == + + def new_asset(data): + """Create new asset into OP DB.""" + # Get project entity + project_col = set_op_project(dbcon, data["project_id"]) + + # Get gazu entity + asset = gazu.asset.get_asset(data["asset_id"]) + + # Insert doc in DB + project_col.insert_one(create_op_asset(asset)) + + # Update + update_asset(data) + + def update_asset(data): + """Update asset into OP DB.""" + project_col = set_op_project(dbcon, data["project_id"]) + project_doc = dbcon.find_one({"type": "project"}) + + # Get gazu entity + asset = gazu.asset.get_asset(data["asset_id"]) + + # Find asset doc + # Query all assets of the local project + zou_ids_and_asset_docs = { + asset_doc["data"]["zou"]["id"]: asset_doc + for asset_doc in project_col.find({"type": "asset"}) + if asset_doc["data"].get("zou", {}).get("id") + } + zou_ids_and_asset_docs[asset["project_id"]] = project_doc + + # Update + asset_doc_id, asset_update = update_op_assets([asset], zou_ids_and_asset_docs)[ + 0 + ] + project_col.update_one({"_id": asset_doc_id}, asset_update) + + def delete_asset(data): + """Delete asset of OP DB.""" + project_col = set_op_project(dbcon, data["project_id"]) + + # Delete + project_col.delete_one({"type": "asset", "data.zou.id": data["asset_id"]}) + gazu.events.add_listener(event_client, "asset:new", new_asset) + gazu.events.add_listener(event_client, "asset:update", update_asset) + gazu.events.add_listener(event_client, "asset:delete", delete_asset) + gazu.events.run_client(event_client) - print("ll") diff --git a/openpype/modules/default_modules/kitsu/utils/openpype.py b/openpype/modules/default_modules/kitsu/utils/openpype.py index 9e795bb8ca..8ef987898d 100644 --- a/openpype/modules/default_modules/kitsu/utils/openpype.py +++ b/openpype/modules/default_modules/kitsu/utils/openpype.py @@ -1,11 +1,125 @@ +from typing import Dict, List import gazu from pymongo import DeleteOne, UpdateOne +from pymongo.collection import Collection + +from gazu.task import ( + all_tasks_for_asset, + all_tasks_for_shot, +) from avalon.api import AvalonMongoDB from openpype.lib import create_project +def create_op_asset(gazu_entity: dict) -> dict: + """Create OP asset dict from gazu entity. + + :param gazu_entity: + """ + return { + "name": gazu_entity["name"], + "type": "asset", + "schema": "openpype:asset-3.0", + "data": {"zou": gazu_entity, "tasks": {}}, + } + + +def set_op_project(dbcon, project_id) -> Collection: + """Set project context. + + :param dbcon: Connection to DB. + :param project_id: Project zou ID + """ + project = gazu.project.get_project(project_id) + project_name = project["name"] + dbcon.Session["AVALON_PROJECT"] = project_name + + return dbcon.database[project_name] + + +def update_op_assets( + entities_list: List[dict], asset_doc_ids: Dict[str, dict] +) -> List[Dict[str, dict]]: + """Update OpenPype assets. + Set 'data' and 'parent' fields. + + :param entities_list: List of zou entities to update + :param asset_doc_ids: Dicts of [{zou_id: asset_doc}, ...] + :return: List of (doc_id, update_dict) tuples + """ + assets_with_update = [] + for item in entities_list: + # Update asset + item_doc = asset_doc_ids[item["id"]] + item_data = item_doc["data"].copy() + item_data["zou"] = item + + # Tasks + tasks_list = [] + if item["type"] == "Asset": + tasks_list = all_tasks_for_asset(item) + elif item["type"] == "Shot": + tasks_list = all_tasks_for_shot(item) + # TODO frame in and out + item_data["tasks"] = { + t["task_type_name"]: {"type": t["task_type_name"]} for t in tasks_list + } + + # Get zou parent id for correct hierarchy + # Use parent substitutes if existing + substitute_parent_item = ( + item_data["parent_substitutes"][0] + if item_data.get("parent_substitutes") + else None + ) + if substitute_parent_item: + parent_zou_id = substitute_parent_item["id"] + else: + parent_zou_id = ( + item.get("parent_id") or item.get("episode_id") or item.get("source_id") + ) # TODO check consistency + + # Visual parent for hierarchy + visual_parent_doc_id = ( + asset_doc_ids[parent_zou_id]["_id"] if parent_zou_id else None + ) + item_data["visualParent"] = visual_parent_doc_id + + # Add parents for hierarchy + item_data["parents"] = [] + while parent_zou_id is not None: + parent_doc = asset_doc_ids[parent_zou_id] + item_data["parents"].insert(0, parent_doc["name"]) + + # Get parent entity + parent_entity = parent_doc["data"]["zou"] + parent_zou_id = parent_entity["parent_id"] + + # Update 'data' different in zou DB + updated_data = { + k: item_data[k] + for k in item_data.keys() + if item_doc["data"].get(k) != item_data[k] + } + if updated_data or not item_doc.get("parent"): + assets_with_update.append( + ( + item_doc["_id"], + { + "$set": { + "name": item["name"], + "data": item_data, + "parent": asset_doc_ids[item["project_id"]]["_id"], + } + }, + ) + ) + + return assets_with_update + + def sync_project(project: dict, dbcon: AvalonMongoDB) -> UpdateOne: """Sync project with database. Create project if doesn't exist. From fe5486886712a77cd40f37cac6ec41d329c17748 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20David?= Date: Wed, 23 Feb 2022 17:04:21 +0100 Subject: [PATCH 239/583] Listen Episode, Sequence and Shot --- .../default_modules/kitsu/listeners.py | 147 ++++++++++++++++++ .../default_modules/kitsu/utils/openpype.py | 2 +- 2 files changed, 148 insertions(+), 1 deletion(-) diff --git a/openpype/modules/default_modules/kitsu/listeners.py b/openpype/modules/default_modules/kitsu/listeners.py index fd4cf6dd3d..bb217452df 100644 --- a/openpype/modules/default_modules/kitsu/listeners.py +++ b/openpype/modules/default_modules/kitsu/listeners.py @@ -112,4 +112,151 @@ def add_listeners(): gazu.events.add_listener(event_client, "asset:update", update_asset) gazu.events.add_listener(event_client, "asset:delete", delete_asset) + # == Episode == + def new_episode(data): + """Create new episode into OP DB.""" + # Get project entity + project_col = set_op_project(dbcon, data["project_id"]) + + # Get gazu entity + episode = gazu.shot.get_episode(data["episode_id"]) + + # Insert doc in DB + project_col.insert_one(create_op_asset(episode)) + + # Update + update_episode(data) + + def update_episode(data): + """Update episode into OP DB.""" + project_col = set_op_project(dbcon, data["project_id"]) + project_doc = dbcon.find_one({"type": "project"}) + + # Get gazu entity + episode = gazu.shot.get_episode(data["episode_id"]) + + # Find asset doc + # Query all assets of the local project + zou_ids_and_asset_docs = { + asset_doc["data"]["zou"]["id"]: asset_doc + for asset_doc in project_col.find({"type": "asset"}) + if asset_doc["data"].get("zou", {}).get("id") + } + zou_ids_and_asset_docs[episode["project_id"]] = project_doc + + # Update + asset_doc_id, asset_update = update_op_assets( + [episode], zou_ids_and_asset_docs + )[0] + project_col.update_one({"_id": asset_doc_id}, asset_update) + + def delete_episode(data): + """Delete shot of OP DB.""" + project_col = set_op_project(dbcon, data["project_id"]) + print("delete episode") # TODO check bugfix + + # Delete + project_col.delete_one({"type": "asset", "data.zou.id": data["episode_id"]}) + + gazu.events.add_listener(event_client, "episode:new", new_episode) + gazu.events.add_listener(event_client, "episode:update", update_episode) + gazu.events.add_listener(event_client, "episode:delete", delete_episode) + + # == Sequence == + def new_sequence(data): + """Create new sequnce into OP DB.""" + # Get project entity + project_col = set_op_project(dbcon, data["project_id"]) + + # Get gazu entity + sequence = gazu.shot.get_sequence(data["sequence_id"]) + + # Insert doc in DB + project_col.insert_one(create_op_asset(sequence)) + + # Update + update_sequence(data) + + def update_sequence(data): + """Update sequence into OP DB.""" + project_col = set_op_project(dbcon, data["project_id"]) + project_doc = dbcon.find_one({"type": "project"}) + + # Get gazu entity + sequence = gazu.shot.get_sequence(data["sequence_id"]) + + # Find asset doc + # Query all assets of the local project + zou_ids_and_asset_docs = { + asset_doc["data"]["zou"]["id"]: asset_doc + for asset_doc in project_col.find({"type": "asset"}) + if asset_doc["data"].get("zou", {}).get("id") + } + zou_ids_and_asset_docs[sequence["project_id"]] = project_doc + + # Update + asset_doc_id, asset_update = update_op_assets( + [sequence], zou_ids_and_asset_docs + )[0] + project_col.update_one({"_id": asset_doc_id}, asset_update) + + def delete_sequence(data): + """Delete sequence of OP DB.""" + project_col = set_op_project(dbcon, data["project_id"]) + print("delete sequence") # TODO check bugfix + + # Delete + project_col.delete_one({"type": "asset", "data.zou.id": data["sequence_id"]}) + + gazu.events.add_listener(event_client, "sequence:new", new_sequence) + gazu.events.add_listener(event_client, "sequence:update", update_sequence) + gazu.events.add_listener(event_client, "sequence:delete", delete_sequence) + + # == Shot == + def new_shot(data): + """Create new shot into OP DB.""" + # Get project entity + project_col = set_op_project(dbcon, data["project_id"]) + + # Get gazu entity + shot = gazu.shot.get_shot(data["shot_id"]) + + # Insert doc in DB + project_col.insert_one(create_op_asset(shot)) + + # Update + update_shot(data) + + def update_shot(data): + """Update shot into OP DB.""" + project_col = set_op_project(dbcon, data["project_id"]) + project_doc = dbcon.find_one({"type": "project"}) + + # Get gazu entity + shot = gazu.shot.get_shot(data["shot_id"]) + + # Find asset doc + # Query all assets of the local project + zou_ids_and_asset_docs = { + asset_doc["data"]["zou"]["id"]: asset_doc + for asset_doc in project_col.find({"type": "asset"}) + if asset_doc["data"].get("zou", {}).get("id") + } + zou_ids_and_asset_docs[shot["project_id"]] = project_doc + + # Update + asset_doc_id, asset_update = update_op_assets([shot], zou_ids_and_asset_docs)[0] + project_col.update_one({"_id": asset_doc_id}, asset_update) + + def delete_shot(data): + """Delete shot of OP DB.""" + project_col = set_op_project(dbcon, data["project_id"]) + + # Delete + project_col.delete_one({"type": "asset", "data.zou.id": data["shot_id"]}) + + gazu.events.add_listener(event_client, "shot:new", new_shot) + gazu.events.add_listener(event_client, "shot:update", update_shot) + gazu.events.add_listener(event_client, "shot:delete", delete_shot) + gazu.events.run_client(event_client) diff --git a/openpype/modules/default_modules/kitsu/utils/openpype.py b/openpype/modules/default_modules/kitsu/utils/openpype.py index 8ef987898d..809748fa78 100644 --- a/openpype/modules/default_modules/kitsu/utils/openpype.py +++ b/openpype/modules/default_modules/kitsu/utils/openpype.py @@ -75,7 +75,7 @@ def update_op_assets( else None ) if substitute_parent_item: - parent_zou_id = substitute_parent_item["id"] + parent_zou_id = substitute_parent_item["parent_id"] else: parent_zou_id = ( item.get("parent_id") or item.get("episode_id") or item.get("source_id") From de153a0450d9cbdcf9229775fa8fa9afa94eef79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20David?= Date: Thu, 24 Feb 2022 15:53:03 +0100 Subject: [PATCH 240/583] Listen tasks, clean code --- .../default_modules/kitsu/kitsu_module.py | 1 - .../default_modules/kitsu/listeners.py | 55 ++++++++++++++++++- .../default_modules/kitsu/utils/openpype.py | 2 +- 3 files changed, 53 insertions(+), 5 deletions(-) diff --git a/openpype/modules/default_modules/kitsu/kitsu_module.py b/openpype/modules/default_modules/kitsu/kitsu_module.py index 0bdd4b12e6..10cbe76db2 100644 --- a/openpype/modules/default_modules/kitsu/kitsu_module.py +++ b/openpype/modules/default_modules/kitsu/kitsu_module.py @@ -9,7 +9,6 @@ in global space here until are required or used. import click import os import re -from typing import Dict, List import gazu from gazu.asset import all_assets_for_project, all_asset_types, new_asset diff --git a/openpype/modules/default_modules/kitsu/listeners.py b/openpype/modules/default_modules/kitsu/listeners.py index bb217452df..16c4e0e69e 100644 --- a/openpype/modules/default_modules/kitsu/listeners.py +++ b/openpype/modules/default_modules/kitsu/listeners.py @@ -1,9 +1,6 @@ -from turtle import update import gazu import os -from pymongo import DeleteOne, UpdateOne - from avalon.api import AvalonMongoDB from openpype.modules.default_modules.kitsu.utils.openpype import ( create_op_asset, @@ -259,4 +256,56 @@ def add_listeners(): gazu.events.add_listener(event_client, "shot:update", update_shot) gazu.events.add_listener(event_client, "shot:delete", delete_shot) + # == Task == + def new_task(data): + """Create new task into OP DB.""" + print("new", data) + # Get project entity + project_col = set_op_project(dbcon, data["project_id"]) + + # Get gazu entity + task = gazu.task.get_task(data["task_id"]) + + # Find asset doc + asset_doc = project_col.find_one( + {"type": "asset", "data.zou.id": task["entity"]["id"]} + ) + + # Update asset tasks with new one + asset_tasks = asset_doc["data"].get("tasks") + task_type_name = task["task_type"]["name"] + asset_tasks[task_type_name] = {"type": task_type_name, "zou": task} + project_col.update_one( + {"_id": asset_doc["_id"]}, {"$set": {"data.tasks": asset_tasks}} + ) + + def update_task(data): + """Update task into OP DB.""" + # TODO is it necessary? + pass + + def delete_task(data): + """Delete task of OP DB.""" + project_col = set_op_project(dbcon, data["project_id"]) + + # Find asset doc + asset_docs = [doc for doc in project_col.find({"type": "asset"})] + for doc in asset_docs: + # Match task + for name, task in doc["data"]["tasks"].items(): + if task.get("zou") and data["task_id"] == task["zou"]["id"]: + # Pop task + asset_tasks = doc["data"].get("tasks") + asset_tasks.pop(name) + + # Delete task in DB + project_col.update_one( + {"_id": doc["_id"]}, {"$set": {"data.tasks": asset_tasks}} + ) + return + + gazu.events.add_listener(event_client, "task:new", new_task) + gazu.events.add_listener(event_client, "task:update", update_task) + gazu.events.add_listener(event_client, "task:delete", delete_task) + gazu.events.run_client(event_client) diff --git a/openpype/modules/default_modules/kitsu/utils/openpype.py b/openpype/modules/default_modules/kitsu/utils/openpype.py index 809748fa78..edcf233ea8 100644 --- a/openpype/modules/default_modules/kitsu/utils/openpype.py +++ b/openpype/modules/default_modules/kitsu/utils/openpype.py @@ -1,7 +1,7 @@ from typing import Dict, List import gazu -from pymongo import DeleteOne, UpdateOne +from pymongo import UpdateOne from pymongo.collection import Collection from gazu.task import ( From 6cf38e1a35ba73a5df64969befb71728ffec4813 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20David?= Date: Fri, 25 Feb 2022 16:23:14 +0100 Subject: [PATCH 241/583] Publish comment to kitsu --- .../default_modules/kitsu/kitsu_module.py | 2 +- .../kitsu/plugins/publish/example_plugin.py | 40 +++++++++++++++++++ .../default_modules/kitsu/utils/openpype.py | 24 +++++------ 3 files changed, 53 insertions(+), 13 deletions(-) diff --git a/openpype/modules/default_modules/kitsu/kitsu_module.py b/openpype/modules/default_modules/kitsu/kitsu_module.py index 10cbe76db2..f62a86f04d 100644 --- a/openpype/modules/default_modules/kitsu/kitsu_module.py +++ b/openpype/modules/default_modules/kitsu/kitsu_module.py @@ -408,7 +408,7 @@ def sync_openpype(): all_entities = [ e for e in all_assets + all_episodes + all_seqs + all_shots - if not e["data"].get("is_substitute") + if e["data"] and not e["data"].get("is_substitute") ] # Sync project. Create if doesn't exist diff --git a/openpype/modules/default_modules/kitsu/plugins/publish/example_plugin.py b/openpype/modules/default_modules/kitsu/plugins/publish/example_plugin.py index 61602f4e78..614d9ecc38 100644 --- a/openpype/modules/default_modules/kitsu/plugins/publish/example_plugin.py +++ b/openpype/modules/default_modules/kitsu/plugins/publish/example_plugin.py @@ -1,9 +1,49 @@ +import os + +import gazu + import pyblish.api +import debugpy + class CollectExampleAddon(pyblish.api.ContextPlugin): order = pyblish.api.CollectorOrder + 0.4 label = "Collect Kitsu" def process(self, context): + debugpy.breakpoint() self.log.info("I'm in Kitsu's plugin!") + + +class IntegrateRig(pyblish.api.InstancePlugin): + """Copy files to an appropriate location where others may reach it""" + + order = pyblish.api.IntegratorOrder + families = ["model"] + + def process(self, instance): + print(instance.data["version"]) + + # Connect to server + gazu.client.set_host(os.environ["KITSU_SERVER"]) + + # Authenticate + gazu.log_in(os.environ["KITSU_LOGIN"], os.environ["KITSU_PWD"]) + + asset_data = instance.data["assetEntity"]["data"] + + # TODO Set local settings for login and password + + # Get task + task_type = gazu.task.get_task_type_by_name(instance.data["task"]) + entity_task = gazu.task.get_task_by_entity(asset_data["zou"], task_type) + + # Comment entity + gazu.task.add_comment( + entity_task, + entity_task["task_status_id"], + comment=f"Version {instance.data['version']} has been published!", + ) + + self.log.info("Copied successfully!") diff --git a/openpype/modules/default_modules/kitsu/utils/openpype.py b/openpype/modules/default_modules/kitsu/utils/openpype.py index edcf233ea8..518872a71c 100644 --- a/openpype/modules/default_modules/kitsu/utils/openpype.py +++ b/openpype/modules/default_modules/kitsu/utils/openpype.py @@ -134,11 +134,18 @@ def sync_project(project: dict, dbcon: AvalonMongoDB) -> UpdateOne: print(f"Creating project '{project_name}'") project_doc = create_project(project_name, project_name, dbcon=dbcon) - print(f"Synchronizing {project_name}...") - # Project data and tasks - if not project["data"]: # Sentinel - project["data"] = {} + project_data = project["data"] or {} + + # Update data + project_data.update( + { + "code": project["code"], + "fps": project["fps"], + "resolutionWidth": project["resolution"].split("x")[0], + "resolutionHeight": project["resolution"].split("x")[1], + } + ) return UpdateOne( {"_id": project_doc["_id"]}, @@ -148,14 +155,7 @@ def sync_project(project: dict, dbcon: AvalonMongoDB) -> UpdateOne: t["name"]: {"short_name": t.get("short_name", t["name"])} for t in gazu.task.all_task_types_for_project(project) }, - "data": project["data"].update( - { - "code": project["code"], - "fps": project["fps"], - "resolutionWidth": project["resolution"].split("x")[0], - "resolutionHeight": project["resolution"].split("x")[1], - } - ), + "data": project_data, } }, ) From cb01f8338c3b651897d626479a1a1bd306a7e05f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20David?= Date: Tue, 1 Mar 2022 17:56:08 +0100 Subject: [PATCH 242/583] Sign in dialog, credentials as local setting and cleaning --- .../default_modules/kitsu/kitsu_module.py | 72 +++---- .../default_modules/kitsu/kitsu_widgets.py | 193 ++++++++++++++++++ .../{example_plugin.py => kitsu_plugin.py} | 8 +- .../schemas/project_schemas/main.json | 30 --- .../schemas/project_schemas/the_template.json | 30 --- .../kitsu/{ => utils}/listeners.py | 2 +- .../modules/default_modules/kitsu/widgets.py | 31 --- .../defaults/system_settings/modules.json | 4 +- .../module_settings/schema_kitsu.json | 10 - pyproject.toml | 3 +- 10 files changed, 224 insertions(+), 159 deletions(-) create mode 100644 openpype/modules/default_modules/kitsu/kitsu_widgets.py rename openpype/modules/default_modules/kitsu/plugins/publish/{example_plugin.py => kitsu_plugin.py} (85%) delete mode 100644 openpype/modules/default_modules/kitsu/settings/schemas/project_schemas/main.json delete mode 100644 openpype/modules/default_modules/kitsu/settings/schemas/project_schemas/the_template.json rename openpype/modules/default_modules/kitsu/{ => utils}/listeners.py (99%) delete mode 100644 openpype/modules/default_modules/kitsu/widgets.py diff --git a/openpype/modules/default_modules/kitsu/kitsu_module.py b/openpype/modules/default_modules/kitsu/kitsu_module.py index f62a86f04d..51ab8aaa42 100644 --- a/openpype/modules/default_modules/kitsu/kitsu_module.py +++ b/openpype/modules/default_modules/kitsu/kitsu_module.py @@ -1,11 +1,5 @@ -"""Addon definition is located here. +"""Kitsu module.""" -Import of python packages that may not be available should not be imported -in global space here until are required or used. -- Qt related imports -- imports of Python 3 packages - - we still support Python 2 hosts where addon definition should available -""" import click import os import re @@ -35,20 +29,12 @@ from openpype.modules.default_modules.kitsu.utils.openpype import ( sync_project, update_op_assets, ) +from openpype.settings.lib import get_local_settings from openpype_interfaces import IPluginPaths, ITrayAction -from .listeners import add_listeners +from .utils.listeners import start_listeners -# Settings definition of this addon using `JsonFilesSettingsDef` -# - JsonFilesSettingsDef is prepared settings definition using json files -# to define settings and store default values class AddonSettingsDef(JsonFilesSettingsDef): - # This will add prefixes to every schema and template from `schemas` - # subfolder. - # - it is not required to fill the prefix but it is highly - # recommended as schemas and templates may have name clashes across - # multiple addons - # - it is also recommended that prefix has addon name in it schema_prefix = "kitsu" def get_settings_root_path(self): @@ -61,20 +47,15 @@ class AddonSettingsDef(JsonFilesSettingsDef): class KitsuModule(OpenPypeModule, IPluginPaths, ITrayAction): - """This Addon has defined it's settings and interface. - - This example has system settings with an enabled option. And use - few other interfaces: - - `IPluginPaths` to define custom plugin paths - - `ITrayAction` to be shown in tray tool - """ + """Kitsu module class.""" label = "Kitsu" name = "kitsu" def initialize(self, settings): - """Initialization of addon.""" + """Initialization of module.""" module_settings = settings[self.name] + local_kitsu_settings = get_local_settings().get("kitsu", {}) # Enabled by settings self.enabled = module_settings.get("enabled", False) @@ -93,8 +74,8 @@ class KitsuModule(OpenPypeModule, IPluginPaths, ITrayAction): self.server_url = kitsu_url # Set credentials - self.script_login = module_settings["script_login"] - self.script_pwd = module_settings["script_pwd"] + self.kitsu_login = local_kitsu_settings["login"] + self.kitsu_password = local_kitsu_settings["password"] # Prepare variables that can be used or set afterwards self._connected_modules = None @@ -113,8 +94,8 @@ class KitsuModule(OpenPypeModule, IPluginPaths, ITrayAction): """Kitsu's global environments.""" return { "KITSU_SERVER": self.server_url, - "KITSU_LOGIN": self.script_login, - "KITSU_PWD": self.script_pwd, + "KITSU_LOGIN": self.kitsu_login, + "KITSU_PWD": self.kitsu_password, } def _create_dialog(self): @@ -122,9 +103,9 @@ class KitsuModule(OpenPypeModule, IPluginPaths, ITrayAction): if self._dialog is not None: return - from .widgets import MyExampleDialog + from .kitsu_widgets import PasswordDialog - self._dialog = MyExampleDialog() + self._dialog = PasswordDialog() def show_dialog(self): """Show dialog with connected modules. @@ -376,7 +357,10 @@ def sync_zou(): @cli_main.command() -def sync_openpype(): +@click.option( + "-l", "--listen", is_flag=True, help="Listen Kitsu server after synchronization." +) +def sync_openpype(listen: bool): """Synchronize openpype database from Zou sever database.""" # Connect to server @@ -471,27 +455,23 @@ def sync_openpype(): dbcon.uninstall() + # Run listening + if listen: + start_listeners() + @cli_main.command() def listen(): - """Show ExampleAddon dialog. - - We don't have access to addon directly through cli so we have to create - it again. - """ - add_listeners() + """Listen to Kitsu server.""" + start_listeners() @cli_main.command() -def show_dialog(): - """Show ExampleAddon dialog. - - We don't have access to addon directly through cli so we have to create - it again. - """ +def sign_in(): + """Show credentials dialog.""" from openpype.tools.utils.lib import qt_app_context manager = ModulesManager() - example_addon = manager.modules_by_name[KitsuModule.name] + kitsu_addon = manager.modules_by_name[KitsuModule.name] with qt_app_context(): - example_addon.show_dialog() + kitsu_addon.show_dialog() diff --git a/openpype/modules/default_modules/kitsu/kitsu_widgets.py b/openpype/modules/default_modules/kitsu/kitsu_widgets.py new file mode 100644 index 0000000000..6bd436f460 --- /dev/null +++ b/openpype/modules/default_modules/kitsu/kitsu_widgets.py @@ -0,0 +1,193 @@ +import os + +import gazu +from Qt import QtWidgets, QtCore, QtGui + +from openpype import style +from openpype.resources import get_resource +from openpype.settings.lib import ( + get_local_settings, + get_system_settings, + save_local_settings, +) + +from openpype.widgets.password_dialog import PressHoverButton + + +class PasswordDialog(QtWidgets.QDialog): + """Stupidly simple dialog to compare password from general settings.""" + + finished = QtCore.Signal(bool) + + def __init__(self, parent=None): + super(PasswordDialog, self).__init__(parent) + + self.setWindowTitle("Kitsu Credentials") + self.resize(300, 120) + + system_settings = get_system_settings() + kitsu_settings = get_local_settings().get("kitsu", {}) + remembered = kitsu_settings.get("remember") + + self._final_result = None + self._connectable = bool( + system_settings["modules"].get("kitsu", {}).get("server") + ) + + # Server label + server_label = QtWidgets.QLabel( + f"Server: {system_settings['modules']['kitsu']['server'] if self._connectable else 'no server url set in Studio Settings...'}", + self, + ) + + # Login input + login_widget = QtWidgets.QWidget(self) + + login_label = QtWidgets.QLabel("Login:", login_widget) + + login_input = QtWidgets.QLineEdit( + login_widget, text=kitsu_settings.get("login") if remembered else None + ) + login_input.setPlaceholderText("Your Kitsu account login...") + + login_layout = QtWidgets.QHBoxLayout(login_widget) + login_layout.setContentsMargins(0, 0, 0, 0) + login_layout.addWidget(login_label) + login_layout.addWidget(login_input) + + # Password input + password_widget = QtWidgets.QWidget(self) + + password_label = QtWidgets.QLabel("Password:", password_widget) + + password_input = QtWidgets.QLineEdit( + password_widget, text=kitsu_settings.get("password") if remembered else None + ) + password_input.setPlaceholderText("Your password...") + password_input.setEchoMode(QtWidgets.QLineEdit.Password) + + show_password_icon_path = get_resource("icons", "eye.png") + show_password_icon = QtGui.QIcon(show_password_icon_path) + show_password_btn = PressHoverButton(password_widget) + show_password_btn.setObjectName("PasswordBtn") + show_password_btn.setIcon(show_password_icon) + show_password_btn.setFocusPolicy(QtCore.Qt.ClickFocus) + + password_layout = QtWidgets.QHBoxLayout(password_widget) + password_layout.setContentsMargins(0, 0, 0, 0) + password_layout.addWidget(password_label) + password_layout.addWidget(password_input) + password_layout.addWidget(show_password_btn) + + # Message label + message_label = QtWidgets.QLabel("", self) + + # Buttons + buttons_widget = QtWidgets.QWidget(self) + + remember_checkbox = QtWidgets.QCheckBox("Remember", buttons_widget) + remember_checkbox.setObjectName("RememberCheckbox") + remember_checkbox.setChecked(remembered if remembered is not None else True) + + ok_btn = QtWidgets.QPushButton("Ok", buttons_widget) + cancel_btn = QtWidgets.QPushButton("Cancel", buttons_widget) + + buttons_layout = QtWidgets.QHBoxLayout(buttons_widget) + buttons_layout.setContentsMargins(0, 0, 0, 0) + buttons_layout.addWidget(remember_checkbox) + buttons_layout.addStretch(1) + buttons_layout.addWidget(ok_btn) + buttons_layout.addWidget(cancel_btn) + + # Main layout + layout = QtWidgets.QVBoxLayout(self) + layout.addSpacing(5) + layout.addWidget(server_label, 0) + layout.addSpacing(5) + layout.addWidget(login_widget, 0) + layout.addWidget(password_widget, 0) + layout.addWidget(message_label, 0) + layout.addStretch(1) + layout.addWidget(buttons_widget, 0) + + ok_btn.clicked.connect(self._on_ok_click) + cancel_btn.clicked.connect(self._on_cancel_click) + show_password_btn.change_state.connect(self._on_show_password) + + self.login_input = login_input + self.password_input = password_input + self.remember_checkbox = remember_checkbox + self.message_label = message_label + + self.setStyleSheet(style.load_stylesheet()) + + def result(self): + return self._final_result + + def keyPressEvent(self, event): + if event.key() in (QtCore.Qt.Key_Return, QtCore.Qt.Key_Enter): + self._on_ok_click() + return event.accept() + super(PasswordDialog, self).keyPressEvent(event) + + def closeEvent(self, event): + super(PasswordDialog, self).closeEvent(event) + self.finished.emit(self.result()) + + def _on_ok_click(self): + # Check if is connectable + if not self._connectable: + self.message_label.setText("Please set server url in Studio Settings!") + return + + # Collect values + login_value = self.login_input.text() + pwd_value = self.password_input.text() + remember = self.remember_checkbox.isChecked() + + # Connect to server + gazu.client.set_host(os.environ["KITSU_SERVER"]) + + # Authenticate + gazu.log_in(login_value, pwd_value) + + # Set logging-in env vars + os.environ["KITSU_LOGIN"] = login_value + os.environ["KITSU_PWD"] = pwd_value + + # Get settings + local_settings = get_local_settings() + local_settings.setdefault("kitsu", {}) + + # Remember password cases + if remember: + # Set local settings + local_settings["kitsu"]["login"] = login_value + local_settings["kitsu"]["password"] = pwd_value + else: + # Clear local settings + local_settings["kitsu"]["login"] = None + local_settings["kitsu"]["password"] = None + + # Clear input fields + self.login_input.clear() + self.password_input.clear() + + # Keep 'remember' parameter + local_settings["kitsu"]["remember"] = remember + + # Save settings + save_local_settings(local_settings) + + self._final_result = True + self.close() + + def _on_show_password(self, show_password): + if show_password: + echo_mode = QtWidgets.QLineEdit.Normal + else: + echo_mode = QtWidgets.QLineEdit.Password + self.password_input.setEchoMode(echo_mode) + + def _on_cancel_click(self): + self.close() diff --git a/openpype/modules/default_modules/kitsu/plugins/publish/example_plugin.py b/openpype/modules/default_modules/kitsu/plugins/publish/kitsu_plugin.py similarity index 85% rename from openpype/modules/default_modules/kitsu/plugins/publish/example_plugin.py rename to openpype/modules/default_modules/kitsu/plugins/publish/kitsu_plugin.py index 614d9ecc38..86ba40a5f4 100644 --- a/openpype/modules/default_modules/kitsu/plugins/publish/example_plugin.py +++ b/openpype/modules/default_modules/kitsu/plugins/publish/kitsu_plugin.py @@ -4,15 +4,12 @@ import gazu import pyblish.api -import debugpy - class CollectExampleAddon(pyblish.api.ContextPlugin): order = pyblish.api.CollectorOrder + 0.4 label = "Collect Kitsu" def process(self, context): - debugpy.breakpoint() self.log.info("I'm in Kitsu's plugin!") @@ -23,7 +20,6 @@ class IntegrateRig(pyblish.api.InstancePlugin): families = ["model"] def process(self, instance): - print(instance.data["version"]) # Connect to server gazu.client.set_host(os.environ["KITSU_SERVER"]) @@ -33,8 +29,6 @@ class IntegrateRig(pyblish.api.InstancePlugin): asset_data = instance.data["assetEntity"]["data"] - # TODO Set local settings for login and password - # Get task task_type = gazu.task.get_task_type_by_name(instance.data["task"]) entity_task = gazu.task.get_task_by_entity(asset_data["zou"], task_type) @@ -46,4 +40,4 @@ class IntegrateRig(pyblish.api.InstancePlugin): comment=f"Version {instance.data['version']} has been published!", ) - self.log.info("Copied successfully!") + self.log.info("Version published to Kitsu successfully!") diff --git a/openpype/modules/default_modules/kitsu/settings/schemas/project_schemas/main.json b/openpype/modules/default_modules/kitsu/settings/schemas/project_schemas/main.json deleted file mode 100644 index 82e58ce9ab..0000000000 --- a/openpype/modules/default_modules/kitsu/settings/schemas/project_schemas/main.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "type": "dict", - "key": "kitsu", - "label": " Kitsu", - "collapsible": true, - "children": [ - { - "type": "number", - "key": "number", - "label": "This is your lucky number:", - "minimum": 7, - "maximum": 7, - "decimals": 0 - }, - { - "type": "template", - "name": "kitsu/the_template", - "template_data": [ - { - "name": "color_1", - "label": "Color 1" - }, - { - "name": "color_2", - "label": "Color 2" - } - ] - } - ] -} diff --git a/openpype/modules/default_modules/kitsu/settings/schemas/project_schemas/the_template.json b/openpype/modules/default_modules/kitsu/settings/schemas/project_schemas/the_template.json deleted file mode 100644 index af8fd9dae4..0000000000 --- a/openpype/modules/default_modules/kitsu/settings/schemas/project_schemas/the_template.json +++ /dev/null @@ -1,30 +0,0 @@ -[ - { - "type": "list-strict", - "key": "{name}", - "label": "{label}", - "object_types": [ - { - "label": "Red", - "type": "number", - "minimum": 0, - "maximum": 1, - "decimal": 3 - }, - { - "label": "Green", - "type": "number", - "minimum": 0, - "maximum": 1, - "decimal": 3 - }, - { - "label": "Blue", - "type": "number", - "minimum": 0, - "maximum": 1, - "decimal": 3 - } - ] - } -] diff --git a/openpype/modules/default_modules/kitsu/listeners.py b/openpype/modules/default_modules/kitsu/utils/listeners.py similarity index 99% rename from openpype/modules/default_modules/kitsu/listeners.py rename to openpype/modules/default_modules/kitsu/utils/listeners.py index 16c4e0e69e..c99870fa36 100644 --- a/openpype/modules/default_modules/kitsu/listeners.py +++ b/openpype/modules/default_modules/kitsu/utils/listeners.py @@ -2,7 +2,7 @@ import gazu import os from avalon.api import AvalonMongoDB -from openpype.modules.default_modules.kitsu.utils.openpype import ( +from .openpype import ( create_op_asset, set_op_project, sync_project, diff --git a/openpype/modules/default_modules/kitsu/widgets.py b/openpype/modules/default_modules/kitsu/widgets.py deleted file mode 100644 index de232113fe..0000000000 --- a/openpype/modules/default_modules/kitsu/widgets.py +++ /dev/null @@ -1,31 +0,0 @@ -from Qt import QtWidgets - -from openpype.style import load_stylesheet - - -class MyExampleDialog(QtWidgets.QDialog): - def __init__(self, parent=None): - super(MyExampleDialog, self).__init__(parent) - - self.setWindowTitle("Connected modules") - - msg = "This is example dialog of Kitsu." - label_widget = QtWidgets.QLabel(msg, self) - - ok_btn = QtWidgets.QPushButton("OK", self) - btns_layout = QtWidgets.QHBoxLayout() - btns_layout.addStretch(1) - btns_layout.addWidget(ok_btn) - - layout = QtWidgets.QVBoxLayout(self) - layout.addWidget(label_widget) - layout.addLayout(btns_layout) - - ok_btn.clicked.connect(self._on_ok_clicked) - - self._label_widget = label_widget - - self.setStyleSheet(load_stylesheet()) - - def _on_ok_clicked(self): - self.done(1) diff --git a/openpype/settings/defaults/system_settings/modules.json b/openpype/settings/defaults/system_settings/modules.json index ddb2edc360..537e287366 100644 --- a/openpype/settings/defaults/system_settings/modules.json +++ b/openpype/settings/defaults/system_settings/modules.json @@ -139,9 +139,7 @@ }, "kitsu": { "enabled": false, - "server": "", - "script_login": "", - "script_pwd": "" + "server": "" }, "timers_manager": { "enabled": true, diff --git a/openpype/settings/entities/schemas/system_schema/module_settings/schema_kitsu.json b/openpype/settings/entities/schemas/system_schema/module_settings/schema_kitsu.json index ae2b52df0d..15a2ccc58d 100644 --- a/openpype/settings/entities/schemas/system_schema/module_settings/schema_kitsu.json +++ b/openpype/settings/entities/schemas/system_schema/module_settings/schema_kitsu.json @@ -16,16 +16,6 @@ "key": "server", "label": "Server" }, - { - "type": "text", - "key": "script_login", - "label": "Script Login" - }, - { - "type": "text", - "key": "script_pwd", - "label": "Script Password" - }, { "type": "splitter" } diff --git a/pyproject.toml b/pyproject.toml index f32e385e80..93caa5ca70 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,7 @@ clique = "1.6.*" Click = "^7" dnspython = "^2.1.0" ftrack-python-api = "2.0.*" +gazu = "^0.8" google-api-python-client = "^1.12.8" # sync server google support (should be separate?) jsonschema = "^2.6.0" keyring = "^22.0.1" @@ -64,7 +65,7 @@ jinxed = [ python3-xlib = { version="*", markers = "sys_platform == 'linux'"} enlighten = "^1.9.0" slack-sdk = "^3.6.0" -requests = "2.25.1" +requests = "^2.25.1" pysftp = "^0.2.9" dropbox = "^11.20.0" From 6b3987708711e72d984b39096efec9a0421ffd0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20David?= Date: Tue, 1 Mar 2022 18:11:42 +0100 Subject: [PATCH 243/583] Moved to modules --- openpype/modules/{default_modules => }/kitsu/__init__.py | 0 openpype/modules/{default_modules => }/kitsu/kitsu_module.py | 2 +- openpype/modules/{default_modules => }/kitsu/kitsu_widgets.py | 0 .../kitsu/plugins/publish/kitsu_plugin.py | 0 openpype/modules/{default_modules => }/kitsu/utils/__init__.py | 0 .../modules/{default_modules => }/kitsu/utils/listeners.py | 3 ++- openpype/modules/{default_modules => }/kitsu/utils/openpype.py | 0 7 files changed, 3 insertions(+), 2 deletions(-) rename openpype/modules/{default_modules => }/kitsu/__init__.py (100%) rename openpype/modules/{default_modules => }/kitsu/kitsu_module.py (99%) rename openpype/modules/{default_modules => }/kitsu/kitsu_widgets.py (100%) rename openpype/modules/{default_modules => }/kitsu/plugins/publish/kitsu_plugin.py (100%) rename openpype/modules/{default_modules => }/kitsu/utils/__init__.py (100%) rename openpype/modules/{default_modules => }/kitsu/utils/listeners.py (99%) rename openpype/modules/{default_modules => }/kitsu/utils/openpype.py (100%) diff --git a/openpype/modules/default_modules/kitsu/__init__.py b/openpype/modules/kitsu/__init__.py similarity index 100% rename from openpype/modules/default_modules/kitsu/__init__.py rename to openpype/modules/kitsu/__init__.py diff --git a/openpype/modules/default_modules/kitsu/kitsu_module.py b/openpype/modules/kitsu/kitsu_module.py similarity index 99% rename from openpype/modules/default_modules/kitsu/kitsu_module.py rename to openpype/modules/kitsu/kitsu_module.py index 51ab8aaa42..a17b509047 100644 --- a/openpype/modules/default_modules/kitsu/kitsu_module.py +++ b/openpype/modules/kitsu/kitsu_module.py @@ -24,7 +24,7 @@ from pymongo import DeleteOne, UpdateOne from avalon.api import AvalonMongoDB from openpype.api import get_project_settings from openpype.modules import JsonFilesSettingsDef, OpenPypeModule, ModulesManager -from openpype.modules.default_modules.kitsu.utils.openpype import ( +from openpype.modules.kitsu.utils.openpype import ( create_op_asset, sync_project, update_op_assets, diff --git a/openpype/modules/default_modules/kitsu/kitsu_widgets.py b/openpype/modules/kitsu/kitsu_widgets.py similarity index 100% rename from openpype/modules/default_modules/kitsu/kitsu_widgets.py rename to openpype/modules/kitsu/kitsu_widgets.py diff --git a/openpype/modules/default_modules/kitsu/plugins/publish/kitsu_plugin.py b/openpype/modules/kitsu/plugins/publish/kitsu_plugin.py similarity index 100% rename from openpype/modules/default_modules/kitsu/plugins/publish/kitsu_plugin.py rename to openpype/modules/kitsu/plugins/publish/kitsu_plugin.py diff --git a/openpype/modules/default_modules/kitsu/utils/__init__.py b/openpype/modules/kitsu/utils/__init__.py similarity index 100% rename from openpype/modules/default_modules/kitsu/utils/__init__.py rename to openpype/modules/kitsu/utils/__init__.py diff --git a/openpype/modules/default_modules/kitsu/utils/listeners.py b/openpype/modules/kitsu/utils/listeners.py similarity index 99% rename from openpype/modules/default_modules/kitsu/utils/listeners.py rename to openpype/modules/kitsu/utils/listeners.py index c99870fa36..961aa1691b 100644 --- a/openpype/modules/default_modules/kitsu/utils/listeners.py +++ b/openpype/modules/kitsu/utils/listeners.py @@ -10,7 +10,8 @@ from .openpype import ( ) -def add_listeners(): +def start_listeners(): + """Start listeners to keep OpenPype up-to-date with Kitsu.""" # Connect to server gazu.client.set_host(os.environ["KITSU_SERVER"]) diff --git a/openpype/modules/default_modules/kitsu/utils/openpype.py b/openpype/modules/kitsu/utils/openpype.py similarity index 100% rename from openpype/modules/default_modules/kitsu/utils/openpype.py rename to openpype/modules/kitsu/utils/openpype.py From a33b3d3b5cdfa37204da8570d175997be3544001 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20David?= Date: Wed, 2 Mar 2022 09:30:33 +0100 Subject: [PATCH 244/583] Remove AddonSettingsDef --- openpype/modules/kitsu/__init__.py | 4 ++-- openpype/modules/kitsu/kitsu_module.py | 12 ------------ 2 files changed, 2 insertions(+), 14 deletions(-) diff --git a/openpype/modules/kitsu/__init__.py b/openpype/modules/kitsu/__init__.py index dc7c2dad50..6cb62bbb15 100644 --- a/openpype/modules/kitsu/__init__.py +++ b/openpype/modules/kitsu/__init__.py @@ -4,6 +4,6 @@ If addon class or settings definition won't be here their definition won't be found by OpenPype discovery. """ -from .kitsu_module import AddonSettingsDef, KitsuModule +from .kitsu_module import KitsuModule -__all__ = ("AddonSettingsDef", "KitsuModule") +__all__ = "KitsuModule" diff --git a/openpype/modules/kitsu/kitsu_module.py b/openpype/modules/kitsu/kitsu_module.py index a17b509047..9efcac9714 100644 --- a/openpype/modules/kitsu/kitsu_module.py +++ b/openpype/modules/kitsu/kitsu_module.py @@ -34,18 +34,6 @@ from openpype_interfaces import IPluginPaths, ITrayAction from .utils.listeners import start_listeners -class AddonSettingsDef(JsonFilesSettingsDef): - schema_prefix = "kitsu" - - def get_settings_root_path(self): - """Implemented abstract class of JsonFilesSettingsDef. - - Return directory path where json files defying addon settings are - located. - """ - return os.path.join(os.path.dirname(os.path.abspath(__file__)), "settings") - - class KitsuModule(OpenPypeModule, IPluginPaths, ITrayAction): """Kitsu module class.""" From 4907ce1362602efef2692253d9b93fbc79d2e522 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20David?= Date: Wed, 2 Mar 2022 09:31:02 +0100 Subject: [PATCH 245/583] Moved up module 'base' changes --- openpype/modules/base.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/modules/base.py b/openpype/modules/base.py index 0dd512ee8b..d77189be6c 100644 --- a/openpype/modules/base.py +++ b/openpype/modules/base.py @@ -860,6 +860,7 @@ class TrayModulesManager(ModulesManager): modules_menu_order = ( "user", "ftrack", + "kitsu", "muster", "launcher_tool", "avalon", From a15c4d2895a5eaf433ec9d3912200a271cac16a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20David?= Date: Wed, 2 Mar 2022 09:48:39 +0100 Subject: [PATCH 246/583] import gazu only at start --- openpype/modules/kitsu/kitsu_module.py | 63 ++++++++++------------- openpype/modules/kitsu/utils/listeners.py | 3 +- openpype/modules/kitsu/utils/openpype.py | 13 ++--- 3 files changed, 35 insertions(+), 44 deletions(-) diff --git a/openpype/modules/kitsu/kitsu_module.py b/openpype/modules/kitsu/kitsu_module.py index 9efcac9714..502ed8ff96 100644 --- a/openpype/modules/kitsu/kitsu_module.py +++ b/openpype/modules/kitsu/kitsu_module.py @@ -4,34 +4,19 @@ import click import os import re -import gazu -from gazu.asset import all_assets_for_project, all_asset_types, new_asset -from gazu.shot import ( - all_episodes_for_project, - all_sequences_for_project, - all_shots_for_project, - new_episode, - new_sequence, - new_shot, -) -from gazu.task import ( - all_task_types, - new_task, - new_task_type, -) from pymongo import DeleteOne, UpdateOne from avalon.api import AvalonMongoDB from openpype.api import get_project_settings -from openpype.modules import JsonFilesSettingsDef, OpenPypeModule, ModulesManager -from openpype.modules.kitsu.utils.openpype import ( +from openpype.modules import OpenPypeModule, ModulesManager +from openpype.settings.lib import get_local_settings +from openpype_interfaces import IPluginPaths, ITrayAction +from .utils.listeners import start_listeners +from .utils.openpype import ( create_op_asset, sync_project, update_op_assets, ) -from openpype.settings.lib import get_local_settings -from openpype_interfaces import IPluginPaths, ITrayAction -from .utils.listeners import start_listeners class KitsuModule(OpenPypeModule, IPluginPaths, ITrayAction): @@ -137,6 +122,7 @@ def cli_main(): @cli_main.command() def sync_zou(): """Synchronize Zou server database (Kitsu backend) with openpype database.""" + import gazu # Connect to server gazu.client.set_host(os.environ["KITSU_SERVER"]) @@ -178,11 +164,11 @@ def sync_zou(): gazu.project.update_project_data(zou_project, data=op_project["data"]) gazu.project.update_project(zou_project) - asset_types = all_asset_types() - all_assets = all_assets_for_project(zou_project) - all_episodes = all_episodes_for_project(zou_project) - all_seqs = all_sequences_for_project(zou_project) - all_shots = all_shots_for_project(zou_project) + asset_types = gazu.asset.all_asset_types() + all_assets = gazu.asset.all_assets_for_project(zou_project) + all_episodes = gazu.shot.all_episodes_for_project(zou_project) + all_seqs = gazu.shot.all_sequences_for_project(zou_project) + all_shots = gazu.shot.all_shots_for_project(zou_project) all_entities_ids = { e["id"] for e in all_episodes + all_seqs + all_shots + all_assets } @@ -217,7 +203,9 @@ def sync_zou(): # Match asset type by it's name match = regex_ep.match(doc["name"]) if not match: # Asset - new_entity = new_asset(zou_project, asset_types[0], doc["name"]) + new_entity = gazu.asset.new_asset( + zou_project, asset_types[0], doc["name"] + ) # Match case in shot Collection: :param dbcon: Connection to DB. :param project_id: Project zou ID """ + import gazu + project = gazu.project.get_project(project_id) project_name = project["name"] dbcon.Session["AVALON_PROJECT"] = project_name @@ -49,6 +45,11 @@ def update_op_assets( :param asset_doc_ids: Dicts of [{zou_id: asset_doc}, ...] :return: List of (doc_id, update_dict) tuples """ + from gazu.task import ( + all_tasks_for_asset, + all_tasks_for_shot, + ) + assets_with_update = [] for item in entities_list: # Update asset From 44100b0da7d30bc34af0635b5e577e2a6df03c01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20David?= Date: Wed, 2 Mar 2022 09:52:26 +0100 Subject: [PATCH 247/583] line length to 79 --- openpype/modules/kitsu/kitsu_module.py | 33 ++++++++++++++----- openpype/modules/kitsu/kitsu_widgets.py | 14 +++++--- .../kitsu/plugins/publish/kitsu_plugin.py | 4 ++- openpype/modules/kitsu/utils/listeners.py | 29 +++++++++++----- openpype/modules/kitsu/utils/openpype.py | 7 ++-- 5 files changed, 63 insertions(+), 24 deletions(-) diff --git a/openpype/modules/kitsu/kitsu_module.py b/openpype/modules/kitsu/kitsu_module.py index 502ed8ff96..a7b3b17eb5 100644 --- a/openpype/modules/kitsu/kitsu_module.py +++ b/openpype/modules/kitsu/kitsu_module.py @@ -42,7 +42,9 @@ class KitsuModule(OpenPypeModule, IPluginPaths, ITrayAction): # Check for "/api" url validity if not kitsu_url.endswith("api"): - kitsu_url = f"{kitsu_url}{'' if kitsu_url.endswith('/') else '/'}api" + kitsu_url = ( + f"{kitsu_url}{'' if kitsu_url.endswith('/') else '/'}api" + ) self.server_url = kitsu_url @@ -161,7 +163,9 @@ def sync_zou(): "resolution": f"{op_project['data']['resolutionWidth']}x{op_project['data']['resolutionHeight']}", } ) - gazu.project.update_project_data(zou_project, data=op_project["data"]) + gazu.project.update_project_data( + zou_project, data=op_project["data"] + ) gazu.project.update_project(zou_project) asset_types = gazu.asset.all_asset_types() @@ -227,7 +231,9 @@ def sync_zou(): # Create new sequence and set it as substitute created_sequence = gazu.shot.new_sequence( - zou_project, substitute_sequence_name, episode=zou_parent_id + zou_project, + substitute_sequence_name, + episode=zou_parent_id, ) gazu.shot.update_sequence_data( created_sequence, {"is_substitute": True} @@ -244,13 +250,16 @@ def sync_zou(): doc["name"], frame_in=doc["data"]["frameStart"], frame_out=doc["data"]["frameEnd"], - nb_frames=doc["data"]["frameEnd"] - doc["data"]["frameStart"], + nb_frames=doc["data"]["frameEnd"] + - doc["data"]["frameStart"], ) elif match.group(2): # Sequence parent_doc = asset_docs[visual_parent_id] new_entity = gazu.shot.new_sequence( - zou_project, doc["name"], episode=parent_doc["data"]["zou"]["id"] + zou_project, + doc["name"], + episode=parent_doc["data"]["zou"]["id"], ) elif match.group(3): # Episode @@ -293,7 +302,10 @@ def sync_zou(): if frame_in or frame_out: entity_data.update( { - "data": {"frame_in": frame_in, "frame_out": frame_out}, + "data": { + "frame_in": frame_in, + "frame_out": frame_out, + }, "nb_frames": frame_out - frame_in, } ) @@ -334,7 +346,10 @@ def sync_zou(): @cli_main.command() @click.option( - "-l", "--listen", is_flag=True, help="Listen Kitsu server after synchronization." + "-l", + "--listen", + is_flag=True, + help="Listen Kitsu server after synchronization.", ) def sync_openpype(listen: bool): """Synchronize openpype database from Zou sever database.""" @@ -410,7 +425,9 @@ def sync_openpype(listen: bool): bulk_writes.extend( [ UpdateOne({"_id": id}, update) - for id, update in update_op_assets(all_entities, zou_ids_and_asset_docs) + for id, update in update_op_assets( + all_entities, zou_ids_and_asset_docs + ) ] ) diff --git a/openpype/modules/kitsu/kitsu_widgets.py b/openpype/modules/kitsu/kitsu_widgets.py index 6bd436f460..1a32182795 100644 --- a/openpype/modules/kitsu/kitsu_widgets.py +++ b/openpype/modules/kitsu/kitsu_widgets.py @@ -46,7 +46,8 @@ class PasswordDialog(QtWidgets.QDialog): login_label = QtWidgets.QLabel("Login:", login_widget) login_input = QtWidgets.QLineEdit( - login_widget, text=kitsu_settings.get("login") if remembered else None + login_widget, + text=kitsu_settings.get("login") if remembered else None, ) login_input.setPlaceholderText("Your Kitsu account login...") @@ -61,7 +62,8 @@ class PasswordDialog(QtWidgets.QDialog): password_label = QtWidgets.QLabel("Password:", password_widget) password_input = QtWidgets.QLineEdit( - password_widget, text=kitsu_settings.get("password") if remembered else None + password_widget, + text=kitsu_settings.get("password") if remembered else None, ) password_input.setPlaceholderText("Your password...") password_input.setEchoMode(QtWidgets.QLineEdit.Password) @@ -87,7 +89,9 @@ class PasswordDialog(QtWidgets.QDialog): remember_checkbox = QtWidgets.QCheckBox("Remember", buttons_widget) remember_checkbox.setObjectName("RememberCheckbox") - remember_checkbox.setChecked(remembered if remembered is not None else True) + remember_checkbox.setChecked( + remembered if remembered is not None else True + ) ok_btn = QtWidgets.QPushButton("Ok", buttons_widget) cancel_btn = QtWidgets.QPushButton("Cancel", buttons_widget) @@ -137,7 +141,9 @@ class PasswordDialog(QtWidgets.QDialog): def _on_ok_click(self): # Check if is connectable if not self._connectable: - self.message_label.setText("Please set server url in Studio Settings!") + self.message_label.setText( + "Please set server url in Studio Settings!" + ) return # Collect values diff --git a/openpype/modules/kitsu/plugins/publish/kitsu_plugin.py b/openpype/modules/kitsu/plugins/publish/kitsu_plugin.py index 86ba40a5f4..24f1e4e80c 100644 --- a/openpype/modules/kitsu/plugins/publish/kitsu_plugin.py +++ b/openpype/modules/kitsu/plugins/publish/kitsu_plugin.py @@ -31,7 +31,9 @@ class IntegrateRig(pyblish.api.InstancePlugin): # Get task task_type = gazu.task.get_task_type_by_name(instance.data["task"]) - entity_task = gazu.task.get_task_by_entity(asset_data["zou"], task_type) + entity_task = gazu.task.get_task_by_entity( + asset_data["zou"], task_type + ) # Comment entity gazu.task.add_comment( diff --git a/openpype/modules/kitsu/utils/listeners.py b/openpype/modules/kitsu/utils/listeners.py index 18f67b13e3..3768b4e8e6 100644 --- a/openpype/modules/kitsu/utils/listeners.py +++ b/openpype/modules/kitsu/utils/listeners.py @@ -95,9 +95,9 @@ def start_listeners(): zou_ids_and_asset_docs[asset["project_id"]] = project_doc # Update - asset_doc_id, asset_update = update_op_assets([asset], zou_ids_and_asset_docs)[ - 0 - ] + asset_doc_id, asset_update = update_op_assets( + [asset], zou_ids_and_asset_docs + )[0] project_col.update_one({"_id": asset_doc_id}, asset_update) def delete_asset(data): @@ -105,7 +105,9 @@ def start_listeners(): project_col = set_op_project(dbcon, data["project_id"]) # Delete - project_col.delete_one({"type": "asset", "data.zou.id": data["asset_id"]}) + project_col.delete_one( + {"type": "asset", "data.zou.id": data["asset_id"]} + ) gazu.events.add_listener(event_client, "asset:new", new_asset) gazu.events.add_listener(event_client, "asset:update", update_asset) @@ -155,7 +157,9 @@ def start_listeners(): print("delete episode") # TODO check bugfix # Delete - project_col.delete_one({"type": "asset", "data.zou.id": data["episode_id"]}) + project_col.delete_one( + {"type": "asset", "data.zou.id": data["episode_id"]} + ) gazu.events.add_listener(event_client, "episode:new", new_episode) gazu.events.add_listener(event_client, "episode:update", update_episode) @@ -205,7 +209,9 @@ def start_listeners(): print("delete sequence") # TODO check bugfix # Delete - project_col.delete_one({"type": "asset", "data.zou.id": data["sequence_id"]}) + project_col.delete_one( + {"type": "asset", "data.zou.id": data["sequence_id"]} + ) gazu.events.add_listener(event_client, "sequence:new", new_sequence) gazu.events.add_listener(event_client, "sequence:update", update_sequence) @@ -244,7 +250,9 @@ def start_listeners(): zou_ids_and_asset_docs[shot["project_id"]] = project_doc # Update - asset_doc_id, asset_update = update_op_assets([shot], zou_ids_and_asset_docs)[0] + asset_doc_id, asset_update = update_op_assets( + [shot], zou_ids_and_asset_docs + )[0] project_col.update_one({"_id": asset_doc_id}, asset_update) def delete_shot(data): @@ -252,7 +260,9 @@ def start_listeners(): project_col = set_op_project(dbcon, data["project_id"]) # Delete - project_col.delete_one({"type": "asset", "data.zou.id": data["shot_id"]}) + project_col.delete_one( + {"type": "asset", "data.zou.id": data["shot_id"]} + ) gazu.events.add_listener(event_client, "shot:new", new_shot) gazu.events.add_listener(event_client, "shot:update", update_shot) @@ -302,7 +312,8 @@ def start_listeners(): # Delete task in DB project_col.update_one( - {"_id": doc["_id"]}, {"$set": {"data.tasks": asset_tasks}} + {"_id": doc["_id"]}, + {"$set": {"data.tasks": asset_tasks}}, ) return diff --git a/openpype/modules/kitsu/utils/openpype.py b/openpype/modules/kitsu/utils/openpype.py index 2443323893..8aabba6de0 100644 --- a/openpype/modules/kitsu/utils/openpype.py +++ b/openpype/modules/kitsu/utils/openpype.py @@ -65,7 +65,8 @@ def update_op_assets( tasks_list = all_tasks_for_shot(item) # TODO frame in and out item_data["tasks"] = { - t["task_type_name"]: {"type": t["task_type_name"]} for t in tasks_list + t["task_type_name"]: {"type": t["task_type_name"]} + for t in tasks_list } # Get zou parent id for correct hierarchy @@ -79,7 +80,9 @@ def update_op_assets( parent_zou_id = substitute_parent_item["parent_id"] else: parent_zou_id = ( - item.get("parent_id") or item.get("episode_id") or item.get("source_id") + item.get("parent_id") + or item.get("episode_id") + or item.get("source_id") ) # TODO check consistency # Visual parent for hierarchy From c26c2f09a8f271419e76cd7407f35b19d579d851 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20David?= Date: Wed, 2 Mar 2022 09:54:47 +0100 Subject: [PATCH 248/583] fix import --- openpype/modules/kitsu/utils/openpype.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openpype/modules/kitsu/utils/openpype.py b/openpype/modules/kitsu/utils/openpype.py index 8aabba6de0..56c99effff 100644 --- a/openpype/modules/kitsu/utils/openpype.py +++ b/openpype/modules/kitsu/utils/openpype.py @@ -132,6 +132,8 @@ def sync_project(project: dict, dbcon: AvalonMongoDB) -> UpdateOne: :param dbcon: DB to create project in :return: Update instance for the project """ + import gazu + project_name = project["name"] project_doc = dbcon.find_one({"type": "project"}) if not project_doc: From e8831947e6c2de902470e0fffdb69cad6429ce32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20David?= Date: Wed, 2 Mar 2022 09:57:00 +0100 Subject: [PATCH 249/583] Fix __all__ --- openpype/modules/kitsu/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/kitsu/__init__.py b/openpype/modules/kitsu/__init__.py index 6cb62bbb15..9737a054f6 100644 --- a/openpype/modules/kitsu/__init__.py +++ b/openpype/modules/kitsu/__init__.py @@ -6,4 +6,4 @@ be found by OpenPype discovery. from .kitsu_module import KitsuModule -__all__ = "KitsuModule" +__all__ = ("KitsuModule", ) From cde925c09b06130f6b4e1f36d993d21aba0fd7bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20David?= Date: Wed, 2 Mar 2022 11:38:47 +0100 Subject: [PATCH 250/583] Use OpenPypeSecureRegistry for authentication --- openpype/modules/kitsu/__init__.py | 2 +- openpype/modules/kitsu/kitsu_module.py | 22 ++++++++++++++--- openpype/modules/kitsu/kitsu_widgets.py | 33 +++++++++++-------------- 3 files changed, 33 insertions(+), 24 deletions(-) diff --git a/openpype/modules/kitsu/__init__.py b/openpype/modules/kitsu/__init__.py index 9737a054f6..9220cb1762 100644 --- a/openpype/modules/kitsu/__init__.py +++ b/openpype/modules/kitsu/__init__.py @@ -6,4 +6,4 @@ be found by OpenPype discovery. from .kitsu_module import KitsuModule -__all__ = ("KitsuModule", ) +__all__ = ("KitsuModule",) diff --git a/openpype/modules/kitsu/kitsu_module.py b/openpype/modules/kitsu/kitsu_module.py index a7b3b17eb5..ebfa0dbeea 100644 --- a/openpype/modules/kitsu/kitsu_module.py +++ b/openpype/modules/kitsu/kitsu_module.py @@ -8,6 +8,7 @@ from pymongo import DeleteOne, UpdateOne from avalon.api import AvalonMongoDB from openpype.api import get_project_settings +from openpype.lib.local_settings import OpenPypeSecureRegistry from openpype.modules import OpenPypeModule, ModulesManager from openpype.settings.lib import get_local_settings from openpype_interfaces import IPluginPaths, ITrayAction @@ -28,7 +29,9 @@ class KitsuModule(OpenPypeModule, IPluginPaths, ITrayAction): def initialize(self, settings): """Initialization of module.""" module_settings = settings[self.name] - local_kitsu_settings = get_local_settings().get("kitsu", {}) + + # Get user registry + user_registry = OpenPypeSecureRegistry("kitsu_user") # Enabled by settings self.enabled = module_settings.get("enabled", False) @@ -49,8 +52,8 @@ class KitsuModule(OpenPypeModule, IPluginPaths, ITrayAction): self.server_url = kitsu_url # Set credentials - self.kitsu_login = local_kitsu_settings["login"] - self.kitsu_password = local_kitsu_settings["password"] + self.kitsu_login = user_registry.get_item("login", None) + self.kitsu_password = user_registry.get_item("password", None) # Prepare variables that can be used or set afterwards self._connected_modules = None @@ -359,7 +362,13 @@ def sync_openpype(listen: bool): gazu.client.set_host(os.environ["KITSU_SERVER"]) # Authenticate - gazu.log_in(os.environ["KITSU_LOGIN"], os.environ["KITSU_PWD"]) + kitsu_login = os.environ.get("KITSU_LOGIN") + kitsu_pwd = os.environ.get("KITSU_PWD") + if not kitsu_login or not kitsu_pwd: # Sentinel to log-in + log_in_dialog() + return + + gazu.log_in(kitsu_login, kitsu_pwd) # Iterate projects dbcon = AvalonMongoDB() @@ -462,6 +471,11 @@ def listen(): @cli_main.command() def sign_in(): + """Sign-in command.""" + log_in_dialog() + + +def log_in_dialog(): """Show credentials dialog.""" from openpype.tools.utils.lib import qt_app_context diff --git a/openpype/modules/kitsu/kitsu_widgets.py b/openpype/modules/kitsu/kitsu_widgets.py index 1a32182795..1a48e6dbc0 100644 --- a/openpype/modules/kitsu/kitsu_widgets.py +++ b/openpype/modules/kitsu/kitsu_widgets.py @@ -4,11 +4,10 @@ import gazu from Qt import QtWidgets, QtCore, QtGui from openpype import style +from openpype.lib.local_settings import OpenPypeSecureRegistry from openpype.resources import get_resource from openpype.settings.lib import ( - get_local_settings, get_system_settings, - save_local_settings, ) from openpype.widgets.password_dialog import PressHoverButton @@ -26,8 +25,11 @@ class PasswordDialog(QtWidgets.QDialog): self.resize(300, 120) system_settings = get_system_settings() - kitsu_settings = get_local_settings().get("kitsu", {}) - remembered = kitsu_settings.get("remember") + user_registry = OpenPypeSecureRegistry("kitsu_user") + remembered = bool( + user_registry.get_item("login", None) + or user_registry.get_item("password", None) + ) self._final_result = None self._connectable = bool( @@ -47,7 +49,7 @@ class PasswordDialog(QtWidgets.QDialog): login_input = QtWidgets.QLineEdit( login_widget, - text=kitsu_settings.get("login") if remembered else None, + text=user_registry.get_item("login") if remembered else None, ) login_input.setPlaceholderText("Your Kitsu account login...") @@ -63,7 +65,7 @@ class PasswordDialog(QtWidgets.QDialog): password_input = QtWidgets.QLineEdit( password_widget, - text=kitsu_settings.get("password") if remembered else None, + text=user_registry.get_item("password") if remembered else None, ) password_input.setPlaceholderText("Your password...") password_input.setEchoMode(QtWidgets.QLineEdit.Password) @@ -161,30 +163,23 @@ class PasswordDialog(QtWidgets.QDialog): os.environ["KITSU_LOGIN"] = login_value os.environ["KITSU_PWD"] = pwd_value - # Get settings - local_settings = get_local_settings() - local_settings.setdefault("kitsu", {}) + # Get user registry + user_registry = OpenPypeSecureRegistry("kitsu_user") # Remember password cases if remember: # Set local settings - local_settings["kitsu"]["login"] = login_value - local_settings["kitsu"]["password"] = pwd_value + user_registry.set_item("login", login_value) + user_registry.set_item("password", pwd_value) else: # Clear local settings - local_settings["kitsu"]["login"] = None - local_settings["kitsu"]["password"] = None + user_registry.delete_item("login") + user_registry.delete_item("password") # Clear input fields self.login_input.clear() self.password_input.clear() - # Keep 'remember' parameter - local_settings["kitsu"]["remember"] = remember - - # Save settings - save_local_settings(local_settings) - self._final_result = True self.close() From c9ea0b3bb262484b43ebd5c1c5d649dcdaf75d7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20David?= Date: Wed, 2 Mar 2022 11:49:49 +0100 Subject: [PATCH 251/583] Line length max 79 --- openpype/modules/kitsu/kitsu_module.py | 28 +++++++++++++++++-------- openpype/modules/kitsu/kitsu_widgets.py | 7 ++++++- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/openpype/modules/kitsu/kitsu_module.py b/openpype/modules/kitsu/kitsu_module.py index ebfa0dbeea..6a2e517832 100644 --- a/openpype/modules/kitsu/kitsu_module.py +++ b/openpype/modules/kitsu/kitsu_module.py @@ -10,7 +10,6 @@ from avalon.api import AvalonMongoDB from openpype.api import get_project_settings from openpype.lib.local_settings import OpenPypeSecureRegistry from openpype.modules import OpenPypeModule, ModulesManager -from openpype.settings.lib import get_local_settings from openpype_interfaces import IPluginPaths, ITrayAction from .utils.listeners import start_listeners from .utils.openpype import ( @@ -126,7 +125,7 @@ def cli_main(): @cli_main.command() def sync_zou(): - """Synchronize Zou server database (Kitsu backend) with openpype database.""" + """Synchronize Zou database (Kitsu backend) with openpype database.""" import gazu # Connect to server @@ -154,7 +153,9 @@ def sync_zou(): # Create project if zou_project is None: raise RuntimeError( - f"Project '{project_name}' doesn't exist in Zou database, please create it in Kitsu and add OpenPype user to it before running synchronization." + f"Project '{project_name}' doesn't exist in Zou database, " + "please create it in Kitsu and add OpenPype user to it before " + "running synchronization." ) # Update project settings and data @@ -163,7 +164,8 @@ def sync_zou(): { "code": op_project["data"]["code"], "fps": op_project["data"]["fps"], - "resolution": f"{op_project['data']['resolutionWidth']}x{op_project['data']['resolutionHeight']}", + "resolution": f"{op_project['data']['resolutionWidth']}" + f"x{op_project['data']['resolutionHeight']}", } ) gazu.project.update_project_data( @@ -213,7 +215,8 @@ def sync_zou(): new_entity = gazu.asset.new_asset( zou_project, asset_types[0], doc["name"] ) - # Match case in shot Date: Wed, 2 Mar 2022 12:11:05 +0100 Subject: [PATCH 252/583] Rename module to Kitsu Connect --- openpype/modules/kitsu/kitsu_module.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/kitsu/kitsu_module.py b/openpype/modules/kitsu/kitsu_module.py index 6a2e517832..d5e744ceb5 100644 --- a/openpype/modules/kitsu/kitsu_module.py +++ b/openpype/modules/kitsu/kitsu_module.py @@ -22,7 +22,7 @@ from .utils.openpype import ( class KitsuModule(OpenPypeModule, IPluginPaths, ITrayAction): """Kitsu module class.""" - label = "Kitsu" + label = "Kitsu Connect" name = "kitsu" def initialize(self, settings): From c39bdee4330b1578cfe821d0506dbc7b59373621 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20David?= Date: Thu, 3 Mar 2022 15:50:35 +0100 Subject: [PATCH 253/583] Refactor for login system following recommendations. --- openpype/modules/kitsu/kitsu_module.py | 457 ++---------------- openpype/modules/kitsu/kitsu_widgets.py | 59 +-- .../kitsu/plugins/publish/kitsu_plugin.py | 2 +- openpype/modules/kitsu/utils/credentials.py | 92 ++++ .../utils/{listeners.py => sync_service.py} | 238 +++++---- .../{openpype.py => update_op_with_zou.py} | 153 +++++- .../modules/kitsu/utils/update_zou_with_op.py | 262 ++++++++++ 7 files changed, 717 insertions(+), 546 deletions(-) create mode 100644 openpype/modules/kitsu/utils/credentials.py rename openpype/modules/kitsu/utils/{listeners.py => sync_service.py} (54%) rename openpype/modules/kitsu/utils/{openpype.py => update_op_with_zou.py} (52%) create mode 100644 openpype/modules/kitsu/utils/update_zou_with_op.py diff --git a/openpype/modules/kitsu/kitsu_module.py b/openpype/modules/kitsu/kitsu_module.py index d5e744ceb5..dca6133e88 100644 --- a/openpype/modules/kitsu/kitsu_module.py +++ b/openpype/modules/kitsu/kitsu_module.py @@ -2,21 +2,9 @@ import click import os -import re -from pymongo import DeleteOne, UpdateOne - -from avalon.api import AvalonMongoDB -from openpype.api import get_project_settings -from openpype.lib.local_settings import OpenPypeSecureRegistry -from openpype.modules import OpenPypeModule, ModulesManager +from openpype.modules import OpenPypeModule from openpype_interfaces import IPluginPaths, ITrayAction -from .utils.listeners import start_listeners -from .utils.openpype import ( - create_op_asset, - sync_project, - update_op_assets, -) class KitsuModule(OpenPypeModule, IPluginPaths, ITrayAction): @@ -29,9 +17,6 @@ class KitsuModule(OpenPypeModule, IPluginPaths, ITrayAction): """Initialization of module.""" module_settings = settings[self.name] - # Get user registry - user_registry = OpenPypeSecureRegistry("kitsu_user") - # Enabled by settings self.enabled = module_settings.get("enabled", False) @@ -44,66 +29,58 @@ class KitsuModule(OpenPypeModule, IPluginPaths, ITrayAction): # Check for "/api" url validity if not kitsu_url.endswith("api"): - kitsu_url = ( - f"{kitsu_url}{'' if kitsu_url.endswith('/') else '/'}api" + kitsu_url = "{}{}api".format( + kitsu_url, "" if kitsu_url.endswith("/") else "/" ) self.server_url = kitsu_url - # Set credentials - self.kitsu_login = user_registry.get_item("login", None) - self.kitsu_password = user_registry.get_item("password", None) - - # Prepare variables that can be used or set afterwards - self._connected_modules = None # UI which must not be created at this time self._dialog = None def tray_init(self): - """Implementation of abstract method for `ITrayAction`. - - We're definitely in tray tool so we can pre create dialog. - """ + """Tray init.""" self._create_dialog() + def tray_start(self): + """Tray start.""" + from .utils.credentials import ( + load_credentials, + validate_credentials, + set_credentials_envs, + ) + + username, password = load_credentials() + + # Check credentials, ask them if needed + if validate_credentials(username, password): + set_credentials_envs(username, password) + else: + self.show_dialog() + def get_global_environments(self): """Kitsu's global environments.""" - return { - "KITSU_SERVER": self.server_url, - "KITSU_LOGIN": self.kitsu_login, - "KITSU_PWD": self.kitsu_password, - } + return {"KITSU_SERVER": self.server_url} def _create_dialog(self): # Don't recreate dialog if already exists if self._dialog is not None: return - from .kitsu_widgets import PasswordDialog + from .kitsu_widgets import KitsuPasswordDialog - self._dialog = PasswordDialog() + self._dialog = KitsuPasswordDialog() def show_dialog(self): - """Show dialog with connected modules. + """Show dialog to log-in.""" - This can be called from anywhere but can also crash in headless mode. - There is no way to prevent addon to do invalid operations if he's - not handling them. - """ # Make sure dialog is created self._create_dialog() + # Show dialog self._dialog.open() - def get_connected_modules(self): - """Custom implementation of addon.""" - names = set() - if self._connected_modules is not None: - for module in self._connected_modules: - names.add(module.name) - return names - def on_action_trigger(self): """Implementation of abstract method for `ITrayAction`.""" self.show_dialog() @@ -124,372 +101,36 @@ def cli_main(): @cli_main.command() -def sync_zou(): - """Synchronize Zou database (Kitsu backend) with openpype database.""" - import gazu - - # Connect to server - gazu.client.set_host(os.environ["KITSU_SERVER"]) - - # Authenticate - gazu.log_in(os.environ["KITSU_LOGIN"], os.environ["KITSU_PWD"]) - - # Iterate projects - dbcon = AvalonMongoDB() - dbcon.install() - - op_projects = [p for p in dbcon.projects()] - bulk_writes = [] - for op_project in op_projects: - # Create project locally - # Try to find project document - project_name = op_project["name"] - dbcon.Session["AVALON_PROJECT"] = project_name - - # Get all entities from zou - print(f"Synchronizing {project_name}...") - zou_project = gazu.project.get_project_by_name(project_name) - - # Create project - if zou_project is None: - raise RuntimeError( - f"Project '{project_name}' doesn't exist in Zou database, " - "please create it in Kitsu and add OpenPype user to it before " - "running synchronization." - ) - - # Update project settings and data - if op_project["data"]: - zou_project.update( - { - "code": op_project["data"]["code"], - "fps": op_project["data"]["fps"], - "resolution": f"{op_project['data']['resolutionWidth']}" - f"x{op_project['data']['resolutionHeight']}", - } - ) - gazu.project.update_project_data( - zou_project, data=op_project["data"] - ) - gazu.project.update_project(zou_project) - - asset_types = gazu.asset.all_asset_types() - all_assets = gazu.asset.all_assets_for_project(zou_project) - all_episodes = gazu.shot.all_episodes_for_project(zou_project) - all_seqs = gazu.shot.all_sequences_for_project(zou_project) - all_shots = gazu.shot.all_shots_for_project(zou_project) - all_entities_ids = { - e["id"] for e in all_episodes + all_seqs + all_shots + all_assets - } - - # Query all assets of the local project - project_module_settings = get_project_settings(project_name)["kitsu"] - project_col = dbcon.database[project_name] - asset_docs = { - asset_doc["_id"]: asset_doc - for asset_doc in project_col.find({"type": "asset"}) - } - - # Create new assets - new_assets_docs = [ - doc - for doc in asset_docs.values() - if doc["data"].get("zou", {}).get("id") not in all_entities_ids - ] - naming_pattern = project_module_settings["entities_naming_pattern"] - regex_ep = re.compile( - r"(.*{}.*)|(.*{}.*)|(.*{}.*)".format( - naming_pattern["shot"].replace("#", ""), - naming_pattern["sequence"].replace("#", ""), - naming_pattern["episode"].replace("#", ""), - ), - re.IGNORECASE, - ) - for doc in new_assets_docs: - visual_parent_id = doc["data"]["visualParent"] - parent_substitutes = [] - - # Match asset type by it's name - match = regex_ep.match(doc["name"]) - if not match: # Asset - new_entity = gazu.asset.new_asset( - zou_project, asset_types[0], doc["name"] - ) - # Match case in shot bool: + """Validate credentials by trying to connect to Kitsu host URL. + + :param login: Kitsu Login + :param password: Kitsu Password + :param kitsu_url: Kitsu host URL + :return: Are credentials valid? + """ + # Connect to server + validate_host(kitsu_url) + + # Authenticate + try: + gazu.log_in(login, password) + except gazu.exception.AuthFailedException: + return False + + return True + + +def validate_host(kitsu_url: str) -> bool: + """Validate credentials by trying to connect to Kitsu host URL. + + :param kitsu_url: Kitsu host URL + :return: Is host valid? + """ + # Connect to server + gazu.set_host(kitsu_url) + + # Test host + if gazu.client.host_is_valid(): + return True + else: + raise gazu.exception.HostException(f"Host '{kitsu_url}' is invalid.") + + +def clear_credentials(): + """Clear credentials in Secure Registry.""" + # Get user registry + user_registry = OpenPypeSecureRegistry("kitsu_user") + + # Set local settings + user_registry.delete_item("login") + user_registry.delete_item("password") + + +def save_credentials(login: str, password: str): + """Save credentials in Secure Registry. + + :param login: Kitsu Login + :param password: Kitsu Password + """ + # Get user registry + user_registry = OpenPypeSecureRegistry("kitsu_user") + + # Set local settings + user_registry.set_item("login", login) + user_registry.set_item("password", password) + + +def load_credentials() -> Tuple[str, str]: + """Load registered credentials. + + :return: Login, Password + """ + # Get user registry + user_registry = OpenPypeSecureRegistry("kitsu_user") + + return user_registry.get_item("login", None), user_registry.get_item( + "password", None + ) + + +def set_credentials_envs(login: str, password: str): + """Set environment variables with Kitsu login and password. + + :param login: Kitsu Login + :param password: Kitsu Password + """ + os.environ["KITSU_LOGIN"] = login + os.environ["KITSU_PWD"] = password diff --git a/openpype/modules/kitsu/utils/listeners.py b/openpype/modules/kitsu/utils/sync_service.py similarity index 54% rename from openpype/modules/kitsu/utils/listeners.py rename to openpype/modules/kitsu/utils/sync_service.py index 3768b4e8e6..831673ec0d 100644 --- a/openpype/modules/kitsu/utils/listeners.py +++ b/openpype/modules/kitsu/utils/sync_service.py @@ -1,72 +1,143 @@ import os +import gazu + from avalon.api import AvalonMongoDB -from .openpype import ( +from .credentials import load_credentials, validate_credentials +from .update_op_with_zou import ( create_op_asset, set_op_project, - sync_project, + write_project_to_op, update_op_assets, ) -def start_listeners(): - """Start listeners to keep OpenPype up-to-date with Kitsu.""" - import gazu +class Listener: + """Host Kitsu listener.""" - # Connect to server - gazu.client.set_host(os.environ["KITSU_SERVER"]) + def __init__(self, login, password): + """Create client and add listeners to events without starting it. - # Authenticate - gazu.log_in(os.environ["KITSU_LOGIN"], os.environ["KITSU_PWD"]) - gazu.set_event_host(os.environ["KITSU_SERVER"].replace("api", "socket.io")) - event_client = gazu.events.init() + Run `listener.start()` to actually start the service. - # Connect to DB - dbcon = AvalonMongoDB() - dbcon.install() + Args: + login (str): Kitsu user login + password (str): Kitsu user password + + Raises: + AuthFailedException: Wrong user login and/or password + """ + self.dbcon = AvalonMongoDB() + self.dbcon.install() + + gazu.client.set_host(os.environ["KITSU_SERVER"]) + + # Authenticate + if not validate_credentials(login, password): + raise gazu.exception.AuthFailedException( + f"Kitsu authentication failed for login: '{login}'..." + ) + + gazu.set_event_host( + os.environ["KITSU_SERVER"].replace("api", "socket.io") + ) + self.event_client = gazu.events.init() + + gazu.events.add_listener( + self.event_client, "project:new", self._new_project + ) + gazu.events.add_listener( + self.event_client, "project:update", self._update_project + ) + gazu.events.add_listener( + self.event_client, "project:delete", self._delete_project + ) + + gazu.events.add_listener( + self.event_client, "asset:new", self._new_asset + ) + gazu.events.add_listener( + self.event_client, "asset:update", self._update_asset + ) + gazu.events.add_listener( + self.event_client, "asset:delete", self._delete_asset + ) + + gazu.events.add_listener( + self.event_client, "episode:new", self._new_episode + ) + gazu.events.add_listener( + self.event_client, "episode:update", self._update_episode + ) + gazu.events.add_listener( + self.event_client, "episode:delete", self._delete_episode + ) + + gazu.events.add_listener( + self.event_client, "sequence:new", self._new_sequence + ) + gazu.events.add_listener( + self.event_client, "sequence:update", self._update_sequence + ) + gazu.events.add_listener( + self.event_client, "sequence:delete", self._delete_sequence + ) + + gazu.events.add_listener(self.event_client, "shot:new", self._new_shot) + gazu.events.add_listener( + self.event_client, "shot:update", self._update_shot + ) + gazu.events.add_listener( + self.event_client, "shot:delete", self._delete_shot + ) + + gazu.events.add_listener(self.event_client, "task:new", self._new_task) + gazu.events.add_listener( + self.event_client, "task:update", self._update_task + ) + gazu.events.add_listener( + self.event_client, "task:delete", self._delete_task + ) + + def start(self): + gazu.events.run_client(self.event_client) # == Project == - - def new_project(data): + def _new_project(self, data): """Create new project into OP DB.""" # Use update process to avoid duplicating code - update_project(data) + self._update_project(data) - def update_project(data): + def _update_project(self, data): """Update project into OP DB.""" # Get project entity project = gazu.project.get_project(data["project_id"]) project_name = project["name"] - dbcon.Session["AVALON_PROJECT"] = project_name - update_project = sync_project(project, dbcon) + update_project = write_project_to_op(project, self.dbcon) # Write into DB if update_project: - project_col = dbcon.database[project_name] + project_col = self.dbcon.database[project_name] project_col.bulk_write([update_project]) - def delete_project(data): + def _delete_project(self, data): """Delete project.""" # Get project entity print(data) # TODO check bugfix project = gazu.project.get_project(data["project_id"]) # Delete project collection - project_col = dbcon.database[project["name"]] + project_col = self.dbcon.database[project["name"]] project_col.drop() - gazu.events.add_listener(event_client, "project:new", new_project) - gazu.events.add_listener(event_client, "project:update", update_project) - gazu.events.add_listener(event_client, "project:delete", delete_project) - # == Asset == - def new_asset(data): + def _new_asset(self, data): """Create new asset into OP DB.""" # Get project entity - project_col = set_op_project(dbcon, data["project_id"]) + project_col = set_op_project(self.dbcon, data["project_id"]) # Get gazu entity asset = gazu.asset.get_asset(data["asset_id"]) @@ -75,12 +146,12 @@ def start_listeners(): project_col.insert_one(create_op_asset(asset)) # Update - update_asset(data) + self._update_asset(data) - def update_asset(data): + def _update_asset(self, data): """Update asset into OP DB.""" - project_col = set_op_project(dbcon, data["project_id"]) - project_doc = dbcon.find_one({"type": "project"}) + project_col = set_op_project(self.dbcon, data["project_id"]) + project_doc = self.dbcon.find_one({"type": "project"}) # Get gazu entity asset = gazu.asset.get_asset(data["asset_id"]) @@ -100,24 +171,20 @@ def start_listeners(): )[0] project_col.update_one({"_id": asset_doc_id}, asset_update) - def delete_asset(data): + def _delete_asset(self, data): """Delete asset of OP DB.""" - project_col = set_op_project(dbcon, data["project_id"]) + project_col = set_op_project(self.dbcon, data["project_id"]) # Delete project_col.delete_one( {"type": "asset", "data.zou.id": data["asset_id"]} ) - gazu.events.add_listener(event_client, "asset:new", new_asset) - gazu.events.add_listener(event_client, "asset:update", update_asset) - gazu.events.add_listener(event_client, "asset:delete", delete_asset) - # == Episode == - def new_episode(data): + def _new_episode(self, data): """Create new episode into OP DB.""" # Get project entity - project_col = set_op_project(dbcon, data["project_id"]) + project_col = set_op_project(self.dbcon, data["project_id"]) # Get gazu entity episode = gazu.shot.get_episode(data["episode_id"]) @@ -126,12 +193,12 @@ def start_listeners(): project_col.insert_one(create_op_asset(episode)) # Update - update_episode(data) + self._update_episode(data) - def update_episode(data): + def _update_episode(self, data): """Update episode into OP DB.""" - project_col = set_op_project(dbcon, data["project_id"]) - project_doc = dbcon.find_one({"type": "project"}) + project_col = set_op_project(self.dbcon, data["project_id"]) + project_doc = self.dbcon.find_one({"type": "project"}) # Get gazu entity episode = gazu.shot.get_episode(data["episode_id"]) @@ -151,9 +218,9 @@ def start_listeners(): )[0] project_col.update_one({"_id": asset_doc_id}, asset_update) - def delete_episode(data): + def _delete_episode(self, data): """Delete shot of OP DB.""" - project_col = set_op_project(dbcon, data["project_id"]) + project_col = set_op_project(self.dbcon, data["project_id"]) print("delete episode") # TODO check bugfix # Delete @@ -161,15 +228,11 @@ def start_listeners(): {"type": "asset", "data.zou.id": data["episode_id"]} ) - gazu.events.add_listener(event_client, "episode:new", new_episode) - gazu.events.add_listener(event_client, "episode:update", update_episode) - gazu.events.add_listener(event_client, "episode:delete", delete_episode) - # == Sequence == - def new_sequence(data): + def _new_sequence(self, data): """Create new sequnce into OP DB.""" # Get project entity - project_col = set_op_project(dbcon, data["project_id"]) + project_col = set_op_project(self.dbcon, data["project_id"]) # Get gazu entity sequence = gazu.shot.get_sequence(data["sequence_id"]) @@ -178,12 +241,12 @@ def start_listeners(): project_col.insert_one(create_op_asset(sequence)) # Update - update_sequence(data) + self._update_sequence(data) - def update_sequence(data): + def _update_sequence(self, data): """Update sequence into OP DB.""" - project_col = set_op_project(dbcon, data["project_id"]) - project_doc = dbcon.find_one({"type": "project"}) + project_col = set_op_project(self.dbcon, data["project_id"]) + project_doc = self.dbcon.find_one({"type": "project"}) # Get gazu entity sequence = gazu.shot.get_sequence(data["sequence_id"]) @@ -203,9 +266,9 @@ def start_listeners(): )[0] project_col.update_one({"_id": asset_doc_id}, asset_update) - def delete_sequence(data): + def _delete_sequence(self, data): """Delete sequence of OP DB.""" - project_col = set_op_project(dbcon, data["project_id"]) + project_col = set_op_project(self.dbcon, data["project_id"]) print("delete sequence") # TODO check bugfix # Delete @@ -213,15 +276,11 @@ def start_listeners(): {"type": "asset", "data.zou.id": data["sequence_id"]} ) - gazu.events.add_listener(event_client, "sequence:new", new_sequence) - gazu.events.add_listener(event_client, "sequence:update", update_sequence) - gazu.events.add_listener(event_client, "sequence:delete", delete_sequence) - # == Shot == - def new_shot(data): + def _new_shot(self, data): """Create new shot into OP DB.""" # Get project entity - project_col = set_op_project(dbcon, data["project_id"]) + project_col = set_op_project(self.dbcon, data["project_id"]) # Get gazu entity shot = gazu.shot.get_shot(data["shot_id"]) @@ -230,12 +289,12 @@ def start_listeners(): project_col.insert_one(create_op_asset(shot)) # Update - update_shot(data) + self._update_shot(data) - def update_shot(data): + def _update_shot(self, data): """Update shot into OP DB.""" - project_col = set_op_project(dbcon, data["project_id"]) - project_doc = dbcon.find_one({"type": "project"}) + project_col = set_op_project(self.dbcon, data["project_id"]) + project_doc = self.dbcon.find_one({"type": "project"}) # Get gazu entity shot = gazu.shot.get_shot(data["shot_id"]) @@ -255,25 +314,20 @@ def start_listeners(): )[0] project_col.update_one({"_id": asset_doc_id}, asset_update) - def delete_shot(data): + def _delete_shot(self, data): """Delete shot of OP DB.""" - project_col = set_op_project(dbcon, data["project_id"]) + project_col = set_op_project(self.dbcon, data["project_id"]) # Delete project_col.delete_one( {"type": "asset", "data.zou.id": data["shot_id"]} ) - gazu.events.add_listener(event_client, "shot:new", new_shot) - gazu.events.add_listener(event_client, "shot:update", update_shot) - gazu.events.add_listener(event_client, "shot:delete", delete_shot) - # == Task == - def new_task(data): + def _new_task(self, data): """Create new task into OP DB.""" - print("new", data) # Get project entity - project_col = set_op_project(dbcon, data["project_id"]) + project_col = set_op_project(self.dbcon, data["project_id"]) # Get gazu entity task = gazu.task.get_task(data["task_id"]) @@ -291,14 +345,14 @@ def start_listeners(): {"_id": asset_doc["_id"]}, {"$set": {"data.tasks": asset_tasks}} ) - def update_task(data): + def _update_task(self, data): """Update task into OP DB.""" # TODO is it necessary? pass - def delete_task(data): + def _delete_task(self, data): """Delete task of OP DB.""" - project_col = set_op_project(dbcon, data["project_id"]) + project_col = set_op_project(self.dbcon, data["project_id"]) # Find asset doc asset_docs = [doc for doc in project_col.find({"type": "asset"})] @@ -307,7 +361,7 @@ def start_listeners(): for name, task in doc["data"]["tasks"].items(): if task.get("zou") and data["task_id"] == task["zou"]["id"]: # Pop task - asset_tasks = doc["data"].get("tasks") + asset_tasks = doc["data"].get("tasks", {}) asset_tasks.pop(name) # Delete task in DB @@ -317,8 +371,20 @@ def start_listeners(): ) return - gazu.events.add_listener(event_client, "task:new", new_task) - gazu.events.add_listener(event_client, "task:update", update_task) - gazu.events.add_listener(event_client, "task:delete", delete_task) - gazu.events.run_client(event_client) +def start_listeners(login: str, password: str): + """Start listeners to keep OpenPype up-to-date with Kitsu. + + Args: + login (str): Kitsu user login + password (str): Kitsu user password + """ + + # Connect to server + listener = Listener(login, password) + listener.start() + + +if __name__ == "__main__": + # TODO not sure when this can be run and if this system is reliable + start_listeners(load_credentials()) diff --git a/openpype/modules/kitsu/utils/openpype.py b/openpype/modules/kitsu/utils/update_op_with_zou.py similarity index 52% rename from openpype/modules/kitsu/utils/openpype.py rename to openpype/modules/kitsu/utils/update_op_with_zou.py index 56c99effff..eb675ad09e 100644 --- a/openpype/modules/kitsu/utils/openpype.py +++ b/openpype/modules/kitsu/utils/update_op_with_zou.py @@ -1,10 +1,17 @@ +"""Functions to update OpenPype data using Kitsu DB (a.k.a Zou).""" from typing import Dict, List -from pymongo import UpdateOne +from pymongo import DeleteOne, UpdateOne from pymongo.collection import Collection +import gazu +from gazu.task import ( + all_tasks_for_asset, + all_tasks_for_shot, +) from avalon.api import AvalonMongoDB from openpype.lib import create_project +from openpype.modules.kitsu.utils.credentials import validate_credentials def create_op_asset(gazu_entity: dict) -> dict: @@ -26,8 +33,6 @@ def set_op_project(dbcon, project_id) -> Collection: :param dbcon: Connection to DB. :param project_id: Project zou ID """ - import gazu - project = gazu.project.get_project(project_id) project_name = project["name"] dbcon.Session["AVALON_PROJECT"] = project_name @@ -45,11 +50,6 @@ def update_op_assets( :param asset_doc_ids: Dicts of [{zou_id: asset_doc}, ...] :return: List of (doc_id, update_dict) tuples """ - from gazu.task import ( - all_tasks_for_asset, - all_tasks_for_shot, - ) - assets_with_update = [] for item in entities_list: # Update asset @@ -124,18 +124,19 @@ def update_op_assets( return assets_with_update -def sync_project(project: dict, dbcon: AvalonMongoDB) -> UpdateOne: - """Sync project with database. +def write_project_to_op(project: dict, dbcon: AvalonMongoDB) -> UpdateOne: + """Write gazu project to OP database. Create project if doesn't exist. - :param project: Gazu project - :param dbcon: DB to create project in - :return: Update instance for the project - """ - import gazu + Args: + project (dict): Gazu project + dbcon (AvalonMongoDB): DB to create project in + Returns: + UpdateOne: Update instance for the project + """ project_name = project["name"] - project_doc = dbcon.find_one({"type": "project"}) + project_doc = dbcon.database[project_name].find_one({"type": "project"}) if not project_doc: print(f"Creating project '{project_name}'") project_doc = create_project(project_name, project_name, dbcon=dbcon) @@ -165,3 +166,123 @@ def sync_project(project: dict, dbcon: AvalonMongoDB) -> UpdateOne: } }, ) + + +def sync_all_project(login: str, password: str): + """Update all OP projects in DB with Zou data. + + Args: + login (str): Kitsu user login + password (str): Kitsu user password + + Raises: + gazu.exception.AuthFailedException: Wrong user login and/or password + """ + + # Authenticate + if not validate_credentials(login, password): + raise gazu.exception.AuthFailedException( + f"Kitsu authentication failed for login: '{login}'..." + ) + + # Iterate projects + dbcon = AvalonMongoDB() + dbcon.install() + all_projects = gazu.project.all_projects() + for project in all_projects: + sync_project_from_kitsu(project["name"], dbcon, project) + + +def sync_project_from_kitsu( + project_name: str, dbcon: AvalonMongoDB, project: dict = None +): + """Update OP project in DB with Zou data. + + Args: + project_name (str): Name of project to sync + dbcon (AvalonMongoDB): MongoDB connection + project (dict, optional): Project dict got using gazu. + Defaults to None. + """ + bulk_writes = [] + + # Get project from zou + if not project: + project = gazu.project.get_project_by_name(project_name) + project_code = project_name + + # Try to find project document + project_col = dbcon.database[project_code] + project_doc = project_col.find_one({"type": "project"}) + + print(f"Synchronizing {project_name}...") + + # Get all assets from zou + all_assets = gazu.asset.all_assets_for_project(project) + all_episodes = gazu.shot.all_episodes_for_project(project) + all_seqs = gazu.shot.all_sequences_for_project(project) + all_shots = gazu.shot.all_shots_for_project(project) + all_entities = [ + e + for e in all_assets + all_episodes + all_seqs + all_shots + if e["data"] and not e["data"].get("is_substitute") + ] + + # Sync project. Create if doesn't exist + bulk_writes.append(write_project_to_op(project, dbcon)) + + # Query all assets of the local project + zou_ids_and_asset_docs = { + asset_doc["data"]["zou"]["id"]: asset_doc + for asset_doc in project_col.find({"type": "asset"}) + if asset_doc["data"].get("zou", {}).get("id") + } + zou_ids_and_asset_docs[project["id"]] = project_doc + + # Create + to_insert = [] + to_insert.extend( + [ + create_op_asset(item) + for item in all_entities + if item["id"] not in zou_ids_and_asset_docs.keys() + ] + ) + if to_insert: + # Insert doc in DB + project_col.insert_many(to_insert) + + # Update existing docs + zou_ids_and_asset_docs.update( + { + asset_doc["data"]["zou"]["id"]: asset_doc + for asset_doc in project_col.find({"type": "asset"}) + if asset_doc["data"].get("zou") + } + ) + + # Update + bulk_writes.extend( + [ + UpdateOne({"_id": id}, update) + for id, update in update_op_assets( + all_entities, zou_ids_and_asset_docs + ) + ] + ) + + # Delete + diff_assets = set(zou_ids_and_asset_docs.keys()) - { + e["id"] for e in all_entities + [project] + } + if diff_assets: + bulk_writes.extend( + [ + DeleteOne(zou_ids_and_asset_docs[asset_id]) + for asset_id in diff_assets + ] + ) + + # Write into DB + if bulk_writes: + project_col.bulk_write(bulk_writes) diff --git a/openpype/modules/kitsu/utils/update_zou_with_op.py b/openpype/modules/kitsu/utils/update_zou_with_op.py new file mode 100644 index 0000000000..d1fcde5601 --- /dev/null +++ b/openpype/modules/kitsu/utils/update_zou_with_op.py @@ -0,0 +1,262 @@ +"""Functions to update Kitsu DB (a.k.a Zou) using OpenPype Data.""" + +import re +from typing import List + +import gazu +from pymongo import UpdateOne + +from avalon.api import AvalonMongoDB +from openpype.api import get_project_settings +from openpype.modules.kitsu.utils.credentials import validate_credentials + + +def sync_zou(login: str, password: str): + """Synchronize Zou database (Kitsu backend) with openpype database. + This is an utility function to help updating zou data with OP's, it may not + handle correctly all cases, a human intervention might + be required after all. + Will work better if OP DB has been previously synchronized from zou/kitsu. + + Args: + login (str): Kitsu user login + password (str): Kitsu user password + + Raises: + gazu.exception.AuthFailedException: Wrong user login and/or password + """ + + # Authenticate + if not validate_credentials(login, password): + raise gazu.exception.AuthFailedException( + f"Kitsu authentication failed for login: '{login}'..." + ) + + # Iterate projects + dbcon = AvalonMongoDB() + dbcon.install() + + op_projects = [p for p in dbcon.projects()] + for project_doc in op_projects: + sync_zou_from_op_project(project_doc["name"], dbcon, project_doc) + + +def sync_zou_from_op_project( + project_name: str, dbcon: AvalonMongoDB, project_doc: dict = None +) -> List[UpdateOne]: + """Update OP project in DB with Zou data. + + Args: + project_name (str): Name of project to sync + dbcon (AvalonMongoDB): MongoDB connection + project_doc (str, optional): Project doc to sync + """ + # Get project doc if not provided + if not project_doc: + project_doc = dbcon.database[project_name].find_one( + {"type": "project"} + ) + + # Get all entities from zou + print(f"Synchronizing {project_name}...") + zou_project = gazu.project.get_project_by_name(project_name) + + # Create project + if zou_project is None: + raise RuntimeError( + f"Project '{project_name}' doesn't exist in Zou database, " + "please create it in Kitsu and add OpenPype user to it before " + "running synchronization." + ) + + # Update project settings and data + if project_doc["data"]: + zou_project.update( + { + "code": project_doc["data"]["code"], + "fps": project_doc["data"]["fps"], + "resolution": f"{project_doc['data']['resolutionWidth']}" + f"x{project_doc['data']['resolutionHeight']}", + } + ) + gazu.project.update_project_data(zou_project, data=project_doc["data"]) + gazu.project.update_project(zou_project) + + asset_types = gazu.asset.all_asset_types() + all_assets = gazu.asset.all_assets_for_project(zou_project) + all_episodes = gazu.shot.all_episodes_for_project(zou_project) + all_seqs = gazu.shot.all_sequences_for_project(zou_project) + all_shots = gazu.shot.all_shots_for_project(zou_project) + all_entities_ids = { + e["id"] for e in all_episodes + all_seqs + all_shots + all_assets + } + + # Query all assets of the local project + project_module_settings = get_project_settings(project_name)["kitsu"] + project_col = dbcon.database[project_name] + asset_docs = { + asset_doc["_id"]: asset_doc + for asset_doc in project_col.find({"type": "asset"}) + } + + # Create new assets + new_assets_docs = [ + doc + for doc in asset_docs.values() + if doc["data"].get("zou", {}).get("id") not in all_entities_ids + ] + naming_pattern = project_module_settings["entities_naming_pattern"] + regex_ep = re.compile( + r"(.*{}.*)|(.*{}.*)|(.*{}.*)".format( + naming_pattern["shot"].replace("#", ""), + naming_pattern["sequence"].replace("#", ""), + naming_pattern["episode"].replace("#", ""), + ), + re.IGNORECASE, + ) + bulk_writes = [] + for doc in new_assets_docs: + visual_parent_id = doc["data"]["visualParent"] + parent_substitutes = [] + + # Match asset type by it's name + match = regex_ep.match(doc["name"]) + if not match: # Asset + new_entity = gazu.asset.new_asset( + zou_project, asset_types[0], doc["name"] + ) + # Match case in shot Date: Thu, 3 Mar 2022 15:55:56 +0100 Subject: [PATCH 254/583] Cleaning. --- openpype/modules/kitsu/utils/credentials.py | 36 ++++++++++++++------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/openpype/modules/kitsu/utils/credentials.py b/openpype/modules/kitsu/utils/credentials.py index b4dd5ee4a2..0529380d6d 100644 --- a/openpype/modules/kitsu/utils/credentials.py +++ b/openpype/modules/kitsu/utils/credentials.py @@ -8,15 +8,21 @@ from openpype.lib.local_settings import OpenPypeSecureRegistry def validate_credentials( - login: str, password: str, kitsu_url: str = os.environ.get("KITSU_SERVER") + login: str, password: str, kitsu_url: str = None ) -> bool: """Validate credentials by trying to connect to Kitsu host URL. - :param login: Kitsu Login - :param password: Kitsu Password - :param kitsu_url: Kitsu host URL - :return: Are credentials valid? + Args: + login (str): Kitsu user login + password (str): Kitsu user password + kitsu_url (str, optional): Kitsu host URL. Defaults to None. + + Returns: + bool: Are credentials valid? """ + if kitsu_url is None: + kitsu_url = os.environ.get("KITSU_SERVER") + # Connect to server validate_host(kitsu_url) @@ -32,8 +38,11 @@ def validate_credentials( def validate_host(kitsu_url: str) -> bool: """Validate credentials by trying to connect to Kitsu host URL. - :param kitsu_url: Kitsu host URL - :return: Is host valid? + Args: + kitsu_url (str, optional): Kitsu host URL. + + Returns: + bool: Is host valid? """ # Connect to server gazu.set_host(kitsu_url) @@ -58,8 +67,9 @@ def clear_credentials(): def save_credentials(login: str, password: str): """Save credentials in Secure Registry. - :param login: Kitsu Login - :param password: Kitsu Password + Args: + login (str): Kitsu user login + password (str): Kitsu user password """ # Get user registry user_registry = OpenPypeSecureRegistry("kitsu_user") @@ -72,7 +82,8 @@ def save_credentials(login: str, password: str): def load_credentials() -> Tuple[str, str]: """Load registered credentials. - :return: Login, Password + Returns: + Tuple[str, str]: (Login, Password) """ # Get user registry user_registry = OpenPypeSecureRegistry("kitsu_user") @@ -85,8 +96,9 @@ def load_credentials() -> Tuple[str, str]: def set_credentials_envs(login: str, password: str): """Set environment variables with Kitsu login and password. - :param login: Kitsu Login - :param password: Kitsu Password + Args: + login (str): Kitsu user login + password (str): Kitsu user password """ os.environ["KITSU_LOGIN"] = login os.environ["KITSU_PWD"] = password From 5db0c1dfa7cbf483976b6e52f6d4cde5813daedc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20David?= Date: Fri, 4 Mar 2022 10:12:08 +0100 Subject: [PATCH 255/583] Python 2 compat and cleaning --- openpype/modules/kitsu/kitsu_module.py | 4 ++-- openpype/modules/kitsu/utils/sync_service.py | 5 ----- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/openpype/modules/kitsu/kitsu_module.py b/openpype/modules/kitsu/kitsu_module.py index dca6133e88..53edfddf9a 100644 --- a/openpype/modules/kitsu/kitsu_module.py +++ b/openpype/modules/kitsu/kitsu_module.py @@ -105,7 +105,7 @@ def cli_main(): @click.option( "--password", envvar="KITSU_PWD", help="Password for kitsu username" ) -def push_to_zou(login: str, password: str): +def push_to_zou(login, password): """Synchronize Zou database (Kitsu backend) with openpype database. Args: @@ -122,7 +122,7 @@ def push_to_zou(login: str, password: str): @click.option( "-p", "--password", envvar="KITSU_PWD", help="Password for kitsu username" ) -def sync_service(login: str, password: str): +def sync_service(login, password): """Synchronize openpype database from Zou sever database. Args: diff --git a/openpype/modules/kitsu/utils/sync_service.py b/openpype/modules/kitsu/utils/sync_service.py index 831673ec0d..6bf98cf308 100644 --- a/openpype/modules/kitsu/utils/sync_service.py +++ b/openpype/modules/kitsu/utils/sync_service.py @@ -383,8 +383,3 @@ def start_listeners(login: str, password: str): # Connect to server listener = Listener(login, password) listener.start() - - -if __name__ == "__main__": - # TODO not sure when this can be run and if this system is reliable - start_listeners(load_credentials()) From 8c3b510887564d52d7230c7114a17f9044157be5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20David?= Date: Fri, 4 Mar 2022 10:12:58 +0100 Subject: [PATCH 256/583] Cleaning --- openpype/modules/kitsu/utils/sync_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/kitsu/utils/sync_service.py b/openpype/modules/kitsu/utils/sync_service.py index 6bf98cf308..2e8fbf77f5 100644 --- a/openpype/modules/kitsu/utils/sync_service.py +++ b/openpype/modules/kitsu/utils/sync_service.py @@ -3,7 +3,7 @@ import os import gazu from avalon.api import AvalonMongoDB -from .credentials import load_credentials, validate_credentials +from .credentials import validate_credentials from .update_op_with_zou import ( create_op_asset, set_op_project, From e5ae5459e135d10d01e1549f8ac3f8a8cb83e689 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20David?= Date: Fri, 4 Mar 2022 15:58:06 +0100 Subject: [PATCH 257/583] Kitsu docs --- website/docs/artist_kitsu.md | 17 ++++++++ .../docs/assets/kitsu/kitsu_credentials.png | Bin 0 -> 15798 bytes website/docs/module_kitsu.md | 37 ++++++++++++++++++ website/sidebars.js | 2 + 4 files changed, 56 insertions(+) create mode 100644 website/docs/artist_kitsu.md create mode 100644 website/docs/assets/kitsu/kitsu_credentials.png create mode 100644 website/docs/module_kitsu.md diff --git a/website/docs/artist_kitsu.md b/website/docs/artist_kitsu.md new file mode 100644 index 0000000000..9ef782c297 --- /dev/null +++ b/website/docs/artist_kitsu.md @@ -0,0 +1,17 @@ +--- +id: artist_kitsu +title: Kitsu +sidebar_label: Kitsu +--- + +# How to use Kitsu in OpenPype + +## Login to Kitsu module in OpenPype +1. Launch OpenPype, the `Kitsu Credentials` window will open automatically, if not, or if you want to log-in with another account, go to systray OpenPype icon and click on `Kitsu Connect`. +2. Enter your credentials and press *Ok*: + + ![kitsu-login](assets/kitsu/kitsu_credentials.png) + +:::tip +In Kitsu, All the publish actions executed by `pyblish` will be attributed to the currently logged-in user. +::: \ No newline at end of file diff --git a/website/docs/assets/kitsu/kitsu_credentials.png b/website/docs/assets/kitsu/kitsu_credentials.png new file mode 100644 index 0000000000000000000000000000000000000000..25c1ad93c4d966a083d1fbdcd15e78a54e659b76 GIT binary patch literal 15798 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 literal 0 HcmV?d00001 diff --git a/website/docs/module_kitsu.md b/website/docs/module_kitsu.md new file mode 100644 index 0000000000..ec38cce5e1 --- /dev/null +++ b/website/docs/module_kitsu.md @@ -0,0 +1,37 @@ +--- +id: module_kitsu +title: Kitsu Administration +sidebar_label: Kitsu +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +Kitsu is a great open source production tracker and can be used for project management instead of Ftrack. This documentation assumes that you are familiar with Kitsu and it's basic principles. If you're new to Kitsu, we recommend having a thorough look at [Kitsu Official Documentation](https://kitsu.cg-wire.com/). + +## Prepare Kitsu for OpenPype + +### Server URL +If you want to connect Kitsu to OpenPype you have to set the `Server` url in Kitsu settings. And that's all! +This setting is available for all the users of the OpenPype instance. + +## Synchronize +Updating OP with Kitsu data is executed running the `sync-service`, which requires to provide your Kitsu credentials with `-l, --login` and `-p, --password` or by setting the environment variables `KITSU_LOGIN` and `KITSU_PWD`. This process will request data from Kitsu and create/delete/update OP assets. +Once this sync is done, the thread will automatically start a loop to listen to Kitsu events. + +```bash +openpype_console module kitsu sync-service -l me@domain.ext -p my_password +``` + +### Events listening +Listening to Kitsu events is the key to automation of many tasks like _project/episode/sequence/shot/asset/task create/update/delete_ and some more. Events listening should run at all times to perform the required processing as it is not possible to catch some of them retrospectively with strong reliability. If such timeout has been encountered, you must relaunch the `sync-service` command to run the synchronization step again. + +### Push to Kitsu +An utility function is provided to help update Kitsu data (a.k.a Zou database) with OpenPype data if the publishing to the production tracker hasn't been possible for some time. Running `push-to-zou` will create the data on behalf of the user. +:::caution +This functionality cannot deal with all cases and is not error proof, some intervention by a human being might be required. +::: + +```bash +openpype_console module kitsu push-to-zou -l me@domain.ext -p my_password +``` diff --git a/website/sidebars.js b/website/sidebars.js index 105afc30eb..eecdcc6e9a 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -28,6 +28,7 @@ module.exports = { "artist_hosts_photoshop", "artist_hosts_tvpaint", "artist_hosts_unreal", + "artist_kitsu", { type: "category", label: "Ftrack", @@ -75,6 +76,7 @@ module.exports = { label: "Modules", items: [ "module_ftrack", + "module_kitsu", "module_site_sync", "module_deadline", "module_muster", From 05158585c778a3bed91c023626bcf5ee3baf29a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20David?= Date: Tue, 8 Mar 2022 09:22:16 +0100 Subject: [PATCH 258/583] Add pyblish comment to kitsu --- openpype/modules/kitsu/kitsu_module.py | 6 +++--- openpype/modules/kitsu/plugins/publish/kitsu_plugin.py | 6 ++++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/openpype/modules/kitsu/kitsu_module.py b/openpype/modules/kitsu/kitsu_module.py index 53edfddf9a..8e7ab6f78c 100644 --- a/openpype/modules/kitsu/kitsu_module.py +++ b/openpype/modules/kitsu/kitsu_module.py @@ -51,11 +51,11 @@ class KitsuModule(OpenPypeModule, IPluginPaths, ITrayAction): set_credentials_envs, ) - username, password = load_credentials() + login, password = load_credentials() # Check credentials, ask them if needed - if validate_credentials(username, password): - set_credentials_envs(username, password) + if validate_credentials(login, password): + set_credentials_envs(login, password) else: self.show_dialog() diff --git a/openpype/modules/kitsu/plugins/publish/kitsu_plugin.py b/openpype/modules/kitsu/plugins/publish/kitsu_plugin.py index b556f2b91f..5fce123d7e 100644 --- a/openpype/modules/kitsu/plugins/publish/kitsu_plugin.py +++ b/openpype/modules/kitsu/plugins/publish/kitsu_plugin.py @@ -39,7 +39,9 @@ class IntegrateRig(pyblish.api.InstancePlugin): gazu.task.add_comment( entity_task, entity_task["task_status_id"], - comment=f"Version {instance.data['version']} has been published!", - ) # TODO add comment from pyblish + comment=f"Version {instance.data['version']} has been published!\n" + "\n" # Add written comment in Pyblish + f"{instance.data['versionEntity']['data']['comment']}".strip(), + ) self.log.info("Version published to Kitsu successfully!") From 1591b91c54611e2bfe55902ad31929e72416515f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20David?= Date: Tue, 8 Mar 2022 09:57:37 +0100 Subject: [PATCH 259/583] Python2 compat --- openpype/modules/kitsu/plugins/publish/kitsu_plugin.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/openpype/modules/kitsu/plugins/publish/kitsu_plugin.py b/openpype/modules/kitsu/plugins/publish/kitsu_plugin.py index 5fce123d7e..5d6c76bc3f 100644 --- a/openpype/modules/kitsu/plugins/publish/kitsu_plugin.py +++ b/openpype/modules/kitsu/plugins/publish/kitsu_plugin.py @@ -39,9 +39,11 @@ class IntegrateRig(pyblish.api.InstancePlugin): gazu.task.add_comment( entity_task, entity_task["task_status_id"], - comment=f"Version {instance.data['version']} has been published!\n" - "\n" # Add written comment in Pyblish - f"{instance.data['versionEntity']['data']['comment']}".strip(), + comment="Version {} has been published!\n".format( + instance.data["version"] + ) + # Add written comment in Pyblish + + "\n{}".format(instance.data["versionEntity"]["data"]["comment"]), ) self.log.info("Version published to Kitsu successfully!") From 9c0c43a0612c70fa7fa26e71c1f91b7605b3792d Mon Sep 17 00:00:00 2001 From: "clement.hector" Date: Tue, 15 Mar 2022 18:01:04 +0100 Subject: [PATCH 260/583] fix first sync crash --- .../modules/kitsu/utils/update_op_with_zou.py | 31 +++++++++---------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/openpype/modules/kitsu/utils/update_op_with_zou.py b/openpype/modules/kitsu/utils/update_op_with_zou.py index eb675ad09e..e76d54d1ad 100644 --- a/openpype/modules/kitsu/utils/update_op_with_zou.py +++ b/openpype/modules/kitsu/utils/update_op_with_zou.py @@ -145,14 +145,15 @@ def write_project_to_op(project: dict, dbcon: AvalonMongoDB) -> UpdateOne: project_data = project["data"] or {} # Update data - project_data.update( - { - "code": project["code"], - "fps": project["fps"], - "resolutionWidth": project["resolution"].split("x")[0], - "resolutionHeight": project["resolution"].split("x")[1], - } - ) + if project_data: + project_data.update( + { + "code": project["code"], + "fps": project["fps"], + "resolutionWidth": project["resolution"].split("x")[0], + "resolutionHeight": project["resolution"].split("x")[1], + } + ) return UpdateOne( {"_id": project_doc["_id"]}, @@ -211,10 +212,6 @@ def sync_project_from_kitsu( project = gazu.project.get_project_by_name(project_name) project_code = project_name - # Try to find project document - project_col = dbcon.database[project_code] - project_doc = project_col.find_one({"type": "project"}) - print(f"Synchronizing {project_name}...") # Get all assets from zou @@ -222,15 +219,15 @@ def sync_project_from_kitsu( all_episodes = gazu.shot.all_episodes_for_project(project) all_seqs = gazu.shot.all_sequences_for_project(project) all_shots = gazu.shot.all_shots_for_project(project) - all_entities = [ - e - for e in all_assets + all_episodes + all_seqs + all_shots - if e["data"] and not e["data"].get("is_substitute") - ] + all_entities = all_assets + all_episodes + all_seqs + all_shots # Sync project. Create if doesn't exist bulk_writes.append(write_project_to_op(project, dbcon)) + # Try to find project document + project_col = dbcon.database[project_code] + project_doc = project_col.find_one({"type": "project"}) + # Query all assets of the local project zou_ids_and_asset_docs = { asset_doc["data"]["zou"]["id"]: asset_doc From 17dd018aa060f8fbaf043e19e86a9a0e318f14dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20David?= Date: Wed, 16 Mar 2022 17:01:30 +0100 Subject: [PATCH 261/583] Build project code --- openpype/modules/kitsu/utils/update_op_with_zou.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/openpype/modules/kitsu/utils/update_op_with_zou.py b/openpype/modules/kitsu/utils/update_op_with_zou.py index e76d54d1ad..98f263efe1 100644 --- a/openpype/modules/kitsu/utils/update_op_with_zou.py +++ b/openpype/modules/kitsu/utils/update_op_with_zou.py @@ -144,11 +144,20 @@ def write_project_to_op(project: dict, dbcon: AvalonMongoDB) -> UpdateOne: # Project data and tasks project_data = project["data"] or {} + # Build project code and update Kitsu + project_code = project.get("code") + if not project_code: + project_code = project["name"].replace(" ", "_").lower() + project["code"] = project_code + + # Update Zou + gazu.project.update_project(project) + # Update data if project_data: project_data.update( { - "code": project["code"], + "code": project_code, "fps": project["fps"], "resolutionWidth": project["resolution"].split("x")[0], "resolutionHeight": project["resolution"].split("x")[1], From 093e801b0b02074e4d5bd7ecd0ff6d791ba6ea92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20David?= Date: Wed, 16 Mar 2022 17:31:22 +0100 Subject: [PATCH 262/583] Sync project FPS and resolution --- .../modules/kitsu/utils/update_op_with_zou.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/openpype/modules/kitsu/utils/update_op_with_zou.py b/openpype/modules/kitsu/utils/update_op_with_zou.py index 98f263efe1..cb2c79b942 100644 --- a/openpype/modules/kitsu/utils/update_op_with_zou.py +++ b/openpype/modules/kitsu/utils/update_op_with_zou.py @@ -154,15 +154,14 @@ def write_project_to_op(project: dict, dbcon: AvalonMongoDB) -> UpdateOne: gazu.project.update_project(project) # Update data - if project_data: - project_data.update( - { - "code": project_code, - "fps": project["fps"], - "resolutionWidth": project["resolution"].split("x")[0], - "resolutionHeight": project["resolution"].split("x")[1], - } - ) + project_data.update( + { + "code": project_code, + "fps": project["fps"], + "resolutionWidth": project["resolution"].split("x")[0], + "resolutionHeight": project["resolution"].split("x")[1], + } + ) return UpdateOne( {"_id": project_doc["_id"]}, From bd06809ffaf8ff8ae8bde463a5ad990b486288c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20David?= Date: Wed, 16 Mar 2022 18:44:26 +0100 Subject: [PATCH 263/583] Fix shot syncs --- openpype/modules/kitsu/utils/update_op_with_zou.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/openpype/modules/kitsu/utils/update_op_with_zou.py b/openpype/modules/kitsu/utils/update_op_with_zou.py index cb2c79b942..6aea2e3930 100644 --- a/openpype/modules/kitsu/utils/update_op_with_zou.py +++ b/openpype/modules/kitsu/utils/update_op_with_zou.py @@ -1,4 +1,5 @@ """Functions to update OpenPype data using Kitsu DB (a.k.a Zou).""" +from copy import deepcopy from typing import Dict, List from pymongo import DeleteOne, UpdateOne @@ -54,9 +55,14 @@ def update_op_assets( for item in entities_list: # Update asset item_doc = asset_doc_ids[item["id"]] - item_data = item_doc["data"].copy() + item_data = deepcopy(item_doc["data"]) + item_data.update(item.get("data") or {}) item_data["zou"] = item + # Asset settings + item_data["frameStart"] = item_data.get("frame_in") + item_data["frameEnd"] = item_data.get("frame_out") + # Tasks tasks_list = [] if item["type"] == "Asset": @@ -103,9 +109,7 @@ def update_op_assets( # Update 'data' different in zou DB updated_data = { - k: item_data[k] - for k in item_data.keys() - if item_doc["data"].get(k) != item_data[k] + k: v for k, v in item_data.items() if item_doc["data"].get(k) != v } if updated_data or not item_doc.get("parent"): assets_with_update.append( From 84a13e4eb6ff99b433ea1d2fddce32d106381e61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20David?= Date: Thu, 17 Mar 2022 15:52:14 +0100 Subject: [PATCH 264/583] shots and assets custom folders root --- openpype/modules/kitsu/utils/sync_service.py | 8 +- .../modules/kitsu/utils/update_op_with_zou.py | 82 +++++++++++++++++-- .../defaults/project_settings/kitsu.json | 4 + .../projects_schema/schema_project_kitsu.json | 17 ++++ 4 files changed, 99 insertions(+), 12 deletions(-) diff --git a/openpype/modules/kitsu/utils/sync_service.py b/openpype/modules/kitsu/utils/sync_service.py index 2e8fbf77f5..746cb843e9 100644 --- a/openpype/modules/kitsu/utils/sync_service.py +++ b/openpype/modules/kitsu/utils/sync_service.py @@ -167,7 +167,7 @@ class Listener: # Update asset_doc_id, asset_update = update_op_assets( - [asset], zou_ids_and_asset_docs + project_col[asset], zou_ids_and_asset_docs )[0] project_col.update_one({"_id": asset_doc_id}, asset_update) @@ -214,7 +214,7 @@ class Listener: # Update asset_doc_id, asset_update = update_op_assets( - [episode], zou_ids_and_asset_docs + project_col, [episode], zou_ids_and_asset_docs )[0] project_col.update_one({"_id": asset_doc_id}, asset_update) @@ -262,7 +262,7 @@ class Listener: # Update asset_doc_id, asset_update = update_op_assets( - [sequence], zou_ids_and_asset_docs + project_col, [sequence], zou_ids_and_asset_docs )[0] project_col.update_one({"_id": asset_doc_id}, asset_update) @@ -310,7 +310,7 @@ class Listener: # Update asset_doc_id, asset_update = update_op_assets( - [shot], zou_ids_and_asset_docs + project_col, [shot], zou_ids_and_asset_docs )[0] project_col.update_one({"_id": asset_doc_id}, asset_update) diff --git a/openpype/modules/kitsu/utils/update_op_with_zou.py b/openpype/modules/kitsu/utils/update_op_with_zou.py index 6aea2e3930..e2ad29bfa0 100644 --- a/openpype/modules/kitsu/utils/update_op_with_zou.py +++ b/openpype/modules/kitsu/utils/update_op_with_zou.py @@ -11,6 +11,7 @@ from gazu.task import ( ) from avalon.api import AvalonMongoDB +from openpype.api import get_project_settings from openpype.lib import create_project from openpype.modules.kitsu.utils.credentials import validate_credentials @@ -42,15 +43,23 @@ def set_op_project(dbcon, project_id) -> Collection: def update_op_assets( - entities_list: List[dict], asset_doc_ids: Dict[str, dict] + project_col: Collection, + entities_list: List[dict], + asset_doc_ids: Dict[str, dict], ) -> List[Dict[str, dict]]: """Update OpenPype assets. Set 'data' and 'parent' fields. - :param entities_list: List of zou entities to update - :param asset_doc_ids: Dicts of [{zou_id: asset_doc}, ...] - :return: List of (doc_id, update_dict) tuples + Args: + project_col (Collection): Mongo project collection to sync + entities_list (List[dict]): List of zou entities to update + asset_doc_ids (Dict[str, dict]): Dicts of [{zou_id: asset_doc}, ...] + + Returns: + List[Dict[str, dict]]: List of (doc_id, update_dict) tuples """ + project_name = project_col.name + assets_with_update = [] for item in entities_list: # Update asset @@ -65,9 +74,10 @@ def update_op_assets( # Tasks tasks_list = [] - if item["type"] == "Asset": + item_type = item["type"] + if item_type == "Asset": tasks_list = all_tasks_for_asset(item) - elif item["type"] == "Shot": + elif item_type == "Shot": tasks_list = all_tasks_for_shot(item) # TODO frame in and out item_data["tasks"] = { @@ -91,10 +101,39 @@ def update_op_assets( or item.get("source_id") ) # TODO check consistency - # Visual parent for hierarchy + # Substitute Episode and Sequence by Shot + project_module_settings = get_project_settings(project_name)["kitsu"] + substitute_item_type = ( + "shots" + if item_type in ["Episode", "Sequence"] + else f"{item_type.lower()}s" + ) + entity_parent_folders = [ + f + for f in project_module_settings["entities_root"] + .get(substitute_item_type) + .split("/") + if f + ] + + # Root parent folder if exist visual_parent_doc_id = ( asset_doc_ids[parent_zou_id]["_id"] if parent_zou_id else None ) + if visual_parent_doc_id is None: + # Find root folder doc + root_folder_doc = project_col.find_one( + { + "type": "asset", + "name": entity_parent_folders[-1], + "data.root_of": substitute_item_type, + }, + ["_id"], + ) + if root_folder_doc: + visual_parent_doc_id = root_folder_doc["_id"] + + # Visual parent for hierarchy item_data["visualParent"] = visual_parent_doc_id # Add parents for hierarchy @@ -107,6 +146,9 @@ def update_op_assets( parent_entity = parent_doc["data"]["zou"] parent_zou_id = parent_entity["parent_id"] + # Set root folders parents + item_data["parents"] = entity_parent_folders + item_data["parents"] + # Update 'data' different in zou DB updated_data = { k: v for k, v in item_data.items() if item_doc["data"].get(k) != v @@ -248,6 +290,30 @@ def sync_project_from_kitsu( } zou_ids_and_asset_docs[project["id"]] = project_doc + # Create entities root folders + project_module_settings = get_project_settings(project_name)["kitsu"] + for entity_type, root in project_module_settings["entities_root"].items(): + parent_folders = root.split("/") + direct_parent_doc = None + for i, folder in enumerate(parent_folders, 1): + parent_doc = project_col.find_one( + {"type": "asset", "name": folder, "data.root_of": entity_type} + ) + if not parent_doc: + direct_parent_doc = project_col.insert_one( + { + "name": folder, + "type": "asset", + "schema": "openpype:asset-3.0", + "data": { + "root_of": entity_type, + "parents": parent_folders[:i], + "visualParent": direct_parent_doc, + "tasks": {}, + }, + } + ) + # Create to_insert = [] to_insert.extend( @@ -275,7 +341,7 @@ def sync_project_from_kitsu( [ UpdateOne({"_id": id}, update) for id, update in update_op_assets( - all_entities, zou_ids_and_asset_docs + project_col, all_entities, zou_ids_and_asset_docs ) ] ) diff --git a/openpype/settings/defaults/project_settings/kitsu.json b/openpype/settings/defaults/project_settings/kitsu.json index 435814a9d1..a37146e1d2 100644 --- a/openpype/settings/defaults/project_settings/kitsu.json +++ b/openpype/settings/defaults/project_settings/kitsu.json @@ -1,4 +1,8 @@ { + "entities_root": { + "assets": "Assets", + "shots": "Shots" + }, "entities_naming_pattern": { "episode": "E##", "sequence": "SQ##", diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_kitsu.json b/openpype/settings/entities/schemas/projects_schema/schema_project_kitsu.json index a504959001..8d71d0ecd6 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_kitsu.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_kitsu.json @@ -5,6 +5,23 @@ "collapsible": true, "is_file": true, "children": [ + { + "type": "dict", + "key": "entities_root", + "label": "Entities root folder", + "children": [ + { + "type": "text", + "key": "assets", + "label": "Assets:" + }, + { + "type": "text", + "key": "shots", + "label": "Shots (includes Episodes & Sequences if any):" + } + ] + }, { "type": "dict", "key": "entities_naming_pattern", From 3970229ce5908abfd62c90a88b656f08f232dbfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20David?= Date: Thu, 17 Mar 2022 16:09:15 +0100 Subject: [PATCH 265/583] Fix fps fallback to project's value --- openpype/modules/kitsu/utils/update_op_with_zou.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/openpype/modules/kitsu/utils/update_op_with_zou.py b/openpype/modules/kitsu/utils/update_op_with_zou.py index e2ad29bfa0..288efe30da 100644 --- a/openpype/modules/kitsu/utils/update_op_with_zou.py +++ b/openpype/modules/kitsu/utils/update_op_with_zou.py @@ -71,6 +71,10 @@ def update_op_assets( # Asset settings item_data["frameStart"] = item_data.get("frame_in") item_data["frameEnd"] = item_data.get("frame_out") + # Sentinel for fps, fallback to project's value when entity fps is deleted + if not item_data.get("fps") and item_doc["data"].get("fps"): + project_doc = project_col.find_one({"type": "project"}) + item_data["fps"] = project_doc["data"]["fps"] # Tasks tasks_list = [] From 78bda28da4ec26cf4b8b8673cabc01316f578678 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20David?= Date: Thu, 17 Mar 2022 16:49:13 +0100 Subject: [PATCH 266/583] frame_in/out fallbacks --- .../modules/kitsu/utils/update_op_with_zou.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/openpype/modules/kitsu/utils/update_op_with_zou.py b/openpype/modules/kitsu/utils/update_op_with_zou.py index 288efe30da..f5c6406722 100644 --- a/openpype/modules/kitsu/utils/update_op_with_zou.py +++ b/openpype/modules/kitsu/utils/update_op_with_zou.py @@ -68,10 +68,19 @@ def update_op_assets( item_data.update(item.get("data") or {}) item_data["zou"] = item - # Asset settings - item_data["frameStart"] = item_data.get("frame_in") - item_data["frameEnd"] = item_data.get("frame_out") - # Sentinel for fps, fallback to project's value when entity fps is deleted + # == Asset settings == + # Frame in, fallback on 0 + frame_in = int(item_data.get("frame_in") or 0) + item_data["frameStart"] = frame_in + # Frame out, fallback on frame_in + duration + frames_duration = int(item.get("nb_frames") or 1) + frame_out = ( + item_data["frame_out"] + if item_data.get("frame_out") + else frame_in + frames_duration + ) + item_data["frameEnd"] = int(frame_out) + # Fps, fallback to project's value when entity fps is deleted if not item_data.get("fps") and item_doc["data"].get("fps"): project_doc = project_col.find_one({"type": "project"}) item_data["fps"] = project_doc["data"]["fps"] From 527f4a710dbddd21feac630bdafb7b986a354c5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20David?= Date: Fri, 25 Mar 2022 10:03:22 +0100 Subject: [PATCH 267/583] Sync only open projects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: ClΓ©ment Hector --- openpype/modules/kitsu/utils/update_op_with_zou.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/kitsu/utils/update_op_with_zou.py b/openpype/modules/kitsu/utils/update_op_with_zou.py index f5c6406722..f43223cdf7 100644 --- a/openpype/modules/kitsu/utils/update_op_with_zou.py +++ b/openpype/modules/kitsu/utils/update_op_with_zou.py @@ -256,7 +256,7 @@ def sync_all_project(login: str, password: str): # Iterate projects dbcon = AvalonMongoDB() dbcon.install() - all_projects = gazu.project.all_projects() + all_projects = gazu.project.all_open_projects() for project in all_projects: sync_project_from_kitsu(project["name"], dbcon, project) From 0c8b7e303a6eefcfc03986aafd6264c268567c59 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 4 May 2022 18:03:16 +0200 Subject: [PATCH 268/583] nuke: improving add write node function fixing imagio override nodes --- openpype/hosts/nuke/api/lib.py | 91 +++++++++++++++++++++------------- 1 file changed, 57 insertions(+), 34 deletions(-) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 6d6a988b44..2d205d0d39 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -365,7 +365,7 @@ def fix_data_for_node_create(data): return data -def add_write_node(name, **kwarg): +def add_write_node(name, file_path, knobs, **kwarg): """Adding nuke write node Arguments: @@ -375,8 +375,6 @@ def add_write_node(name, **kwarg): Returns: node (obj): nuke write node """ - file_path = kwarg["path"] - knobs = kwarg["knobs"] frame_range = kwarg.get("frame_range", None) w = nuke.createNode( @@ -386,7 +384,7 @@ def add_write_node(name, **kwarg): w["file"].setValue(file_path) # finally add knob overrides - set_node_knobs_from_settings(w, knobs) + set_node_knobs_from_settings(w, knobs, **kwarg) if frame_range: w["use_limit"].setValue(True) @@ -501,7 +499,6 @@ def get_imageio_node_setting(node_class, plugin_name, subset): ''' imageio_nodes = get_nuke_imageio_settings()["nodes"] required_nodes = imageio_nodes["requiredNodes"] - override_nodes = imageio_nodes["overrideNodes"] imageio_node = None for node in required_nodes: @@ -515,14 +512,34 @@ def get_imageio_node_setting(node_class, plugin_name, subset): log.debug("__ imageio_node: {}".format(imageio_node)) + # find overrides and update knobs with them + get_imageio_node_override_setting( + node_class, + plugin_name, + subset, + imageio_node["knobs"] + ) + + log.info("ImageIO node: {}".format(imageio_node)) + return imageio_node + + +def get_imageio_node_override_setting( + node_class, plugin_name, subset, knobs_settings +): + ''' Get imageio node overrides from settings + ''' + imageio_nodes = get_nuke_imageio_settings()["nodes"] + override_nodes = imageio_nodes["overrideNodes"] + # find matching override node override_imageio_node = None for onode in override_nodes: log.info(onode) - if node_class not in node["nukeNodeClass"]: + if node_class not in onode["nukeNodeClass"]: continue - if plugin_name not in node["plugins"]: + if plugin_name not in onode["plugins"]: continue if ( @@ -538,10 +555,10 @@ def get_imageio_node_setting(node_class, plugin_name, subset): # add overrides to imageio_node if override_imageio_node: # get all knob names in imageio_node - knob_names = [k["name"] for k in imageio_node["knobs"]] + knob_names = [k["name"] for k in knobs_settings] for oknob in override_imageio_node["knobs"]: - for knob in imageio_node["knobs"]: + for knob in knobs_settings: # override matching knob name if oknob["name"] == knob["name"]: log.debug( @@ -550,7 +567,7 @@ def get_imageio_node_setting(node_class, plugin_name, subset): )) if not oknob["value"]: # remove original knob if no value found in oknob - imageio_node["knobs"].remove(knob) + knobs_settings.remove(knob) else: # override knob value with oknob's knob["value"] = oknob["value"] @@ -559,11 +576,10 @@ def get_imageio_node_setting(node_class, plugin_name, subset): if oknob["name"] not in knob_names: log.debug( "_ adding knob: `{}`".format(oknob)) - imageio_node["knobs"].append(oknob) + knobs_settings.append(oknob) knob_names.append(oknob["name"]) - log.info("ImageIO node: {}".format(imageio_node)) - return imageio_node + return knobs_settings def get_imageio_input_colorspace(filename): @@ -812,7 +828,13 @@ def add_button_clear_rendered(node, path): node.addKnob(knob) -def create_prenodes(prev_node, nodes_setting, **kwargs): +def create_prenodes( + prev_node, + nodes_setting, + plugin_name=None, + subset=None, + **kwargs +): last_node = None for_dependency = {} for name, node in nodes_setting.items(): @@ -831,6 +853,15 @@ def create_prenodes(prev_node, nodes_setting, **kwargs): "dependent": node["dependent"] } + if all([plugin_name, subset]): + # find imageio overrides + get_imageio_node_override_setting( + now_node.Class(), + plugin_name, + subset, + knobs + ) + # add data to knob set_node_knobs_from_settings(now_node, knobs, **kwargs) @@ -902,7 +933,7 @@ def create_write_node( prenodes = prenodes or {} # group node knob overrides - knob_overrides = data.get("knobs", []) + knob_overrides = data.pop("knobs", []) # filtering variables plugin_name = data["creator"] @@ -948,21 +979,6 @@ def create_write_node( log.warning("Path does not exist! I am creating it.") os.makedirs(os.path.dirname(fpath)) - _wn_props = { - "path": fpath, - "knobs": imageio_writes["knobs"] - } - - # adding dataflow template - log.debug("__ _wn_props: `{}`".format( - pformat(_wn_props) - )) - - if "frame_range" in data.keys(): - _wn_props["frame_range"] = data.get("frame_range", None) - log.debug("_wn_props[frame_range]: `{}`".format( - _wn_props["frame_range"])) - GN = nuke.createNode("Group", "name {}".format(name)) prev_node = None @@ -979,14 +995,22 @@ def create_write_node( prev_node.hideControlPanel() # creating pre-write nodes `prenodes` - last_prenode = create_prenodes(prev_node, prenodes, **kwargs) + last_prenode = create_prenodes( + prev_node, + prenodes, + plugin_name, + subset, + **kwargs + ) if last_prenode: prev_node = last_prenode # creating write node write_node = now_node = add_write_node( "inside_{}".format(name), - **_wn_props + fpath, + imageio_writes["knobs"], + **data ) write_node.hideControlPanel() # connect to previous node @@ -1063,7 +1087,7 @@ def create_write_node( # set tile color tile_color = next( iter( - k["value"] for k in _wn_props["knobs"] + k["value"] for k in imageio_writes["knobs"] if "tile_color" in k["name"] ), "0xff0000ff" ) @@ -1145,7 +1169,6 @@ def set_node_knobs_from_settings(node, knob_settings, **kwargs): elif knob_type in ["2d_vector", "3d_vector", "color"]: knob_value = [float(v) for v in knob_value] - node[knob_name].setValue(knob_value) From 63096b4e6b819081bfc3ea962ea82ab8d2ef507e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20David?= Date: Wed, 4 May 2022 18:26:58 +0200 Subject: [PATCH 269/583] change avalon API --- openpype/modules/kitsu/utils/sync_service.py | 2 +- openpype/modules/kitsu/utils/update_op_with_zou.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/modules/kitsu/utils/sync_service.py b/openpype/modules/kitsu/utils/sync_service.py index 746cb843e9..01596e2667 100644 --- a/openpype/modules/kitsu/utils/sync_service.py +++ b/openpype/modules/kitsu/utils/sync_service.py @@ -2,7 +2,7 @@ import os import gazu -from avalon.api import AvalonMongoDB +from openpype.pipeline import AvalonMongoDB from .credentials import validate_credentials from .update_op_with_zou import ( create_op_asset, diff --git a/openpype/modules/kitsu/utils/update_op_with_zou.py b/openpype/modules/kitsu/utils/update_op_with_zou.py index f43223cdf7..25c89800d4 100644 --- a/openpype/modules/kitsu/utils/update_op_with_zou.py +++ b/openpype/modules/kitsu/utils/update_op_with_zou.py @@ -10,7 +10,7 @@ from gazu.task import ( all_tasks_for_shot, ) -from avalon.api import AvalonMongoDB +from openpype.pipeline import AvalonMongoDB from openpype.api import get_project_settings from openpype.lib import create_project from openpype.modules.kitsu.utils.credentials import validate_credentials From 0359ec6da4e03bf9c7df49060a423c028195d86c Mon Sep 17 00:00:00 2001 From: "clement.hector" Date: Thu, 7 Apr 2022 15:46:40 +0200 Subject: [PATCH 270/583] create kitsu collector --- .../publish/collect_kitsu_credential.py | 18 ++++++ .../plugins/publish/collect_kitsu_entities.py | 64 +++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 openpype/modules/kitsu/plugins/publish/collect_kitsu_credential.py create mode 100644 openpype/modules/kitsu/plugins/publish/collect_kitsu_entities.py diff --git a/openpype/modules/kitsu/plugins/publish/collect_kitsu_credential.py b/openpype/modules/kitsu/plugins/publish/collect_kitsu_credential.py new file mode 100644 index 0000000000..bd0af16c8b --- /dev/null +++ b/openpype/modules/kitsu/plugins/publish/collect_kitsu_credential.py @@ -0,0 +1,18 @@ +import os + +import gazu + +import pyblish.api + + +class CollectKitsuSession(pyblish.api.ContextPlugin): + """Collect Kitsu session using user credentials""" + + order = pyblish.api.CollectorOrder + label = "Kitsu user session" + + + def process(self, context): + + gazu.client.set_host(os.environ["KITSU_SERVER"]) + gazu.log_in(os.environ["KITSU_LOGIN"], os.environ["KITSU_PWD"]) \ No newline at end of file diff --git a/openpype/modules/kitsu/plugins/publish/collect_kitsu_entities.py b/openpype/modules/kitsu/plugins/publish/collect_kitsu_entities.py new file mode 100644 index 0000000000..e4773f7b2a --- /dev/null +++ b/openpype/modules/kitsu/plugins/publish/collect_kitsu_entities.py @@ -0,0 +1,64 @@ +import os + +import gazu + +import pyblish.api + + +class CollectKitsuEntities(pyblish.api.ContextPlugin): + """Collect Kitsu entities according to the current context""" + + order = pyblish.api.CollectorOrder + 0.499 + label = "Kitsu entities" + + def process(self, context): + + os.environ["AVALON_PROJECT"], + os.environ["AVALON_ASSET"], + os.environ["AVALON_TASK"], + os.environ["AVALON_APP_NAME"] + + asset_data = context.data["assetEntity"]["data"] + zoo_asset_data = asset_data.get("zou") + if not zoo_asset_data: + raise + + kitsu_project = gazu.project.get_project(zoo_asset_data["project_id"]) + if not kitsu_project: + raise + context.data["kitsu_project"] = kitsu_project + + kitsu_asset = gazu.asset.get_asset(zoo_asset_data["entity_type_id"]) + if not kitsu_asset: + raise + context.data["kitsu_asset"] = kitsu_asset + + # kitsu_task_type = gazu.task.get_task_type_by_name(instance.data["task"]) + # if not kitsu_task_type: + # raise + # context.data["kitsu_task_type"] = kitsu_task_type + + zoo_task_data = asset_data["tasks"][os.environ["AVALON_TASK"]].get("zou") + kitsu_task = gazu.task.get_task( + asset_data["zou"], + kitsu_task_type + ) + if not kitsu_task: + raise + context.data["kitsu_task"] = kitsu_task + + wip = gazu.task.get_task_status_by_short_name("wip") + + task = gazu.task.get_task_by_name(asset, modeling) + comment = gazu.task.add_comment(task, wip, "Change status to work in progress") + + person = gazu.person.get_person_by_desktop_login("john.doe") + + # task_type = gazu.task.get_task_type_by_name(instance.data["task"]) + + # entity_task = gazu.task.get_task_by_entity( + # asset_data["zou"], + # task_type + # ) + + raise \ No newline at end of file From 196c81ab78dffdafcca28c00657d11c799a9d7f4 Mon Sep 17 00:00:00 2001 From: "clement.hector" Date: Fri, 8 Apr 2022 12:03:43 +0200 Subject: [PATCH 271/583] collect all kitsu entities in context --- .../plugins/publish/collect_kitsu_entities.py | 51 ++++++------------- 1 file changed, 15 insertions(+), 36 deletions(-) diff --git a/openpype/modules/kitsu/plugins/publish/collect_kitsu_entities.py b/openpype/modules/kitsu/plugins/publish/collect_kitsu_entities.py index e4773f7b2a..f599fd0c14 100644 --- a/openpype/modules/kitsu/plugins/publish/collect_kitsu_entities.py +++ b/openpype/modules/kitsu/plugins/publish/collect_kitsu_entities.py @@ -13,52 +13,31 @@ class CollectKitsuEntities(pyblish.api.ContextPlugin): def process(self, context): - os.environ["AVALON_PROJECT"], - os.environ["AVALON_ASSET"], - os.environ["AVALON_TASK"], - os.environ["AVALON_APP_NAME"] - asset_data = context.data["assetEntity"]["data"] zoo_asset_data = asset_data.get("zou") if not zoo_asset_data: - raise + raise AssertionError("Zoo asset data not found in OpenPype!") + self.log.debug("Collected zoo asset data: {}".format(zoo_asset_data)) + + zoo_task_data = asset_data["tasks"][os.environ["AVALON_TASK"]].get("zou") + if not zoo_task_data: + raise AssertionError("Zoo task data not found in OpenPype!") + self.log.debug("Collected zoo task data: {}".format(zoo_task_data)) kitsu_project = gazu.project.get_project(zoo_asset_data["project_id"]) if not kitsu_project: - raise + raise AssertionError("Project not not found in kitsu!") context.data["kitsu_project"] = kitsu_project + self.log.debug("Collect kitsu project: {}".format(kitsu_project)) - kitsu_asset = gazu.asset.get_asset(zoo_asset_data["entity_type_id"]) + kitsu_asset = gazu.asset.get_asset(zoo_asset_data["id"]) if not kitsu_asset: - raise + raise AssertionError("Asset not not found in kitsu!") context.data["kitsu_asset"] = kitsu_asset + self.log.debug("Collect kitsu asset: {}".format(kitsu_asset)) - # kitsu_task_type = gazu.task.get_task_type_by_name(instance.data["task"]) - # if not kitsu_task_type: - # raise - # context.data["kitsu_task_type"] = kitsu_task_type - - zoo_task_data = asset_data["tasks"][os.environ["AVALON_TASK"]].get("zou") - kitsu_task = gazu.task.get_task( - asset_data["zou"], - kitsu_task_type - ) + kitsu_task = gazu.task.get_task(zoo_task_data["id"]) if not kitsu_task: - raise + raise AssertionError("Task not not found in kitsu!") context.data["kitsu_task"] = kitsu_task - - wip = gazu.task.get_task_status_by_short_name("wip") - - task = gazu.task.get_task_by_name(asset, modeling) - comment = gazu.task.add_comment(task, wip, "Change status to work in progress") - - person = gazu.person.get_person_by_desktop_login("john.doe") - - # task_type = gazu.task.get_task_type_by_name(instance.data["task"]) - - # entity_task = gazu.task.get_task_by_entity( - # asset_data["zou"], - # task_type - # ) - - raise \ No newline at end of file + self.log.debug("Collect kitsu task: {}".format(kitsu_task)) From eb288959f50666e0c9a4751d7908decd540de9de Mon Sep 17 00:00:00 2001 From: "clement.hector" Date: Sat, 9 Apr 2022 11:13:48 +0200 Subject: [PATCH 272/583] integrate note and status --- .../publish/collect_kitsu_credential.py | 2 +- .../plugins/publish/integrate_kitsu_note.py | 36 +++++++++++++++++++ .../plugins/publish/integrate_kitsu_review.py | 16 +++++++++ .../plugins/publish/validate_kitsu_intent.py | 27 ++++++++++++++ 4 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py create mode 100644 openpype/modules/kitsu/plugins/publish/integrate_kitsu_review.py create mode 100644 openpype/modules/kitsu/plugins/publish/validate_kitsu_intent.py diff --git a/openpype/modules/kitsu/plugins/publish/collect_kitsu_credential.py b/openpype/modules/kitsu/plugins/publish/collect_kitsu_credential.py index bd0af16c8b..c9d94d128a 100644 --- a/openpype/modules/kitsu/plugins/publish/collect_kitsu_credential.py +++ b/openpype/modules/kitsu/plugins/publish/collect_kitsu_credential.py @@ -10,7 +10,7 @@ class CollectKitsuSession(pyblish.api.ContextPlugin): order = pyblish.api.CollectorOrder label = "Kitsu user session" - + # families = ["kitsu"] def process(self, context): diff --git a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py new file mode 100644 index 0000000000..5601dea586 --- /dev/null +++ b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py @@ -0,0 +1,36 @@ +import gazu +import pyblish.api + + +class IntegrateKitsuNote(pyblish.api.ContextPlugin): + """Integrate Kitsu Note""" + + order = pyblish.api.IntegratorOrder + label = "Kitsu Note and Status" + # families = ["kitsu"] + optional = True + + def process(self, context): + + publish_comment = context.data.get("comment") + if not publish_comment: + self.log.info("Comment is not set.") + + publish_status = context.data.get("intent", {}).get("value") + if not publish_status: + self.log.info("Status is not set.") + + self.log.debug("Comment is `{}`".format(publish_comment)) + self.log.debug("Status is `{}`".format(publish_status)) + + kitsu_status = context.data.get("kitsu_status") + if not kitsu_status: + self.log.info("The status will not be changed") + kitsu_status = context.data["kitsu_task"].get("task_status") + self.log.debug("Kitsu status: {}".format(kitsu_status)) + + gazu.task.add_comment( + context.data["kitsu_task"], + kitsu_status, + comment = publish_comment + ) \ No newline at end of file diff --git a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_review.py b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_review.py new file mode 100644 index 0000000000..1853bf569f --- /dev/null +++ b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_review.py @@ -0,0 +1,16 @@ +# import gazu +import pyblish.api + + +class IntegrateKitsuVersion(pyblish.api.InstancePlugin): + """Integrate Kitsu Review""" + + order = pyblish.api.IntegratorOrder + label = "Kitsu Review" + # families = ["kitsu"] + + def process(self, instance): + pass + + # gazu.task.upload_preview_file(preview, file_path, normalize_movie=True, client=) + # gazu.task.add_preview(task, comment, preview_file_path, normalize_movie=True, client=) \ No newline at end of file diff --git a/openpype/modules/kitsu/plugins/publish/validate_kitsu_intent.py b/openpype/modules/kitsu/plugins/publish/validate_kitsu_intent.py new file mode 100644 index 0000000000..9708ebb0dd --- /dev/null +++ b/openpype/modules/kitsu/plugins/publish/validate_kitsu_intent.py @@ -0,0 +1,27 @@ +from typing import Optional +import pyblish.api +import gazu + + +class IntegrateKitsuNote(pyblish.api.ContextPlugin): + """Integrate Kitsu Note""" + + order = pyblish.api.ValidatorOrder + label = "Kitsu Intent/Status" + # families = ["kitsu"] + optional = True + + def process(self, context): + + publish_status = context.data.get("intent", {}).get("value") + if not publish_status: + self.log.info("Status is not set.") + + kitsu_status = gazu.task.get_task_status_by_short_name(publish_status) + if not kitsu_status: + raise AssertionError( + "Status `{}` not not found in kitsu!".format(kitsu_status) + ) + self.log.debug("Collect kitsu status: {}".format(kitsu_status)) + + context.data["kitsu_status"] = kitsu_status \ No newline at end of file From ff6c8a6a54b2146fce11b78ea7dccf048536d1e8 Mon Sep 17 00:00:00 2001 From: "clement.hector" Date: Sun, 10 Apr 2022 10:47:11 +0200 Subject: [PATCH 273/583] Add kitsu log out --- .../plugins/publish/collect_kitsu_credential.py | 4 ++-- .../plugins/publish/collect_kitsu_entities.py | 2 +- .../plugins/publish/integrate_kitsu_file.py | 1 + .../plugins/publish/integrate_kitsu_note.py | 1 + .../plugins/publish/integrate_kitsu_review.py | 3 ++- .../kitsu/plugins/publish/other_kitsu_log_out.py | 16 ++++++++++++++++ 6 files changed, 23 insertions(+), 4 deletions(-) create mode 100644 openpype/modules/kitsu/plugins/publish/integrate_kitsu_file.py create mode 100644 openpype/modules/kitsu/plugins/publish/other_kitsu_log_out.py diff --git a/openpype/modules/kitsu/plugins/publish/collect_kitsu_credential.py b/openpype/modules/kitsu/plugins/publish/collect_kitsu_credential.py index c9d94d128a..4a27117e03 100644 --- a/openpype/modules/kitsu/plugins/publish/collect_kitsu_credential.py +++ b/openpype/modules/kitsu/plugins/publish/collect_kitsu_credential.py @@ -1,11 +1,11 @@ +# -*- coding: utf-8 -*- import os import gazu - import pyblish.api -class CollectKitsuSession(pyblish.api.ContextPlugin): +class CollectKitsuSession(pyblish.api.ContextPlugin): #rename log in """Collect Kitsu session using user credentials""" order = pyblish.api.CollectorOrder diff --git a/openpype/modules/kitsu/plugins/publish/collect_kitsu_entities.py b/openpype/modules/kitsu/plugins/publish/collect_kitsu_entities.py index f599fd0c14..c5df20b349 100644 --- a/openpype/modules/kitsu/plugins/publish/collect_kitsu_entities.py +++ b/openpype/modules/kitsu/plugins/publish/collect_kitsu_entities.py @@ -1,7 +1,7 @@ +# -*- coding: utf-8 -*- import os import gazu - import pyblish.api diff --git a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_file.py b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_file.py new file mode 100644 index 0000000000..7c68785e9d --- /dev/null +++ b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_file.py @@ -0,0 +1 @@ +# -*- coding: utf-8 -*- \ No newline at end of file diff --git a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py index 5601dea586..8844581237 100644 --- a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py +++ b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- import gazu import pyblish.api diff --git a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_review.py b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_review.py index 1853bf569f..a800ca9b57 100644 --- a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_review.py +++ b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_review.py @@ -1,4 +1,5 @@ -# import gazu +# -*- coding: utf-8 -*- +import gazu import pyblish.api diff --git a/openpype/modules/kitsu/plugins/publish/other_kitsu_log_out.py b/openpype/modules/kitsu/plugins/publish/other_kitsu_log_out.py new file mode 100644 index 0000000000..ff76d9c4f6 --- /dev/null +++ b/openpype/modules/kitsu/plugins/publish/other_kitsu_log_out.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +""".""" +import gazu +import pyblish.api + + +class KitsuLogOut(pyblish.api.ContextPlugin): + """ + Log out from Kitsu API + """ + + order = pyblish.api.IntegratorOrder + 10 + label = "Kitsu Log Out" + + def process(self, context): + gazu.client.log_out() From ed8c01c2639321126b43d84ad83bd06496cb1bad Mon Sep 17 00:00:00 2001 From: "clement.hector" Date: Wed, 20 Apr 2022 11:57:33 +0200 Subject: [PATCH 274/583] upload file to kitsu --- .../plugins/publish/integrate_kitsu_note.py | 8 ++++--- .../plugins/publish/integrate_kitsu_review.py | 24 +++++++++++++++---- .../plugins/publish/validate_kitsu_intent.py | 5 ++-- 3 files changed, 27 insertions(+), 10 deletions(-) diff --git a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py index 8844581237..afe388fd82 100644 --- a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py +++ b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py @@ -9,7 +9,7 @@ class IntegrateKitsuNote(pyblish.api.ContextPlugin): order = pyblish.api.IntegratorOrder label = "Kitsu Note and Status" # families = ["kitsu"] - optional = True + # optional = True def process(self, context): @@ -30,8 +30,10 @@ class IntegrateKitsuNote(pyblish.api.ContextPlugin): kitsu_status = context.data["kitsu_task"].get("task_status") self.log.debug("Kitsu status: {}".format(kitsu_status)) - gazu.task.add_comment( + kitsu_comment = gazu.task.add_comment( context.data["kitsu_task"], kitsu_status, comment = publish_comment - ) \ No newline at end of file + ) + + context.data["kitsu_comment"] = kitsu_comment \ No newline at end of file diff --git a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_review.py b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_review.py index a800ca9b57..e69937e9bf 100644 --- a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_review.py +++ b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_review.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +import os import gazu import pyblish.api @@ -6,12 +7,27 @@ import pyblish.api class IntegrateKitsuVersion(pyblish.api.InstancePlugin): """Integrate Kitsu Review""" - order = pyblish.api.IntegratorOrder + order = pyblish.api.IntegratorOrder + 0.01 label = "Kitsu Review" # families = ["kitsu"] def process(self, instance): - pass - # gazu.task.upload_preview_file(preview, file_path, normalize_movie=True, client=) - # gazu.task.add_preview(task, comment, preview_file_path, normalize_movie=True, client=) \ No newline at end of file + context = instance.context + task = context.data["kitsu_task"] + comment = context.data["kitsu_comment"] + + for representation in instance.data.get("representations", []): + + local_path = representation.get("published_path") + self.log.info("*"*40) + self.log.info(local_path) + self.log.info(representation.get("tags", [])) + + # code = os.path.basename(local_path) + + if representation.get("tags", []): + continue + + # gazu.task.upload_preview_file(preview, file_path, normalize_movie=True) + gazu.task.add_preview(task, comment, local_path, normalize_movie=True) \ No newline at end of file diff --git a/openpype/modules/kitsu/plugins/publish/validate_kitsu_intent.py b/openpype/modules/kitsu/plugins/publish/validate_kitsu_intent.py index 9708ebb0dd..0597e8546a 100644 --- a/openpype/modules/kitsu/plugins/publish/validate_kitsu_intent.py +++ b/openpype/modules/kitsu/plugins/publish/validate_kitsu_intent.py @@ -1,10 +1,9 @@ -from typing import Optional import pyblish.api import gazu -class IntegrateKitsuNote(pyblish.api.ContextPlugin): - """Integrate Kitsu Note""" +class ValidateKitsuIntent(pyblish.api.ContextPlugin): + """Validate Kitsu Status""" order = pyblish.api.ValidatorOrder label = "Kitsu Intent/Status" From 2e0f6ce42d631674d910dc3caffc1654b6d781f2 Mon Sep 17 00:00:00 2001 From: "clement.hector" Date: Wed, 20 Apr 2022 17:47:17 +0200 Subject: [PATCH 275/583] use task type if no task data in OP --- .../plugins/publish/collect_kitsu_entities.py | 33 +++++++++++++++---- 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/openpype/modules/kitsu/plugins/publish/collect_kitsu_entities.py b/openpype/modules/kitsu/plugins/publish/collect_kitsu_entities.py index c5df20b349..c907c22e0f 100644 --- a/openpype/modules/kitsu/plugins/publish/collect_kitsu_entities.py +++ b/openpype/modules/kitsu/plugins/publish/collect_kitsu_entities.py @@ -21,7 +21,7 @@ class CollectKitsuEntities(pyblish.api.ContextPlugin): zoo_task_data = asset_data["tasks"][os.environ["AVALON_TASK"]].get("zou") if not zoo_task_data: - raise AssertionError("Zoo task data not found in OpenPype!") + self.log.warning("Zoo task data not found in OpenPype!") self.log.debug("Collected zoo task data: {}".format(zoo_task_data)) kitsu_project = gazu.project.get_project(zoo_asset_data["project_id"]) @@ -36,8 +36,29 @@ class CollectKitsuEntities(pyblish.api.ContextPlugin): context.data["kitsu_asset"] = kitsu_asset self.log.debug("Collect kitsu asset: {}".format(kitsu_asset)) - kitsu_task = gazu.task.get_task(zoo_task_data["id"]) - if not kitsu_task: - raise AssertionError("Task not not found in kitsu!") - context.data["kitsu_task"] = kitsu_task - self.log.debug("Collect kitsu task: {}".format(kitsu_task)) + if zoo_task_data: + kitsu_task = gazu.task.get_task(zoo_task_data["id"]) + if not kitsu_task: + raise AssertionError("Task not not found in kitsu!") + context.data["kitsu_task"] = kitsu_task + self.log.debug("Collect kitsu task: {}".format(kitsu_task)) + + else: + kitsu_task_type = gazu.task.get_task_type_by_name( + os.environ["AVALON_TASK"] + ) + if not kitsu_task_type: + raise AssertionError( + "Task type {} not found in Kitsu!".format( + os.environ["AVALON_TASK"] + ) + ) + + kitsu_task = gazu.task.get_task_by_name( + kitsu_asset, + kitsu_task_type + ) + if not kitsu_task: + raise AssertionError("Task not not found in kitsu!") + context.data["kitsu_task"] = kitsu_task + self.log.debug("Collect kitsu task: {}".format(kitsu_task)) \ No newline at end of file From f72cb8ba9e6201be23f09515e3e3190408fc30a0 Mon Sep 17 00:00:00 2001 From: "clement.hector" Date: Wed, 20 Apr 2022 17:56:39 +0200 Subject: [PATCH 276/583] fix log out --- openpype/modules/kitsu/plugins/publish/other_kitsu_log_out.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/kitsu/plugins/publish/other_kitsu_log_out.py b/openpype/modules/kitsu/plugins/publish/other_kitsu_log_out.py index ff76d9c4f6..d7e1616f8d 100644 --- a/openpype/modules/kitsu/plugins/publish/other_kitsu_log_out.py +++ b/openpype/modules/kitsu/plugins/publish/other_kitsu_log_out.py @@ -13,4 +13,4 @@ class KitsuLogOut(pyblish.api.ContextPlugin): label = "Kitsu Log Out" def process(self, context): - gazu.client.log_out() + gazu.log_out() From 3d6a6fcbf065c5ed000aea43c7ce4b5db91cd47a Mon Sep 17 00:00:00 2001 From: "clement.hector" Date: Wed, 20 Apr 2022 18:10:58 +0200 Subject: [PATCH 277/583] upload review --- .../plugins/publish/integrate_kitsu_review.py | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_review.py b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_review.py index e69937e9bf..23ee4a668e 100644 --- a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_review.py +++ b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_review.py @@ -1,15 +1,17 @@ # -*- coding: utf-8 -*- import os +from typing import Optional import gazu import pyblish.api -class IntegrateKitsuVersion(pyblish.api.InstancePlugin): +class IntegrateKitsuReview(pyblish.api.InstancePlugin): """Integrate Kitsu Review""" order = pyblish.api.IntegratorOrder + 0.01 label = "Kitsu Review" # families = ["kitsu"] + optional = True def process(self, instance): @@ -20,14 +22,16 @@ class IntegrateKitsuVersion(pyblish.api.InstancePlugin): for representation in instance.data.get("representations", []): local_path = representation.get("published_path") - self.log.info("*"*40) - self.log.info(local_path) - self.log.info(representation.get("tags", [])) - # code = os.path.basename(local_path) - - if representation.get("tags", []): + if 'review' not in representation.get("tags", []): continue - # gazu.task.upload_preview_file(preview, file_path, normalize_movie=True) - gazu.task.add_preview(task, comment, local_path, normalize_movie=True) \ No newline at end of file + self.log.debug("Found review at: {}".format(local_path)) + + gazu.task.add_preview( + task, + comment, + local_path, + normalize_movie=True + ) + self.log.info("Review upload on comment") From db8719b895cf553de6f24ad80f610fff28acfc21 Mon Sep 17 00:00:00 2001 From: "clement.hector" Date: Wed, 20 Apr 2022 18:11:38 +0200 Subject: [PATCH 278/583] remove unused import --- .../modules/kitsu/plugins/publish/integrate_kitsu_review.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_review.py b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_review.py index 23ee4a668e..59b3bcf53e 100644 --- a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_review.py +++ b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_review.py @@ -1,6 +1,4 @@ # -*- coding: utf-8 -*- -import os -from typing import Optional import gazu import pyblish.api From 7787056c969b0ca91f2f1eee1d58c6c92de2e4f8 Mon Sep 17 00:00:00 2001 From: "clement.hector" Date: Wed, 20 Apr 2022 18:14:14 +0200 Subject: [PATCH 279/583] Do some cleanup --- .../plugins/publish/collect_kitsu_entities.py | 37 +++++++------- .../plugins/publish/integrate_kitsu_file.py | 1 - .../plugins/publish/integrate_kitsu_note.py | 5 +- .../plugins/publish/integrate_kitsu_review.py | 8 +-- .../kitsu/plugins/publish/kitsu_plugin.py | 49 ------------------- .../plugins/publish/other_kitsu_log_out.py | 1 - .../plugins/publish/validate_kitsu_intent.py | 5 +- 7 files changed, 28 insertions(+), 78 deletions(-) delete mode 100644 openpype/modules/kitsu/plugins/publish/integrate_kitsu_file.py delete mode 100644 openpype/modules/kitsu/plugins/publish/kitsu_plugin.py diff --git a/openpype/modules/kitsu/plugins/publish/collect_kitsu_entities.py b/openpype/modules/kitsu/plugins/publish/collect_kitsu_entities.py index c907c22e0f..935b020641 100644 --- a/openpype/modules/kitsu/plugins/publish/collect_kitsu_entities.py +++ b/openpype/modules/kitsu/plugins/publish/collect_kitsu_entities.py @@ -14,35 +14,36 @@ class CollectKitsuEntities(pyblish.api.ContextPlugin): def process(self, context): asset_data = context.data["assetEntity"]["data"] - zoo_asset_data = asset_data.get("zou") - if not zoo_asset_data: - raise AssertionError("Zoo asset data not found in OpenPype!") - self.log.debug("Collected zoo asset data: {}".format(zoo_asset_data)) + zou_asset_data = asset_data.get("zou") + if not zou_asset_data: + raise AssertionError("Zou asset data not found in OpenPype!") + self.log.debug("Collected zou asset data: {}".format(zou_asset_data)) - zoo_task_data = asset_data["tasks"][os.environ["AVALON_TASK"]].get("zou") - if not zoo_task_data: - self.log.warning("Zoo task data not found in OpenPype!") - self.log.debug("Collected zoo task data: {}".format(zoo_task_data)) + zou_task_data = asset_data["tasks"][ + os.environ["AVALON_TASK"]].get("zou") + if not zou_task_data: + self.log.warning("Zou task data not found in OpenPype!") + self.log.debug("Collected zou task data: {}".format(zou_task_data)) - kitsu_project = gazu.project.get_project(zoo_asset_data["project_id"]) + kitsu_project = gazu.project.get_project(zou_asset_data["project_id"]) if not kitsu_project: - raise AssertionError("Project not not found in kitsu!") + raise AssertionError("Project not found in kitsu!") context.data["kitsu_project"] = kitsu_project self.log.debug("Collect kitsu project: {}".format(kitsu_project)) - kitsu_asset = gazu.asset.get_asset(zoo_asset_data["id"]) + kitsu_asset = gazu.asset.get_asset(zou_asset_data["id"]) if not kitsu_asset: - raise AssertionError("Asset not not found in kitsu!") + raise AssertionError("Asset not found in kitsu!") context.data["kitsu_asset"] = kitsu_asset self.log.debug("Collect kitsu asset: {}".format(kitsu_asset)) - if zoo_task_data: - kitsu_task = gazu.task.get_task(zoo_task_data["id"]) + if zou_task_data: + kitsu_task = gazu.task.get_task(zou_task_data["id"]) if not kitsu_task: - raise AssertionError("Task not not found in kitsu!") + raise AssertionError("Task not found in kitsu!") context.data["kitsu_task"] = kitsu_task self.log.debug("Collect kitsu task: {}".format(kitsu_task)) - + else: kitsu_task_type = gazu.task.get_task_type_by_name( os.environ["AVALON_TASK"] @@ -59,6 +60,6 @@ class CollectKitsuEntities(pyblish.api.ContextPlugin): kitsu_task_type ) if not kitsu_task: - raise AssertionError("Task not not found in kitsu!") + raise AssertionError("Task not found in kitsu!") context.data["kitsu_task"] = kitsu_task - self.log.debug("Collect kitsu task: {}".format(kitsu_task)) \ No newline at end of file + self.log.debug("Collect kitsu task: {}".format(kitsu_task)) diff --git a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_file.py b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_file.py deleted file mode 100644 index 7c68785e9d..0000000000 --- a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_file.py +++ /dev/null @@ -1 +0,0 @@ -# -*- coding: utf-8 -*- \ No newline at end of file diff --git a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py index afe388fd82..61e4d2454c 100644 --- a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py +++ b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py @@ -9,7 +9,6 @@ class IntegrateKitsuNote(pyblish.api.ContextPlugin): order = pyblish.api.IntegratorOrder label = "Kitsu Note and Status" # families = ["kitsu"] - # optional = True def process(self, context): @@ -31,8 +30,8 @@ class IntegrateKitsuNote(pyblish.api.ContextPlugin): self.log.debug("Kitsu status: {}".format(kitsu_status)) kitsu_comment = gazu.task.add_comment( - context.data["kitsu_task"], - kitsu_status, + context.data["kitsu_task"], + kitsu_status, comment = publish_comment ) diff --git a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_review.py b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_review.py index 59b3bcf53e..c38f14e8a4 100644 --- a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_review.py +++ b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_review.py @@ -19,17 +19,17 @@ class IntegrateKitsuReview(pyblish.api.InstancePlugin): for representation in instance.data.get("representations", []): - local_path = representation.get("published_path") + review_path = representation.get("published_path") if 'review' not in representation.get("tags", []): continue - - self.log.debug("Found review at: {}".format(local_path)) + + self.log.debug("Found review at: {}".format(review_path)) gazu.task.add_preview( task, comment, - local_path, + review_path, normalize_movie=True ) self.log.info("Review upload on comment") diff --git a/openpype/modules/kitsu/plugins/publish/kitsu_plugin.py b/openpype/modules/kitsu/plugins/publish/kitsu_plugin.py deleted file mode 100644 index 5d6c76bc3f..0000000000 --- a/openpype/modules/kitsu/plugins/publish/kitsu_plugin.py +++ /dev/null @@ -1,49 +0,0 @@ -import os - -import gazu - -import pyblish.api - - -class CollectExampleAddon(pyblish.api.ContextPlugin): - order = pyblish.api.CollectorOrder + 0.4 - label = "Collect Kitsu" - - def process(self, context): - self.log.info("I'm in Kitsu's plugin!") - - -class IntegrateRig(pyblish.api.InstancePlugin): - """Copy files to an appropriate location where others may reach it""" - - order = pyblish.api.IntegratorOrder - families = ["model"] - - def process(self, instance): - - # Connect to server - gazu.client.set_host(os.environ["KITSU_SERVER"]) - - # Authenticate - gazu.log_in(os.environ["KITSU_LOGIN"], os.environ["KITSU_PWD"]) - - asset_data = instance.data["assetEntity"]["data"] - - # Get task - task_type = gazu.task.get_task_type_by_name(instance.data["task"]) - entity_task = gazu.task.get_task_by_entity( - asset_data["zou"], task_type - ) - - # Comment entity - gazu.task.add_comment( - entity_task, - entity_task["task_status_id"], - comment="Version {} has been published!\n".format( - instance.data["version"] - ) - # Add written comment in Pyblish - + "\n{}".format(instance.data["versionEntity"]["data"]["comment"]), - ) - - self.log.info("Version published to Kitsu successfully!") diff --git a/openpype/modules/kitsu/plugins/publish/other_kitsu_log_out.py b/openpype/modules/kitsu/plugins/publish/other_kitsu_log_out.py index d7e1616f8d..c4a5b390e0 100644 --- a/openpype/modules/kitsu/plugins/publish/other_kitsu_log_out.py +++ b/openpype/modules/kitsu/plugins/publish/other_kitsu_log_out.py @@ -1,5 +1,4 @@ # -*- coding: utf-8 -*- -""".""" import gazu import pyblish.api diff --git a/openpype/modules/kitsu/plugins/publish/validate_kitsu_intent.py b/openpype/modules/kitsu/plugins/publish/validate_kitsu_intent.py index 0597e8546a..c82130b33b 100644 --- a/openpype/modules/kitsu/plugins/publish/validate_kitsu_intent.py +++ b/openpype/modules/kitsu/plugins/publish/validate_kitsu_intent.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- import pyblish.api import gazu @@ -9,7 +10,7 @@ class ValidateKitsuIntent(pyblish.api.ContextPlugin): label = "Kitsu Intent/Status" # families = ["kitsu"] optional = True - + def process(self, context): publish_status = context.data.get("intent", {}).get("value") @@ -19,7 +20,7 @@ class ValidateKitsuIntent(pyblish.api.ContextPlugin): kitsu_status = gazu.task.get_task_status_by_short_name(publish_status) if not kitsu_status: raise AssertionError( - "Status `{}` not not found in kitsu!".format(kitsu_status) + "Status `{}` not found in kitsu!".format(kitsu_status) ) self.log.debug("Collect kitsu status: {}".format(kitsu_status)) From 3744b21266258fdd69814807a4152e268dca3d27 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Thu, 5 May 2022 09:46:51 +0200 Subject: [PATCH 280/583] fix the output dir --- .../modules/deadline/plugins/publish/submit_maya_deadline.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py index 8f776a3371..7bf68f51ee 100644 --- a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py @@ -266,6 +266,7 @@ def get_renderer_variables(renderlayer, root): filename_prefix = cmds.getAttr(prefix_attr) return {"ext": extension, + "renderer": renderer, "filename_prefix": filename_prefix, "padding": padding, "filename_0": filename_0} @@ -440,7 +441,8 @@ class MayaSubmitDeadline(pyblish.api.InstancePlugin): output_filename_0 = filename_0 - dirname = os.path.dirname(output_filename_0) + if render_variables["renderer"] == "renderman": + dirname = os.path.dirname(output_filename_0) # Create render folder ---------------------------------------------- try: From 1997eaf0f4bfa1a47bf849afed1d6c0f313fef5a Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 5 May 2022 11:26:53 +0200 Subject: [PATCH 281/583] Remove project_settings.py --- openpype/widgets/project_settings.py | 494 --------------------------- 1 file changed, 494 deletions(-) delete mode 100644 openpype/widgets/project_settings.py diff --git a/openpype/widgets/project_settings.py b/openpype/widgets/project_settings.py deleted file mode 100644 index 687e17b3bf..0000000000 --- a/openpype/widgets/project_settings.py +++ /dev/null @@ -1,494 +0,0 @@ -import os -import getpass -import platform - -from Qt import QtCore, QtGui, QtWidgets - -from openpype import style -import ftrack_api - - -class Project_name_getUI(QtWidgets.QWidget): - ''' - Project setting ui: here all the neceserry ui widgets are created - they are going to be used i later proces for dynamic linking of project - in list to project's attributes - ''' - - def __init__(self, parent=None): - super(Project_name_getUI, self).__init__(parent) - - self.platform = platform.system() - self.new_index = 0 - # get projects from ftrack - self.session = ftrack_api.Session() - self.projects_from_ft = self.session.query( - 'Project where status is active') - self.disks_from_ft = self.session.query('Disk') - self.schemas_from_ft = self.session.query('ProjectSchema') - self.projects = self._get_projects_ftrack() - - # define window geometry - self.setWindowTitle('Set project attributes') - self.setWindowFlags(QtCore.Qt.WindowStaysOnTopHint) - self.resize(550, 340) - self.setStyleSheet(style.load_stylesheet()) - - # define disk combobox widget - self.disks = self._get_all_disks() - self.disk_combobox_label = QtWidgets.QLabel('Destination storage:') - self.disk_combobox = QtWidgets.QComboBox() - - # define schema combobox widget - self.schemas = self._get_all_schemas() - self.schema_combobox_label = QtWidgets.QLabel('Project schema:') - self.schema_combobox = QtWidgets.QComboBox() - - # define fps widget - self.fps_label = QtWidgets.QLabel('Fps:') - self.fps_label.setAlignment( - QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter) - self.fps = QtWidgets.QLineEdit() - - # define project dir widget - self.project_dir_label = QtWidgets.QLabel('Project dir:') - self.project_dir_label.setAlignment( - QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter) - self.project_dir = QtWidgets.QLineEdit() - - self.project_path_label = QtWidgets.QLabel( - 'Project_path (if not then created):') - self.project_path_label.setAlignment( - QtCore.Qt.AlignHCenter | QtCore.Qt.AlignVCenter) - project_path_font = QtGui.QFont( - "Helvetica [Cronyx]", 12, QtGui.QFont.Bold) - self.project_path = QtWidgets.QLabel() - self.project_path.setObjectName('nom_plan_label') - self.project_path.setStyleSheet( - 'QtWidgets.QLabel#nom_plan_label {color: red}') - self.project_path.setAlignment( - QtCore.Qt.AlignHCenter | QtCore.Qt.AlignVCenter) - self.project_path.setFont(project_path_font) - - # define handles widget - self.handles_label = QtWidgets.QLabel('Handles:') - self.handles_label.setAlignment( - QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter) - self.handles = QtWidgets.QLineEdit() - - # define resolution widget - self.resolution_w_label = QtWidgets.QLabel('W:') - self.resolution_w = QtWidgets.QLineEdit() - self.resolution_h_label = QtWidgets.QLabel('H:') - self.resolution_h = QtWidgets.QLineEdit() - - devider = QtWidgets.QFrame() - # devider.Shape(QFrame.HLine) - devider.setFrameShape(QtWidgets.QFrame.HLine) - devider.setFrameShadow(QtWidgets.QFrame.Sunken) - - self.generate_lines() - - # define push buttons - self.set_pushbutton = QtWidgets.QPushButton('Set project') - self.cancel_pushbutton = QtWidgets.QPushButton('Cancel') - - # definition of layouts - ############################################ - action_layout = QtWidgets.QHBoxLayout() - action_layout.addWidget(self.set_pushbutton) - action_layout.addWidget(self.cancel_pushbutton) - - # schema property - schema_layout = QtWidgets.QGridLayout() - schema_layout.addWidget(self.schema_combobox, 0, 1) - schema_layout.addWidget(self.schema_combobox_label, 0, 0) - - # storage property - storage_layout = QtWidgets.QGridLayout() - storage_layout.addWidget(self.disk_combobox, 0, 1) - storage_layout.addWidget(self.disk_combobox_label, 0, 0) - - # fps property - fps_layout = QtWidgets.QGridLayout() - fps_layout.addWidget(self.fps, 1, 1) - fps_layout.addWidget(self.fps_label, 1, 0) - - # project dir property - project_dir_layout = QtWidgets.QGridLayout() - project_dir_layout.addWidget(self.project_dir, 1, 1) - project_dir_layout.addWidget(self.project_dir_label, 1, 0) - - # project path property - project_path_layout = QtWidgets.QGridLayout() - spacer_1_item = QtWidgets.QSpacerItem(10, 10) - project_path_layout.addItem(spacer_1_item, 0, 1) - project_path_layout.addWidget(self.project_path_label, 1, 1) - project_path_layout.addWidget(self.project_path, 2, 1) - spacer_2_item = QtWidgets.QSpacerItem(20, 20) - project_path_layout.addItem(spacer_2_item, 3, 1) - - # handles property - handles_layout = QtWidgets.QGridLayout() - handles_layout.addWidget(self.handles, 1, 1) - handles_layout.addWidget(self.handles_label, 1, 0) - - # resolution property - resolution_layout = QtWidgets.QGridLayout() - resolution_layout.addWidget(self.resolution_w_label, 1, 1) - resolution_layout.addWidget(self.resolution_w, 2, 1) - resolution_layout.addWidget(self.resolution_h_label, 1, 2) - resolution_layout.addWidget(self.resolution_h, 2, 2) - - # form project property layout - p_layout = QtWidgets.QGridLayout() - p_layout.addLayout(storage_layout, 1, 0) - p_layout.addLayout(schema_layout, 2, 0) - p_layout.addLayout(project_dir_layout, 3, 0) - p_layout.addLayout(fps_layout, 4, 0) - p_layout.addLayout(handles_layout, 5, 0) - p_layout.addLayout(resolution_layout, 6, 0) - p_layout.addWidget(devider, 7, 0) - spacer_item = QtWidgets.QSpacerItem( - 150, - 40, - QtWidgets.QSizePolicy.Minimum, - QtWidgets.QSizePolicy.Expanding - ) - p_layout.addItem(spacer_item, 8, 0) - - # form with list to one layout with project property - list_layout = QtWidgets.QGridLayout() - list_layout.addLayout(p_layout, 1, 0) - list_layout.addWidget(self.listWidget, 1, 1) - - root_layout = QtWidgets.QVBoxLayout() - root_layout.addLayout(project_path_layout) - root_layout.addWidget(devider) - root_layout.addLayout(list_layout) - root_layout.addLayout(action_layout) - - self.setLayout(root_layout) - - def generate_lines(self): - ''' - Will generate lines of project list - ''' - - self.listWidget = QtWidgets.QListWidget() - for self.index, p in enumerate(self.projects): - item = QtWidgets.QListWidgetItem("{full_name}".format(**p)) - # item.setSelected(False) - self.listWidget.addItem(item) - print(self.listWidget.indexFromItem(item)) - # self.listWidget.setCurrentItem(self.listWidget.itemFromIndex(1)) - - # add options to schemas widget - self.schema_combobox.addItems(self.schemas) - - # add options to disk widget - self.disk_combobox.addItems(self.disks) - - # populate content of project info widgets - self.projects[1] = self._fill_project_attributes_widgets(p, None) - - def _fill_project_attributes_widgets(self, p=None, index=None): - ''' - will generate actual informations wich are saved on ftrack - ''' - - if index is None: - self.new_index = 1 - - if not p: - pass - # change schema selection - for i, schema in enumerate(self.schemas): - if p['project_schema']['name'] in schema: - break - self.schema_combobox.setCurrentIndex(i) - - disk_name, disk_path = self._build_disk_path() - for i, disk in enumerate(self.disks): - if disk_name in disk: - break - # change disk selection - self.disk_combobox.setCurrentIndex(i) - - # change project_dir selection - if "{root}".format(**p): - self.project_dir.setPlaceholderText("{root}".format(**p)) - else: - print("not root so it was replaced with name") - self.project_dir.setPlaceholderText("{name}".format(**p)) - p['root'] = p['name'] - - # set project path to show where it will be created - self.project_path.setText( - os.path.join(self.disks[i].split(' ')[-1], - self.project_dir.text())) - - # change fps selection - self.fps.setPlaceholderText("{custom_attributes[fps]}".format(**p)) - - # change handles selection - self.handles.setPlaceholderText( - "{custom_attributes[handles]}".format(**p)) - - # change resolution selection - self.resolution_w.setPlaceholderText( - "{custom_attributes[resolution_width]}".format(**p)) - self.resolution_h.setPlaceholderText( - "{custom_attributes[resolution_height]}".format(**p)) - - self.update_disk() - - return p - - def fix_project_path_literals(self, dir): - return dir.replace(' ', '_').lower() - - def update_disk(self): - disk = self.disk_combobox.currentText().split(' ')[-1] - - dir = self.project_dir.text() - if not dir: - dir = "{root}".format(**self.projects[self.new_index]) - self.projects[self.new_index]['project_path'] = os.path.normpath( - self.fix_project_path_literals(os.path.join(disk, dir))) - else: - self.projects[self.new_index]['project_path'] = os.path.normpath( - self.fix_project_path_literals(os.path.join(disk, dir))) - - self.projects[self.new_index]['disk'] = self.disks_from_ft[ - self.disk_combobox.currentIndex()] - self.projects[self.new_index]['disk_id'] = self.projects[ - self.new_index]['disk']['id'] - - # set project path to show where it will be created - self.project_path.setText( - self.projects[self.new_index]['project_path']) - - def update_resolution(self): - # update all values in resolution - if self.resolution_w.text(): - self.projects[self.new_index]['custom_attributes'][ - "resolutionWidth"] = int(self.resolution_w.text()) - if self.resolution_h.text(): - self.projects[self.new_index]['custom_attributes'][ - "resolutionHeight"] = int(self.resolution_h.text()) - - def _update_attributes_by_list_selection(self): - # generate actual selection index - self.new_index = self.listWidget.currentRow() - self.project_dir.setText('') - self.fps.setText('') - self.handles.setText('') - self.resolution_w.setText('') - self.resolution_h.setText('') - - # update project properities widgets and write changes - # into project dictionaries - self.projects[self.new_index] = self._fill_project_attributes_widgets( - self.projects[self.new_index], self.new_index) - - self.update_disk() - - def _build_disk_path(self): - if self.platform == "Windows": - print(self.projects[self.index].keys()) - print(self.projects[self.new_index]['disk']) - return self.projects[self.new_index]['disk'][ - 'name'], self.projects[self.new_index]['disk']['windows'] - else: - return self.projects[self.new_index]['disk'][ - 'name'], self.projects[self.new_index]['disk']['unix'] - - def _get_all_schemas(self): - schemas_list = [] - - for s in self.schemas_from_ft: - # print d.keys() - # if 'Pokus' in s['name']: - # continue - schemas_list.append('{}'.format(s['name'])) - print("\nschemas in ftrack: {}\n".format(schemas_list)) - return schemas_list - - def _get_all_disks(self): - disks_list = [] - for d in self.disks_from_ft: - # print d.keys() - if self.platform == "Windows": - if 'Local drive' in d['name']: - d['windows'] = os.path.join(d['windows'], - os.getenv('USERNAME') - or os.getenv('USER') - or os.getenv('LOGNAME')) - disks_list.append('"{}" at {}'.format(d['name'], d['windows'])) - else: - if 'Local drive' in d['name']: - d['unix'] = os.path.join(d['unix'], getpass.getuser()) - disks_list.append('"{}" at {}'.format(d['name'], d['unix'])) - return disks_list - - def _get_projects_ftrack(self): - - projects_lst = [] - for project in self.projects_from_ft: - # print project.keys() - projects_dict = {} - - for k in project.keys(): - ''' # TODO: delete this in production version ''' - - # if 'test' not in project['name']: - # continue - - # print '{}: {}\n'.format(k, project[k]) - - if '_link' == k: - # print project[k] - content = project[k] - for kc in content[0].keys(): - if content[0]['name']: - content[0][kc] = content[0][kc].encode( - 'ascii', 'ignore').decode('ascii') - print('{}: {}\n'.format(kc, content[0][kc])) - projects_dict[k] = content - print(project[k]) - print(projects_dict[k]) - elif 'root' == k: - print('{}: {}\n'.format(k, project[k])) - projects_dict[k] = project[k] - elif 'disk' == k: - print('{}: {}\n'.format(k, project[k])) - projects_dict[k] = project[k] - elif 'name' == k: - print('{}: {}\n'.format(k, project[k])) - projects_dict[k] = project[k].encode( - 'ascii', 'ignore').decode('ascii') - elif 'disk_id' == k: - print('{}: {}\n'.format(k, project[k])) - projects_dict[k] = project[k] - elif 'id' == k: - print('{}: {}\n'.format(k, project[k])) - projects_dict[k] = project[k] - elif 'full_name' == k: - print('{}: {}\n'.format(k, project[k])) - projects_dict[k] = project[k].encode( - 'ascii', 'ignore').decode('ascii') - elif 'project_schema_id' == k: - print('{}: {}\n'.format(k, project[k])) - projects_dict[k] = project[k] - elif 'project_schema' == k: - print('{}: {}\n'.format(k, project[k])) - projects_dict[k] = project[k] - elif 'custom_attributes' == k: - print('{}: {}\n'.format(k, project[k])) - projects_dict[k] = project[k] - else: - pass - - if projects_dict: - projects_lst.append(projects_dict) - - return projects_lst - - -class Project_name_get(Project_name_getUI): - def __init__(self, parent=None): - super(Project_name_get, self).__init__(parent) - # self.input_project_name.textChanged.connect(self.input_project_name.placeholderText) - - self.set_pushbutton.clicked.connect(lambda: self.execute()) - self.cancel_pushbutton.clicked.connect(self.close) - - self.listWidget.itemSelectionChanged.connect( - self._update_attributes_by_list_selection) - self.disk_combobox.currentIndexChanged.connect(self.update_disk) - self.schema_combobox.currentIndexChanged.connect(self.update_schema) - self.project_dir.textChanged.connect(self.update_disk) - self.fps.textChanged.connect(self.update_fps) - self.handles.textChanged.connect(self.update_handles) - self.resolution_w.textChanged.connect(self.update_resolution) - self.resolution_h.textChanged.connect(self.update_resolution) - - def update_handles(self): - self.projects[self.new_index]['custom_attributes']['handles'] = int( - self.handles.text()) - - def update_fps(self): - self.projects[self.new_index]['custom_attributes']['fps'] = int( - self.fps.text()) - - def update_schema(self): - self.projects[self.new_index]['project_schema'] = self.schemas_from_ft[ - self.schema_combobox.currentIndex()] - self.projects[self.new_index]['project_schema_id'] = self.projects[ - self.new_index]['project_schema']['id'] - - def execute(self): - # import ft_utils - # import hiero - # get the project which has been selected - print("well and what") - # set the project as context and create entity - # entity is task created with the name of user which is creating it - - # get the project_path and create dir if there is not any - print(self.projects[self.new_index]['project_path'].replace( - self.disk_combobox.currentText().split(' ')[-1].lower(), '')) - - # get the schema and recreate a starting project regarding the selection - # set_hiero_template(project_schema=self.projects[self.new_index][ - # 'project_schema']['name']) - - # set all project properities - # project = hiero.core.Project() - # project.setFramerate( - # int(self.projects[self.new_index]['custom_attributes']['fps'])) - # project.projectRoot() - # print 'handles: {}'.format(self.projects[self.new_index]['custom_attributes']['handles']) - # print 'resolution_width: {}'.format(self.projects[self.new_index]['custom_attributes']["resolutionWidth"]) - # print 'resolution_width: {}'.format(self.projects[self.new_index]['custom_attributes']["resolutionHeight"]) - # print "<< {}".format(self.projects[self.new_index]) - - # get path for the hrox file - # root = context.data('ftrackData')['Project']['root'] - # hrox_script_path = ft_utils.getPathsYaml(taskid, templateList=templates, root=root) - - # save the hrox into the correct path - self.session.commit() - self.close() - -# -# def set_hiero_template(project_schema=None): -# import hiero -# hiero.core.closeAllProjects() -# hiero_plugin_path = [ -# p for p in os.environ['HIERO_PLUGIN_PATH'].split(';') -# if 'hiero_plugin_path' in p -# ][0] -# path = os.path.normpath( -# os.path.join(hiero_plugin_path, 'Templates', project_schema + '.hrox')) -# print('---> path to template: {}'.format(path)) -# return hiero.core.openProject(path) - - -# def set_out_ft_session(): -# session = ftrack_api.Session() -# projects_to_ft = session.query('Project where status is active') - - -def main(): - import sys - app = QtWidgets.QApplication(sys.argv) - panel = Project_name_get() - panel.show() - - sys.exit(app.exec_()) - - -if __name__ == "__main__": - main() From d9062b762ab82b85fb048084ef27ebf863a92366 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20David?= Date: Thu, 5 May 2022 11:49:16 +0200 Subject: [PATCH 282/583] Update openpype/modules/kitsu/utils/update_zou_with_op.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- openpype/modules/kitsu/utils/update_zou_with_op.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/kitsu/utils/update_zou_with_op.py b/openpype/modules/kitsu/utils/update_zou_with_op.py index d1fcde5601..526159d101 100644 --- a/openpype/modules/kitsu/utils/update_zou_with_op.py +++ b/openpype/modules/kitsu/utils/update_zou_with_op.py @@ -6,7 +6,7 @@ from typing import List import gazu from pymongo import UpdateOne -from avalon.api import AvalonMongoDB +from openpype.pipeline import AvalonMongoDB from openpype.api import get_project_settings from openpype.modules.kitsu.utils.credentials import validate_credentials From fe0978a8b2932ade893be5f3ef95951bb869f955 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 5 May 2022 16:19:26 +0200 Subject: [PATCH 283/583] Tweak grammar --- openpype/lib/applications.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/openpype/lib/applications.py b/openpype/lib/applications.py index b52da52dc9..a84ff990b2 100644 --- a/openpype/lib/applications.py +++ b/openpype/lib/applications.py @@ -1012,8 +1012,8 @@ class ApplicationLaunchContext: self.log.debug("Discovery of launch hooks started.") paths = self.paths_to_launch_hooks() - self.log.debug("Paths where will look for launch hooks:{}".format( - "\n- ".join(paths) + self.log.debug("Paths searched for launch hooks:\n{}".format( + "\n".join("- {}".format(path) for path in paths) )) all_classes = { @@ -1023,7 +1023,7 @@ class ApplicationLaunchContext: for path in paths: if not os.path.exists(path): self.log.info( - "Path to launch hooks does not exists: \"{}\"".format(path) + "Path to launch hooks does not exist: \"{}\"".format(path) ) continue @@ -1044,7 +1044,8 @@ class ApplicationLaunchContext: hook = klass(self) if not hook.is_valid: self.log.debug( - "Hook is not valid for current launch context." + "Hook is not valid for current " + "launch context: {}".format(str(hook)) ) continue From 19b6cb7b6cbee03b60cf3d152af322a914f35514 Mon Sep 17 00:00:00 2001 From: jrsndlr Date: Thu, 5 May 2022 18:05:22 +0200 Subject: [PATCH 284/583] concatenation output formats fix --- .../plugins/publish/extract_review_slate.py | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/openpype/plugins/publish/extract_review_slate.py b/openpype/plugins/publish/extract_review_slate.py index 59150e9d4a..77b40b785d 100644 --- a/openpype/plugins/publish/extract_review_slate.py +++ b/openpype/plugins/publish/extract_review_slate.py @@ -226,13 +226,15 @@ class ExtractReviewSlate(openpype.api.Extractor): )) input_args.extend(["-timecode {}".format(offset_timecode)]) if use_legacy_code: + format_args = [] codec_args = repre["_profile"].get('codec', []) output_args.extend(codec_args) # preset's output data output_args.extend(repre["_profile"].get('output', [])) else: # Codecs are copied from source for whole input - codec_args = self._get_codec_args(repre) + format_args, codec_args = self._get_format_codec_args(repre) + output_args.extend(format_args) output_args.extend(codec_args) # make sure colors are correct @@ -338,6 +340,11 @@ class ExtractReviewSlate(openpype.api.Extractor): "-c", "copy", "-timecode", offset_timecode, ] + # NOTE: Added because of OP Atom demuxers + # Add format arguments if there are any + # - keep format of output + if format_args: + concat_args.extend(format_args) # Use arguments from ffmpeg preset source_ffmpeg_cmd = repre.get("ffmpeg_cmd") if source_ffmpeg_cmd: @@ -351,7 +358,7 @@ class ExtractReviewSlate(openpype.api.Extractor): concat_args.append(arg) # assumes arg has one parameter concat_args.append(args[indx + 1]) - # add output + # add final output path concat_args.append(output_path) if not input_audio: @@ -431,7 +438,7 @@ class ExtractReviewSlate(openpype.api.Extractor): return vf_back - def _get_codec_args(self, repre): +def _get_format_codec_args(self, repre): """Detect possible codec arguments from representation.""" codec_args = [] @@ -454,16 +461,12 @@ class ExtractReviewSlate(openpype.api.Extractor): return codec_args source_ffmpeg_cmd = repre.get("ffmpeg_cmd") - codec_args.extend( - get_ffmpeg_format_args(ffprobe_data, source_ffmpeg_cmd) - ) - codec_args.extend( - get_ffmpeg_codec_args( - ffprobe_data, source_ffmpeg_cmd, logger=self.log - ) + format_args = get_ffmpeg_format_args(ffprobe_data, source_ffmpeg_cmd) + codec_args = get_ffmpeg_codec_args( + ffprobe_data, source_ffmpeg_cmd, logger=self.log ) - return codec_args + return format_args, codec_args def _tc_offset(self, timecode, framerate=24.0, frame_offset=-1): """Offsets timecode by frame""" From 2e7c82316a24d98c152d5571e20bcde512a1924e Mon Sep 17 00:00:00 2001 From: jrsndlr Date: Thu, 5 May 2022 18:06:43 +0200 Subject: [PATCH 285/583] mad dog fix --- openpype/plugins/publish/extract_review_slate.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/plugins/publish/extract_review_slate.py b/openpype/plugins/publish/extract_review_slate.py index 77b40b785d..17ac4b68bc 100644 --- a/openpype/plugins/publish/extract_review_slate.py +++ b/openpype/plugins/publish/extract_review_slate.py @@ -438,6 +438,7 @@ class ExtractReviewSlate(openpype.api.Extractor): return vf_back + def _get_format_codec_args(self, repre): """Detect possible codec arguments from representation.""" codec_args = [] From fe514cf385bf99683ef87d0fa78bd022f9fa69dd Mon Sep 17 00:00:00 2001 From: jrsndlr Date: Thu, 5 May 2022 19:30:38 +0200 Subject: [PATCH 286/583] more fixes --- openpype/plugins/publish/extract_review_slate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/plugins/publish/extract_review_slate.py b/openpype/plugins/publish/extract_review_slate.py index 17ac4b68bc..832799601c 100644 --- a/openpype/plugins/publish/extract_review_slate.py +++ b/openpype/plugins/publish/extract_review_slate.py @@ -439,7 +439,7 @@ class ExtractReviewSlate(openpype.api.Extractor): return vf_back -def _get_format_codec_args(self, repre): + def _get_format_codec_args(self, repre): """Detect possible codec arguments from representation.""" codec_args = [] From e5083dded8e69d2fdb572694ae98eb6983c6cab0 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 6 May 2022 10:12:17 +0200 Subject: [PATCH 287/583] added dataclasses to required python modules --- setup.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 899e9375c0..d3b967a4b6 100644 --- a/setup.py +++ b/setup.py @@ -106,7 +106,8 @@ install_requires = [ "dns", # Python defaults (cx_Freeze skip them by default) "dbm", - "sqlite3" + "sqlite3", + "dataclasses" ] includes = [] From 840e830b30442830b5f8813d2e40113872c14dee Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 6 May 2022 11:00:53 +0200 Subject: [PATCH 288/583] Log name of class in a more readable manner --- openpype/lib/applications.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/openpype/lib/applications.py b/openpype/lib/applications.py index a84ff990b2..01bd2768ed 100644 --- a/openpype/lib/applications.py +++ b/openpype/lib/applications.py @@ -1045,13 +1045,13 @@ class ApplicationLaunchContext: if not hook.is_valid: self.log.debug( "Hook is not valid for current " - "launch context: {}".format(str(hook)) + "launch context: {}".format(klass.__name__) ) continue if inspect.isabstract(hook): self.log.debug("Skipped abstract hook: {}".format( - str(hook) + klass.__name__ )) continue @@ -1063,7 +1063,8 @@ class ApplicationLaunchContext: except Exception: self.log.warning( - "Initialization of hook failed. {}".format(str(klass)), + "Initialization of hook failed: " + "{}".format(klass.__name__), exc_info=True ) From 1eb8300831ccec8ace6d3a030464197b9d35bcb5 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 6 May 2022 11:43:49 +0200 Subject: [PATCH 289/583] Less scary log message --- openpype/lib/applications.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/lib/applications.py b/openpype/lib/applications.py index 01bd2768ed..6ade33b59c 100644 --- a/openpype/lib/applications.py +++ b/openpype/lib/applications.py @@ -1044,8 +1044,8 @@ class ApplicationLaunchContext: hook = klass(self) if not hook.is_valid: self.log.debug( - "Hook is not valid for current " - "launch context: {}".format(klass.__name__) + "Skipped hook invalid for current launch context: " + "{}".format(klass.__name__) ) continue From 6a123fcdb994aed27c3e0e64f340ccfea9ce05b2 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 6 May 2022 13:08:34 +0200 Subject: [PATCH 290/583] nuke: change hex type to color_gui in settings --- .../defaults/project_anatomy/imageio.json | 27 ++++++++++++++----- .../schemas/template_nuke_knob_inputs.json | 9 ++++--- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/openpype/settings/defaults/project_anatomy/imageio.json b/openpype/settings/defaults/project_anatomy/imageio.json index 2023dae23c..c03f5f8ee8 100644 --- a/openpype/settings/defaults/project_anatomy/imageio.json +++ b/openpype/settings/defaults/project_anatomy/imageio.json @@ -75,9 +75,14 @@ "value": true }, { - "type": "hex", + "type": "color_gui", "name": "tile_color", - "value": "0xff0000ff" + "value": [ + 186, + 35, + 35, + 255 + ] }, { "type": "text", @@ -123,9 +128,14 @@ "value": true }, { - "type": "hex", + "type": "color_gui", "name": "tile_color", - "value": "0xadab1dff" + "value": [ + 171, + 171, + 10, + 255 + ] }, { "type": "text", @@ -166,9 +176,14 @@ "value": "Deflate" }, { - "type": "hex", + "type": "color_gui", "name": "tile_color", - "value": "0x23ff00ff" + "value": [ + 56, + 162, + 7, + 255 + ] }, { "type": "text", diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/template_nuke_knob_inputs.json b/openpype/settings/entities/schemas/projects_schema/schemas/template_nuke_knob_inputs.json index e6ca1e7fd4..d2fa05e55c 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/template_nuke_knob_inputs.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/template_nuke_knob_inputs.json @@ -65,8 +65,8 @@ ] }, { - "key": "hex", - "label": "Hexadecimal (0x)", + "key": "color_gui", + "label": "Color GUI", "children": [ { "type": "text", @@ -74,9 +74,10 @@ "label": "Name" }, { - "type": "text", + "type": "color", "key": "value", - "label": "Value" + "label": "Value", + "use_alpha": false } ] }, From d640ea3e526c4bb4380505d0d08981e693b8a4b7 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 6 May 2022 13:08:58 +0200 Subject: [PATCH 291/583] nuke: remove redundant code --- openpype/hosts/nuke/plugins/create/create_write_still.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/openpype/hosts/nuke/plugins/create/create_write_still.py b/openpype/hosts/nuke/plugins/create/create_write_still.py index 0b1a942e0e..5b3141355c 100644 --- a/openpype/hosts/nuke/plugins/create/create_write_still.py +++ b/openpype/hosts/nuke/plugins/create/create_write_still.py @@ -2,10 +2,6 @@ import nuke from openpype.hosts.nuke.api import plugin from openpype.hosts.nuke.api.lib import create_write_node -from openpype.api import ( - Logger -) -log = Logger.get_logger(__name__) class CreateWriteStill(plugin.AbstractWriteRender): From e7a1c840d02c1dbb8ef8c79b2b9e115df2a9d4ee Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 6 May 2022 13:09:13 +0200 Subject: [PATCH 292/583] nuke: implementing `color_gui` type --- openpype/hosts/nuke/api/lib.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 2d205d0d39..152dfffce7 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -1089,10 +1089,10 @@ def create_write_node( iter( k["value"] for k in imageio_writes["knobs"] if "tile_color" in k["name"] - ), "0xff0000ff" + ), [255, 0, 0, 255] ) GN["tile_color"].setValue( - int(tile_color, 16)) + color_gui_to_int(tile_color)) # finally add knob overrides set_node_knobs_from_settings(GN, knob_overrides, **kwargs) @@ -1159,19 +1159,20 @@ def set_node_knobs_from_settings(node, knob_settings, **kwargs): knob_value = int(knob_value) elif knob_type == "text": knob_value = knob_value - elif knob_type == "hex": - if not knob_value.startswith("0x"): - raise ValueError( - "Check your settings! Input Hexa is wrong! \n{}".format( - pformat(knob_settings) - )) - knob_value = int(knob_value, 16) + elif knob_type == "color_gui": + knob_value = color_gui_to_int(knob_value) elif knob_type in ["2d_vector", "3d_vector", "color"]: knob_value = [float(v) for v in knob_value] node[knob_name].setValue(knob_value) +def color_gui_to_int(color_gui): + hex_value = ( + "0x{0:0>2x}{1:0>2x}{2:0>2x}{3:0>2x}").format(*color_gui) + return int(hex_value, 16) + + def add_rendering_knobs(node, farm=True): ''' Adds additional rendering knobs to given node From 166ca1b814675b81e02f66998aba4e0a8ded6023 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 6 May 2022 14:16:04 +0200 Subject: [PATCH 293/583] OP-3113 - added timeit module to requirements psd_tools requires it --- setup.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index d3b967a4b6..8b5a545c16 100644 --- a/setup.py +++ b/setup.py @@ -107,7 +107,8 @@ install_requires = [ # Python defaults (cx_Freeze skip them by default) "dbm", "sqlite3", - "dataclasses" + "dataclasses", + "timeit" ] includes = [] From b374e8e8a1cb4f3b02c67490d536d639da3abeea Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 6 May 2022 14:17:05 +0200 Subject: [PATCH 294/583] nuke: returning legacy code for backward compatibility --- openpype/hosts/nuke/api/lib.py | 380 +++++++++++++++++- openpype/hosts/nuke/api/plugin.py | 17 +- .../plugins/create/create_write_prerender.py | 27 +- .../plugins/create/create_write_render.py | 43 +- .../nuke/plugins/create/create_write_still.py | 48 ++- 5 files changed, 481 insertions(+), 34 deletions(-) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 152dfffce7..25a64a5eef 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -365,6 +365,40 @@ def fix_data_for_node_create(data): return data +def add_write_node_legacy(name, **kwarg): + """Adding nuke write node + Arguments: + name (str): nuke node name + kwarg (attrs): data for nuke knobs + Returns: + node (obj): nuke write node + """ + frame_range = kwarg.get("use_range_limit", None) + + w = nuke.createNode( + "Write", + "name {}".format(name)) + + w["file"].setValue(kwarg["file"]) + + for k, v in kwarg.items(): + if "frame_range" in k: + continue + log.info([k, v]) + try: + w[k].setValue(v) + except KeyError as e: + log.debug(e) + continue + + if frame_range: + w["use_limit"].setValue(True) + w["first"].setValue(frame_range[0]) + w["last"].setValue(frame_range[1]) + + return w + + def add_write_node(name, file_path, knobs, **kwarg): """Adding nuke write node @@ -375,7 +409,7 @@ def add_write_node(name, file_path, knobs, **kwarg): Returns: node (obj): nuke write node """ - frame_range = kwarg.get("frame_range", None) + frame_range = kwarg.get("use_range_limit", None) w = nuke.createNode( "Write", @@ -494,6 +528,80 @@ def get_nuke_imageio_settings(): return get_anatomy_settings(Context.project_name)["imageio"]["nuke"] +def get_created_node_imageio_setting_legacy(nodeclass, creator, subset): + ''' Get preset data for dataflow (fileType, compression, bitDepth) + ''' + + assert any([creator, nodeclass]), nuke.message( + "`{}`: Missing mandatory kwargs `host`, `cls`".format(__file__)) + + imageio_nodes = get_nuke_imageio_settings()["nodes"] + required_nodes = imageio_nodes["requiredNodes"] + override_nodes = imageio_nodes["overrideNodes"] + + imageio_node = None + for node in required_nodes: + log.info(node) + if ( + nodeclass in node["nukeNodeClass"] + and creator in node["plugins"] + ): + imageio_node = node + break + + log.debug("__ imageio_node: {}".format(imageio_node)) + + # find matching override node + override_imageio_node = None + for onode in override_nodes: + log.info(onode) + if nodeclass not in node["nukeNodeClass"]: + continue + + if creator not in node["plugins"]: + continue + + if ( + onode["subsets"] + and not any(re.search(s, subset) for s in onode["subsets"]) + ): + continue + + override_imageio_node = onode + break + + log.debug("__ override_imageio_node: {}".format(override_imageio_node)) + # add overrides to imageio_node + if override_imageio_node: + # get all knob names in imageio_node + knob_names = [k["name"] for k in imageio_node["knobs"]] + + for oknob in override_imageio_node["knobs"]: + for knob in imageio_node["knobs"]: + # override matching knob name + if oknob["name"] == knob["name"]: + log.debug( + "_ overriding knob: `{}` > `{}`".format( + knob, oknob + )) + if not oknob["value"]: + # remove original knob if no value found in oknob + imageio_node["knobs"].remove(knob) + else: + # override knob value with oknob's + knob["value"] = oknob["value"] + + # add missing knobs into imageio_node + if oknob["name"] not in knob_names: + log.debug( + "_ adding knob: `{}`".format(oknob)) + imageio_node["knobs"].append(oknob) + knob_names.append(oknob["name"]) + + log.info("ImageIO node: {}".format(imageio_node)) + return imageio_node + + def get_imageio_node_setting(node_class, plugin_name, subset): ''' Get preset data for dataflow (fileType, compression, bitDepth) ''' @@ -1100,6 +1208,276 @@ def create_write_node( return GN +def create_write_node_legacy(name, data, input=None, prenodes=None, + review=True, linked_knobs=None, farm=True): + ''' Creating write node which is group node + + Arguments: + name (str): name of node + data (dict): data to be imprinted + input (node): selected node to connect to + prenodes (list, optional): list of lists, definitions for nodes + to be created before write + review (bool): adding review knob + + Example: + prenodes = [ + { + "nodeName": { + "class": "" # string + "knobs": [ + ("knobName": value), + ... + ], + "dependent": [ + following_node_01, + ... + ] + } + }, + ... + ] + + Return: + node (obj): group node with avalon data as Knobs + ''' + knob_overrides = data.get("knobs", []) + nodeclass = data["nodeclass"] + creator = data["creator"] + subset = data["subset"] + + imageio_writes = get_created_node_imageio_setting_legacy( + nodeclass, creator, subset + ) + for knob in imageio_writes["knobs"]: + if knob["name"] == "file_type": + representation = knob["value"] + + host_name = os.environ.get("AVALON_APP") + try: + data.update({ + "app": host_name, + "imageio_writes": imageio_writes, + "representation": representation, + }) + anatomy_filled = format_anatomy(data) + + except Exception as e: + msg = "problem with resolving anatomy template: {}".format(e) + log.error(msg) + nuke.message(msg) + + # build file path to workfiles + fdir = str(anatomy_filled["work"]["folder"]).replace("\\", "/") + fpath = data["fpath_template"].format( + work=fdir, version=data["version"], subset=data["subset"], + frame=data["frame"], + ext=representation + ) + + # create directory + if not os.path.isdir(os.path.dirname(fpath)): + log.warning("Path does not exist! I am creating it.") + os.makedirs(os.path.dirname(fpath)) + + _data = OrderedDict({ + "file": fpath + }) + + # adding dataflow template + log.debug("imageio_writes: `{}`".format(imageio_writes)) + for knob in imageio_writes["knobs"]: + _data[knob["name"]] = knob["value"] + + _data = fix_data_for_node_create(_data) + + log.debug("_data: `{}`".format(_data)) + + if "frame_range" in data.keys(): + _data["frame_range"] = data.get("frame_range", None) + log.debug("_data[frame_range]: `{}`".format(_data["frame_range"])) + + GN = nuke.createNode("Group", "name {}".format(name)) + + prev_node = None + with GN: + if input: + input_name = str(input.name()).replace(" ", "") + # if connected input node was defined + prev_node = nuke.createNode( + "Input", "name {}".format(input_name)) + else: + # generic input node connected to nothing + prev_node = nuke.createNode( + "Input", "name {}".format("rgba")) + prev_node.hideControlPanel() + # creating pre-write nodes `prenodes` + if prenodes: + for node in prenodes: + # get attributes + pre_node_name = node["name"] + klass = node["class"] + knobs = node["knobs"] + dependent = node["dependent"] + + # create node + now_node = nuke.createNode( + klass, "name {}".format(pre_node_name)) + now_node.hideControlPanel() + + # add data to knob + for _knob in knobs: + knob, value = _knob + try: + now_node[knob].value() + except NameError: + log.warning( + "knob `{}` does not exist on node `{}`".format( + knob, now_node["name"].value() + )) + continue + + if not knob and not value: + continue + + log.info((knob, value)) + + if isinstance(value, str): + if "[" in value: + now_node[knob].setExpression(value) + else: + now_node[knob].setValue(value) + + # connect to previous node + if dependent: + if isinstance(dependent, (tuple or list)): + for i, node_name in enumerate(dependent): + input_node = nuke.createNode( + "Input", "name {}".format(node_name)) + input_node.hideControlPanel() + now_node.setInput(1, input_node) + + elif isinstance(dependent, str): + input_node = nuke.createNode( + "Input", "name {}".format(node_name)) + input_node.hideControlPanel() + now_node.setInput(0, input_node) + + else: + now_node.setInput(0, prev_node) + + # switch actual node to previous + prev_node = now_node + + # creating write node + + write_node = now_node = add_write_node_legacy( + "inside_{}".format(name), + **_data + ) + write_node.hideControlPanel() + # connect to previous node + now_node.setInput(0, prev_node) + + # switch actual node to previous + prev_node = now_node + + now_node = nuke.createNode("Output", "name Output1") + now_node.hideControlPanel() + + # connect to previous node + now_node.setInput(0, prev_node) + + # imprinting group node + set_avalon_knob_data(GN, data["avalon"]) + add_publish_knob(GN) + add_rendering_knobs(GN, farm) + + if review: + add_review_knob(GN) + + # add divider + GN.addKnob(nuke.Text_Knob('', 'Rendering')) + + # Add linked knobs. + linked_knob_names = [] + + # add input linked knobs and create group only if any input + if linked_knobs: + linked_knob_names.append("_grp-start_") + linked_knob_names.extend(linked_knobs) + linked_knob_names.append("_grp-end_") + + linked_knob_names.append("Render") + + for _k_name in linked_knob_names: + if "_grp-start_" in _k_name: + knob = nuke.Tab_Knob( + "rnd_attr", "Rendering attributes", nuke.TABBEGINCLOSEDGROUP) + GN.addKnob(knob) + elif "_grp-end_" in _k_name: + knob = nuke.Tab_Knob( + "rnd_attr_end", "Rendering attributes", nuke.TABENDGROUP) + GN.addKnob(knob) + else: + if "___" in _k_name: + # add divider + GN.addKnob(nuke.Text_Knob("")) + else: + # add linked knob by _k_name + link = nuke.Link_Knob("") + link.makeLink(write_node.name(), _k_name) + link.setName(_k_name) + + # make render + if "Render" in _k_name: + link.setLabel("Render Local") + link.setFlag(0x1000) + GN.addKnob(link) + + # adding write to read button + add_button_write_to_read(GN) + + # adding write to read button + add_button_clear_rendered(GN, os.path.dirname(fpath)) + + # Deadline tab. + add_deadline_tab(GN) + + # open the our Tab as default + GN[_NODE_TAB_NAME].setFlag(0) + + # set tile color + tile_color = _data.get("tile_color", "0xff0000ff") + GN["tile_color"].setValue(tile_color) + + # overrie knob values from settings + for knob in knob_overrides: + knob_type = knob["type"] + knob_name = knob["name"] + knob_value = knob["value"] + if knob_name not in GN.knobs(): + continue + if not knob_value: + continue + + # set correctly knob types + if knob_type == "string": + knob_value = str(knob_value) + if knob_type == "number": + knob_value = int(knob_value) + if knob_type == "decimal_number": + knob_value = float(knob_value) + if knob_type == "bool": + knob_value = bool(knob_value) + if knob_type in ["2d_vector", "3d_vector"]: + knob_value = list(knob_value) + + GN[knob_name].setValue(knob_value) + + return GN + + def set_node_knobs_from_settings(node, knob_settings, **kwargs): """ Overriding knob values from settings diff --git a/openpype/hosts/nuke/api/plugin.py b/openpype/hosts/nuke/api/plugin.py index 1eaccef795..76c1e7a37c 100644 --- a/openpype/hosts/nuke/api/plugin.py +++ b/openpype/hosts/nuke/api/plugin.py @@ -17,7 +17,8 @@ from .lib import ( reset_selection, maintained_selection, set_avalon_knob_data, - add_publish_knob + add_publish_knob, + get_nuke_imageio_settings ) @@ -700,6 +701,20 @@ class AbstractWriteRender(OpenPypeCreator): return write_node + def is_legacy(self): + """Check if it needs to run legacy code + + In case where `type` key is missing in singe + knob it is legacy project anatomy. + + Returns: + bool: True if legacy + """ + imageio_nodes = get_nuke_imageio_settings()["nodes"] + node = imageio_nodes["requiredNodes"][0] + if "type" not in node["knobs"][0]: + return True + @abstractmethod def _create_write_node(self, selected_node, inputs, outputs, write_data): """Family dependent implementation of Write node creation diff --git a/openpype/hosts/nuke/plugins/create/create_write_prerender.py b/openpype/hosts/nuke/plugins/create/create_write_prerender.py index f86ed7b89e..32ee1fd86f 100644 --- a/openpype/hosts/nuke/plugins/create/create_write_prerender.py +++ b/openpype/hosts/nuke/plugins/create/create_write_prerender.py @@ -1,7 +1,8 @@ import nuke from openpype.hosts.nuke.api import plugin -from openpype.hosts.nuke.api.lib import create_write_node +from openpype.hosts.nuke.api.lib import ( + create_write_node, create_write_node_legacy) class CreateWritePrerender(plugin.AbstractWriteRender): @@ -25,14 +26,24 @@ class CreateWritePrerender(plugin.AbstractWriteRender): def _create_write_node(self, selected_node, inputs, outputs, write_data): # add fpath_template write_data["fpath_template"] = self.fpath_template + write_data["use_range_limit"] = self.use_range_limit - return create_write_node( - self.data["subset"], - write_data, - input=selected_node, - review=self.reviewable, - linked_knobs=["channels", "___", "first", "last", "use_limit"] - ) + if not self.is_legacy(): + return create_write_node( + self.data["subset"], + write_data, + input=selected_node, + review=self.reviewable, + linked_knobs=["channels", "___", "first", "last", "use_limit"] + ) + else: + return create_write_node_legacy( + self.data["subset"], + write_data, + input=selected_node, + review=self.reviewable, + linked_knobs=["channels", "___", "first", "last", "use_limit"] + ) def _modify_write_node(self, write_node): # open group node diff --git a/openpype/hosts/nuke/plugins/create/create_write_render.py b/openpype/hosts/nuke/plugins/create/create_write_render.py index 43b1a9dcb4..23846c0332 100644 --- a/openpype/hosts/nuke/plugins/create/create_write_render.py +++ b/openpype/hosts/nuke/plugins/create/create_write_render.py @@ -1,7 +1,8 @@ import nuke from openpype.hosts.nuke.api import plugin -from openpype.hosts.nuke.api.lib import create_write_node +from openpype.hosts.nuke.api.lib import ( + create_write_node, create_write_node_legacy) class CreateWriteRender(plugin.AbstractWriteRender): @@ -50,16 +51,36 @@ class CreateWriteRender(plugin.AbstractWriteRender): actual_format = nuke.root().knob('format').value() width, height = (actual_format.width(), actual_format.height()) - return create_write_node( - self.data["subset"], - write_data, - input=selected_node, - prenodes=self.prenodes, - **{ - "width": width, - "height": height - } - ) + if not self.is_legacy(): + return create_write_node( + self.data["subset"], + write_data, + input=selected_node, + prenodes=self.prenodes, + **{ + "width": width, + "height": height + } + ) + else: + _prenodes = [ + { + "name": "Reformat01", + "class": "Reformat", + "knobs": [ + ("resize", 0), + ("black_outside", 1), + ], + "dependent": None + } + ] + + return create_write_node_legacy( + self.data["subset"], + write_data, + input=selected_node, + prenodes=_prenodes + ) def _modify_write_node(self, write_node): return write_node diff --git a/openpype/hosts/nuke/plugins/create/create_write_still.py b/openpype/hosts/nuke/plugins/create/create_write_still.py index 5b3141355c..4007ccf51e 100644 --- a/openpype/hosts/nuke/plugins/create/create_write_still.py +++ b/openpype/hosts/nuke/plugins/create/create_write_still.py @@ -1,7 +1,8 @@ import nuke from openpype.hosts.nuke.api import plugin -from openpype.hosts.nuke.api.lib import create_write_node +from openpype.hosts.nuke.api.lib import ( + create_write_node, create_write_node_legacy) class CreateWriteStill(plugin.AbstractWriteRender): @@ -42,18 +43,39 @@ class CreateWriteStill(plugin.AbstractWriteRender): # add fpath_template write_data["fpath_template"] = self.fpath_template - return create_write_node( - self.name, - write_data, - input=selected_node, - review=False, - prenodes=self.prenodes, - farm=False, - linked_knobs=["channels", "___", "first", "last", "use_limit"], - **{ - "frame": nuke.frame() - } - ) + if not self.is_legacy(): + return create_write_node( + self.name, + write_data, + input=selected_node, + review=False, + prenodes=self.prenodes, + farm=False, + linked_knobs=["channels", "___", "first", "last", "use_limit"], + **{ + "frame": nuke.frame() + } + ) + else: + _prenodes = [ + { + "name": "FrameHold01", + "class": "FrameHold", + "knobs": [ + ("first_frame", nuke.frame()) + ], + "dependent": None + } + ] + return create_write_node_legacy( + self.name, + write_data, + input=selected_node, + review=False, + prenodes=_prenodes, + farm=False, + linked_knobs=["channels", "___", "first", "last", "use_limit"] + ) def _modify_write_node(self, write_node): write_node.begin() From 89499e97a38f75386bb10a4452bb8ebe225dd88f Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 6 May 2022 15:04:29 +0200 Subject: [PATCH 295/583] nuke: skipping override if no node was found --- openpype/hosts/nuke/api/lib.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 25a64a5eef..0025141310 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -620,6 +620,9 @@ def get_imageio_node_setting(node_class, plugin_name, subset): log.debug("__ imageio_node: {}".format(imageio_node)) + if not imageio_node: + return + # find overrides and update knobs with them get_imageio_node_override_setting( node_class, From f14cabb667b06d5a5501f5dd4564193ae34967ad Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 6 May 2022 15:15:07 +0200 Subject: [PATCH 296/583] hound --- openpype/hosts/nuke/api/lib.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index ff3932c63d..ba8aa7a8db 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -1211,8 +1211,10 @@ def create_write_node( return GN -def create_write_node_legacy(name, data, input=None, prenodes=None, - review=True, linked_knobs=None, farm=True): +def create_write_node_legacy( + name, data, input=None, prenodes=None, + review=True, linked_knobs=None, farm=True +): ''' Creating write node which is group node Arguments: From a0284fd23947f8c4fc882947b86134227dc5d3c6 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 6 May 2022 15:41:55 +0200 Subject: [PATCH 297/583] nuke: adding legacy type --- openpype/hosts/nuke/api/plugin.py | 8 ++++++++ .../schemas/template_nuke_knob_inputs.json | 16 ++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/openpype/hosts/nuke/api/plugin.py b/openpype/hosts/nuke/api/plugin.py index 76c1e7a37c..2bad6f2c78 100644 --- a/openpype/hosts/nuke/api/plugin.py +++ b/openpype/hosts/nuke/api/plugin.py @@ -713,6 +713,14 @@ class AbstractWriteRender(OpenPypeCreator): imageio_nodes = get_nuke_imageio_settings()["nodes"] node = imageio_nodes["requiredNodes"][0] if "type" not in node["knobs"][0]: + # if type is not yet in project anatomy + return True + elif next(iter( + _k for _k in node["knobs"] + if _k.get("type") == "__legacy__" + ), None): + # in case someone re-saved anatomy + # with old configuration return True @abstractmethod diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/template_nuke_knob_inputs.json b/openpype/settings/entities/schemas/projects_schema/schemas/template_nuke_knob_inputs.json index d2fa05e55c..52a14e0636 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/template_nuke_knob_inputs.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/template_nuke_knob_inputs.json @@ -251,6 +251,22 @@ ] } ] + }, + { + "key": "__legacy__", + "label": "_ Legacy type _", + "children": [ + { + "type": "text", + "key": "name", + "label": "Name" + }, + { + "type": "text", + "key": "value", + "label": "Value" + } + ] } ] } From e87024d7ff830d2229cd25753e917d586557ee5e Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 6 May 2022 16:26:15 +0200 Subject: [PATCH 298/583] added backwards compatibility for imageio values --- openpype/settings/lib.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/openpype/settings/lib.py b/openpype/settings/lib.py index f921b9c318..f1a4541850 100644 --- a/openpype/settings/lib.py +++ b/openpype/settings/lib.py @@ -291,6 +291,22 @@ def _system_settings_backwards_compatible_conversion(studio_overrides): } +def _project_anatomy_backwards_compatible_conversion(project_anatomy): + # Backwards compatibility of node settings in Nuke 3.9.x - 3.10.0 + # - source PR - https://github.com/pypeclub/OpenPype/pull/3143 + value = project_anatomy + for key in ("imageio", "nuke", "nodes", "requiredNodes"): + if key not in value: + return + value = value[key] + + for item in value: + for node in item.get("requiredNodes") or []: + if "type" in node: + break + node["type"] = "__legacy__" + + @require_handler def get_studio_system_settings_overrides(return_version=False): output = _SETTINGS_HANDLER.get_studio_system_settings_overrides( @@ -326,7 +342,9 @@ def get_project_settings_overrides(project_name, return_version=False): @require_handler def get_project_anatomy_overrides(project_name): - return _SETTINGS_HANDLER.get_project_anatomy_overrides(project_name) + output = _SETTINGS_HANDLER.get_project_anatomy_overrides(project_name) + _project_anatomy_backwards_compatible_conversion(output) + return output @require_handler From b9c211c4b5e37fa31ac6058e040bf9656d3deae5 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 6 May 2022 16:27:53 +0200 Subject: [PATCH 299/583] fixed order of variables in ftrack action delete old versions --- .../ftrack/event_handlers_user/action_delete_old_versions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/ftrack/event_handlers_user/action_delete_old_versions.py b/openpype/modules/ftrack/event_handlers_user/action_delete_old_versions.py index f5addde8ae..a0bf6622e9 100644 --- a/openpype/modules/ftrack/event_handlers_user/action_delete_old_versions.py +++ b/openpype/modules/ftrack/event_handlers_user/action_delete_old_versions.py @@ -569,7 +569,7 @@ class DeleteOldVersions(BaseAction): context["frame"] = self.sequence_splitter sequence_path = os.path.normpath( StringTemplate.format_strict_template( - context, template + template, context ) ) From 065964525ad367c8d536cd8768a250e1af5a0f98 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 6 May 2022 16:53:43 +0200 Subject: [PATCH 300/583] fix key access --- openpype/settings/lib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/settings/lib.py b/openpype/settings/lib.py index f1a4541850..6df41112c8 100644 --- a/openpype/settings/lib.py +++ b/openpype/settings/lib.py @@ -301,7 +301,7 @@ def _project_anatomy_backwards_compatible_conversion(project_anatomy): value = value[key] for item in value: - for node in item.get("requiredNodes") or []: + for node in item.get("knobs") or []: if "type" in node: break node["type"] = "__legacy__" From ec49131a5b6647a45718574e7a1628363a03ec04 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 6 May 2022 18:18:05 +0200 Subject: [PATCH 301/583] add support for compressed bgeo to standalone and simple loader to houdini --- .../hosts/houdini/plugins/load/load_bgeo.py | 107 ++++++++++++++++++ .../widgets/widget_drop_frame.py | 2 +- 2 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 openpype/hosts/houdini/plugins/load/load_bgeo.py diff --git a/openpype/hosts/houdini/plugins/load/load_bgeo.py b/openpype/hosts/houdini/plugins/load/load_bgeo.py new file mode 100644 index 0000000000..a463d51383 --- /dev/null +++ b/openpype/hosts/houdini/plugins/load/load_bgeo.py @@ -0,0 +1,107 @@ +# -*- coding: utf-8 -*- +import os +import re + +from openpype.pipeline import ( + load, + get_representation_path, +) +from openpype.hosts.houdini.api import pipeline + + +class BgeoLoader(load.LoaderPlugin): + """Load bgeo files to Houdini.""" + + label = "Load bgeo" + families = ["model", "pointcache", "bgeo"] + representations = [ + "bgeo", "bgeosc", "bgeogz", + "bgeo.sc", "bgeo.gz", "bgeo.lzma", "bgeo.bz2"] + order = -10 + icon = "code-fork" + color = "orange" + + def load(self, context, name=None, namespace=None, data=None): + + import hou + + # Get the root node + obj = hou.node("/obj") + + # Define node name + namespace = namespace if namespace else context["asset"]["name"] + node_name = "{}_{}".format(namespace, name) if namespace else name + + # Create a new geo node + container = obj.createNode("geo", node_name=node_name) + is_sequence = bool(context["representation"]["context"].get("frame")) + + # Remove the file node, it only loads static meshes + # Houdini 17 has removed the file node from the geo node + file_node = container.node("file1") + if file_node: + file_node.destroy() + + # Explicitly create a file node + file_node = container.createNode("file", node_name=node_name) + file_node.setParms({"file": self.format_path(self.fname, is_sequence)}) + + # Set display on last node + file_node.setDisplayFlag(True) + + nodes = [container, file_node] + self[:] = nodes + + return pipeline.containerise( + node_name, + namespace, + nodes, + context, + self.__class__.__name__, + suffix="", + ) + + @staticmethod + def format_path(path, is_sequence): + """Format file path correctly for single bgeo or bgeo sequence.""" + if not os.path.exists(path): + raise RuntimeError("Path does not exist: %s" % path) + + # The path is either a single file or sequence in a folder. + if not is_sequence: + filename = path + print("single") + else: + filename = re.sub(r"(.*)\.(\d+)\.(bgeo.*)", "\\1.$F4.\\3", path) + + filename = os.path.join(path, filename) + + filename = os.path.normpath(filename) + filename = filename.replace("\\", "/") + + return filename + + def update(self, container, representation): + + node = container["node"] + try: + file_node = next( + n for n in node.children() if n.type().name() == "file" + ) + except StopIteration: + self.log.error("Could not find node of type `alembic`") + return + + # Update the file path + file_path = get_representation_path(representation) + file_path = self.format_path(file_path) + + file_node.setParms({"fileName": file_path}) + + # Update attribute + node.setParms({"representation": str(representation["_id"])}) + + def remove(self, container): + + node = container["node"] + node.destroy() diff --git a/openpype/tools/standalonepublish/widgets/widget_drop_frame.py b/openpype/tools/standalonepublish/widgets/widget_drop_frame.py index e6c7328e88..f8a8273b26 100644 --- a/openpype/tools/standalonepublish/widgets/widget_drop_frame.py +++ b/openpype/tools/standalonepublish/widgets/widget_drop_frame.py @@ -38,7 +38,7 @@ class DropDataFrame(QtWidgets.QFrame): } sequence_types = [ - ".bgeo", ".vdb" + ".bgeo", ".vdb", ".bgeosc", ".bgeogz" ] def __init__(self, parent): From d746f9410572b2dab73e31c75037561710f31b32 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 6 May 2022 18:41:17 +0200 Subject: [PATCH 302/583] add comment and get renderer from instance --- .../modules/deadline/plugins/publish/submit_maya_deadline.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py index 7bf68f51ee..8562c85f7d 100644 --- a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py @@ -266,7 +266,6 @@ def get_renderer_variables(renderlayer, root): filename_prefix = cmds.getAttr(prefix_attr) return {"ext": extension, - "renderer": renderer, "filename_prefix": filename_prefix, "padding": padding, "filename_0": filename_0} @@ -441,7 +440,9 @@ class MayaSubmitDeadline(pyblish.api.InstancePlugin): output_filename_0 = filename_0 - if render_variables["renderer"] == "renderman": + # this is needed because renderman handles directory and file + # prefixes separately + if self._instance.data["renderer"] == "renderman": dirname = os.path.dirname(output_filename_0) # Create render folder ---------------------------------------------- From 7226d82c8f0b6ab52ed7d0a95e7c61deb9e81015 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Mon, 9 May 2022 15:10:20 +0200 Subject: [PATCH 303/583] add support for hw prefixes --- openpype/hosts/maya/plugins/create/create_render.py | 6 ++++-- openpype/hosts/maya/plugins/publish/collect_render.py | 4 ++-- .../plugins/publish/validate_render_single_camera.py | 3 ++- .../maya/plugins/publish/validate_rendersettings.py | 10 ++++++---- 4 files changed, 14 insertions(+), 9 deletions(-) diff --git a/openpype/hosts/maya/plugins/create/create_render.py b/openpype/hosts/maya/plugins/create/create_render.py index 8e9bd0e22b..93ee6679e5 100644 --- a/openpype/hosts/maya/plugins/create/create_render.py +++ b/openpype/hosts/maya/plugins/create/create_render.py @@ -77,7 +77,8 @@ class CreateRender(plugin.Creator): 'vray': 'vraySettings.fileNamePrefix', 'arnold': 'defaultRenderGlobals.imageFilePrefix', 'renderman': 'rmanGlobals.imageFileFormat', - 'redshift': 'defaultRenderGlobals.imageFilePrefix' + 'redshift': 'defaultRenderGlobals.imageFilePrefix', + 'mayahardware2': 'defaultRenderGlobals.imageFilePrefix', } _image_prefixes = { @@ -87,7 +88,8 @@ class CreateRender(plugin.Creator): # this needs `imageOutputDir` # (/renders/maya/) set separately 'renderman': '_..', - 'redshift': 'maya///' # noqa + 'redshift': 'maya///', # noqa + 'mayahardware2': 'maya///', # noqa } _aov_chars = { diff --git a/openpype/hosts/maya/plugins/publish/collect_render.py b/openpype/hosts/maya/plugins/publish/collect_render.py index 912fe179dd..e66983780e 100644 --- a/openpype/hosts/maya/plugins/publish/collect_render.py +++ b/openpype/hosts/maya/plugins/publish/collect_render.py @@ -326,8 +326,8 @@ class CollectMayaRender(pyblish.api.ContextPlugin): "byFrameStep": int( self.get_render_attribute("byFrameStep", layer=layer_name)), - "renderer": self.get_render_attribute("currentRenderer", - layer=layer_name), + "renderer": self.get_render_attribute( + "currentRenderer", layer=layer_name).lower(), # instance subset "family": "renderlayer", "families": ["renderlayer"], diff --git a/openpype/hosts/maya/plugins/publish/validate_render_single_camera.py b/openpype/hosts/maya/plugins/publish/validate_render_single_camera.py index 0838b4fbf8..e6c6ef6c9e 100644 --- a/openpype/hosts/maya/plugins/publish/validate_render_single_camera.py +++ b/openpype/hosts/maya/plugins/publish/validate_render_single_camera.py @@ -12,7 +12,8 @@ ImagePrefixes = { 'vray': 'vraySettings.fileNamePrefix', 'arnold': 'defaultRenderGlobals.imageFilePrefix', 'renderman': 'defaultRenderGlobals.imageFilePrefix', - 'redshift': 'defaultRenderGlobals.imageFilePrefix' + 'redshift': 'defaultRenderGlobals.imageFilePrefix', + 'mayahardware2': 'defaultRenderGlobals.imageFilePrefix', } diff --git a/openpype/hosts/maya/plugins/publish/validate_rendersettings.py b/openpype/hosts/maya/plugins/publish/validate_rendersettings.py index 023e27de17..e212e8978d 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rendersettings.py +++ b/openpype/hosts/maya/plugins/publish/validate_rendersettings.py @@ -50,15 +50,17 @@ class ValidateRenderSettings(pyblish.api.InstancePlugin): 'vray': 'vraySettings.fileNamePrefix', 'arnold': 'defaultRenderGlobals.imageFilePrefix', 'renderman': 'rmanGlobals.imageFileFormat', - 'redshift': 'defaultRenderGlobals.imageFilePrefix' + 'redshift': 'defaultRenderGlobals.imageFilePrefix', + 'mayahardware2': 'defaultRenderGlobals.imageFilePrefix', } ImagePrefixTokens = { - + 'mentalray': 'maya///{aov_separator}', 'arnold': 'maya///{aov_separator}', # noqa 'redshift': 'maya///', 'vray': 'maya///', - 'renderman': '{aov_separator}..' # noqa + 'renderman': '{aov_separator}..', # noqa + 'mayahardware2': 'maya///', # noqa } _aov_chars = { @@ -234,7 +236,7 @@ class ValidateRenderSettings(pyblish.api.InstancePlugin): # load validation definitions from settings validation_settings = ( instance.context.data["project_settings"]["maya"]["publish"]["ValidateRenderSettings"].get( # noqa: E501 - "{}_render_attributes".format(renderer)) + "{}_render_attributes".format(renderer)) or [] ) # go through definitions and test if such node.attribute exists. From 10e4764da21de5aec20df48f619c02a06644cdc7 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 10 May 2022 11:15:42 +0200 Subject: [PATCH 304/583] OP-3113 - removed unnecessary default Task type enum is dynamically created from Ftrack, value here is targeted only for single customer, so it shouldn't probably be in defaults at all. --- openpype/settings/defaults/project_anatomy/tasks.json | 3 --- 1 file changed, 3 deletions(-) diff --git a/openpype/settings/defaults/project_anatomy/tasks.json b/openpype/settings/defaults/project_anatomy/tasks.json index 178f2af639..74504cc4d7 100644 --- a/openpype/settings/defaults/project_anatomy/tasks.json +++ b/openpype/settings/defaults/project_anatomy/tasks.json @@ -40,8 +40,5 @@ }, "Compositing": { "short_name": "comp" - }, - "Background": { - "short_name": "back" } } \ No newline at end of file From 7ff23fd4294532d670adffe179c8ea893db9feda Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 10 May 2022 12:06:08 +0200 Subject: [PATCH 305/583] fix extension collection --- .../traypublisher/plugins/publish/collect_simple_instances.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/traypublisher/plugins/publish/collect_simple_instances.py b/openpype/hosts/traypublisher/plugins/publish/collect_simple_instances.py index 9facd90a48..b2be43c701 100644 --- a/openpype/hosts/traypublisher/plugins/publish/collect_simple_instances.py +++ b/openpype/hosts/traypublisher/plugins/publish/collect_simple_instances.py @@ -33,7 +33,8 @@ class CollectSettingsSimpleInstances(pyblish.api.InstancePlugin): instance.data["stagingDir"] = filepath_item["directory"] filenames = filepath_item["filenames"] - ext = os.path.splitext(filenames[0])[-1] + _, ext = os.path.splitext(filenames[0]) + ext = ext[1:] if len(filenames) == 1: filenames = filenames[0] From 235baf4f4c40fc92f3faf148d846655590bf6cdb Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 10 May 2022 14:48:53 +0200 Subject: [PATCH 306/583] store width and height into thumbnail representation --- .../plugins/publish/extract_thumbnail.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/standalonepublisher/plugins/publish/extract_thumbnail.py b/openpype/hosts/standalonepublisher/plugins/publish/extract_thumbnail.py index 23f0b104c8..941a76b05b 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/extract_thumbnail.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/extract_thumbnail.py @@ -2,7 +2,10 @@ import os import tempfile import pyblish.api import openpype.api -import openpype.lib +from openpype.lib import ( + get_ffmpeg_tool_path, + get_ffprobe_streams, +) class ExtractThumbnailSP(pyblish.api.InstancePlugin): @@ -71,7 +74,7 @@ class ExtractThumbnailSP(pyblish.api.InstancePlugin): full_thumbnail_path = tempfile.mkstemp(suffix=".jpg")[1] self.log.info("output {}".format(full_thumbnail_path)) - ffmpeg_path = openpype.lib.get_ffmpeg_tool_path("ffmpeg") + ffmpeg_path = get_ffmpeg_tool_path("ffmpeg") ffmpeg_args = self.ffmpeg_args or {} @@ -110,6 +113,13 @@ class ExtractThumbnailSP(pyblish.api.InstancePlugin): # remove thumbnail key from origin repre thumbnail_repre.pop("thumbnail") + streams = get_ffprobe_streams(full_thumbnail_path) + width = height = None + for stream in streams: + if "width" in stream and "height" in stream: + width = stream["width"] + height = stream["height"] + break filename = os.path.basename(full_thumbnail_path) staging_dir = staging_dir or os.path.dirname(full_thumbnail_path) @@ -122,6 +132,9 @@ class ExtractThumbnailSP(pyblish.api.InstancePlugin): "stagingDir": staging_dir, "tags": ["thumbnail"], } + if width and height: + representation["width"] = width + representation["height"] = height # # add Delete tag when temp file was rendered if not is_jpeg: From 5da19f09ab487419f15a2179394a923a037e9ef4 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 10 May 2022 14:49:37 +0200 Subject: [PATCH 307/583] integrate ftrack instance is collecting width and height from representation and uses ffprobe if are not available --- .../publish/integrate_ftrack_instances.py | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_instances.py b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_instances.py index 5eecf34c3d..7181be6f01 100644 --- a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_instances.py +++ b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_instances.py @@ -3,6 +3,7 @@ import json import copy import pyblish.api +from openpype.lib import get_ffprobe_streams from openpype.lib.profiles_filtering import filter_profiles @@ -142,6 +143,7 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): # Create thumbnail components # TODO what if there is multiple thumbnails? first_thumbnail_component = None + first_thumbnail_component_repre = None for repre in thumbnail_representations: published_path = repre.get("published_path") if not published_path: @@ -169,12 +171,45 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): src_components_to_add.append(copy.deepcopy(thumbnail_item)) # Create copy of first thumbnail if first_thumbnail_component is None: - first_thumbnail_component = copy.deepcopy(thumbnail_item) + first_thumbnail_component_repre = repre + first_thumbnail_component = thumbnail_item # Set location thumbnail_item["component_location"] = ftrack_server_location # Add item to component list component_list.append(thumbnail_item) + if first_thumbnail_component is not None: + width = first_thumbnail_component_repre.get("width") + height = first_thumbnail_component_repre.get("height") + if not width or not height: + component_path = first_thumbnail_component["component_path"] + streams = [] + try: + streams = get_ffprobe_streams(component_path) + except Exception: + self.log.debug( + "Failed to retrieve information about intput {}".format( + component_path + ) + ) + + for stream in streams: + if "width" in stream and "height" in stream: + width = stream["width"] + height = stream["height"] + break + + if width and height: + component_data = first_thumbnail_component["component_data"] + component_data["name"] = "ftrackreview-image" + component_data["metadata"] = { + "ftr_meta": json.dumps({ + "width": width, + "height": height, + "format": "image" + }) + } + # Create review components # Change asset name of each new component for review is_first_review_repre = True From a20891e5687514b2852440948c44a9b5cf0da3b4 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 10 May 2022 15:17:58 +0200 Subject: [PATCH 308/583] fix line length --- .../ftrack/plugins/publish/integrate_ftrack_instances.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_instances.py b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_instances.py index 7181be6f01..0dd7b1c6e4 100644 --- a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_instances.py +++ b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_instances.py @@ -187,11 +187,9 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): try: streams = get_ffprobe_streams(component_path) except Exception: - self.log.debug( - "Failed to retrieve information about intput {}".format( - component_path - ) - ) + self.log.debug(( + "Failed to retrieve information about intput {}" + ).format(component_path)) for stream in streams: if "width" in stream and "height" in stream: From b80bb7f7df51d43ec0f15d80076fba3a90077f51 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 10 May 2022 15:36:27 +0200 Subject: [PATCH 309/583] OP-3137 - use validation settings to create subset name Uses Setting to replace invalid characters automatically. Creates valid subset name and renames layer. --- .../publish/collect_color_coded_instances.py | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/openpype/hosts/photoshop/plugins/publish/collect_color_coded_instances.py b/openpype/hosts/photoshop/plugins/publish/collect_color_coded_instances.py index 122428eea0..ca8b5d962f 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_color_coded_instances.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_color_coded_instances.py @@ -5,6 +5,7 @@ import pyblish.api from openpype.lib import prepare_template_data from openpype.hosts.photoshop import api as photoshop +from openpype.settings import get_project_settings class CollectColorCodedInstances(pyblish.api.ContextPlugin): @@ -49,6 +50,12 @@ class CollectColorCodedInstances(pyblish.api.ContextPlugin): asset_name = context.data["asset"] task_name = context.data["task"] variant = context.data["variant"] + project_name = context.data["projectEntity"]["name"] + + naming_conventions = get_project_settings(project_name).get( + "photoshop", {}).get( + "publish", {}).get( + "ValidateNaming", {}) stub = photoshop.stub() layers = stub.get_layers() @@ -83,6 +90,9 @@ class CollectColorCodedInstances(pyblish.api.ContextPlugin): subset = resolved_subset_template.format( **prepare_template_data(fill_pairs)) + subset = self._clean_subset_name(stub, naming_conventions, + subset, layer) + if subset in existing_subset_names: self.log.info( "Subset {} already created, skipping.".format(subset)) @@ -186,3 +196,21 @@ class CollectColorCodedInstances(pyblish.api.ContextPlugin): self.log.debug("resolved_subset_template {}".format( resolved_subset_template)) return family, resolved_subset_template + + def _clean_subset_name(self, stub, naming_conventions, subset, layer): + """Cleans invalid characters from subset name and layer name.""" + if re.search(naming_conventions["invalid_chars"], subset): + subset = re.sub( + naming_conventions["invalid_chars"], + naming_conventions["replace_char"], + subset + ) + layer_name = re.sub( + naming_conventions["invalid_chars"], + naming_conventions["replace_char"], + layer.clean_name + ) + layer.name = layer_name + stub.rename_layer(layer.id, layer_name) + + return subset From 734258cc195f2dee0cd7481b64b88bb3bcdbd261 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 10 May 2022 15:58:35 +0200 Subject: [PATCH 310/583] OP-3137 - use validation settings to create subset name Uses Setting to replace invalid characters automatically. Creates valid subset name and renames layer. --- .../photoshop/plugins/publish/collect_color_coded_instances.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/hosts/photoshop/plugins/publish/collect_color_coded_instances.py b/openpype/hosts/photoshop/plugins/publish/collect_color_coded_instances.py index ca8b5d962f..ae025fc61d 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_color_coded_instances.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_color_coded_instances.py @@ -151,6 +151,7 @@ class CollectColorCodedInstances(pyblish.api.ContextPlugin): instance.data["task"] = task_name instance.data["subset"] = subset instance.data["layer"] = layer + instance.data["families"] = [] return instance From edef0cca11c0cdf22097a33cdb786abd4762eca9 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 10 May 2022 14:32:18 +0200 Subject: [PATCH 311/583] OP-3137 - fix naming validator Layer name validator was looking into wrong field. Layer name should follow same naming convention as subset name to see relation between subset name and layer name better. Introduced clean_name property returning layer name without publishing highlight icons. --- openpype/hosts/photoshop/api/ws_stub.py | 10 ++++++++++ .../plugins/publish/validate_naming.py | 19 ++++++++++++------- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/photoshop/api/ws_stub.py b/openpype/hosts/photoshop/api/ws_stub.py index fa076ecc7e..b49bf1c73f 100644 --- a/openpype/hosts/photoshop/api/ws_stub.py +++ b/openpype/hosts/photoshop/api/ws_stub.py @@ -29,6 +29,16 @@ class PSItem(object): color_code = attr.ib(default=None) # color code of layer instance_id = attr.ib(default=None) + @property + def clean_name(self): + """Returns layer name without publish icon highlight + + Returns: + (str) + """ + return (self.name.replace(PhotoshopServerStub.PUBLISH_ICON, '') + .replace(PhotoshopServerStub.LOADED_ICON, '')) + class PhotoshopServerStub: """ diff --git a/openpype/hosts/photoshop/plugins/publish/validate_naming.py b/openpype/hosts/photoshop/plugins/publish/validate_naming.py index bcae24108c..b53f4e8198 100644 --- a/openpype/hosts/photoshop/plugins/publish/validate_naming.py +++ b/openpype/hosts/photoshop/plugins/publish/validate_naming.py @@ -42,7 +42,8 @@ class ValidateNamingRepair(pyblish.api.Action): layer_name = re.sub(invalid_chars, replace_char, - current_layer_state.name) + current_layer_state.clean_name) + layer_name = stub.PUBLISH_ICON + layer_name stub.rename_layer(current_layer_state.id, layer_name) @@ -73,13 +74,17 @@ class ValidateNaming(pyblish.api.InstancePlugin): def process(self, instance): help_msg = ' Use Repair action (A) in Pyblish to fix it.' - msg = "Name \"{}\" is not allowed.{}".format(instance.data["name"], - help_msg) - formatting_data = {"msg": msg} - if re.search(self.invalid_chars, instance.data["name"]): - raise PublishXmlValidationError(self, msg, - formatting_data=formatting_data) + layer = instance.data.get("layer") + if layer: + msg = "Name \"{}\" is not allowed.{}".format(layer.clean_name, + help_msg) + + formatting_data = {"msg": msg} + if re.search(self.invalid_chars, layer.clean_name): + raise PublishXmlValidationError(self, msg, + formatting_data=formatting_data + ) msg = "Subset \"{}\" is not allowed.{}".format(instance.data["subset"], help_msg) From f7fc6a3e7007d80d0f75ccedcf381267c676f089 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 10 May 2022 16:52:13 +0200 Subject: [PATCH 312/583] flame: fixing attr_name issue --- openpype/hosts/flame/api/lib.py | 2 +- openpype/hosts/flame/otio/flame_export.py | 91 +++++------------------ 2 files changed, 21 insertions(+), 72 deletions(-) diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py index c7c444c1fb..2e9b535764 100644 --- a/openpype/hosts/flame/api/lib.py +++ b/openpype/hosts/flame/api/lib.py @@ -560,7 +560,7 @@ def get_segment_attributes(segment): if not hasattr(segment, attr_name): continue attr = getattr(segment, attr_name) - segment_attrs_data[attr] = str(attr).replace("+", ":") + segment_attrs_data[attr_name] = str(attr).replace("+", ":") if attr_name in ["record_in", "record_out"]: clip_data[attr_name] = attr.relative_frame diff --git a/openpype/hosts/flame/otio/flame_export.py b/openpype/hosts/flame/otio/flame_export.py index 4fe05ec1d8..d84ee98256 100644 --- a/openpype/hosts/flame/otio/flame_export.py +++ b/openpype/hosts/flame/otio/flame_export.py @@ -94,83 +94,30 @@ def create_otio_time_range(start_frame, frame_duration, fps): def _get_metadata(item): if hasattr(item, 'metadata'): - if not item.metadata: - return {} - return {key: value for key, value in dict(item.metadata)} + return dict(dict(item.metadata)) if item.metadata else {} return {} -def create_time_effects(otio_clip, item): - # todo #2426: add retiming effects to export - # get all subtrack items - # subTrackItems = flatten(track_item.parent().subTrackItems()) - # speed = track_item.playbackSpeed() +# def create_time_effects(otio_clip, clip_data): +# otio_effect = None - # otio_effect = None - # # retime on track item - # if speed != 1.: - # # make effect - # otio_effect = otio.schema.LinearTimeWarp() - # otio_effect.name = "Speed" - # otio_effect.time_scalar = speed - # otio_effect.metadata = {} +# # retime on track item +# if speed != 1.: +# # make effect +# otio_effect = otio.schema.LinearTimeWarp() +# otio_effect.name = "Speed" +# otio_effect.time_scalar = speed +# otio_effect.metadata = {} - # # freeze frame effect - # if speed == 0.: - # otio_effect = otio.schema.FreezeFrame() - # otio_effect.name = "FreezeFrame" - # otio_effect.metadata = {} +# # freeze frame effect +# if speed == 0.: +# otio_effect = otio.schema.FreezeFrame() +# otio_effect.name = "FreezeFrame" +# otio_effect.metadata = {} - # if otio_effect: - # # add otio effect to clip effects - # otio_clip.effects.append(otio_effect) - - # # loop through and get all Timewarps - # for effect in subTrackItems: - # if ((track_item not in effect.linkedItems()) - # and (len(effect.linkedItems()) > 0)): - # continue - # # avoid all effect which are not TimeWarp and disabled - # if "TimeWarp" not in effect.name(): - # continue - - # if not effect.isEnabled(): - # continue - - # node = effect.node() - # name = node["name"].value() - - # # solve effect class as effect name - # _name = effect.name() - # if "_" in _name: - # effect_name = re.sub(r"(?:_)[_0-9]+", "", _name) # more numbers - # else: - # effect_name = re.sub(r"\d+", "", _name) # one number - - # metadata = {} - # # add knob to metadata - # for knob in ["lookup", "length"]: - # value = node[knob].value() - # animated = node[knob].isAnimated() - # if animated: - # value = [ - # ((node[knob].getValueAt(i)) - i) - # for i in range( - # track_item.timelineIn(), - # track_item.timelineOut() + 1) - # ] - - # metadata[knob] = value - - # # make effect - # otio_effect = otio.schema.TimeEffect() - # otio_effect.name = name - # otio_effect.effect_name = effect_name - # otio_effect.metadata = metadata - - # # add otio effect to clip effects - # otio_clip.effects.append(otio_effect) - pass +# if otio_effect: +# # add otio effect to clip effects +# otio_clip.effects.append(otio_effect) def _get_marker_color(flame_colour): @@ -363,6 +310,8 @@ def create_otio_clip(clip_data): if MARKERS_INCLUDE: create_otio_markers(otio_clip, segment) + create_time_effects(otio_clip, clip_data) + return otio_clip From 21b83e341b754506d982c8ec05cb28d647c296cd Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 10 May 2022 16:59:20 +0200 Subject: [PATCH 313/583] flame" adding empty families to workfile instance --- openpype/hosts/flame/plugins/publish/collect_timeline_otio.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/flame/plugins/publish/collect_timeline_otio.py b/openpype/hosts/flame/plugins/publish/collect_timeline_otio.py index f2ae1f62a9..0a9b0db334 100644 --- a/openpype/hosts/flame/plugins/publish/collect_timeline_otio.py +++ b/openpype/hosts/flame/plugins/publish/collect_timeline_otio.py @@ -39,7 +39,8 @@ class CollecTimelineOTIO(pyblish.api.ContextPlugin): "name": subset_name, "asset": asset_doc["name"], "subset": subset_name, - "family": "workfile" + "family": "workfile", + "families": [] } # create instance with workfile From 9483adc91eaa0958f14090ba8af3e773b80247f9 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 10 May 2022 16:59:39 +0200 Subject: [PATCH 314/583] Flame: commenting out effects - not yet implemented --- openpype/hosts/flame/otio/flame_export.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/flame/otio/flame_export.py b/openpype/hosts/flame/otio/flame_export.py index d84ee98256..fc960b670c 100644 --- a/openpype/hosts/flame/otio/flame_export.py +++ b/openpype/hosts/flame/otio/flame_export.py @@ -310,7 +310,7 @@ def create_otio_clip(clip_data): if MARKERS_INCLUDE: create_otio_markers(otio_clip, segment) - create_time_effects(otio_clip, clip_data) + # create_time_effects(otio_clip, clip_data) return otio_clip From de31b8d1df9458f1daa1ac9a385e648f588f4cf6 Mon Sep 17 00:00:00 2001 From: jrsndlr Date: Tue, 10 May 2022 20:14:02 +0200 Subject: [PATCH 315/583] add silent audio to slate --- .../plugins/publish/extract_review_slate.py | 74 ++++++++++++------- 1 file changed, 47 insertions(+), 27 deletions(-) diff --git a/openpype/plugins/publish/extract_review_slate.py b/openpype/plugins/publish/extract_review_slate.py index 832799601c..2ef5308225 100644 --- a/openpype/plugins/publish/extract_review_slate.py +++ b/openpype/plugins/publish/extract_review_slate.py @@ -1,5 +1,4 @@ import os -import shutil import openpype.api import pyblish from openpype.lib import ( @@ -11,6 +10,7 @@ from openpype.lib import ( get_ffmpeg_format_args, ) + class ExtractReviewSlate(openpype.api.Extractor): """ Will add slate frame at the start of the video files @@ -122,10 +122,14 @@ class ExtractReviewSlate(openpype.api.Extractor): if stream["channel_layout"]: audio_channel_layout = str( stream.get("channel_layout")) + if stream["codec_name"]: + audio_codec = str( + stream.get("codec_name")) if ( audio_channels and audio_sample_rate and audio_channel_layout + and audio_codec ): input_audio = True break @@ -200,16 +204,6 @@ class ExtractReviewSlate(openpype.api.Extractor): input_args.append("-loop 1 -i {}".format( openpype.lib.path_to_subprocess_arg(slate_path))) - # if input has an audio, add silent audio to the slate - if input_audio: - input_args.extend( - ["-f lavfi -i anullsrc=r={}:cl={}:d={}".format( - audio_sample_rate, - audio_channel_layout, - one_frame_duration - )] - ) - input_args.extend(["-r {}".format(input_frame_rate)]) input_args.extend(["-frames:v 1"]) # add timecode from source to the slate, substract one frame @@ -314,6 +308,41 @@ class ExtractReviewSlate(openpype.api.Extractor): slate_subprocess_cmd, shell=True, logger=self.log ) + # Create slate with silent audio track + if input_audio: + # silent slate output path + slate_silent_path = slate_path.replace(".png", "Silent" + ext) + _remove_at_end.append(slate_silent_path) + + slate_silent_args = [ + ffmpeg_path, + "-i", slate_v_path, + "-f", "lavfi", "-i", + "anullsrc=r={}:cl={}:d={}".format( + audio_sample_rate, + audio_channel_layout, + one_frame_duration + ), + "-c:v", "copy", + "-c:a", audio_codec, + "-map", "0:v", + "-map", "1:a", + "-shortest", + "-y", + slate_silent_path + ] + slate_silent_subprocess_cmd = " ".join(slate_silent_args) + # run slate generation subprocess + self.log.debug( + "Silent Slate Executing: {}".format(slate_silent_subprocess_cmd) + ) + openpype.api.run_subprocess( + slate_silent_subprocess_cmd, shell=True, logger=self.log + ) + + # replace slate with silent slate for concat + slate_v_path = slate_silent_path + # create ffmpeg concat text file path conc_text_file = input_file.replace(ext, "") + "_concat" + ".txt" conc_text_path = os.path.join( @@ -361,21 +390,13 @@ class ExtractReviewSlate(openpype.api.Extractor): # add final output path concat_args.append(output_path) - if not input_audio: - # ffmpeg concat subprocess - self.log.debug( - "Executing concat: {}".format(" ".join(concat_args)) - ) - openpype.api.run_subprocess( - concat_args, logger=self.log - ) - else: - self.log.warning( - "Audio found. Creating slate with audio" - " is not supported at this time. Outputing slate-less" - ":\n{}".format(input_file)) - # skip concatenating slate, use slate-less file instead - shutil.copyfile(input_path, output_path) + # ffmpeg concat subprocess + self.log.debug( + "Executing concat: {}".format(" ".join(concat_args)) + ) + openpype.api.run_subprocess( + concat_args, logger=self.log + ) self.log.debug("__ repre[tags]: {}".format(repre["tags"])) repre_update = { @@ -438,7 +459,6 @@ class ExtractReviewSlate(openpype.api.Extractor): return vf_back - def _get_format_codec_args(self, repre): """Detect possible codec arguments from representation.""" codec_args = [] From b0c9e8a29d4c16d3276989fee37c8d8762d67809 Mon Sep 17 00:00:00 2001 From: jrsndlr Date: Tue, 10 May 2022 20:35:04 +0200 Subject: [PATCH 316/583] hound --- openpype/plugins/publish/extract_review_slate.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/plugins/publish/extract_review_slate.py b/openpype/plugins/publish/extract_review_slate.py index 2ef5308225..d3f8934620 100644 --- a/openpype/plugins/publish/extract_review_slate.py +++ b/openpype/plugins/publish/extract_review_slate.py @@ -334,8 +334,8 @@ class ExtractReviewSlate(openpype.api.Extractor): slate_silent_subprocess_cmd = " ".join(slate_silent_args) # run slate generation subprocess self.log.debug( - "Silent Slate Executing: {}".format(slate_silent_subprocess_cmd) - ) + "Silent Slate Executing: {}".format( + slate_silent_subprocess_cmd)) openpype.api.run_subprocess( slate_silent_subprocess_cmd, shell=True, logger=self.log ) From ff37e6135b6a2e22fede236268683ae3e5e1936c Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 10 May 2022 20:51:28 +0200 Subject: [PATCH 317/583] global: removing `clip` family from excluded - obsolete --- openpype/plugins/publish/integrate_new.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/plugins/publish/integrate_new.py b/openpype/plugins/publish/integrate_new.py index bf13a4050e..353314fff2 100644 --- a/openpype/plugins/publish/integrate_new.py +++ b/openpype/plugins/publish/integrate_new.py @@ -113,7 +113,7 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): "usdOverride", "simpleUnrealTexture" ] - exclude_families = ["clip", "render.farm"] + exclude_families = ["render.farm"] db_representation_context_keys = [ "project", "asset", "task", "subset", "version", "representation", "family", "hierarchy", "task", "username" From dbbc633e834beabb99d0045939b03b28d98d94c9 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 11 May 2022 10:36:41 +0200 Subject: [PATCH 318/583] Fix - added missing task Task used in validations later. --- openpype/hosts/harmony/plugins/publish/collect_farm_render.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/hosts/harmony/plugins/publish/collect_farm_render.py b/openpype/hosts/harmony/plugins/publish/collect_farm_render.py index f5bf051243..3e9e680efd 100644 --- a/openpype/hosts/harmony/plugins/publish/collect_farm_render.py +++ b/openpype/hosts/harmony/plugins/publish/collect_farm_render.py @@ -144,6 +144,7 @@ class CollectFarmRender(openpype.lib.abstract_collect_render. label=node.split("/")[1], subset=subset_name, asset=legacy_io.Session["AVALON_ASSET"], + task=task_name, attachTo=False, setMembers=[node], publish=info[4], From 97e2bd13c7ca1bce903a3cbdc6975ad276c62e19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Wed, 11 May 2022 11:42:35 +0200 Subject: [PATCH 319/583] fix setting of subset group and primary family --- .../modules/deadline/plugins/publish/submit_publish_job.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py index 306237c78c..ffa5718d4a 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -468,7 +468,8 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): new_instance = copy(instance_data) new_instance["subset"] = subset_name - new_instance["subsetGroup"] = group_name + if not instance_data.get("append"): + new_instance["subsetGroup"] = group_name if preview: new_instance["review"] = True @@ -883,8 +884,8 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): new_i = copy(i) new_i["version"] = at.get("version") new_i["subset"] = at.get("subset") + new_i["family"] = at.get("family") new_i["append"] = True - new_i["families"].append(at.get("family")) new_instances.append(new_i) self.log.info(" - {} / v{}".format( at.get("subset"), at.get("version"))) From 45a1cb821e6a5fdea8cba16dfdbff842c4aa25c8 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 11 May 2022 11:48:39 +0200 Subject: [PATCH 320/583] Update validate_rendersettings.py --- .../hosts/maya/plugins/publish/validate_rendersettings.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_rendersettings.py b/openpype/hosts/maya/plugins/publish/validate_rendersettings.py index e212e8978d..ba6c1397ab 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rendersettings.py +++ b/openpype/hosts/maya/plugins/publish/validate_rendersettings.py @@ -55,12 +55,12 @@ class ValidateRenderSettings(pyblish.api.InstancePlugin): } ImagePrefixTokens = { - 'mentalray': 'maya///{aov_separator}', - 'arnold': 'maya///{aov_separator}', # noqa + 'mentalray': 'maya///{aov_separator}', # noqa: E501 + 'arnold': 'maya///{aov_separator}', # noqa: E501 'redshift': 'maya///', 'vray': 'maya///', - 'renderman': '{aov_separator}..', # noqa - 'mayahardware2': 'maya///', # noqa + 'renderman': '{aov_separator}..', + 'mayahardware2': 'maya///', } _aov_chars = { From ba1587efb6faf0e8cf21bf949fa36dbc5061d780 Mon Sep 17 00:00:00 2001 From: jrsndlr Date: Wed, 11 May 2022 12:22:34 +0200 Subject: [PATCH 321/583] safer code --- .../plugins/publish/extract_review_slate.py | 32 +++++++++++-------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/openpype/plugins/publish/extract_review_slate.py b/openpype/plugins/publish/extract_review_slate.py index d3f8934620..b84dbbc2ac 100644 --- a/openpype/plugins/publish/extract_review_slate.py +++ b/openpype/plugins/publish/extract_review_slate.py @@ -79,7 +79,7 @@ class ExtractReviewSlate(openpype.api.Extractor): ) # Get video metadata for stream in streams: - input_timecode = None + input_timecode = "" input_width = None input_height = None input_frame_rate = None @@ -89,18 +89,20 @@ class ExtractReviewSlate(openpype.api.Extractor): tags = stream.get("tags") or {} input_timecode = tags.get("timecode") or "" if "width" in stream and "height" in stream: - input_width = int(stream.get("width")) - input_height = int(stream.get("height")) + input_width = stream.get("width") + input_height = stream.get("height") if "r_frame_rate" in stream: # get frame rate in a form of # x/y, like 24000/1001 for 23.976 - input_frame_rate = str(stream.get("r_frame_rate")) + input_frame_rate = stream.get("r_frame_rate") if ( - input_timecode - and input_width + input_width and input_height and input_frame_rate ): + input_width = int(input_width) + input_height = int(input_height) + input_frame_rate = str(input_frame_rate) break # Raise exception of any stream didn't define input resolution if input_width is None: @@ -131,7 +133,10 @@ class ExtractReviewSlate(openpype.api.Extractor): and audio_channel_layout and audio_codec ): - input_audio = True + input_audio = str(input_audio) + audio_sample_rate = str(audio_sample_rate) + audio_channel_layout = str(audio_channel_layout) + audio_codec = str(audio_codec) break # Get duration of one frame in micro seconds one_frame_duration = "40000us" @@ -207,7 +212,7 @@ class ExtractReviewSlate(openpype.api.Extractor): input_args.extend(["-r {}".format(input_frame_rate)]) input_args.extend(["-frames:v 1"]) # add timecode from source to the slate, substract one frame - offset_timecode = "00:00:00:00" + offset_timecode = "" if input_timecode: offset_timecode = str(input_timecode) offset_timecode = self._tc_offset( @@ -311,7 +316,8 @@ class ExtractReviewSlate(openpype.api.Extractor): # Create slate with silent audio track if input_audio: # silent slate output path - slate_silent_path = slate_path.replace(".png", "Silent" + ext) + slate_silent_path = "_silent".join( + os.path.splitext(slate_v_path)) _remove_at_end.append(slate_silent_path) slate_silent_args = [ @@ -331,13 +337,12 @@ class ExtractReviewSlate(openpype.api.Extractor): "-y", slate_silent_path ] - slate_silent_subprocess_cmd = " ".join(slate_silent_args) # run slate generation subprocess self.log.debug( "Silent Slate Executing: {}".format( - slate_silent_subprocess_cmd)) + " ".join(slate_silent_args))) openpype.api.run_subprocess( - slate_silent_subprocess_cmd, shell=True, logger=self.log + slate_silent_args, logger=self.log ) # replace slate with silent slate for concat @@ -367,8 +372,9 @@ class ExtractReviewSlate(openpype.api.Extractor): "-safe", "0", "-i", conc_text_path, "-c", "copy", - "-timecode", offset_timecode, ] + if offset_timecode: + concat_args.extend(["-timecode", offset_timecode]) # NOTE: Added because of OP Atom demuxers # Add format arguments if there are any # - keep format of output From 26fcd19a5d8bed9849c924c62a949b25e62c6189 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20David?= Date: Wed, 11 May 2022 12:29:30 +0200 Subject: [PATCH 322/583] use dbcon.Session instead of collection --- openpype/modules/kitsu/utils/sync_service.py | 83 +++++++++---------- .../modules/kitsu/utils/update_op_with_zou.py | 42 +++++----- .../modules/kitsu/utils/update_zou_with_op.py | 6 +- 3 files changed, 63 insertions(+), 68 deletions(-) diff --git a/openpype/modules/kitsu/utils/sync_service.py b/openpype/modules/kitsu/utils/sync_service.py index 01596e2667..ad18e1f391 100644 --- a/openpype/modules/kitsu/utils/sync_service.py +++ b/openpype/modules/kitsu/utils/sync_service.py @@ -119,8 +119,8 @@ class Listener: # Write into DB if update_project: - project_col = self.dbcon.database[project_name] - project_col.bulk_write([update_project]) + self.dbcon = self.dbcon.database[project_name] + self.dbcon.bulk_write([update_project]) def _delete_project(self, data): """Delete project.""" @@ -129,28 +129,27 @@ class Listener: project = gazu.project.get_project(data["project_id"]) # Delete project collection - project_col = self.dbcon.database[project["name"]] - project_col.drop() + # self.dbcon = self.dbcon.database[project["name"]] # == Asset == def _new_asset(self, data): """Create new asset into OP DB.""" # Get project entity - project_col = set_op_project(self.dbcon, data["project_id"]) + set_op_project(self.dbcon, data["project_id"]) # Get gazu entity asset = gazu.asset.get_asset(data["asset_id"]) # Insert doc in DB - project_col.insert_one(create_op_asset(asset)) + self.dbcon.insert_one(create_op_asset(asset)) # Update self._update_asset(data) def _update_asset(self, data): """Update asset into OP DB.""" - project_col = set_op_project(self.dbcon, data["project_id"]) + set_op_project(self.dbcon, data["project_id"]) project_doc = self.dbcon.find_one({"type": "project"}) # Get gazu entity @@ -160,23 +159,23 @@ class Listener: # Query all assets of the local project zou_ids_and_asset_docs = { asset_doc["data"]["zou"]["id"]: asset_doc - for asset_doc in project_col.find({"type": "asset"}) + for asset_doc in self.dbcon.find({"type": "asset"}) if asset_doc["data"].get("zou", {}).get("id") } zou_ids_and_asset_docs[asset["project_id"]] = project_doc # Update asset_doc_id, asset_update = update_op_assets( - project_col[asset], zou_ids_and_asset_docs + self.dbcon[asset], zou_ids_and_asset_docs )[0] - project_col.update_one({"_id": asset_doc_id}, asset_update) + self.dbcon.update_one({"_id": asset_doc_id}, asset_update) def _delete_asset(self, data): """Delete asset of OP DB.""" - project_col = set_op_project(self.dbcon, data["project_id"]) + set_op_project(self.dbcon, data["project_id"]) # Delete - project_col.delete_one( + self.dbcon.delete_one( {"type": "asset", "data.zou.id": data["asset_id"]} ) @@ -184,20 +183,20 @@ class Listener: def _new_episode(self, data): """Create new episode into OP DB.""" # Get project entity - project_col = set_op_project(self.dbcon, data["project_id"]) + set_op_project(self.dbcon, data["project_id"]) # Get gazu entity episode = gazu.shot.get_episode(data["episode_id"]) # Insert doc in DB - project_col.insert_one(create_op_asset(episode)) + self.dbcon.insert_one(create_op_asset(episode)) # Update self._update_episode(data) def _update_episode(self, data): """Update episode into OP DB.""" - project_col = set_op_project(self.dbcon, data["project_id"]) + set_op_project(self.dbcon, data["project_id"]) project_doc = self.dbcon.find_one({"type": "project"}) # Get gazu entity @@ -207,24 +206,24 @@ class Listener: # Query all assets of the local project zou_ids_and_asset_docs = { asset_doc["data"]["zou"]["id"]: asset_doc - for asset_doc in project_col.find({"type": "asset"}) + for asset_doc in self.dbcon.find({"type": "asset"}) if asset_doc["data"].get("zou", {}).get("id") } zou_ids_and_asset_docs[episode["project_id"]] = project_doc # Update asset_doc_id, asset_update = update_op_assets( - project_col, [episode], zou_ids_and_asset_docs + self.dbcon, [episode], zou_ids_and_asset_docs )[0] - project_col.update_one({"_id": asset_doc_id}, asset_update) + self.dbcon.update_one({"_id": asset_doc_id}, asset_update) def _delete_episode(self, data): """Delete shot of OP DB.""" - project_col = set_op_project(self.dbcon, data["project_id"]) + set_op_project(self.dbcon, data["project_id"]) print("delete episode") # TODO check bugfix # Delete - project_col.delete_one( + self.dbcon.delete_one( {"type": "asset", "data.zou.id": data["episode_id"]} ) @@ -232,20 +231,20 @@ class Listener: def _new_sequence(self, data): """Create new sequnce into OP DB.""" # Get project entity - project_col = set_op_project(self.dbcon, data["project_id"]) + set_op_project(self.dbcon, data["project_id"]) # Get gazu entity sequence = gazu.shot.get_sequence(data["sequence_id"]) # Insert doc in DB - project_col.insert_one(create_op_asset(sequence)) + self.dbcon.insert_one(create_op_asset(sequence)) # Update self._update_sequence(data) def _update_sequence(self, data): """Update sequence into OP DB.""" - project_col = set_op_project(self.dbcon, data["project_id"]) + set_op_project(self.dbcon, data["project_id"]) project_doc = self.dbcon.find_one({"type": "project"}) # Get gazu entity @@ -255,24 +254,24 @@ class Listener: # Query all assets of the local project zou_ids_and_asset_docs = { asset_doc["data"]["zou"]["id"]: asset_doc - for asset_doc in project_col.find({"type": "asset"}) + for asset_doc in self.dbcon.find({"type": "asset"}) if asset_doc["data"].get("zou", {}).get("id") } zou_ids_and_asset_docs[sequence["project_id"]] = project_doc # Update asset_doc_id, asset_update = update_op_assets( - project_col, [sequence], zou_ids_and_asset_docs + self.dbcon, [sequence], zou_ids_and_asset_docs )[0] - project_col.update_one({"_id": asset_doc_id}, asset_update) + self.dbcon.update_one({"_id": asset_doc_id}, asset_update) def _delete_sequence(self, data): """Delete sequence of OP DB.""" - project_col = set_op_project(self.dbcon, data["project_id"]) + set_op_project(self.dbcon, data["project_id"]) print("delete sequence") # TODO check bugfix # Delete - project_col.delete_one( + self.dbcon.delete_one( {"type": "asset", "data.zou.id": data["sequence_id"]} ) @@ -280,20 +279,20 @@ class Listener: def _new_shot(self, data): """Create new shot into OP DB.""" # Get project entity - project_col = set_op_project(self.dbcon, data["project_id"]) + set_op_project(self.dbcon, data["project_id"]) # Get gazu entity shot = gazu.shot.get_shot(data["shot_id"]) # Insert doc in DB - project_col.insert_one(create_op_asset(shot)) + self.dbcon.insert_one(create_op_asset(shot)) # Update self._update_shot(data) def _update_shot(self, data): """Update shot into OP DB.""" - project_col = set_op_project(self.dbcon, data["project_id"]) + set_op_project(self.dbcon, data["project_id"]) project_doc = self.dbcon.find_one({"type": "project"}) # Get gazu entity @@ -303,23 +302,23 @@ class Listener: # Query all assets of the local project zou_ids_and_asset_docs = { asset_doc["data"]["zou"]["id"]: asset_doc - for asset_doc in project_col.find({"type": "asset"}) + for asset_doc in self.dbcon.find({"type": "asset"}) if asset_doc["data"].get("zou", {}).get("id") } zou_ids_and_asset_docs[shot["project_id"]] = project_doc # Update asset_doc_id, asset_update = update_op_assets( - project_col, [shot], zou_ids_and_asset_docs + self.dbcon, [shot], zou_ids_and_asset_docs )[0] - project_col.update_one({"_id": asset_doc_id}, asset_update) + self.dbcon.update_one({"_id": asset_doc_id}, asset_update) def _delete_shot(self, data): """Delete shot of OP DB.""" - project_col = set_op_project(self.dbcon, data["project_id"]) + set_op_project(self.dbcon, data["project_id"]) # Delete - project_col.delete_one( + self.dbcon.delete_one( {"type": "asset", "data.zou.id": data["shot_id"]} ) @@ -327,13 +326,13 @@ class Listener: def _new_task(self, data): """Create new task into OP DB.""" # Get project entity - project_col = set_op_project(self.dbcon, data["project_id"]) + set_op_project(self.dbcon, data["project_id"]) # Get gazu entity task = gazu.task.get_task(data["task_id"]) # Find asset doc - asset_doc = project_col.find_one( + asset_doc = self.dbcon.find_one( {"type": "asset", "data.zou.id": task["entity"]["id"]} ) @@ -341,7 +340,7 @@ class Listener: asset_tasks = asset_doc["data"].get("tasks") task_type_name = task["task_type"]["name"] asset_tasks[task_type_name] = {"type": task_type_name, "zou": task} - project_col.update_one( + self.dbcon.update_one( {"_id": asset_doc["_id"]}, {"$set": {"data.tasks": asset_tasks}} ) @@ -352,10 +351,10 @@ class Listener: def _delete_task(self, data): """Delete task of OP DB.""" - project_col = set_op_project(self.dbcon, data["project_id"]) + set_op_project(self.dbcon, data["project_id"]) # Find asset doc - asset_docs = [doc for doc in project_col.find({"type": "asset"})] + asset_docs = [doc for doc in self.dbcon.find({"type": "asset"})] for doc in asset_docs: # Match task for name, task in doc["data"]["tasks"].items(): @@ -365,7 +364,7 @@ class Listener: asset_tasks.pop(name) # Delete task in DB - project_col.update_one( + self.dbcon.update_one( {"_id": doc["_id"]}, {"$set": {"data.tasks": asset_tasks}}, ) diff --git a/openpype/modules/kitsu/utils/update_op_with_zou.py b/openpype/modules/kitsu/utils/update_op_with_zou.py index 25c89800d4..6e82ffbd05 100644 --- a/openpype/modules/kitsu/utils/update_op_with_zou.py +++ b/openpype/modules/kitsu/utils/update_op_with_zou.py @@ -29,18 +29,17 @@ def create_op_asset(gazu_entity: dict) -> dict: } -def set_op_project(dbcon, project_id) -> Collection: +def set_op_project(dbcon: AvalonMongoDB, project_id: str): """Set project context. - :param dbcon: Connection to DB. - :param project_id: Project zou ID + Args: + dbcon (AvalonMongoDB): Connection to DB. + project_id (str): Project zou ID """ project = gazu.project.get_project(project_id) project_name = project["name"] dbcon.Session["AVALON_PROJECT"] = project_name - return dbcon.database[project_name] - def update_op_assets( project_col: Collection, @@ -258,28 +257,25 @@ def sync_all_project(login: str, password: str): dbcon.install() all_projects = gazu.project.all_open_projects() for project in all_projects: - sync_project_from_kitsu(project["name"], dbcon, project) + sync_project_from_kitsu(dbcon, project) def sync_project_from_kitsu( - project_name: str, dbcon: AvalonMongoDB, project: dict = None + dbcon: AvalonMongoDB, project: dict ): """Update OP project in DB with Zou data. Args: - project_name (str): Name of project to sync dbcon (AvalonMongoDB): MongoDB connection - project (dict, optional): Project dict got using gazu. - Defaults to None. + project (dict): Project dict got using gazu. """ bulk_writes = [] # Get project from zou if not project: - project = gazu.project.get_project_by_name(project_name) - project_code = project_name + project = gazu.project.get_project_by_name(project["name"]) - print(f"Synchronizing {project_name}...") + print(f"Synchronizing {project['name']}...") # Get all assets from zou all_assets = gazu.asset.all_assets_for_project(project) @@ -292,28 +288,28 @@ def sync_project_from_kitsu( bulk_writes.append(write_project_to_op(project, dbcon)) # Try to find project document - project_col = dbcon.database[project_code] - project_doc = project_col.find_one({"type": "project"}) + dbcon.Session["AVALON_PROJECT"] = project["name"] + project_doc = dbcon.find_one({"type": "project"}) # Query all assets of the local project zou_ids_and_asset_docs = { asset_doc["data"]["zou"]["id"]: asset_doc - for asset_doc in project_col.find({"type": "asset"}) + for asset_doc in dbcon.find({"type": "asset"}) if asset_doc["data"].get("zou", {}).get("id") } zou_ids_and_asset_docs[project["id"]] = project_doc # Create entities root folders - project_module_settings = get_project_settings(project_name)["kitsu"] + project_module_settings = get_project_settings(project["name"])["kitsu"] for entity_type, root in project_module_settings["entities_root"].items(): parent_folders = root.split("/") direct_parent_doc = None for i, folder in enumerate(parent_folders, 1): - parent_doc = project_col.find_one( + parent_doc = dbcon.find_one( {"type": "asset", "name": folder, "data.root_of": entity_type} ) if not parent_doc: - direct_parent_doc = project_col.insert_one( + direct_parent_doc = dbcon.insert_one( { "name": folder, "type": "asset", @@ -338,13 +334,13 @@ def sync_project_from_kitsu( ) if to_insert: # Insert doc in DB - project_col.insert_many(to_insert) + dbcon.insert_many(to_insert) # Update existing docs zou_ids_and_asset_docs.update( { asset_doc["data"]["zou"]["id"]: asset_doc - for asset_doc in project_col.find({"type": "asset"}) + for asset_doc in dbcon.find({"type": "asset"}) if asset_doc["data"].get("zou") } ) @@ -354,7 +350,7 @@ def sync_project_from_kitsu( [ UpdateOne({"_id": id}, update) for id, update in update_op_assets( - project_col, all_entities, zou_ids_and_asset_docs + dbcon, all_entities, zou_ids_and_asset_docs ) ] ) @@ -373,4 +369,4 @@ def sync_project_from_kitsu( # Write into DB if bulk_writes: - project_col.bulk_write(bulk_writes) + dbcon.bulk_write(bulk_writes) diff --git a/openpype/modules/kitsu/utils/update_zou_with_op.py b/openpype/modules/kitsu/utils/update_zou_with_op.py index 526159d101..81d421206f 100644 --- a/openpype/modules/kitsu/utils/update_zou_with_op.py +++ b/openpype/modules/kitsu/utils/update_zou_with_op.py @@ -93,10 +93,10 @@ def sync_zou_from_op_project( # Query all assets of the local project project_module_settings = get_project_settings(project_name)["kitsu"] - project_col = dbcon.database[project_name] + dbcon.Session["AVALON_PROJECT"] = project_name asset_docs = { asset_doc["_id"]: asset_doc - for asset_doc in project_col.find({"type": "asset"}) + for asset_doc in dbcon.find({"type": "asset"}) } # Create new assets @@ -259,4 +259,4 @@ def sync_zou_from_op_project( # Write into DB if bulk_writes: - project_col.bulk_write(bulk_writes) + dbcon.bulk_write(bulk_writes) From 765546e6f96cf8ba480c20e65ad3f19cc8a24267 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20David?= Date: Wed, 11 May 2022 12:31:08 +0200 Subject: [PATCH 323/583] fix unused var --- openpype/modules/kitsu/utils/sync_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/kitsu/utils/sync_service.py b/openpype/modules/kitsu/utils/sync_service.py index ad18e1f391..8c7ce730a7 100644 --- a/openpype/modules/kitsu/utils/sync_service.py +++ b/openpype/modules/kitsu/utils/sync_service.py @@ -126,7 +126,7 @@ class Listener: """Delete project.""" # Get project entity print(data) # TODO check bugfix - project = gazu.project.get_project(data["project_id"]) + # project = gazu.project.get_project(data["project_id"]) # Delete project collection # self.dbcon = self.dbcon.database[project["name"]] From 43baf02d98e2fa899ec84ab163cb97d8453167d8 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 11 May 2022 13:22:42 +0200 Subject: [PATCH 324/583] flame: refactory MediaInfo class - to be able match collection if sequence with holes - also make code more explicit about input arguments --- openpype/hosts/flame/api/lib.py | 119 ++++++++++++++++++++++++++++---- 1 file changed, 105 insertions(+), 14 deletions(-) diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py index 2e9b535764..15e6f8ae80 100644 --- a/openpype/hosts/flame/api/lib.py +++ b/openpype/hosts/flame/api/lib.py @@ -3,6 +3,7 @@ import os import re import json import pickle +import clique import tempfile import itertools import contextlib @@ -773,17 +774,24 @@ class MediaInfoFile(object): self._validate_media_script_path() # derivate other feed variables - self.feed_basename = os.path.basename(path) - self.feed_dir = os.path.dirname(path) - self.feed_ext = os.path.splitext(self.feed_basename)[1][1:].lower() + feed_basename = os.path.basename(path) + feed_dir = os.path.dirname(path) + feed_ext = os.path.splitext(feed_basename)[1][1:].lower() with maintained_temp_file_path(".clip") as tmp_path: self.log.info("Temp File: {}".format(tmp_path)) - self._generate_media_info_file(tmp_path) + self._generate_media_info_file(tmp_path, feed_ext, feed_dir) + + if os.path.exists(os.path.join(feed_dir, feed_basename)): + test_fname = feed_basename + else: + # get collection containing feed_basename from path + test_fname = self._get_collection(feed_basename, feed_dir, feed_ext) # get clip data and make them single if there is multiple # clips data - xml_data = self._make_single_clip_media_info(tmp_path) + xml_data = self._make_single_clip_media_info( + tmp_path, feed_basename, test_fname) self.log.debug("xml_data: {}".format(xml_data)) self.log.debug("type: {}".format(type(xml_data))) @@ -794,6 +802,73 @@ class MediaInfoFile(object): self.log.debug("drop frame: {}".format(self.drop_mode)) self.clip_data = xml_data + def _get_collection(self, feed_basename, feed_dir, feed_ext): + partialname = self._separate_file_head(feed_basename, feed_ext) + log.debug("__ partialname: {}".format(partialname)) + + # make sure partial input basename is having correct extensoon + if not partialname: + raise IOError("File doesnt exists. Basename - {}, Ext - {}".format( + feed_basename, feed_ext + )) + + # get all related files + files = [ + f for f in os.listdir(feed_dir) + if partialname == self._separate_file_head(f, feed_ext) + ] + + # ignore reminders as we dont need them + collections = clique.assemble(files)[0] + + # if no collection rise + if not collections: + raise IOError("_get_collection is failing on: {} {} {}".format( + feed_basename, feed_dir, feed_ext + )) + else: + # we expect only one collection + collection = collections[0] + + if collection.is_contiguous(): + # if no holes then return collection + return collection.format("{head}[{range}]{tail}") + + # add `[` in front to make sure it want capture + # shot name with the same number + number_from_path = "[" + self._separate_number(feed_basename, feed_ext) + # convert to multiple collections + _continues_colls = collection.separate() + for _coll in _continues_colls: + coll_to_text = _coll.format("{head}[{range}]{tail}") + log.debug("__ coll_to_text: {}".format(coll_to_text)) + if number_from_path in coll_to_text: + return coll_to_text + + def _separate_file_head(self, basename, extension): + # in case sequence file + found = re.findall( + r"(.*)[._][\d]*(?=.{})".format(extension), + basename, + ) + if found: + return found.pop() + + # in case single file + name, ext = os.path.splitext(basename) + + if extension == ext[1:]: + return name + + def _separate_number(self, basename, extension): + # in case sequence file + found = re.findall( + r"[._]([\d]*)(?=.{})".format(extension), + basename, + ) + if found: + return found.pop() + @property def clip_data(self): """Clip's xml clip data @@ -851,13 +926,13 @@ class MediaInfoFile(object): raise IOError("Media Scirpt does not exist: `{}`".format( self.MEDIA_SCRIPT_PATH)) - def _generate_media_info_file(self, fpath): + def _generate_media_info_file(self, fpath, feed_ext, feed_dir): # Create cmd arguments for gettig xml file info file cmd_args = [ self.MEDIA_SCRIPT_PATH, - "-e", self.feed_ext, + "-e", feed_ext, "-o", fpath, - self.feed_dir + feed_dir ] try: @@ -867,7 +942,7 @@ class MediaInfoFile(object): raise TypeError( "Error creating `{}` due: {}".format(fpath, error)) - def _make_single_clip_media_info(self, fpath): + def _make_single_clip_media_info(self, fpath, feed_basename, path_pattern): with open(fpath) as f: lines = f.readlines() _added_root = itertools.chain( @@ -878,14 +953,32 @@ class MediaInfoFile(object): xml_clips = new_root.findall("clip") matching_clip = None for xml_clip in xml_clips: - if xml_clip.find("name").text in self.feed_basename: - matching_clip = xml_clip + clip_name = xml_clip.find("name").text + log.debug("__ clip_name: `{}`".format(clip_name)) + if clip_name not in feed_basename: + continue + + # test path pattern + for out_track in xml_clip.iter("track"): + for out_feed in out_track.iter("feed"): + for span in out_feed.iter("span"): + # start frame + span_path = span.find("path") + if not span_path: + continue + log.debug( + "__ span_path.text: {}, path_pattern: {}".format( + span_path.text, path_pattern + ) + ) + if path_pattern in span_path.text: + matching_clip = xml_clip if matching_clip is None: # return warning there is missing clip raise ET.ParseError( "Missing clip in `{}`. Available clips {}".format( - self.feed_basename, [ + feed_basename, [ xml_clip.find("name").text for xml_clip in xml_clips ] @@ -912,8 +1005,6 @@ class MediaInfoFile(object): 'startTimecode/dropMode') self.drop_mode = out_feed_drop_mode_obj.text break - else: - continue except Exception as msg: self.log.warning(msg) From c34ecfd9127004e0c739be7bb0efdb11d162c447 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 11 May 2022 13:35:35 +0200 Subject: [PATCH 325/583] flame: fixing logger --- openpype/hosts/flame/api/lib.py | 8 ++++---- openpype/hosts/flame/otio/flame_export.py | 4 +++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py index 15e6f8ae80..6fff3edc30 100644 --- a/openpype/hosts/flame/api/lib.py +++ b/openpype/hosts/flame/api/lib.py @@ -804,7 +804,7 @@ class MediaInfoFile(object): def _get_collection(self, feed_basename, feed_dir, feed_ext): partialname = self._separate_file_head(feed_basename, feed_ext) - log.debug("__ partialname: {}".format(partialname)) + self.log.debug("__ partialname: {}".format(partialname)) # make sure partial input basename is having correct extensoon if not partialname: @@ -841,7 +841,7 @@ class MediaInfoFile(object): _continues_colls = collection.separate() for _coll in _continues_colls: coll_to_text = _coll.format("{head}[{range}]{tail}") - log.debug("__ coll_to_text: {}".format(coll_to_text)) + self.log.debug("__ coll_to_text: {}".format(coll_to_text)) if number_from_path in coll_to_text: return coll_to_text @@ -954,7 +954,7 @@ class MediaInfoFile(object): matching_clip = None for xml_clip in xml_clips: clip_name = xml_clip.find("name").text - log.debug("__ clip_name: `{}`".format(clip_name)) + self.log.debug("__ clip_name: `{}`".format(clip_name)) if clip_name not in feed_basename: continue @@ -966,7 +966,7 @@ class MediaInfoFile(object): span_path = span.find("path") if not span_path: continue - log.debug( + self.log.debug( "__ span_path.text: {}, path_pattern: {}".format( span_path.text, path_pattern ) diff --git a/openpype/hosts/flame/otio/flame_export.py b/openpype/hosts/flame/otio/flame_export.py index fc960b670c..c54ebb43d3 100644 --- a/openpype/hosts/flame/otio/flame_export.py +++ b/openpype/hosts/flame/otio/flame_export.py @@ -280,7 +280,9 @@ def create_otio_clip(clip_data): segment = clip_data["PySegment"] # calculate source in - media_info = MediaInfoFile(clip_data["fpath"]) + media_info = MediaInfoFile(clip_data["fpath"], **{ + "logger": log + }) media_timecode_start = media_info.start_frame media_fps = media_info.fps From 27d9e9bd3e9a9ef03489290101535aeb79617e23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20David?= Date: Wed, 11 May 2022 14:08:05 +0200 Subject: [PATCH 326/583] Fix delete project --- openpype/modules/kitsu/utils/sync_service.py | 9 +++++---- openpype/modules/kitsu/utils/update_op_with_zou.py | 1 + 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/openpype/modules/kitsu/utils/sync_service.py b/openpype/modules/kitsu/utils/sync_service.py index 8c7ce730a7..8d1ffb199d 100644 --- a/openpype/modules/kitsu/utils/sync_service.py +++ b/openpype/modules/kitsu/utils/sync_service.py @@ -124,12 +124,13 @@ class Listener: def _delete_project(self, data): """Delete project.""" - # Get project entity - print(data) # TODO check bugfix - # project = gazu.project.get_project(data["project_id"]) + project_doc = self.dbcon.find_one( + {"type": "project", "data.zou_id": data["project_id"]} + ) # Delete project collection - # self.dbcon = self.dbcon.database[project["name"]] + self.dbcon.database[project_doc["name"]].drop() + # == Asset == diff --git a/openpype/modules/kitsu/utils/update_op_with_zou.py b/openpype/modules/kitsu/utils/update_op_with_zou.py index 6e82ffbd05..03a10e76a6 100644 --- a/openpype/modules/kitsu/utils/update_op_with_zou.py +++ b/openpype/modules/kitsu/utils/update_op_with_zou.py @@ -218,6 +218,7 @@ def write_project_to_op(project: dict, dbcon: AvalonMongoDB) -> UpdateOne: "fps": project["fps"], "resolutionWidth": project["resolution"].split("x")[0], "resolutionHeight": project["resolution"].split("x")[1], + "zou_id": project["id"], } ) From aac7797f06934dfc123c8a97e53106b6c442c54b Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 11 May 2022 14:26:36 +0200 Subject: [PATCH 327/583] flame: check collection first --- openpype/hosts/flame/api/lib.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py index 6fff3edc30..8e2ef2d624 100644 --- a/openpype/hosts/flame/api/lib.py +++ b/openpype/hosts/flame/api/lib.py @@ -782,11 +782,15 @@ class MediaInfoFile(object): self.log.info("Temp File: {}".format(tmp_path)) self._generate_media_info_file(tmp_path, feed_ext, feed_dir) - if os.path.exists(os.path.join(feed_dir, feed_basename)): + # get collection containing feed_basename from path + test_fname = self._get_collection( + feed_basename, feed_dir, feed_ext) + + if ( + not test_fname + and os.path.exists(os.path.join(feed_dir, feed_basename)) + ): test_fname = feed_basename - else: - # get collection containing feed_basename from path - test_fname = self._get_collection(feed_basename, feed_dir, feed_ext) # get clip data and make them single if there is multiple # clips data From 497094cabc84184143969ecab9e4473df252745e Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 11 May 2022 14:30:44 +0200 Subject: [PATCH 328/583] Remove invalid submodules --- openpype/modules/default_modules/ftrack/python2_vendor/arrow | 1 - .../default_modules/ftrack/python2_vendor/ftrack-python-api | 1 - repos/avalon-core | 1 - repos/avalon-unreal-integration | 1 - 4 files changed, 4 deletions(-) delete mode 160000 openpype/modules/default_modules/ftrack/python2_vendor/arrow delete mode 160000 openpype/modules/default_modules/ftrack/python2_vendor/ftrack-python-api delete mode 160000 repos/avalon-core delete mode 160000 repos/avalon-unreal-integration diff --git a/openpype/modules/default_modules/ftrack/python2_vendor/arrow b/openpype/modules/default_modules/ftrack/python2_vendor/arrow deleted file mode 160000 index b746fedf72..0000000000 --- a/openpype/modules/default_modules/ftrack/python2_vendor/arrow +++ /dev/null @@ -1 +0,0 @@ -Subproject commit b746fedf7286c3755a46f07ab72f4c414cd41fc0 diff --git a/openpype/modules/default_modules/ftrack/python2_vendor/ftrack-python-api b/openpype/modules/default_modules/ftrack/python2_vendor/ftrack-python-api deleted file mode 160000 index d277f474ab..0000000000 --- a/openpype/modules/default_modules/ftrack/python2_vendor/ftrack-python-api +++ /dev/null @@ -1 +0,0 @@ -Subproject commit d277f474ab016e7b53479c36af87cb861d0cc53e diff --git a/repos/avalon-core b/repos/avalon-core deleted file mode 160000 index 159d2f23e4..0000000000 --- a/repos/avalon-core +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 159d2f23e4c79c04dfac57b68d2ee6ac67adec1b diff --git a/repos/avalon-unreal-integration b/repos/avalon-unreal-integration deleted file mode 160000 index 43f6ea9439..0000000000 --- a/repos/avalon-unreal-integration +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 43f6ea943980b29c02a170942b566ae11f2b7080 From 82e62230fa78520d6cf167b0eaaec8095e5d18a6 Mon Sep 17 00:00:00 2001 From: jrsndlr Date: Wed, 11 May 2022 14:35:38 +0200 Subject: [PATCH 329/583] int back --- openpype/plugins/publish/extract_review_slate.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/openpype/plugins/publish/extract_review_slate.py b/openpype/plugins/publish/extract_review_slate.py index b84dbbc2ac..7b2df4dc5f 100644 --- a/openpype/plugins/publish/extract_review_slate.py +++ b/openpype/plugins/publish/extract_review_slate.py @@ -89,8 +89,8 @@ class ExtractReviewSlate(openpype.api.Extractor): tags = stream.get("tags") or {} input_timecode = tags.get("timecode") or "" if "width" in stream and "height" in stream: - input_width = stream.get("width") - input_height = stream.get("height") + input_width = int(stream.get("width")) + input_height = int(stream.get("height")) if "r_frame_rate" in stream: # get frame rate in a form of # x/y, like 24000/1001 for 23.976 @@ -100,8 +100,6 @@ class ExtractReviewSlate(openpype.api.Extractor): and input_height and input_frame_rate ): - input_width = int(input_width) - input_height = int(input_height) input_frame_rate = str(input_frame_rate) break # Raise exception of any stream didn't define input resolution From b7f7af46fbd00e8b62822efd8b999f28fdb5bf6b Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 11 May 2022 14:47:34 +0200 Subject: [PATCH 330/583] flame: redundant condition --- openpype/hosts/flame/api/lib.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py index 8e2ef2d624..660466b0aa 100644 --- a/openpype/hosts/flame/api/lib.py +++ b/openpype/hosts/flame/api/lib.py @@ -968,8 +968,6 @@ class MediaInfoFile(object): for span in out_feed.iter("span"): # start frame span_path = span.find("path") - if not span_path: - continue self.log.debug( "__ span_path.text: {}, path_pattern: {}".format( span_path.text, path_pattern From 7f1b8f7f038d63cd53ee36e6d419aff62271adb3 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 11 May 2022 16:00:30 +0200 Subject: [PATCH 331/583] flame: adding docstrings --- openpype/hosts/flame/api/lib.py | 39 ++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py index 660466b0aa..7125a540a0 100644 --- a/openpype/hosts/flame/api/lib.py +++ b/openpype/hosts/flame/api/lib.py @@ -807,12 +807,26 @@ class MediaInfoFile(object): self.clip_data = xml_data def _get_collection(self, feed_basename, feed_dir, feed_ext): + """ Get collection string + + Args: + feed_basename (str): file base name + feed_dir (str): file's directory + feed_ext (str): file extension + + Raises: + AttributeError: feed_ext is not matching feed_basename + IOError: Failing on not correct input data + + Returns: + str: collection basename with range of sequence + """ partialname = self._separate_file_head(feed_basename, feed_ext) self.log.debug("__ partialname: {}".format(partialname)) # make sure partial input basename is having correct extensoon if not partialname: - raise IOError("File doesnt exists. Basename - {}, Ext - {}".format( + raise AttributeError("Wrong input attributes. Basename - {}, Ext - {}".format( feed_basename, feed_ext )) @@ -850,6 +864,15 @@ class MediaInfoFile(object): return coll_to_text def _separate_file_head(self, basename, extension): + """ Get only head with out sequence and extension + + Args: + basename (str): file base name + extension (str): file extension + + Returns: + str: file head + """ # in case sequence file found = re.findall( r"(.*)[._][\d]*(?=.{})".format(extension), @@ -865,6 +888,15 @@ class MediaInfoFile(object): return name def _separate_number(self, basename, extension): + """ Get only sequence number as string + + Args: + basename (str): file base name + extension (str): file extension + + Returns: + str: number with padding + """ # in case sequence file found = re.findall( r"[._]([\d]*)(?=.{})".format(extension), @@ -989,6 +1021,11 @@ class MediaInfoFile(object): return matching_clip def _get_time_info_from_origin(self, xml_data): + """Set time info to class attributes + + Args: + xml_data (ET.Element): clip data + """ try: for out_track in xml_data.iter('track'): for out_feed in out_track.iter('feed'): From ac16e1f8bb6f79a3dca750848b5d7450571ff6b3 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 11 May 2022 16:05:22 +0200 Subject: [PATCH 332/583] flame: adding more docstring --- openpype/hosts/flame/api/lib.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py index 7125a540a0..03e3d117a3 100644 --- a/openpype/hosts/flame/api/lib.py +++ b/openpype/hosts/flame/api/lib.py @@ -963,6 +963,16 @@ class MediaInfoFile(object): self.MEDIA_SCRIPT_PATH)) def _generate_media_info_file(self, fpath, feed_ext, feed_dir): + """ Generate media info xml .clip file + + Args: + fpath (str): .clip file path + feed_ext (str): file extension to be filtered + feed_dir (str): look up directory + + Raises: + TypeError: Type error if it fails + """ # Create cmd arguments for gettig xml file info file cmd_args = [ self.MEDIA_SCRIPT_PATH, @@ -979,6 +989,19 @@ class MediaInfoFile(object): "Error creating `{}` due: {}".format(fpath, error)) def _make_single_clip_media_info(self, fpath, feed_basename, path_pattern): + """ Separate only relative clip object form .clip file + + Args: + fpath (str): clip file path + feed_basename (str): search basename + path_pattern (str): search file pattern (file.[1-2].exr) + + Raises: + ET.ParseError: if nothing found + + Returns: + ET.Element: xml element data of matching clip + """ with open(fpath) as f: lines = f.readlines() _added_root = itertools.chain( From 7b225feb13778872f52a56bc5cc0a54d6d652d4c Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 11 May 2022 16:36:29 +0200 Subject: [PATCH 333/583] it is properly checked for not allowed characters --- openpype/lib/transcoding.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/openpype/lib/transcoding.py b/openpype/lib/transcoding.py index f20bef3854..526a73de08 100644 --- a/openpype/lib/transcoding.py +++ b/openpype/lib/transcoding.py @@ -624,14 +624,13 @@ def convert_input_paths_for_ffmpeg( len(attr_value) ) - if erase_attribute: - for char in NOT_ALLOWED_FFMPEG_CHARS: - if char in attr_value: - erase_attribute = True - erase_reason = ( - "contains unsupported character \"{}\"." - ).format(char) - break + for char in NOT_ALLOWED_FFMPEG_CHARS: + if char in attr_value: + erase_attribute = True + erase_reason = ( + "contains unsupported character \"{}\"." + ).format(char) + break if erase_attribute: # Set attribute to empty string From af77d5a888f371c3bab4c2e38483ecaa7e7c6023 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20David?= Date: Wed, 11 May 2022 16:37:17 +0200 Subject: [PATCH 334/583] fix wrong name entities makes crash: they are skipped --- openpype/modules/kitsu/utils/sync_service.py | 2 +- .../modules/kitsu/utils/update_op_with_zou.py | 15 +++++++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/openpype/modules/kitsu/utils/sync_service.py b/openpype/modules/kitsu/utils/sync_service.py index 8d1ffb199d..46d3422727 100644 --- a/openpype/modules/kitsu/utils/sync_service.py +++ b/openpype/modules/kitsu/utils/sync_service.py @@ -131,7 +131,6 @@ class Listener: # Delete project collection self.dbcon.database[project_doc["name"]].drop() - # == Asset == def _new_asset(self, data): @@ -150,6 +149,7 @@ class Listener: def _update_asset(self, data): """Update asset into OP DB.""" + # TODO check if asset doesn't exist, create it (case where name wasn't valid) set_op_project(self.dbcon, data["project_id"]) project_doc = self.dbcon.find_one({"type": "project"}) diff --git a/openpype/modules/kitsu/utils/update_op_with_zou.py b/openpype/modules/kitsu/utils/update_op_with_zou.py index 03a10e76a6..fbc23cf52e 100644 --- a/openpype/modules/kitsu/utils/update_op_with_zou.py +++ b/openpype/modules/kitsu/utils/update_op_with_zou.py @@ -1,5 +1,6 @@ """Functions to update OpenPype data using Kitsu DB (a.k.a Zou).""" from copy import deepcopy +import re from typing import Dict, List from pymongo import DeleteOne, UpdateOne @@ -16,6 +17,10 @@ from openpype.lib import create_project from openpype.modules.kitsu.utils.credentials import validate_credentials +# Accepted namin pattern for OP +naming_pattern = re.compile("^[a-zA-Z0-9_.]*$") + + def create_op_asset(gazu_entity: dict) -> dict: """Create OP asset dict from gazu entity. @@ -261,9 +266,7 @@ def sync_all_project(login: str, password: str): sync_project_from_kitsu(dbcon, project) -def sync_project_from_kitsu( - dbcon: AvalonMongoDB, project: dict -): +def sync_project_from_kitsu(dbcon: AvalonMongoDB, project: dict): """Update OP project in DB with Zou data. Args: @@ -283,7 +286,11 @@ def sync_project_from_kitsu( all_episodes = gazu.shot.all_episodes_for_project(project) all_seqs = gazu.shot.all_sequences_for_project(project) all_shots = gazu.shot.all_shots_for_project(project) - all_entities = all_assets + all_episodes + all_seqs + all_shots + all_entities = [ + item + for item in all_assets + all_episodes + all_seqs + all_shots + if naming_pattern.match(item["name"]) + ] # Sync project. Create if doesn't exist bulk_writes.append(write_project_to_op(project, dbcon)) From ea54b0dc2512eeb428b1f3ea702a10a55ac6ccf3 Mon Sep 17 00:00:00 2001 From: "clement.hector" Date: Mon, 9 May 2022 10:28:09 +0200 Subject: [PATCH 335/583] refactor --- openpype/hosts/nuke/startup/menu.py | 31 +++++++++++++++++++ .../defaults/project_settings/nuke.json | 4 +++ .../projects_schema/schema_project_maya.json | 2 +- .../projects_schema/schema_project_nuke.json | 4 +++ ...riptsmenu.json => schema_scriptsmenu.json} | 0 5 files changed, 40 insertions(+), 1 deletion(-) rename openpype/settings/entities/schemas/projects_schema/schemas/{schema_maya_scriptsmenu.json => schema_scriptsmenu.json} (100%) diff --git a/openpype/hosts/nuke/startup/menu.py b/openpype/hosts/nuke/startup/menu.py index 9ed43b2110..dee1d9d868 100644 --- a/openpype/hosts/nuke/startup/menu.py +++ b/openpype/hosts/nuke/startup/menu.py @@ -1,5 +1,7 @@ import nuke +import os +import avalon.api from openpype.api import Logger from openpype.pipeline import install_host from openpype.hosts.nuke import api @@ -9,6 +11,7 @@ from openpype.hosts.nuke.api.lib import ( WorkfileSettings, dirmap_file_name_filter ) +from openpype.settings import get_project_settings log = Logger.get_logger(__name__) @@ -28,3 +31,31 @@ nuke.addOnScriptLoad(WorkfileSettings().set_context_settings) nuke.addFilenameFilter(dirmap_file_name_filter) log.info('Automatic syncing of write file knob to script version') + + +def add_scripts_menu(): + try: + from scriptsmenu import launchfornuke + except ImportError: + log.warning( + "Skipping studio.menu install, because " + "'scriptsmenu' module seems unavailable." + ) + return + + # load configuration of custom menu + project_settings = get_project_settings(os.getenv("AVALON_PROJECT")) + config = project_settings["nuke"]["scriptsmenu"]["definition"] + _menu = project_settings["nuke"]["scriptsmenu"]["name"] + + if not config: + log.warning("Skipping studio menu, no definition found.") + return + + # run the launcher for Maya menu + studio_menu = launchfornuke.main(title=_menu.title()) + + # apply configuration + studio_menu.build_from_configuration(studio_menu, config) + +add_scripts_menu() diff --git a/openpype/settings/defaults/project_settings/nuke.json b/openpype/settings/defaults/project_settings/nuke.json index 128d440732..a9d284873c 100644 --- a/openpype/settings/defaults/project_settings/nuke.json +++ b/openpype/settings/defaults/project_settings/nuke.json @@ -15,6 +15,10 @@ "destination-path": [] } }, + "scriptsmenu": { + "name": "OpenPype Tools", + "definition": [] + }, "create": { "CreateWriteRender": { "fpath_template": "{work}/renders/nuke/{subset}/{subset}.{frame}.{ext}", diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_maya.json b/openpype/settings/entities/schemas/projects_schema/schema_project_maya.json index cc70516c72..0c7943447b 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_maya.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_maya.json @@ -49,7 +49,7 @@ }, { "type": "schema", - "name": "schema_maya_scriptsmenu" + "name": "schema_scriptsmenu" }, { "type": "schema", diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_nuke.json b/openpype/settings/entities/schemas/projects_schema/schema_project_nuke.json index bc572cbdc8..1ae4efd8ea 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_nuke.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_nuke.json @@ -79,6 +79,10 @@ } ] }, + { + "type": "schema", + "name": "schema_scriptsmenu" + }, { "type": "dict", "collapsible": true, diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_scriptsmenu.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_scriptsmenu.json similarity index 100% rename from openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_scriptsmenu.json rename to openpype/settings/entities/schemas/projects_schema/schemas/schema_scriptsmenu.json From 8a1f7c10061387b03674eb0e21faf05f0bf8fbd5 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 11 May 2022 17:12:54 +0200 Subject: [PATCH 336/583] flame: fix for single file use --- openpype/hosts/flame/api/lib.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py index 03e3d117a3..933cfbe267 100644 --- a/openpype/hosts/flame/api/lib.py +++ b/openpype/hosts/flame/api/lib.py @@ -839,14 +839,13 @@ class MediaInfoFile(object): # ignore reminders as we dont need them collections = clique.assemble(files)[0] - # if no collection rise + # in case no collection found return None + # it is probably just single file if not collections: - raise IOError("_get_collection is failing on: {} {} {}".format( - feed_basename, feed_dir, feed_ext - )) - else: - # we expect only one collection - collection = collections[0] + return + + # we expect only one collection + collection = collections[0] if collection.is_contiguous(): # if no holes then return collection From f7d4cbecfe36826865cb3aba3e33da10fd0aeb71 Mon Sep 17 00:00:00 2001 From: Thomas Fricard Date: Mon, 2 May 2022 17:17:29 +0200 Subject: [PATCH 337/583] add the scriptsmenu schema to nuke --- .../projects_schema/schema_project_nuke.json | 4 ++++ .../schemas/schema_nuke_scriptsmenu.json | 22 +++++++++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_scriptsmenu.json diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_nuke.json b/openpype/settings/entities/schemas/projects_schema/schema_project_nuke.json index 1ae4efd8ea..1fc925557b 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_nuke.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_nuke.json @@ -290,6 +290,10 @@ } ] }, + { + "type": "schema", + "name": "schema_nuke_scriptsmenu" + }, { "type": "schema", "name": "schema_nuke_publish", diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_scriptsmenu.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_scriptsmenu.json new file mode 100644 index 0000000000..e841d6ba77 --- /dev/null +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_scriptsmenu.json @@ -0,0 +1,22 @@ +{ + "type": "dict", + "collapsible": true, + "key": "scriptsmenu", + "label": "Scripts Menu Definition", + "children": [ + { + "type": "text", + "key": "name", + "label": "Menu Name" + }, + { + "type": "splitter" + }, + { + "type": "raw-json", + "key": "definition", + "label": "Menu definition", + "is_list": true + } + ] +} \ No newline at end of file From eb5605a3ade0e4f59b8ffb21a7914c446b75372d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20David?= Date: Wed, 11 May 2022 17:24:11 +0200 Subject: [PATCH 338/583] fix updated asset skipped because of wrong name --- openpype/modules/kitsu/utils/sync_service.py | 9 ++++--- .../modules/kitsu/utils/update_op_with_zou.py | 24 ++++++++++++------- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/openpype/modules/kitsu/utils/sync_service.py b/openpype/modules/kitsu/utils/sync_service.py index 46d3422727..6c003942f8 100644 --- a/openpype/modules/kitsu/utils/sync_service.py +++ b/openpype/modules/kitsu/utils/sync_service.py @@ -149,7 +149,6 @@ class Listener: def _update_asset(self, data): """Update asset into OP DB.""" - # TODO check if asset doesn't exist, create it (case where name wasn't valid) set_op_project(self.dbcon, data["project_id"]) project_doc = self.dbcon.find_one({"type": "project"}) @@ -167,7 +166,7 @@ class Listener: # Update asset_doc_id, asset_update = update_op_assets( - self.dbcon[asset], zou_ids_and_asset_docs + self.dbcon, project_doc, [asset], zou_ids_and_asset_docs )[0] self.dbcon.update_one({"_id": asset_doc_id}, asset_update) @@ -214,7 +213,7 @@ class Listener: # Update asset_doc_id, asset_update = update_op_assets( - self.dbcon, [episode], zou_ids_and_asset_docs + self.dbcon, project_doc, [episode], zou_ids_and_asset_docs )[0] self.dbcon.update_one({"_id": asset_doc_id}, asset_update) @@ -262,7 +261,7 @@ class Listener: # Update asset_doc_id, asset_update = update_op_assets( - self.dbcon, [sequence], zou_ids_and_asset_docs + self.dbcon, project_doc, [sequence], zou_ids_and_asset_docs )[0] self.dbcon.update_one({"_id": asset_doc_id}, asset_update) @@ -310,7 +309,7 @@ class Listener: # Update asset_doc_id, asset_update = update_op_assets( - self.dbcon, [shot], zou_ids_and_asset_docs + self.dbcon, project_doc, [shot], zou_ids_and_asset_docs )[0] self.dbcon.update_one({"_id": asset_doc_id}, asset_update) diff --git a/openpype/modules/kitsu/utils/update_op_with_zou.py b/openpype/modules/kitsu/utils/update_op_with_zou.py index fbc23cf52e..fa0bee8365 100644 --- a/openpype/modules/kitsu/utils/update_op_with_zou.py +++ b/openpype/modules/kitsu/utils/update_op_with_zou.py @@ -38,7 +38,7 @@ def set_op_project(dbcon: AvalonMongoDB, project_id: str): """Set project context. Args: - dbcon (AvalonMongoDB): Connection to DB. + dbcon (AvalonMongoDB): Connection to DB project_id (str): Project zou ID """ project = gazu.project.get_project(project_id) @@ -47,7 +47,8 @@ def set_op_project(dbcon: AvalonMongoDB, project_id: str): def update_op_assets( - project_col: Collection, + dbcon: AvalonMongoDB, + project_doc: dict, entities_list: List[dict], asset_doc_ids: Dict[str, dict], ) -> List[Dict[str, dict]]: @@ -55,19 +56,27 @@ def update_op_assets( Set 'data' and 'parent' fields. Args: - project_col (Collection): Mongo project collection to sync + dbcon (AvalonMongoDB): Connection to DB entities_list (List[dict]): List of zou entities to update asset_doc_ids (Dict[str, dict]): Dicts of [{zou_id: asset_doc}, ...] Returns: List[Dict[str, dict]]: List of (doc_id, update_dict) tuples """ - project_name = project_col.name + project_name = project_doc["name"] assets_with_update = [] for item in entities_list: + # Check asset exists + item_doc = asset_doc_ids.get(item["id"]) + if not item_doc: # Create asset + op_asset = create_op_asset(item) + insert_result = dbcon.insert_one(op_asset) + item_doc = dbcon.find_one( + {"type": "asset", "_id": insert_result.inserted_id} + ) + # Update asset - item_doc = asset_doc_ids[item["id"]] item_data = deepcopy(item_doc["data"]) item_data.update(item.get("data") or {}) item_data["zou"] = item @@ -86,7 +95,6 @@ def update_op_assets( item_data["frameEnd"] = int(frame_out) # Fps, fallback to project's value when entity fps is deleted if not item_data.get("fps") and item_doc["data"].get("fps"): - project_doc = project_col.find_one({"type": "project"}) item_data["fps"] = project_doc["data"]["fps"] # Tasks @@ -139,7 +147,7 @@ def update_op_assets( ) if visual_parent_doc_id is None: # Find root folder doc - root_folder_doc = project_col.find_one( + root_folder_doc = dbcon.find_one( { "type": "asset", "name": entity_parent_folders[-1], @@ -358,7 +366,7 @@ def sync_project_from_kitsu(dbcon: AvalonMongoDB, project: dict): [ UpdateOne({"_id": id}, update) for id, update in update_op_assets( - dbcon, all_entities, zou_ids_and_asset_docs + dbcon, project_doc, all_entities, zou_ids_and_asset_docs ) ] ) From 9640a7463bb4cd7c22bbce35bf28de1c1c6809e9 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 11 May 2022 17:21:40 +0200 Subject: [PATCH 339/583] fix not allowed characters properly --- openpype/lib/transcoding.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/openpype/lib/transcoding.py b/openpype/lib/transcoding.py index 526a73de08..adb9bb2c3a 100644 --- a/openpype/lib/transcoding.py +++ b/openpype/lib/transcoding.py @@ -493,8 +493,9 @@ def convert_for_ffmpeg( erase_reason = "has too long value ({} chars).".format( len(attr_value) ) + erase_attribute = True - if erase_attribute: + if not erase_attribute: for char in NOT_ALLOWED_FFMPEG_CHARS: if char in attr_value: erase_attribute = True @@ -623,14 +624,16 @@ def convert_input_paths_for_ffmpeg( erase_reason = "has too long value ({} chars).".format( len(attr_value) ) + erase_attribute = True - for char in NOT_ALLOWED_FFMPEG_CHARS: - if char in attr_value: - erase_attribute = True - erase_reason = ( - "contains unsupported character \"{}\"." - ).format(char) - break + if not erase_attribute: + for char in NOT_ALLOWED_FFMPEG_CHARS: + if char in attr_value: + erase_attribute = True + erase_reason = ( + "contains unsupported character \"{}\"." + ).format(char) + break if erase_attribute: # Set attribute to empty string From c90a949076a1c8d2237fcc6ec118549344a50343 Mon Sep 17 00:00:00 2001 From: Thomas Fricard Date: Tue, 3 May 2022 12:41:37 +0200 Subject: [PATCH 340/583] call the launchfornuke module from the nuke menu.py to generate custom menu --- openpype/hosts/nuke/startup/menu.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/hosts/nuke/startup/menu.py b/openpype/hosts/nuke/startup/menu.py index dee1d9d868..3a0bfdb28f 100644 --- a/openpype/hosts/nuke/startup/menu.py +++ b/openpype/hosts/nuke/startup/menu.py @@ -58,4 +58,5 @@ def add_scripts_menu(): # apply configuration studio_menu.build_from_configuration(studio_menu, config) + add_scripts_menu() From a18c4d3d16e5454f86cd1b4f539c6e3031bd7c9c Mon Sep 17 00:00:00 2001 From: Thomas Fricard Date: Tue, 3 May 2022 17:27:32 +0200 Subject: [PATCH 341/583] add a function in the nuke menu.py module to also add gizmos --- openpype/hosts/nuke/startup/menu.py | 58 +++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/openpype/hosts/nuke/startup/menu.py b/openpype/hosts/nuke/startup/menu.py index 3a0bfdb28f..bb81ee7fac 100644 --- a/openpype/hosts/nuke/startup/menu.py +++ b/openpype/hosts/nuke/startup/menu.py @@ -1,5 +1,6 @@ import nuke import os +import json import avalon.api from openpype.api import Logger @@ -59,4 +60,61 @@ def add_scripts_menu(): studio_menu.build_from_configuration(studio_menu, config) +def add_gizmos(): + """ Build a custom gizmo menu from a yaml description file. + """ + quad_plugin_path = os.environ.get("QUAD_PLUGIN_PATH") + gizmos_folder = os.path.join(quad_plugin_path, 'nuke/gizmos') + icons_folder = os.path.join(quad_plugin_path, 'nuke/icons') + json_file = os.path.join(quad_plugin_path, 'nuke/toolbar.json') + + if os.path.isdir(gizmos_folder): + for p in os.listdir(gizmos_folder): + if os.path.isdir(os.path.join(gizmos_folder, p)): + nuke.pluginAddPath(os.path.join(gizmos_folder, p)) + nuke.pluginAddPath(gizmos_folder) + + with open(json_file, 'rb') as fd: + try: + data = json.loads(fd.read()) + except Exception as e: + print(f"Problem occurs when reading toolbar file: {e}") + return + + if data is None or not isinstance(data, list): + # return early if the json file is empty or not well structured + return + + bar = nuke.menu("Nodes") + menu = bar.addMenu( + "FixStudio", + icon=os.path.join(icons_folder, 'fixstudio.png') + ) + + # populate the menu + for entry in data: + # make fail if the name or command key doesn't exists + name = entry['name'] + + command = entry.get('command', "") + + if command.find('{pipe_path}') > -1: + command = command.format(pipe_path=os.environ['QUAD_PLUGIN_PATH']) + + hotkey = entry.get('hotkey', "") + icon = entry.get('icon', "") + + parent_name = os.path.dirname(name) + + if 'separator' in name: + current = menu.findItem(parent_name) + if current: + current.addSeparator() + else: + menu.addCommand( + name, command=command, shortcut=hotkey, icon=icon, + ) + + +add_gizmos() add_scripts_menu() From f479dd8635b95c1463bab44d79c85d19fc72095e Mon Sep 17 00:00:00 2001 From: Thomas Fricard Date: Tue, 3 May 2022 17:54:30 +0200 Subject: [PATCH 342/583] Revert "add a function in the nuke menu.py module to also add gizmos" This reverts commit 2f3bafb2fb8cbf247c354a8904acbc78ae081731. --- openpype/hosts/nuke/startup/menu.py | 58 ----------------------------- 1 file changed, 58 deletions(-) diff --git a/openpype/hosts/nuke/startup/menu.py b/openpype/hosts/nuke/startup/menu.py index bb81ee7fac..3a0bfdb28f 100644 --- a/openpype/hosts/nuke/startup/menu.py +++ b/openpype/hosts/nuke/startup/menu.py @@ -1,6 +1,5 @@ import nuke import os -import json import avalon.api from openpype.api import Logger @@ -60,61 +59,4 @@ def add_scripts_menu(): studio_menu.build_from_configuration(studio_menu, config) -def add_gizmos(): - """ Build a custom gizmo menu from a yaml description file. - """ - quad_plugin_path = os.environ.get("QUAD_PLUGIN_PATH") - gizmos_folder = os.path.join(quad_plugin_path, 'nuke/gizmos') - icons_folder = os.path.join(quad_plugin_path, 'nuke/icons') - json_file = os.path.join(quad_plugin_path, 'nuke/toolbar.json') - - if os.path.isdir(gizmos_folder): - for p in os.listdir(gizmos_folder): - if os.path.isdir(os.path.join(gizmos_folder, p)): - nuke.pluginAddPath(os.path.join(gizmos_folder, p)) - nuke.pluginAddPath(gizmos_folder) - - with open(json_file, 'rb') as fd: - try: - data = json.loads(fd.read()) - except Exception as e: - print(f"Problem occurs when reading toolbar file: {e}") - return - - if data is None or not isinstance(data, list): - # return early if the json file is empty or not well structured - return - - bar = nuke.menu("Nodes") - menu = bar.addMenu( - "FixStudio", - icon=os.path.join(icons_folder, 'fixstudio.png') - ) - - # populate the menu - for entry in data: - # make fail if the name or command key doesn't exists - name = entry['name'] - - command = entry.get('command', "") - - if command.find('{pipe_path}') > -1: - command = command.format(pipe_path=os.environ['QUAD_PLUGIN_PATH']) - - hotkey = entry.get('hotkey', "") - icon = entry.get('icon', "") - - parent_name = os.path.dirname(name) - - if 'separator' in name: - current = menu.findItem(parent_name) - if current: - current.addSeparator() - else: - menu.addCommand( - name, command=command, shortcut=hotkey, icon=icon, - ) - - -add_gizmos() add_scripts_menu() From 4e694a3f36f740fe9b5488cf75ba421e4b520d97 Mon Sep 17 00:00:00 2001 From: Thomas Fricard Date: Mon, 9 May 2022 11:27:51 +0200 Subject: [PATCH 343/583] changes from comments --- .../entities/schemas/projects_schema/schema_project_nuke.json | 4 ---- 1 file changed, 4 deletions(-) diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_nuke.json b/openpype/settings/entities/schemas/projects_schema/schema_project_nuke.json index 1fc925557b..1ae4efd8ea 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_nuke.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_nuke.json @@ -290,10 +290,6 @@ } ] }, - { - "type": "schema", - "name": "schema_nuke_scriptsmenu" - }, { "type": "schema", "name": "schema_nuke_publish", From 2eea2522be0ecce65c8fc5876a628b15169f3189 Mon Sep 17 00:00:00 2001 From: Thomas Fricard Date: Mon, 9 May 2022 11:31:05 +0200 Subject: [PATCH 344/583] delete schema_nuke_scriptsmenu.json since we use a schema_scriptsmenu.json for both maya and nuke --- .../schemas/schema_nuke_scriptsmenu.json | 22 ------------------- 1 file changed, 22 deletions(-) delete mode 100644 openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_scriptsmenu.json diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_scriptsmenu.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_scriptsmenu.json deleted file mode 100644 index e841d6ba77..0000000000 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_scriptsmenu.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "type": "dict", - "collapsible": true, - "key": "scriptsmenu", - "label": "Scripts Menu Definition", - "children": [ - { - "type": "text", - "key": "name", - "label": "Menu Name" - }, - { - "type": "splitter" - }, - { - "type": "raw-json", - "key": "definition", - "label": "Menu definition", - "is_list": true - } - ] -} \ No newline at end of file From f081fe3521158cba9425f86a866fd50fc8106f6f Mon Sep 17 00:00:00 2001 From: Thomas Fricard Date: Mon, 9 May 2022 11:58:28 +0200 Subject: [PATCH 345/583] add nuke doc for custom menu --- website/docs/admin_hosts_nuke.md | 14 ++++++++++++++ website/docs/assets/nuke-admin_scriptsmenu.png | Bin 0 -> 21712 bytes website/sidebars.js | 1 + 3 files changed, 15 insertions(+) create mode 100644 website/docs/admin_hosts_nuke.md create mode 100644 website/docs/assets/nuke-admin_scriptsmenu.png diff --git a/website/docs/admin_hosts_nuke.md b/website/docs/admin_hosts_nuke.md new file mode 100644 index 0000000000..46f596a2dc --- /dev/null +++ b/website/docs/admin_hosts_nuke.md @@ -0,0 +1,14 @@ +--- +id: admin_hosts_nuke +title: Nuke +sidebar_label: Nuke +--- + +## Custom Menu +You can add your custom tools menu into Nuke by extending definitions in **Nuke -> Scripts Menu Definition**. +![Custom menu definition](assets/nuke-admin_scriptsmenu.png) + +:::note Work in progress +This is still work in progress. Menu definition will be handled more friendly with widgets and not +raw json. +::: diff --git a/website/docs/assets/nuke-admin_scriptsmenu.png b/website/docs/assets/nuke-admin_scriptsmenu.png new file mode 100644 index 0000000000000000000000000000000000000000..cad2a4411d058a35995863a57bdbcc4cd2793457 GIT binary patch literal 21712 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 literal 0 HcmV?d00001 diff --git a/website/sidebars.js b/website/sidebars.js index 105afc30eb..0e33bed949 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -88,6 +88,7 @@ module.exports = { items: [ "admin_hosts_blender", "admin_hosts_maya", + "admin_hosts_nuke", "admin_hosts_resolve", "admin_hosts_harmony", "admin_hosts_aftereffects", From 70bb0fd7bc5c37447e6f7d7d9dcdce5f940de919 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20David?= Date: Wed, 11 May 2022 17:38:46 +0200 Subject: [PATCH 346/583] fix flake --- openpype/modules/kitsu/utils/update_op_with_zou.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/modules/kitsu/utils/update_op_with_zou.py b/openpype/modules/kitsu/utils/update_op_with_zou.py index fa0bee8365..10349a999c 100644 --- a/openpype/modules/kitsu/utils/update_op_with_zou.py +++ b/openpype/modules/kitsu/utils/update_op_with_zou.py @@ -4,7 +4,6 @@ import re from typing import Dict, List from pymongo import DeleteOne, UpdateOne -from pymongo.collection import Collection import gazu from gazu.task import ( all_tasks_for_asset, From d5fa437912b8f03dfa705967c60fe2212065996a Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 11 May 2022 17:51:11 +0200 Subject: [PATCH 347/583] flame: thumbnail frame number if not `Sequence Publish` --- .../flame/plugins/publish/extract_subset_resources.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py index fd0ece2590..7dcaec7eee 100644 --- a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py +++ b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py @@ -236,6 +236,17 @@ class ExtractSubsetResources(openpype.api.Extractor): # define kwargs based on preset type if "thumbnail" in unique_name: + if export_type != "Sequence Publish": + # if not sequence preset + in_mark = int(source_start_handles - source_first_frame) + + self.log.debug("__ in_mark: {}".format(in_mark)) + self.log.debug("__ source_duration_handles: {}".format( + source_duration_handles)) + self.log.debug("__ thumb_frame_number: {}".format( + int(in_mark + (source_duration_handles / 2)) + )) + export_kwargs["thumb_frame_number"] = int(in_mark + ( source_duration_handles / 2)) else: From 8f24a764fd3f5061573931757e0eb6817949e77f Mon Sep 17 00:00:00 2001 From: Thomas Fricard Date: Wed, 11 May 2022 18:02:22 +0200 Subject: [PATCH 348/583] remove avalon.api import --- openpype/hosts/nuke/startup/menu.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/hosts/nuke/startup/menu.py b/openpype/hosts/nuke/startup/menu.py index 3a0bfdb28f..49edb22a89 100644 --- a/openpype/hosts/nuke/startup/menu.py +++ b/openpype/hosts/nuke/startup/menu.py @@ -1,7 +1,6 @@ import nuke import os -import avalon.api from openpype.api import Logger from openpype.pipeline import install_host from openpype.hosts.nuke import api From 3615c79fdd338044f3a8fe4d2bca70996b2201f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Wed, 11 May 2022 18:29:45 +0200 Subject: [PATCH 349/583] handle subsetGroup in attached renders --- .../modules/deadline/plugins/publish/submit_publish_job.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py index ffa5718d4a..782f85c9d2 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -468,8 +468,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): new_instance = copy(instance_data) new_instance["subset"] = subset_name - if not instance_data.get("append"): - new_instance["subsetGroup"] = group_name + new_instance["subsetGroup"] = group_name if preview: new_instance["review"] = True @@ -886,6 +885,8 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): new_i["subset"] = at.get("subset") new_i["family"] = at.get("family") new_i["append"] = True + # don't set subsetGroup if we are attaching + new_i.pop("subsetGroup") new_instances.append(new_i) self.log.info(" - {} / v{}".format( at.get("subset"), at.get("version"))) From 8892422b0edb6182a119dc848bdfa534e344744b Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 11 May 2022 21:22:01 +0200 Subject: [PATCH 350/583] flame: attempt to solve issue with single frame imported clip --- openpype/hosts/flame/api/lib.py | 24 ++++++++++++++---- .../publish/extract_subset_resources.py | 25 ++++++++++++++----- 2 files changed, 38 insertions(+), 11 deletions(-) diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py index 933cfbe267..80818fbbfd 100644 --- a/openpype/hosts/flame/api/lib.py +++ b/openpype/hosts/flame/api/lib.py @@ -763,6 +763,7 @@ class MediaInfoFile(object): _start_frame = None _fps = None _drop_mode = None + _file_pattern = None def __init__(self, path, **kwargs): @@ -778,24 +779,25 @@ class MediaInfoFile(object): feed_dir = os.path.dirname(path) feed_ext = os.path.splitext(feed_basename)[1][1:].lower() + with maintained_temp_file_path(".clip") as tmp_path: self.log.info("Temp File: {}".format(tmp_path)) self._generate_media_info_file(tmp_path, feed_ext, feed_dir) # get collection containing feed_basename from path - test_fname = self._get_collection( + self.file_pattern = self._get_collection( feed_basename, feed_dir, feed_ext) if ( - not test_fname + not self.file_pattern and os.path.exists(os.path.join(feed_dir, feed_basename)) ): - test_fname = feed_basename + self.file_pattern = feed_basename # get clip data and make them single if there is multiple # clips data xml_data = self._make_single_clip_media_info( - tmp_path, feed_basename, test_fname) + tmp_path, feed_basename, self.file_pattern) self.log.debug("xml_data: {}".format(xml_data)) self.log.debug("type: {}".format(type(xml_data))) @@ -816,7 +818,6 @@ class MediaInfoFile(object): Raises: AttributeError: feed_ext is not matching feed_basename - IOError: Failing on not correct input data Returns: str: collection basename with range of sequence @@ -956,6 +957,19 @@ class MediaInfoFile(object): def drop_mode(self, text): self._drop_mode = str(text) + @property + def file_pattern(self): + """Clips file patter + + Returns: + str: file pattern. ex. file.[1-2].exr + """ + return self._file_pattern + + @file_pattern.setter + def file_pattern(self, fpattern): + self._file_pattern = fpattern + def _validate_media_script_path(self): if not os.path.isfile(self.MEDIA_SCRIPT_PATH): raise IOError("Media Scirpt does not exist: `{}`".format( diff --git a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py index 7dcaec7eee..d8cc14a506 100644 --- a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py +++ b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py @@ -6,6 +6,7 @@ from copy import deepcopy import pyblish.api import openpype.api from openpype.hosts.flame import api as opfapi +from openpype.hosts.flame.api import MediaInfoFile import flame @@ -67,6 +68,7 @@ class ExtractSubsetResources(openpype.api.Extractor): instance.data["representations"] = [] # flame objects + self.project = instance.context.data["flameProject"] segment = instance.data["item"] asset_name = instance.data["asset"] segment_name = segment.name.get_value() @@ -239,16 +241,18 @@ class ExtractSubsetResources(openpype.api.Extractor): if export_type != "Sequence Publish": # if not sequence preset in_mark = int(source_start_handles - source_first_frame) + thumb_frame_number = int(in_mark + ( + source_duration_handles / 2)) + else: + thumb_frame_number = int(in_mark + ( + (clip_out - clip_in) / 2)) self.log.debug("__ in_mark: {}".format(in_mark)) - self.log.debug("__ source_duration_handles: {}".format( - source_duration_handles)) self.log.debug("__ thumb_frame_number: {}".format( - int(in_mark + (source_duration_handles / 2)) + thumb_frame_number )) - export_kwargs["thumb_frame_number"] = int(in_mark + ( - source_duration_handles / 2)) + export_kwargs["thumb_frame_number"] = thumb_frame_number else: export_kwargs.update({ "in_mark": in_mark, @@ -419,7 +423,16 @@ class ExtractSubsetResources(openpype.api.Extractor): """ Import clip from path """ - clips = flame.import_clips(path) + media_info = MediaInfoFile(path, **{ + "logger": self.log + }) + file_pattern = media_info.file_pattern + self.log.debug("__ file_pattern: {}".format(file_pattern)) + + project_desktop = self.project.current_workspace.desktop + reel = project_desktop.reel_groups[0].reels[0] + + clips = flame.import_clips(file_pattern, reel) self.log.info("Clips [{}] imported from `{}`".format(clips, path)) if not clips: self.log.warning("Path `{}` is not having any clips".format(path)) From 12abc9e2bec0715836bd7d0ad3fe740b92e6eab4 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 11 May 2022 21:31:12 +0200 Subject: [PATCH 351/583] flame: thumbnails and mov presets are created correctly now --- .../flame/plugins/publish/extract_subset_resources.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py index d8cc14a506..6098f2e1e9 100644 --- a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py +++ b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py @@ -423,17 +423,19 @@ class ExtractSubsetResources(openpype.api.Extractor): """ Import clip from path """ + dir_path = os.path.dirname(path) media_info = MediaInfoFile(path, **{ "logger": self.log }) file_pattern = media_info.file_pattern self.log.debug("__ file_pattern: {}".format(file_pattern)) - project_desktop = self.project.current_workspace.desktop - reel = project_desktop.reel_groups[0].reels[0] + # rejoin the pattern to dir path + new_path = os.path.join(dir_path, file_pattern) - clips = flame.import_clips(file_pattern, reel) + clips = flame.import_clips(new_path) self.log.info("Clips [{}] imported from `{}`".format(clips, path)) + if not clips: self.log.warning("Path `{}` is not having any clips".format(path)) return None From df1b5c6e66cdf48f5f26fca6812d208ad24dd5b3 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 12 May 2022 08:57:04 +0200 Subject: [PATCH 352/583] flame: reducing code redundancy --- .../publish/extract_subset_resources.py | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py index 6098f2e1e9..176629fbfc 100644 --- a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py +++ b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py @@ -68,7 +68,6 @@ class ExtractSubsetResources(openpype.api.Extractor): instance.data["representations"] = [] # flame objects - self.project = instance.context.data["flameProject"] segment = instance.data["item"] asset_name = instance.data["asset"] segment_name = segment.name.get_value() @@ -182,15 +181,15 @@ class ExtractSubsetResources(openpype.api.Extractor): name_patern_xml = ( "__{}.").format( unique_name) + + # change in/out marks to timeline in/out + in_mark = clip_in + out_mark = clip_out else: exporting_clip = self.import_clip(clip_path) exporting_clip.name.set_value("{}_{}".format( asset_name, segment_name)) - # change in/out marks to timeline in/out - in_mark = clip_in - out_mark = clip_out - # add xml tags modifications modify_xml_data.update({ "exportHandles": True, @@ -238,14 +237,8 @@ class ExtractSubsetResources(openpype.api.Extractor): # define kwargs based on preset type if "thumbnail" in unique_name: - if export_type != "Sequence Publish": - # if not sequence preset - in_mark = int(source_start_handles - source_first_frame) - thumb_frame_number = int(in_mark + ( - source_duration_handles / 2)) - else: - thumb_frame_number = int(in_mark + ( - (clip_out - clip_in) / 2)) + thumb_frame_number = int(in_mark + ( + source_duration_handles / 2)) self.log.debug("__ in_mark: {}".format(in_mark)) self.log.debug("__ thumb_frame_number: {}".format( From 987b5df1ddf44a0feabe27feca8930782a05d18b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20David?= Date: Thu, 12 May 2022 09:16:49 +0200 Subject: [PATCH 353/583] optim get_project_settings --- openpype/modules/kitsu/utils/update_op_with_zou.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/kitsu/utils/update_op_with_zou.py b/openpype/modules/kitsu/utils/update_op_with_zou.py index 10349a999c..0c72537c94 100644 --- a/openpype/modules/kitsu/utils/update_op_with_zou.py +++ b/openpype/modules/kitsu/utils/update_op_with_zou.py @@ -63,6 +63,7 @@ def update_op_assets( List[Dict[str, dict]]: List of (doc_id, update_dict) tuples """ project_name = project_doc["name"] + project_module_settings = get_project_settings(project_name)["kitsu"] assets_with_update = [] for item in entities_list: @@ -126,7 +127,6 @@ def update_op_assets( ) # TODO check consistency # Substitute Episode and Sequence by Shot - project_module_settings = get_project_settings(project_name)["kitsu"] substitute_item_type = ( "shots" if item_type in ["Episode", "Sequence"] From e0bd8777d1b9563d292a72ff592e461eb47e50f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20David?= Date: Thu, 12 May 2022 09:42:51 +0200 Subject: [PATCH 354/583] pop useless item_data --- openpype/modules/kitsu/utils/update_op_with_zou.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openpype/modules/kitsu/utils/update_op_with_zou.py b/openpype/modules/kitsu/utils/update_op_with_zou.py index 0c72537c94..673a195747 100644 --- a/openpype/modules/kitsu/utils/update_op_with_zou.py +++ b/openpype/modules/kitsu/utils/update_op_with_zou.py @@ -85,6 +85,7 @@ def update_op_assets( # Frame in, fallback on 0 frame_in = int(item_data.get("frame_in") or 0) item_data["frameStart"] = frame_in + item_data.pop("frame_in") # Frame out, fallback on frame_in + duration frames_duration = int(item.get("nb_frames") or 1) frame_out = ( @@ -93,6 +94,7 @@ def update_op_assets( else frame_in + frames_duration ) item_data["frameEnd"] = int(frame_out) + item_data.pop("frame_out") # Fps, fallback to project's value when entity fps is deleted if not item_data.get("fps") and item_doc["data"].get("fps"): item_data["fps"] = project_doc["data"]["fps"] From ae69db29cac488410fa00c547042126831d3be0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20David?= Date: Thu, 12 May 2022 09:59:02 +0200 Subject: [PATCH 355/583] black pyblish --- .../plugins/publish/collect_kitsu_credential.py | 4 ++-- .../plugins/publish/collect_kitsu_entities.py | 16 ++++------------ .../plugins/publish/integrate_kitsu_note.py | 6 ++---- .../plugins/publish/integrate_kitsu_review.py | 9 ++------- .../plugins/publish/validate_kitsu_intent.py | 6 ++---- 5 files changed, 12 insertions(+), 29 deletions(-) diff --git a/openpype/modules/kitsu/plugins/publish/collect_kitsu_credential.py b/openpype/modules/kitsu/plugins/publish/collect_kitsu_credential.py index 4a27117e03..b7f6f67a40 100644 --- a/openpype/modules/kitsu/plugins/publish/collect_kitsu_credential.py +++ b/openpype/modules/kitsu/plugins/publish/collect_kitsu_credential.py @@ -5,7 +5,7 @@ import gazu import pyblish.api -class CollectKitsuSession(pyblish.api.ContextPlugin): #rename log in +class CollectKitsuSession(pyblish.api.ContextPlugin): # rename log in """Collect Kitsu session using user credentials""" order = pyblish.api.CollectorOrder @@ -15,4 +15,4 @@ class CollectKitsuSession(pyblish.api.ContextPlugin): #rename log in def process(self, context): gazu.client.set_host(os.environ["KITSU_SERVER"]) - gazu.log_in(os.environ["KITSU_LOGIN"], os.environ["KITSU_PWD"]) \ No newline at end of file + gazu.log_in(os.environ["KITSU_LOGIN"], os.environ["KITSU_PWD"]) diff --git a/openpype/modules/kitsu/plugins/publish/collect_kitsu_entities.py b/openpype/modules/kitsu/plugins/publish/collect_kitsu_entities.py index 935b020641..66c35e54c4 100644 --- a/openpype/modules/kitsu/plugins/publish/collect_kitsu_entities.py +++ b/openpype/modules/kitsu/plugins/publish/collect_kitsu_entities.py @@ -19,8 +19,7 @@ class CollectKitsuEntities(pyblish.api.ContextPlugin): raise AssertionError("Zou asset data not found in OpenPype!") self.log.debug("Collected zou asset data: {}".format(zou_asset_data)) - zou_task_data = asset_data["tasks"][ - os.environ["AVALON_TASK"]].get("zou") + zou_task_data = asset_data["tasks"][os.environ["AVALON_TASK"]].get("zou") if not zou_task_data: self.log.warning("Zou task data not found in OpenPype!") self.log.debug("Collected zou task data: {}".format(zou_task_data)) @@ -45,20 +44,13 @@ class CollectKitsuEntities(pyblish.api.ContextPlugin): self.log.debug("Collect kitsu task: {}".format(kitsu_task)) else: - kitsu_task_type = gazu.task.get_task_type_by_name( - os.environ["AVALON_TASK"] - ) + kitsu_task_type = gazu.task.get_task_type_by_name(os.environ["AVALON_TASK"]) if not kitsu_task_type: raise AssertionError( - "Task type {} not found in Kitsu!".format( - os.environ["AVALON_TASK"] - ) + "Task type {} not found in Kitsu!".format(os.environ["AVALON_TASK"]) ) - kitsu_task = gazu.task.get_task_by_name( - kitsu_asset, - kitsu_task_type - ) + kitsu_task = gazu.task.get_task_by_name(kitsu_asset, kitsu_task_type) if not kitsu_task: raise AssertionError("Task not found in kitsu!") context.data["kitsu_task"] = kitsu_task diff --git a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py index 61e4d2454c..99d891d514 100644 --- a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py +++ b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py @@ -30,9 +30,7 @@ class IntegrateKitsuNote(pyblish.api.ContextPlugin): self.log.debug("Kitsu status: {}".format(kitsu_status)) kitsu_comment = gazu.task.add_comment( - context.data["kitsu_task"], - kitsu_status, - comment = publish_comment + context.data["kitsu_task"], kitsu_status, comment=publish_comment ) - context.data["kitsu_comment"] = kitsu_comment \ No newline at end of file + context.data["kitsu_comment"] = kitsu_comment diff --git a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_review.py b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_review.py index c38f14e8a4..65179bc0bf 100644 --- a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_review.py +++ b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_review.py @@ -21,15 +21,10 @@ class IntegrateKitsuReview(pyblish.api.InstancePlugin): review_path = representation.get("published_path") - if 'review' not in representation.get("tags", []): + if "review" not in representation.get("tags", []): continue self.log.debug("Found review at: {}".format(review_path)) - gazu.task.add_preview( - task, - comment, - review_path, - normalize_movie=True - ) + gazu.task.add_preview(task, comment, review_path, normalize_movie=True) self.log.info("Review upload on comment") diff --git a/openpype/modules/kitsu/plugins/publish/validate_kitsu_intent.py b/openpype/modules/kitsu/plugins/publish/validate_kitsu_intent.py index c82130b33b..e0fad3b79f 100644 --- a/openpype/modules/kitsu/plugins/publish/validate_kitsu_intent.py +++ b/openpype/modules/kitsu/plugins/publish/validate_kitsu_intent.py @@ -19,9 +19,7 @@ class ValidateKitsuIntent(pyblish.api.ContextPlugin): kitsu_status = gazu.task.get_task_status_by_short_name(publish_status) if not kitsu_status: - raise AssertionError( - "Status `{}` not found in kitsu!".format(kitsu_status) - ) + raise AssertionError("Status `{}` not found in kitsu!".format(kitsu_status)) self.log.debug("Collect kitsu status: {}".format(kitsu_status)) - context.data["kitsu_status"] = kitsu_status \ No newline at end of file + context.data["kitsu_status"] = kitsu_status From 3583d85cd2a7d25abf184411c23a273b0f8671c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20David?= Date: Thu, 12 May 2022 09:59:35 +0200 Subject: [PATCH 356/583] black pyblish --- .../plugins/publish/collect_kitsu_entities.py | 16 ++++++++++++---- .../plugins/publish/integrate_kitsu_review.py | 4 +++- .../plugins/publish/validate_kitsu_intent.py | 4 +++- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/openpype/modules/kitsu/plugins/publish/collect_kitsu_entities.py b/openpype/modules/kitsu/plugins/publish/collect_kitsu_entities.py index 66c35e54c4..84c400bde9 100644 --- a/openpype/modules/kitsu/plugins/publish/collect_kitsu_entities.py +++ b/openpype/modules/kitsu/plugins/publish/collect_kitsu_entities.py @@ -19,7 +19,9 @@ class CollectKitsuEntities(pyblish.api.ContextPlugin): raise AssertionError("Zou asset data not found in OpenPype!") self.log.debug("Collected zou asset data: {}".format(zou_asset_data)) - zou_task_data = asset_data["tasks"][os.environ["AVALON_TASK"]].get("zou") + zou_task_data = asset_data["tasks"][os.environ["AVALON_TASK"]].get( + "zou" + ) if not zou_task_data: self.log.warning("Zou task data not found in OpenPype!") self.log.debug("Collected zou task data: {}".format(zou_task_data)) @@ -44,13 +46,19 @@ class CollectKitsuEntities(pyblish.api.ContextPlugin): self.log.debug("Collect kitsu task: {}".format(kitsu_task)) else: - kitsu_task_type = gazu.task.get_task_type_by_name(os.environ["AVALON_TASK"]) + kitsu_task_type = gazu.task.get_task_type_by_name( + os.environ["AVALON_TASK"] + ) if not kitsu_task_type: raise AssertionError( - "Task type {} not found in Kitsu!".format(os.environ["AVALON_TASK"]) + "Task type {} not found in Kitsu!".format( + os.environ["AVALON_TASK"] + ) ) - kitsu_task = gazu.task.get_task_by_name(kitsu_asset, kitsu_task_type) + kitsu_task = gazu.task.get_task_by_name( + kitsu_asset, kitsu_task_type + ) if not kitsu_task: raise AssertionError("Task not found in kitsu!") context.data["kitsu_task"] = kitsu_task diff --git a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_review.py b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_review.py index 65179bc0bf..08fa4ee010 100644 --- a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_review.py +++ b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_review.py @@ -26,5 +26,7 @@ class IntegrateKitsuReview(pyblish.api.InstancePlugin): self.log.debug("Found review at: {}".format(review_path)) - gazu.task.add_preview(task, comment, review_path, normalize_movie=True) + gazu.task.add_preview( + task, comment, review_path, normalize_movie=True + ) self.log.info("Review upload on comment") diff --git a/openpype/modules/kitsu/plugins/publish/validate_kitsu_intent.py b/openpype/modules/kitsu/plugins/publish/validate_kitsu_intent.py index e0fad3b79f..6b2635bf05 100644 --- a/openpype/modules/kitsu/plugins/publish/validate_kitsu_intent.py +++ b/openpype/modules/kitsu/plugins/publish/validate_kitsu_intent.py @@ -19,7 +19,9 @@ class ValidateKitsuIntent(pyblish.api.ContextPlugin): kitsu_status = gazu.task.get_task_status_by_short_name(publish_status) if not kitsu_status: - raise AssertionError("Status `{}` not found in kitsu!".format(kitsu_status)) + raise AssertionError( + "Status `{}` not found in kitsu!".format(kitsu_status) + ) self.log.debug("Collect kitsu status: {}".format(kitsu_status)) context.data["kitsu_status"] = kitsu_status From 2a94ac1248446d8735bf34ebda62356af5658358 Mon Sep 17 00:00:00 2001 From: "clement.hector" Date: Thu, 12 May 2022 10:10:57 +0200 Subject: [PATCH 357/583] Add doc to default value --- openpype/settings/defaults/project_settings/nuke.json | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/openpype/settings/defaults/project_settings/nuke.json b/openpype/settings/defaults/project_settings/nuke.json index a9d284873c..7f916424ed 100644 --- a/openpype/settings/defaults/project_settings/nuke.json +++ b/openpype/settings/defaults/project_settings/nuke.json @@ -17,7 +17,15 @@ }, "scriptsmenu": { "name": "OpenPype Tools", - "definition": [] + "definition": [ + { + "type": "action", + "sourcetype": "python", + "title": "OpenPype Docs", + "command": "import webbrowser;webbrowser.open(url='https://openpype.io/docs/artist_hosts_nuke_tut')", + "tooltip": "Open the OpenPype Nuke user doc page" + } + ] }, "create": { "CreateWriteRender": { From e7e6bf142f1ef4802059a03b41e58e4bc8f124fd Mon Sep 17 00:00:00 2001 From: Milan Kolar Date: Thu, 12 May 2022 10:13:02 +0200 Subject: [PATCH 358/583] Add maya 2023 to defaults --- .../system_settings/applications.json | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/openpype/settings/defaults/system_settings/applications.json b/openpype/settings/defaults/system_settings/applications.json index 0fb99a2608..2b0de44fa9 100644 --- a/openpype/settings/defaults/system_settings/applications.json +++ b/openpype/settings/defaults/system_settings/applications.json @@ -12,6 +12,26 @@ "LC_ALL": "C" }, "variants": { + "2023": { + "use_python_2": false, + "executables": { + "windows": [ + "C:\\Program Files\\Autodesk\\Maya2023\\bin\\maya.exe" + ], + "darwin": [], + "linux": [ + "/usr/autodesk/maya2023/bin/maya" + ] + }, + "arguments": { + "windows": [], + "darwin": [], + "linux": [] + }, + "environment": { + "MAYA_VERSION": "2023" + } + }, "2022": { "use_python_2": false, "executables": { @@ -91,9 +111,6 @@ "environment": { "MAYA_VERSION": "2018" } - }, - "__dynamic_keys_labels__": { - "2022": "2022" } } }, From 687f7260ceef7c0c3b2966f387e0f678a0fa0f8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20David?= Date: Thu, 12 May 2022 10:57:18 +0200 Subject: [PATCH 359/583] optim publish status intent --- .../modules/kitsu/plugins/publish/validate_kitsu_intent.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/modules/kitsu/plugins/publish/validate_kitsu_intent.py b/openpype/modules/kitsu/plugins/publish/validate_kitsu_intent.py index 6b2635bf05..e2023b171e 100644 --- a/openpype/modules/kitsu/plugins/publish/validate_kitsu_intent.py +++ b/openpype/modules/kitsu/plugins/publish/validate_kitsu_intent.py @@ -12,10 +12,11 @@ class ValidateKitsuIntent(pyblish.api.ContextPlugin): optional = True def process(self, context): - + # Check publish status exists publish_status = context.data.get("intent", {}).get("value") if not publish_status: self.log.info("Status is not set.") + return kitsu_status = gazu.task.get_task_status_by_short_name(publish_status) if not kitsu_status: From 2e5dd57fbf0600d6039a034826cbd34bc388ae59 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 12 May 2022 11:46:55 +0200 Subject: [PATCH 360/583] general: calculation of duration should not exclude one frame --- openpype/lib/editorial.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/lib/editorial.py b/openpype/lib/editorial.py index bf868953ea..1ee21deedc 100644 --- a/openpype/lib/editorial.py +++ b/openpype/lib/editorial.py @@ -17,7 +17,7 @@ def otio_range_to_frame_range(otio_range): start = _ot.to_frames( otio_range.start_time, otio_range.start_time.rate) end = start + _ot.to_frames( - otio_range.duration, otio_range.duration.rate) - 1 + otio_range.duration, otio_range.duration.rate) return start, end @@ -254,7 +254,7 @@ def get_media_range_with_retimes(otio_clip, handle_start, handle_end): media_in + source_in + offset_in) media_out_trimmed = ( media_in + source_in + ( - ((source_range.duration.value - 1) * abs( + (source_range.duration.value * abs( time_scalar)) + offset_out)) # calculate available handles From 1c43403ec5766ca59fd605451d12b31f362b6a4e Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 12 May 2022 11:47:23 +0200 Subject: [PATCH 361/583] hiero: fitting new duration calculation --- openpype/hosts/hiero/api/otio/hiero_export.py | 2 +- openpype/hosts/hiero/plugins/publish/precollect_instances.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/hiero/api/otio/hiero_export.py b/openpype/hosts/hiero/api/otio/hiero_export.py index 1e4088d9c0..64fb81aed4 100644 --- a/openpype/hosts/hiero/api/otio/hiero_export.py +++ b/openpype/hosts/hiero/api/otio/hiero_export.py @@ -151,7 +151,7 @@ def create_otio_reference(clip): padding = media_source.filenamePadding() file_head = media_source.filenameHead() is_sequence = not media_source.singleFile() - frame_duration = media_source.duration() + frame_duration = media_source.duration() - 1 fps = utils.get_rate(clip) or self.project_fps extension = os.path.splitext(path)[-1] diff --git a/openpype/hosts/hiero/plugins/publish/precollect_instances.py b/openpype/hosts/hiero/plugins/publish/precollect_instances.py index 4eac6a008a..46f0b2440e 100644 --- a/openpype/hosts/hiero/plugins/publish/precollect_instances.py +++ b/openpype/hosts/hiero/plugins/publish/precollect_instances.py @@ -296,6 +296,8 @@ class PrecollectInstances(pyblish.api.ContextPlugin): continue if otio_clip.name not in track_item.name(): continue + self.log.debug("__ parent_range: {}".format(parent_range)) + self.log.debug("__ timeline_range: {}".format(timeline_range)) if openpype.lib.is_overlapping_otio_ranges( parent_range, timeline_range, strict=True): From 20f819c1dd2eab2e6401a80c3996c3a93ca0a089 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20David?= Date: Thu, 12 May 2022 12:36:47 +0200 Subject: [PATCH 362/583] change intent wrongly used as status choice --- .../plugins/publish/collect_kitsu_entities.py | 16 +++-------- .../plugins/publish/integrate_kitsu_note.py | 20 ++++++++----- .../plugins/publish/integrate_kitsu_review.py | 21 ++++++++------ .../plugins/publish/validate_kitsu_intent.py | 28 ------------------- 4 files changed, 30 insertions(+), 55 deletions(-) delete mode 100644 openpype/modules/kitsu/plugins/publish/validate_kitsu_intent.py diff --git a/openpype/modules/kitsu/plugins/publish/collect_kitsu_entities.py b/openpype/modules/kitsu/plugins/publish/collect_kitsu_entities.py index 84c400bde9..66c35e54c4 100644 --- a/openpype/modules/kitsu/plugins/publish/collect_kitsu_entities.py +++ b/openpype/modules/kitsu/plugins/publish/collect_kitsu_entities.py @@ -19,9 +19,7 @@ class CollectKitsuEntities(pyblish.api.ContextPlugin): raise AssertionError("Zou asset data not found in OpenPype!") self.log.debug("Collected zou asset data: {}".format(zou_asset_data)) - zou_task_data = asset_data["tasks"][os.environ["AVALON_TASK"]].get( - "zou" - ) + zou_task_data = asset_data["tasks"][os.environ["AVALON_TASK"]].get("zou") if not zou_task_data: self.log.warning("Zou task data not found in OpenPype!") self.log.debug("Collected zou task data: {}".format(zou_task_data)) @@ -46,19 +44,13 @@ class CollectKitsuEntities(pyblish.api.ContextPlugin): self.log.debug("Collect kitsu task: {}".format(kitsu_task)) else: - kitsu_task_type = gazu.task.get_task_type_by_name( - os.environ["AVALON_TASK"] - ) + kitsu_task_type = gazu.task.get_task_type_by_name(os.environ["AVALON_TASK"]) if not kitsu_task_type: raise AssertionError( - "Task type {} not found in Kitsu!".format( - os.environ["AVALON_TASK"] - ) + "Task type {} not found in Kitsu!".format(os.environ["AVALON_TASK"]) ) - kitsu_task = gazu.task.get_task_by_name( - kitsu_asset, kitsu_task_type - ) + kitsu_task = gazu.task.get_task_by_name(kitsu_asset, kitsu_task_type) if not kitsu_task: raise AssertionError("Task not found in kitsu!") context.data["kitsu_task"] = kitsu_task diff --git a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py index 99d891d514..980589365d 100644 --- a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py +++ b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py @@ -11,24 +11,30 @@ class IntegrateKitsuNote(pyblish.api.ContextPlugin): # families = ["kitsu"] def process(self, context): + # Check if work version for user + is_work_version = bool(context.data.get("intent", {}).get("value")) + if is_work_version: + self.log.info("Work version, nothing pushed to Kitsu.") + return + # Get comment text body publish_comment = context.data.get("comment") if not publish_comment: self.log.info("Comment is not set.") - publish_status = context.data.get("intent", {}).get("value") - if not publish_status: - self.log.info("Status is not set.") - self.log.debug("Comment is `{}`".format(publish_comment)) - self.log.debug("Status is `{}`".format(publish_status)) - kitsu_status = context.data.get("kitsu_status") + # Get Waiting for Approval status + kitsu_status = gazu.task.get_task_status_by_short_name("wfa") if not kitsu_status: - self.log.info("The status will not be changed") + self.log.info( + "Cannot find 'Waiting For Approval' status." + "The status will not be changed" + ) kitsu_status = context.data["kitsu_task"].get("task_status") self.log.debug("Kitsu status: {}".format(kitsu_status)) + # Add comment to kitsu task kitsu_comment = gazu.task.add_comment( context.data["kitsu_task"], kitsu_status, comment=publish_comment ) diff --git a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_review.py b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_review.py index 08fa4ee010..76cfe62988 100644 --- a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_review.py +++ b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_review.py @@ -15,18 +15,23 @@ class IntegrateKitsuReview(pyblish.api.InstancePlugin): context = instance.context task = context.data["kitsu_task"] - comment = context.data["kitsu_comment"] + comment = context.data.get("kitsu_comment") - for representation in instance.data.get("representations", []): + # Check comment has been created + if not comment: + self.log.debug("Comment not created, review not pushed to preview.") + return + + # Add review representations as preview of comment + for representation in [ + r + for r in instance.data.get("representations", []) + if "review" in representation.get("tags", []) + ]: review_path = representation.get("published_path") - if "review" not in representation.get("tags", []): - continue - self.log.debug("Found review at: {}".format(review_path)) - gazu.task.add_preview( - task, comment, review_path, normalize_movie=True - ) + gazu.task.add_preview(task, comment, review_path, normalize_movie=True) self.log.info("Review upload on comment") diff --git a/openpype/modules/kitsu/plugins/publish/validate_kitsu_intent.py b/openpype/modules/kitsu/plugins/publish/validate_kitsu_intent.py deleted file mode 100644 index e2023b171e..0000000000 --- a/openpype/modules/kitsu/plugins/publish/validate_kitsu_intent.py +++ /dev/null @@ -1,28 +0,0 @@ -# -*- coding: utf-8 -*- -import pyblish.api -import gazu - - -class ValidateKitsuIntent(pyblish.api.ContextPlugin): - """Validate Kitsu Status""" - - order = pyblish.api.ValidatorOrder - label = "Kitsu Intent/Status" - # families = ["kitsu"] - optional = True - - def process(self, context): - # Check publish status exists - publish_status = context.data.get("intent", {}).get("value") - if not publish_status: - self.log.info("Status is not set.") - return - - kitsu_status = gazu.task.get_task_status_by_short_name(publish_status) - if not kitsu_status: - raise AssertionError( - "Status `{}` not found in kitsu!".format(kitsu_status) - ) - self.log.debug("Collect kitsu status: {}".format(kitsu_status)) - - context.data["kitsu_status"] = kitsu_status From 330d4340cc082307bd2a5bda951d7f2e491279f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20David?= Date: Thu, 12 May 2022 12:39:03 +0200 Subject: [PATCH 363/583] black --- .../plugins/publish/collect_kitsu_entities.py | 16 ++++++++++++---- .../plugins/publish/integrate_kitsu_review.py | 10 +++++++--- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/openpype/modules/kitsu/plugins/publish/collect_kitsu_entities.py b/openpype/modules/kitsu/plugins/publish/collect_kitsu_entities.py index 66c35e54c4..84c400bde9 100644 --- a/openpype/modules/kitsu/plugins/publish/collect_kitsu_entities.py +++ b/openpype/modules/kitsu/plugins/publish/collect_kitsu_entities.py @@ -19,7 +19,9 @@ class CollectKitsuEntities(pyblish.api.ContextPlugin): raise AssertionError("Zou asset data not found in OpenPype!") self.log.debug("Collected zou asset data: {}".format(zou_asset_data)) - zou_task_data = asset_data["tasks"][os.environ["AVALON_TASK"]].get("zou") + zou_task_data = asset_data["tasks"][os.environ["AVALON_TASK"]].get( + "zou" + ) if not zou_task_data: self.log.warning("Zou task data not found in OpenPype!") self.log.debug("Collected zou task data: {}".format(zou_task_data)) @@ -44,13 +46,19 @@ class CollectKitsuEntities(pyblish.api.ContextPlugin): self.log.debug("Collect kitsu task: {}".format(kitsu_task)) else: - kitsu_task_type = gazu.task.get_task_type_by_name(os.environ["AVALON_TASK"]) + kitsu_task_type = gazu.task.get_task_type_by_name( + os.environ["AVALON_TASK"] + ) if not kitsu_task_type: raise AssertionError( - "Task type {} not found in Kitsu!".format(os.environ["AVALON_TASK"]) + "Task type {} not found in Kitsu!".format( + os.environ["AVALON_TASK"] + ) ) - kitsu_task = gazu.task.get_task_by_name(kitsu_asset, kitsu_task_type) + kitsu_task = gazu.task.get_task_by_name( + kitsu_asset, kitsu_task_type + ) if not kitsu_task: raise AssertionError("Task not found in kitsu!") context.data["kitsu_task"] = kitsu_task diff --git a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_review.py b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_review.py index 76cfe62988..57e0286b00 100644 --- a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_review.py +++ b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_review.py @@ -19,19 +19,23 @@ class IntegrateKitsuReview(pyblish.api.InstancePlugin): # Check comment has been created if not comment: - self.log.debug("Comment not created, review not pushed to preview.") + self.log.debug( + "Comment not created, review not pushed to preview." + ) return # Add review representations as preview of comment for representation in [ r for r in instance.data.get("representations", []) - if "review" in representation.get("tags", []) + if "review" in r.get("tags", []) ]: review_path = representation.get("published_path") self.log.debug("Found review at: {}".format(review_path)) - gazu.task.add_preview(task, comment, review_path, normalize_movie=True) + gazu.task.add_preview( + task, comment, review_path, normalize_movie=True + ) self.log.info("Review upload on comment") From dd77f7cd9bff9bfa5405ba13aa5760675b1b2a89 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 12 May 2022 13:37:34 +0200 Subject: [PATCH 364/583] flame: fixing padding in collection ranges --- openpype/hosts/flame/api/lib.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py index 80818fbbfd..f2f5db184b 100644 --- a/openpype/hosts/flame/api/lib.py +++ b/openpype/hosts/flame/api/lib.py @@ -848,9 +848,10 @@ class MediaInfoFile(object): # we expect only one collection collection = collections[0] + self.log.debug("__ collection: {}".format(collection)) + if collection.is_contiguous(): - # if no holes then return collection - return collection.format("{head}[{range}]{tail}") + return self._format_collection(collection) # add `[` in front to make sure it want capture # shot name with the same number @@ -858,11 +859,25 @@ class MediaInfoFile(object): # convert to multiple collections _continues_colls = collection.separate() for _coll in _continues_colls: - coll_to_text = _coll.format("{head}[{range}]{tail}") + coll_to_text = self._format_collection(_coll) self.log.debug("__ coll_to_text: {}".format(coll_to_text)) if number_from_path in coll_to_text: return coll_to_text + @staticmethod + def _format_collection(collection): + # if no holes then return collection + head = collection.format("{head}") + tail = collection.format("{tail}") + range_template = "[{{:0{0}d}}-{{:0{0}d}}]".format( + len(str(max(collection.indexes)))) + ranges = range_template.format( + min(collection.indexes), + max(collection.indexes) + ) + # if no holes then return collection + return "{}{}{}".format(head, ranges, tail) + def _separate_file_head(self, basename, extension): """ Get only head with out sequence and extension From aeff57dab75cb1a37d0f971b96d36b3cebd0ef0e Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 12 May 2022 15:08:38 +0200 Subject: [PATCH 365/583] flame: expanding retiming features --- openpype/hosts/flame/otio/flame_export.py | 74 ++++++++++++++--------- 1 file changed, 47 insertions(+), 27 deletions(-) diff --git a/openpype/hosts/flame/otio/flame_export.py b/openpype/hosts/flame/otio/flame_export.py index c54ebb43d3..e3801a0a4f 100644 --- a/openpype/hosts/flame/otio/flame_export.py +++ b/openpype/hosts/flame/otio/flame_export.py @@ -98,26 +98,26 @@ def _get_metadata(item): return {} -# def create_time_effects(otio_clip, clip_data): -# otio_effect = None +def create_time_effects(otio_clip, speed): + otio_effect = None -# # retime on track item -# if speed != 1.: -# # make effect -# otio_effect = otio.schema.LinearTimeWarp() -# otio_effect.name = "Speed" -# otio_effect.time_scalar = speed -# otio_effect.metadata = {} + # retime on track item + if speed != 1.: + # make effect + otio_effect = otio.schema.LinearTimeWarp() + otio_effect.name = "Speed" + otio_effect.time_scalar = speed + otio_effect.metadata = {} -# # freeze frame effect -# if speed == 0.: -# otio_effect = otio.schema.FreezeFrame() -# otio_effect.name = "FreezeFrame" -# otio_effect.metadata = {} + # freeze frame effect + if speed == 0.: + otio_effect = otio.schema.FreezeFrame() + otio_effect.name = "FreezeFrame" + otio_effect.metadata = {} -# if otio_effect: -# # add otio effect to clip effects -# otio_clip.effects.append(otio_effect) + if otio_effect: + # add otio effect to clip effects + otio_clip.effects.append(otio_effect) def _get_marker_color(flame_colour): @@ -205,7 +205,7 @@ def create_otio_markers(otio_item, item): otio_item.markers.append(otio_marker) -def create_otio_reference(clip_data, fps=None): +def create_otio_reference(clip_data, duration, fps=None): metadata = _get_metadata(clip_data) # get file info for path and start frame @@ -220,7 +220,6 @@ def create_otio_reference(clip_data, fps=None): # get padding and other file infos log.debug("_ path: {}".format(path)) - frame_duration = clip_data["source_duration"] otio_ex_ref_item = None is_sequence = frame_number = utils.get_frame_from_filename(file_name) @@ -247,7 +246,7 @@ def create_otio_reference(clip_data, fps=None): rate=fps, available_range=create_otio_time_range( frame_start, - frame_duration, + duration, fps ) ) @@ -263,7 +262,7 @@ def create_otio_reference(clip_data, fps=None): target_url=reformated_path, available_range=create_otio_time_range( frame_start, - frame_duration, + duration, fps ) ) @@ -286,19 +285,39 @@ def create_otio_clip(clip_data): media_timecode_start = media_info.start_frame media_fps = media_info.fps - # create media reference - media_reference = create_otio_reference(clip_data, media_fps) - # define first frame first_frame = media_timecode_start or utils.get_frame_from_filename( clip_data["fpath"]) or 0 - source_in = int(clip_data["source_in"]) - int(first_frame) + _clip_source_in = int(clip_data["source_in"]) + _clip_source_out = int(clip_data["source_out"]) + _clip_record_duration = int(clip_data["record_duration"]) + + # first solve if the reverse timing + speed = 1 + if clip_data["source_in"] > clip_data["source_out"]: + source_in = _clip_source_out - int(first_frame) + source_out = _clip_source_in - int(first_frame) + speed = -1 + else: + source_in = _clip_source_in - int(first_frame) + source_out = _clip_source_out - int(first_frame) + + source_duration = (source_out - source_in + 1) + + # secondly check if any change of speed + if source_duration != _clip_record_duration: + retime_speed = source_duration / _clip_record_duration + speed *= retime_speed + + # create media reference + media_reference = create_otio_reference( + clip_data, source_duration, media_fps) # creatae source range source_range = create_otio_time_range( source_in, - clip_data["record_duration"], + _clip_record_duration, CTX.get_fps() ) @@ -312,7 +331,8 @@ def create_otio_clip(clip_data): if MARKERS_INCLUDE: create_otio_markers(otio_clip, segment) - # create_time_effects(otio_clip, clip_data) + if speed != 1: + create_time_effects(otio_clip, speed) return otio_clip From ce250a6749cd0f6d6f220ada9830ed154ef4e481 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 12 May 2022 15:10:17 +0200 Subject: [PATCH 366/583] flame: debug logging --- openpype/hosts/flame/otio/flame_export.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/openpype/hosts/flame/otio/flame_export.py b/openpype/hosts/flame/otio/flame_export.py index e3801a0a4f..500b1a3eb1 100644 --- a/openpype/hosts/flame/otio/flame_export.py +++ b/openpype/hosts/flame/otio/flame_export.py @@ -305,6 +305,11 @@ def create_otio_clip(clip_data): source_duration = (source_out - source_in + 1) + log.debug("_ source_in: {}".format(source_in)) + log.debug("_ source_out: {}".format(source_out)) + log.debug("_ speed: {}".format(speed)) + log.debug("_ source_duration: {}".format(source_duration)) + # secondly check if any change of speed if source_duration != _clip_record_duration: retime_speed = source_duration / _clip_record_duration From ee274f81e33fdb8a9741f2c2d397355702de5699 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 12 May 2022 15:12:03 +0200 Subject: [PATCH 367/583] flame: solving issue with frame longer renders --- .../hosts/flame/plugins/publish/extract_subset_resources.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py index 176629fbfc..0e04336211 100644 --- a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py +++ b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py @@ -160,7 +160,7 @@ class ExtractSubsetResources(openpype.api.Extractor): # get frame range with handles for representation range frame_start_handle = frame_start - handle_start source_duration_handles = ( - source_end_handles - source_start_handles) + 1 + source_end_handles - source_start_handles) # define in/out marks in_mark = (source_start_handles - source_first_frame) + 1 From 3f917055c135be7b6ba984a6c1245384b3389c79 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 12 May 2022 15:21:15 +0200 Subject: [PATCH 368/583] flame: improving logging --- openpype/hosts/flame/otio/flame_export.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/flame/otio/flame_export.py b/openpype/hosts/flame/otio/flame_export.py index 500b1a3eb1..8562a766e9 100644 --- a/openpype/hosts/flame/otio/flame_export.py +++ b/openpype/hosts/flame/otio/flame_export.py @@ -305,15 +305,17 @@ def create_otio_clip(clip_data): source_duration = (source_out - source_in + 1) + # secondly check if any change of speed + if source_duration != _clip_record_duration: + retime_speed = source_duration / _clip_record_duration + log.debug("_ retime_speed: {}".format(retime_speed)) + speed *= retime_speed + log.debug("_ source_in: {}".format(source_in)) log.debug("_ source_out: {}".format(source_out)) log.debug("_ speed: {}".format(speed)) log.debug("_ source_duration: {}".format(source_duration)) - - # secondly check if any change of speed - if source_duration != _clip_record_duration: - retime_speed = source_duration / _clip_record_duration - speed *= retime_speed + log.debug("_ _clip_record_duration: {}".format(_clip_record_duration)) # create media reference media_reference = create_otio_reference( From 71bd7eb337bfc372b66973a10ff00d32c838a628 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 12 May 2022 15:24:38 +0200 Subject: [PATCH 369/583] flame: retime is float value --- openpype/hosts/flame/otio/flame_export.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/flame/otio/flame_export.py b/openpype/hosts/flame/otio/flame_export.py index 8562a766e9..08478d4b98 100644 --- a/openpype/hosts/flame/otio/flame_export.py +++ b/openpype/hosts/flame/otio/flame_export.py @@ -307,7 +307,7 @@ def create_otio_clip(clip_data): # secondly check if any change of speed if source_duration != _clip_record_duration: - retime_speed = source_duration / _clip_record_duration + retime_speed = float(source_duration / _clip_record_duration) log.debug("_ retime_speed: {}".format(retime_speed)) speed *= retime_speed From b105287b507d4c2dc112fe5d738206993419069d Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 12 May 2022 17:29:12 +0200 Subject: [PATCH 370/583] extract jpeg exr does not create multiple representation names with "thumbnail" name --- openpype/plugins/publish/extract_jpeg_exr.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/openpype/plugins/publish/extract_jpeg_exr.py b/openpype/plugins/publish/extract_jpeg_exr.py index d6d6854092..06de858e4a 100644 --- a/openpype/plugins/publish/extract_jpeg_exr.py +++ b/openpype/plugins/publish/extract_jpeg_exr.py @@ -49,6 +49,7 @@ class ExtractJpegEXR(pyblish.api.InstancePlugin): return filtered_repres = self._get_filtered_repres(instance) + name_counter = 0 for repre in filtered_repres: repre_files = repre["files"] if not isinstance(repre_files, (list, tuple)): @@ -134,8 +135,12 @@ class ExtractJpegEXR(pyblish.api.InstancePlugin): self.log.warning("Conversion crashed", exc_info=True) raise + repre_name = "thumbnail" + if name_counter > 0: + repre_name += str(name_counter) + name_counter += 1 new_repre = { - "name": "thumbnail", + "name": repre_name, "ext": "jpg", "files": jpeg_file, "stagingDir": stagingdir, From bbdc0ce523c7c0a7c37e33c255785f8ef8d9445f Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 12 May 2022 17:39:00 +0200 Subject: [PATCH 371/583] create only one thumbnail representation --- openpype/plugins/publish/extract_jpeg_exr.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/openpype/plugins/publish/extract_jpeg_exr.py b/openpype/plugins/publish/extract_jpeg_exr.py index 06de858e4a..ae29f8b95b 100644 --- a/openpype/plugins/publish/extract_jpeg_exr.py +++ b/openpype/plugins/publish/extract_jpeg_exr.py @@ -49,7 +49,7 @@ class ExtractJpegEXR(pyblish.api.InstancePlugin): return filtered_repres = self._get_filtered_repres(instance) - name_counter = 0 + for repre in filtered_repres: repre_files = repre["files"] if not isinstance(repre_files, (list, tuple)): @@ -135,12 +135,8 @@ class ExtractJpegEXR(pyblish.api.InstancePlugin): self.log.warning("Conversion crashed", exc_info=True) raise - repre_name = "thumbnail" - if name_counter > 0: - repre_name += str(name_counter) - name_counter += 1 new_repre = { - "name": repre_name, + "name": "thumbnail", "ext": "jpg", "files": jpeg_file, "stagingDir": stagingdir, @@ -156,6 +152,11 @@ class ExtractJpegEXR(pyblish.api.InstancePlugin): if convert_dir is not None and os.path.exists(convert_dir): shutil.rmtree(convert_dir) + # Create only one representation with name 'thumbnail' + # TODO maybe handle way how to decide from which representation + # will be thumbnail created + break + def _get_filtered_repres(self, instance): filtered_repres = [] src_repres = instance.data.get("representations") or [] From 7c61e36f8f04536fdf81341d1cf7897195241704 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 12 May 2022 17:55:02 +0200 Subject: [PATCH 372/583] integrate ftrack instances does not query location entities only set location names on components --- .../plugins/publish/integrate_ftrack_api.py | 66 +++++++++++++++++++ .../publish/integrate_ftrack_instances.py | 30 ++++----- 2 files changed, 81 insertions(+), 15 deletions(-) diff --git a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_api.py b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_api.py index 64af8cb208..c4f7b1f05d 100644 --- a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_api.py +++ b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_api.py @@ -12,6 +12,7 @@ Provides: import os import sys +import collections import six import pyblish.api import clique @@ -84,6 +85,7 @@ class IntegrateFtrackApi(pyblish.api.InstancePlugin): asset_types_by_short = self._ensure_asset_types_exists( session, component_list ) + self._fill_component_locations(session, component_list) asset_versions_data_by_id = {} used_asset_versions = [] @@ -193,6 +195,70 @@ class IntegrateFtrackApi(pyblish.api.InstancePlugin): session._configure_locations() six.reraise(tp, value, tb) + def _fill_component_locations(self, session, component_list): + components_by_location_name = collections.defaultdict(list) + components_by_location_id = collections.defaultdict(list) + for component_item in component_list: + # Location entity can be prefilled + # - this is not recommended as connection to ftrack server may + # be lost and in that case the entity is not valid when gets + # to this plugin + location = component_item.get("component_location") + if location is not None: + continue + + # Collect location id + location_id = component_item.get("component_location_id") + if location_id: + components_by_location_id[location_id].append( + component_item + ) + continue + + location_name = component_item.get("component_location_name") + if location_name: + components_by_location_name[location_name].append( + component_item + ) + continue + + # Skip if there is nothing to do + if not components_by_location_name and not components_by_location_id: + return + + # Query locations + query_filters = [] + if components_by_location_id: + joined_location_ids = ",".join([ + '"{}"'.format(location_id) + for location_id in components_by_location_id + ]) + query_filters.append("id in ({})".format(joined_location_ids)) + + if components_by_location_name: + joined_location_names = ",".join([ + '"{}"'.format(location_name) + for location_name in components_by_location_name + ]) + query_filters.append("name in ({})".format(joined_location_names)) + + locations = session.query( + "select id, name from Location where {}".format( + " or ".join(query_filters) + ) + ).all() + # Fill locations in components + for location in locations: + location_id = location["id"] + location_name = location["name"] + if location_id in components_by_location_id: + for component in components_by_location_id[location_id]: + component["component_location"] = location + + if location_name in components_by_location_name: + for component in components_by_location_name[location_name]: + component["component_location"] = location + def _ensure_asset_types_exists(self, session, component_list): """Make sure that all AssetType entities exists for integration. diff --git a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_instances.py b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_instances.py index 0dd7b1c6e4..170be4b173 100644 --- a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_instances.py +++ b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_instances.py @@ -106,11 +106,10 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): # These must be changed for each component "component_data": None, "component_path": None, - "component_location": None + "component_location": None, + "component_location_name": None } - ft_session = instance.context.data["ftrackSession"] - # Filter types of representations review_representations = [] thumbnail_representations = [] @@ -128,12 +127,8 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): other_representations.append(repre) # Prepare ftrack locations - unmanaged_location = ft_session.query( - "Location where name is \"ftrack.unmanaged\"" - ).one() - ftrack_server_location = ft_session.query( - "Location where name is \"ftrack.server\"" - ).one() + unmanaged_location_name = "ftrack.unmanaged" + ftrack_server_location_name = "ftrack.server" # Components data component_list = [] @@ -174,7 +169,10 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): first_thumbnail_component_repre = repre first_thumbnail_component = thumbnail_item # Set location - thumbnail_item["component_location"] = ftrack_server_location + thumbnail_item["component_location_name"] = ( + ftrack_server_location_name + ) + # Add item to component list component_list.append(thumbnail_item) @@ -293,7 +291,9 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): src_components_to_add.append(copy.deepcopy(review_item)) # Set location - review_item["component_location"] = ftrack_server_location + review_item["component_location_name"] = ( + ftrack_server_location_name + ) # Add item to component list component_list.append(review_item) @@ -305,8 +305,8 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): first_thumbnail_component ) new_thumbnail_component["asset_data"]["name"] = asset_name - new_thumbnail_component["component_location"] = ( - ftrack_server_location + new_thumbnail_component["component_location_name"] = ( + ftrack_server_location_name ) component_list.append(new_thumbnail_component) @@ -315,7 +315,7 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): # Make sure thumbnail is disabled copy_src_item["thumbnail"] = False # Set location - copy_src_item["component_location"] = unmanaged_location + copy_src_item["component_location_name"] = unmanaged_location_name # Modify name of component to have suffix "_src" component_data = copy_src_item["component_data"] component_name = component_data["name"] @@ -340,7 +340,7 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): other_item["component_data"] = { "name": repre["name"] } - other_item["component_location"] = unmanaged_location + other_item["component_location_name"] = unmanaged_location_name other_item["component_path"] = published_path component_list.append(other_item) From 64380785876ed7933e20f4b7e22c9093cf932d38 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 13 May 2022 11:12:43 +0200 Subject: [PATCH 373/583] creators can be filtered by host name --- openpype/pipeline/create/context.py | 15 +++++++++++++++ openpype/pipeline/create/creator_plugins.py | 6 ++++++ 2 files changed, 21 insertions(+) diff --git a/openpype/pipeline/create/context.py b/openpype/pipeline/create/context.py index 6f862e0588..2f1922c103 100644 --- a/openpype/pipeline/create/context.py +++ b/openpype/pipeline/create/context.py @@ -749,6 +749,10 @@ class CreateContext: """Is host valid for creation.""" return self._host_is_valid + @property + def host_name(self): + return os.environ["AVALON_APP"] + @property def log(self): """Dynamic access to logger.""" @@ -861,6 +865,17 @@ class CreateContext: "Using first and skipping following" )) continue + + # Filter by host name + if ( + creator_class.host_name + and creator_class.host_name != self.host_name + ): + self.log.info(( + "Creator's host name is not supported for current host {}" + ).format(creator_class.host_name, self.host_name)) + continue + creator = creator_class( self, system_settings, diff --git a/openpype/pipeline/create/creator_plugins.py b/openpype/pipeline/create/creator_plugins.py index cbe19da064..c776f94d56 100644 --- a/openpype/pipeline/create/creator_plugins.py +++ b/openpype/pipeline/create/creator_plugins.py @@ -63,6 +63,12 @@ class BaseCreator: # `openpype.pipeline.attribute_definitions` instance_attr_defs = [] + # Filtering by host name - can be used to be filtered by host name + # - used on all hosts when set to 'None' for Backwards compatibility + # - was added afterwards + # QUESTIOn make this required? + host_name = None + def __init__( self, create_context, system_settings, project_settings, headless=False ): From ababf4053e8de157b9edcc9227d662e476f3e497 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 13 May 2022 11:24:36 +0200 Subject: [PATCH 374/583] flame: splitting function into smaller parts --- .../publish/extract_subset_resources.py | 80 +++++++++---------- 1 file changed, 40 insertions(+), 40 deletions(-) diff --git a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py index 0e04336211..9ad3b21687 100644 --- a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py +++ b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py @@ -60,12 +60,7 @@ class ExtractSubsetResources(openpype.api.Extractor): export_presets_mapping = {} def process(self, instance): - if ( - self.keep_original_representation - and "representations" not in instance.data - or not self.keep_original_representation - ): - instance.data["representations"] = [] + self._make_representation_data(instance) # flame objects segment = instance.data["item"] @@ -92,7 +87,6 @@ class ExtractSubsetResources(openpype.api.Extractor): handles = max(handle_start, handle_end) # get media source range with handles - source_end_handles = instance.data["sourceEndH"] source_start_handles = instance.data["sourceStartH"] source_end_handles = instance.data["sourceEndH"] @@ -109,27 +103,7 @@ class ExtractSubsetResources(openpype.api.Extractor): for unique_name, preset_config in export_presets.items(): modify_xml_data = {} - # get activating attributes - activated_preset = preset_config["active"] - filter_path_regex = preset_config.get("filter_path_regex") - - self.log.info( - "Preset `{}` is active `{}` with filter `{}`".format( - unique_name, activated_preset, filter_path_regex - ) - ) - self.log.debug( - "__ clip_path: `{}`".format(clip_path)) - - # skip if not activated presete - if not activated_preset: - continue - - # exclude by regex filter if any - if ( - filter_path_regex - and not re.search(filter_path_regex, clip_path) - ): + if self._should_skip(preset_config, clip_path, unique_name): continue # get all presets attributes @@ -147,18 +121,10 @@ class ExtractSubsetResources(openpype.api.Extractor): ) ) - # get attribures related loading in integrate_batch_group - load_to_batch_group = preset_config.get( - "load_to_batch_group") - batch_group_loader_name = preset_config.get( - "batch_group_loader_name") - - # convert to None if empty string - if batch_group_loader_name == "": - batch_group_loader_name = None - # get frame range with handles for representation range frame_start_handle = frame_start - handle_start + + # calculate duration with handles source_duration_handles = ( source_end_handles - source_start_handles) @@ -272,8 +238,10 @@ class ExtractSubsetResources(openpype.api.Extractor): "data": { "colorspace": color_out }, - "load_to_batch_group": load_to_batch_group, - "batch_group_loader_name": batch_group_loader_name + "load_to_batch_group": preset_config.get( + "load_to_batch_group"), + "batch_group_loader_name": preset_config.get( + "batch_group_loader_name") or None } # collect all available content of export dir @@ -328,6 +296,38 @@ class ExtractSubsetResources(openpype.api.Extractor): self.log.debug("All representations: {}".format( pformat(instance.data["representations"]))) + def _should_skip(self, preset_config, clip_path, unique_name): + # get activating attributes + activated_preset = preset_config["active"] + filter_path_regex = preset_config.get("filter_path_regex") + + self.log.info( + "Preset `{}` is active `{}` with filter `{}`".format( + unique_name, activated_preset, filter_path_regex + ) + ) + self.log.debug( + "__ clip_path: `{}`".format(clip_path)) + + # skip if not activated presete + if not activated_preset: + return True + + # exclude by regex filter if any + if ( + filter_path_regex + and not re.search(filter_path_regex, clip_path) + ): + return True + + def _make_representation_data(self, instance): + if ( + self.keep_original_representation + and "representations" not in instance.data + or not self.keep_original_representation + ): + instance.data["representations"] = [] + def _unfolds_nested_folders(self, stage_dir, files_list, ext): """Unfolds nested folders From 0ae531d4cec91870774169f51ce373bb73e7261f Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 13 May 2022 11:25:29 +0200 Subject: [PATCH 375/583] flame: fixing small hickups removing frame range input form reference --- openpype/hosts/flame/otio/flame_export.py | 7 ++++--- openpype/lib/editorial.py | 8 ++++---- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/flame/otio/flame_export.py b/openpype/hosts/flame/otio/flame_export.py index 08478d4b98..ffb82b97c2 100644 --- a/openpype/hosts/flame/otio/flame_export.py +++ b/openpype/hosts/flame/otio/flame_export.py @@ -205,8 +205,9 @@ def create_otio_markers(otio_item, item): otio_item.markers.append(otio_marker) -def create_otio_reference(clip_data, duration, fps=None): +def create_otio_reference(clip_data, fps=None): metadata = _get_metadata(clip_data) + duration = int(clip_data["source_duration"]) # get file info for path and start frame frame_start = 0 @@ -307,7 +308,7 @@ def create_otio_clip(clip_data): # secondly check if any change of speed if source_duration != _clip_record_duration: - retime_speed = float(source_duration / _clip_record_duration) + retime_speed = float(source_duration) / float(_clip_record_duration) log.debug("_ retime_speed: {}".format(retime_speed)) speed *= retime_speed @@ -319,7 +320,7 @@ def create_otio_clip(clip_data): # create media reference media_reference = create_otio_reference( - clip_data, source_duration, media_fps) + clip_data, media_fps) # creatae source range source_range = create_otio_time_range( diff --git a/openpype/lib/editorial.py b/openpype/lib/editorial.py index 1ee21deedc..4979bac159 100644 --- a/openpype/lib/editorial.py +++ b/openpype/lib/editorial.py @@ -269,16 +269,16 @@ def get_media_range_with_retimes(otio_clip, handle_start, handle_end): "retime": True, "speed": time_scalar, "timewarps": time_warp_nodes, - "handleStart": handle_start, - "handleEnd": handle_end + "handleStart": round(handle_start), + "handleEnd": round(handle_end) } } returning_dict = { "mediaIn": media_in_trimmed, "mediaOut": media_out_trimmed, - "handleStart": handle_start, - "handleEnd": handle_end + "handleStart": round(handle_start), + "handleEnd": round(handle_end) } # add version data only if retime From d2afd26ff694bd2c52e337cde8f75b2cdf721ef2 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 13 May 2022 11:29:19 +0200 Subject: [PATCH 376/583] implemented collecting of creator plugins for host --- openpype/modules/base.py | 26 ++++++++++++++++++++++++++ openpype/modules/interfaces.py | 31 ++++++++++++++++++++++++++++++- 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/openpype/modules/base.py b/openpype/modules/base.py index 0dd512ee8b..5b49649359 100644 --- a/openpype/modules/base.py +++ b/openpype/modules/base.py @@ -702,6 +702,32 @@ class ModulesManager: ).format(expected_keys, " | ".join(msg_items))) return output + def collect_creator_plugin_paths(self, host_name): + """Helper to collect creator plugin paths from modules. + + Args: + host_name (str): For which host are creators meants. + + Returns: + list: List of creator plugin paths. + """ + # Output structure + from openpype_interfaces import IPluginPaths + + output = [] + for module in self.get_enabled_modules(): + # Skip module that do not inherit from `IPluginPaths` + if not isinstance(module, IPluginPaths): + continue + + paths = module.get_creator_plugin_paths(host_name) + if paths: + # Convert to list if value is not list + if not isinstance(paths, (list, tuple, set)): + paths = [paths] + output.extend(paths) + return output + def collect_launch_hook_paths(self): """Helper to collect hooks from modules inherited ILaunchHookPaths. diff --git a/openpype/modules/interfaces.py b/openpype/modules/interfaces.py index 13cbea690b..e553151428 100644 --- a/openpype/modules/interfaces.py +++ b/openpype/modules/interfaces.py @@ -1,5 +1,7 @@ from abc import abstractmethod +import six + from openpype import resources from openpype.modules import OpenPypeInterface @@ -14,11 +16,38 @@ class IPluginPaths(OpenPypeInterface): "publish": ["path/to/publish_plugins"] } """ - # TODO validation of an output + @abstractmethod def get_plugin_paths(self): pass + def get_creator_plugin_paths(self, host_name): + """Retreive creator plugin paths. + + Give addons ability to add creator plugin paths based on host name. + + NOTES: + - Default implementation uses 'get_plugin_paths' and always return + all creator plugins. + - Host name may help to organize plugins by host, but each creator + alsomay have host filtering. + + Args: + host_name (str): For which host are the plugins meant. + """ + + paths = self.get_plugin_paths() + if not paths or "create" not in paths: + return [] + + create_paths = paths["create"] + if not create_paths: + return [] + + if not isinstance(create_paths, (list, tuple, set)): + create_paths = [create_paths] + return create_paths + class ILaunchHookPaths(OpenPypeInterface): """Module has launch hook paths to return. From 29cbc12ca8bfb240883ba57212205b3ed8a6ebc4 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 13 May 2022 11:47:03 +0200 Subject: [PATCH 377/583] install creator plugins from modules on openpype plugin install --- openpype/pipeline/context_tools.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/openpype/pipeline/context_tools.py b/openpype/pipeline/context_tools.py index 06bd639776..cda9b10f44 100644 --- a/openpype/pipeline/context_tools.py +++ b/openpype/pipeline/context_tools.py @@ -12,7 +12,7 @@ import pyblish.api from pyblish.lib import MessageHandler import openpype -from openpype.modules import load_modules +from openpype.modules import load_modules, ModulesManager from openpype.settings import get_project_settings from openpype.lib import ( Anatomy, @@ -107,7 +107,7 @@ def install_host(host): install_openpype_plugins() -def install_openpype_plugins(project_name=None): +def install_openpype_plugins(project_name=None, host_name=None): # Make sure modules are loaded load_modules() @@ -116,6 +116,14 @@ def install_openpype_plugins(project_name=None): pyblish.api.register_discovery_filter(filter_pyblish_plugins) register_loader_plugin_path(LOAD_PATH) + if host_name is None: + host_name = os.environ.get("AVALON_APP") + + modules_manager = ModulesManager() + creator_paths = modules_manager.collect_creator_plugin_paths(host_name) + for creator_path in creator_paths: + register_creator_plugin_path(creator_path) + if project_name is None: project_name = os.environ.get("AVALON_PROJECT") From 43aee1989c197b74ce692b64581b233371aa8003 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 13 May 2022 11:55:03 +0200 Subject: [PATCH 378/583] flame: fixing padding if it is higher then needed --- openpype/hosts/flame/api/lib.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py index f2f5db184b..6dc7d3d887 100644 --- a/openpype/hosts/flame/api/lib.py +++ b/openpype/hosts/flame/api/lib.py @@ -855,22 +855,24 @@ class MediaInfoFile(object): # add `[` in front to make sure it want capture # shot name with the same number - number_from_path = "[" + self._separate_number(feed_basename, feed_ext) + number_from_path = self._separate_number(feed_basename, feed_ext) + search_number_pattern = "[" + number_from_path # convert to multiple collections _continues_colls = collection.separate() for _coll in _continues_colls: - coll_to_text = self._format_collection(_coll) + coll_to_text = self._format_collection(_coll, len(number_from_path)) self.log.debug("__ coll_to_text: {}".format(coll_to_text)) - if number_from_path in coll_to_text: + if search_number_pattern in coll_to_text: return coll_to_text @staticmethod - def _format_collection(collection): + def _format_collection(collection, padding=None): + padding = padding or collection.padding # if no holes then return collection head = collection.format("{head}") tail = collection.format("{tail}") range_template = "[{{:0{0}d}}-{{:0{0}d}}]".format( - len(str(max(collection.indexes)))) + padding) ranges = range_template.format( min(collection.indexes), max(collection.indexes) From 7e23f1cc32bb22a464135df8d77283dd930c0342 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 13 May 2022 12:00:50 +0200 Subject: [PATCH 379/583] fix typo --- openpype/pipeline/create/creator_plugins.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/pipeline/create/creator_plugins.py b/openpype/pipeline/create/creator_plugins.py index c776f94d56..8006d4f4f8 100644 --- a/openpype/pipeline/create/creator_plugins.py +++ b/openpype/pipeline/create/creator_plugins.py @@ -66,7 +66,7 @@ class BaseCreator: # Filtering by host name - can be used to be filtered by host name # - used on all hosts when set to 'None' for Backwards compatibility # - was added afterwards - # QUESTIOn make this required? + # QUESTION make this required? host_name = None def __init__( From c4907af980f8aa03bc0b3442e7a2c578d58dca07 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 13 May 2022 12:04:40 +0200 Subject: [PATCH 380/583] traypublisher creator has host name filter --- openpype/hosts/traypublisher/api/plugin.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/hosts/traypublisher/api/plugin.py b/openpype/hosts/traypublisher/api/plugin.py index 603f34ee29..202664cfc6 100644 --- a/openpype/hosts/traypublisher/api/plugin.py +++ b/openpype/hosts/traypublisher/api/plugin.py @@ -14,6 +14,7 @@ from .pipeline import ( class TrayPublishCreator(Creator): create_allow_context_change = True + host_name = "traypublisher" def collect_instances(self): for instance_data in list_instances(): From 40ea037ed4ee525f61a25e2cf271acdc100190b6 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 13 May 2022 12:08:30 +0200 Subject: [PATCH 381/583] register module publish plugins during openpype plugin installation instead of using environment variable --- openpype/pipeline/context_tools.py | 6 +++++- start.py | 12 ------------ 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/openpype/pipeline/context_tools.py b/openpype/pipeline/context_tools.py index cda9b10f44..a54809bc36 100644 --- a/openpype/pipeline/context_tools.py +++ b/openpype/pipeline/context_tools.py @@ -116,10 +116,14 @@ def install_openpype_plugins(project_name=None, host_name=None): pyblish.api.register_discovery_filter(filter_pyblish_plugins) register_loader_plugin_path(LOAD_PATH) + modules_manager = ModulesManager() + publish_plugin_dirs = modules_manager.collect_plugin_paths()["publish"] + for path in publish_plugin_dirs: + pyblish.api.register_plugin_path(PUBLISH_PATH) + if host_name is None: host_name = os.environ.get("AVALON_APP") - modules_manager = ModulesManager() creator_paths = modules_manager.collect_creator_plugin_paths(host_name) for creator_path in creator_paths: register_creator_plugin_path(creator_path) diff --git a/start.py b/start.py index 6e339fabab..ace33ab92a 100644 --- a/start.py +++ b/start.py @@ -386,18 +386,6 @@ def set_modules_environments(): modules_manager = ModulesManager() module_envs = modules_manager.collect_global_environments() - publish_plugin_dirs = modules_manager.collect_plugin_paths()["publish"] - - # Set pyblish plugins paths if any module want to register them - if publish_plugin_dirs: - publish_paths_str = os.environ.get("PYBLISHPLUGINPATH") or "" - publish_paths = publish_paths_str.split(os.pathsep) - _publish_paths = { - os.path.normpath(path) for path in publish_paths if path - } - for path in publish_plugin_dirs: - _publish_paths.add(os.path.normpath(path)) - module_envs["PYBLISHPLUGINPATH"] = os.pathsep.join(_publish_paths) # Merge environments with current environments and update values if module_envs: From cf7a83eb490c36cafedc81f6988e45bbdb78a156 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 13 May 2022 12:19:54 +0200 Subject: [PATCH 382/583] remove unused import --- openpype/modules/interfaces.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/openpype/modules/interfaces.py b/openpype/modules/interfaces.py index e553151428..334485cab2 100644 --- a/openpype/modules/interfaces.py +++ b/openpype/modules/interfaces.py @@ -1,7 +1,5 @@ from abc import abstractmethod -import six - from openpype import resources from openpype.modules import OpenPypeInterface From dadf816d3de7684e9dbdefad37f998323e414dcc Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 13 May 2022 12:21:42 +0200 Subject: [PATCH 383/583] fix registering of paths --- openpype/pipeline/context_tools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/pipeline/context_tools.py b/openpype/pipeline/context_tools.py index a54809bc36..e849f5b0d1 100644 --- a/openpype/pipeline/context_tools.py +++ b/openpype/pipeline/context_tools.py @@ -119,7 +119,7 @@ def install_openpype_plugins(project_name=None, host_name=None): modules_manager = ModulesManager() publish_plugin_dirs = modules_manager.collect_plugin_paths()["publish"] for path in publish_plugin_dirs: - pyblish.api.register_plugin_path(PUBLISH_PATH) + pyblish.api.register_plugin_path(path) if host_name is None: host_name = os.environ.get("AVALON_APP") From 6a75497bfcaaba4ebd87c8443af77aec03d4ef24 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 13 May 2022 15:16:46 +0200 Subject: [PATCH 384/583] OP-2790 - added clean_import option to Maya's Import loader By selecting this option all occurrences of cbId of imported nodes will be removed. --- openpype/hosts/maya/plugins/load/actions.py | 35 ++++++++++++++++----- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/openpype/hosts/maya/plugins/load/actions.py b/openpype/hosts/maya/plugins/load/actions.py index 4b7871a40c..14518ead5d 100644 --- a/openpype/hosts/maya/plugins/load/actions.py +++ b/openpype/hosts/maya/plugins/load/actions.py @@ -1,7 +1,7 @@ """A module containing generic loader actions that will display in the Loader. """ - +import qargparse from openpype.pipeline import load from openpype.hosts.maya.api.lib import ( maintained_selection, @@ -98,6 +98,15 @@ class ImportMayaLoader(load.LoaderPlugin): icon = "arrow-circle-down" color = "#775555" + options = [ + qargparse.Boolean( + "clean_import", + label="Clean import", + default=False, + help="Should all occurences of cbId be purged?" + ) + ] + def load(self, context, name=None, namespace=None, data=None): import maya.cmds as cmds @@ -105,6 +114,8 @@ class ImportMayaLoader(load.LoaderPlugin): if choice is False: return + clean_import = data.get("clean_import", False) + asset = context['asset'] namespace = namespace or unique_namespace( @@ -114,13 +125,21 @@ class ImportMayaLoader(load.LoaderPlugin): ) with maintained_selection(): - cmds.file(self.fname, - i=True, - preserveReferences=True, - namespace=namespace, - returnNewNodes=True, - groupReference=True, - groupName="{}:{}".format(namespace, name)) + nodes = cmds.file(self.fname, + i=True, + preserveReferences=True, + namespace=namespace, + returnNewNodes=True, + groupReference=True, + groupName="{}:{}".format(namespace, name)) + + if clean_import: + shapes = cmds.ls(nodes, shapes=True, long=True) + for shape in shapes: + meshes = cmds.ls('{}.cbId'.format(shape)) + for mesh in meshes: + print("Removing ... " + (mesh)) + cmds.deleteAttr(mesh) # We do not containerize imported content, it remains unmanaged return From f7c0f3b46ec1b38ee2a9a99c2f00ca7d492c71ec Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 13 May 2022 17:01:09 +0200 Subject: [PATCH 385/583] don't create ftrackreview-image if there are mp4 reviews --- .../ftrack/plugins/publish/integrate_ftrack_instances.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_instances.py b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_instances.py index 170be4b173..c8d9e4117d 100644 --- a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_instances.py +++ b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_instances.py @@ -176,7 +176,10 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): # Add item to component list component_list.append(thumbnail_item) - if first_thumbnail_component is not None: + if ( + not review_representations + and first_thumbnail_component is not None + ): width = first_thumbnail_component_repre.get("width") height = first_thumbnail_component_repre.get("height") if not width or not height: From 0ed1caa5f9d347fd820ca9e2ab471235fda80481 Mon Sep 17 00:00:00 2001 From: Milan Kolar Date: Fri, 13 May 2022 18:15:03 +0200 Subject: [PATCH 386/583] add write family to default nuke version sync settings --- openpype/settings/defaults/project_settings/deadline.json | 2 +- openpype/settings/defaults/project_settings/nuke.json | 3 ++- .../schemas/projects_schema/schemas/schema_nuke_publish.json | 3 +++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/openpype/settings/defaults/project_settings/deadline.json b/openpype/settings/defaults/project_settings/deadline.json index f0b2a7e555..5c5a14bf21 100644 --- a/openpype/settings/defaults/project_settings/deadline.json +++ b/openpype/settings/defaults/project_settings/deadline.json @@ -98,4 +98,4 @@ } } } -} +} \ No newline at end of file diff --git a/openpype/settings/defaults/project_settings/nuke.json b/openpype/settings/defaults/project_settings/nuke.json index 128d440732..33ddc2f251 100644 --- a/openpype/settings/defaults/project_settings/nuke.json +++ b/openpype/settings/defaults/project_settings/nuke.json @@ -87,7 +87,8 @@ "camera", "gizmo", "source", - "render" + "render", + "write" ] }, "ValidateInstanceInContext": { diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json index 94b52bba13..04df957d67 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json @@ -41,6 +41,9 @@ }, { "render": "render" + }, + { + "write": "write" } ] } From 30123edb55ddbda74259318c6b85e0df19da0cd8 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 13 May 2022 20:10:51 +0200 Subject: [PATCH 387/583] flame: adding xml element if it is missing and its path possible --- openpype/hosts/flame/api/render_utils.py | 41 +++++++++++++++++++++--- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/flame/api/render_utils.py b/openpype/hosts/flame/api/render_utils.py index 473fb2f985..9957550af9 100644 --- a/openpype/hosts/flame/api/render_utils.py +++ b/openpype/hosts/flame/api/render_utils.py @@ -1,5 +1,8 @@ import os from xml.etree import ElementTree as ET +import openpype.api as openpype + +log = openpype.Logger.get_logger(__name__) def export_clip(export_path, clip, preset_path, **kwargs): @@ -143,10 +146,40 @@ def modify_preset_file(xml_path, staging_dir, data): # change xml following data keys with open(xml_path, "r") as datafile: - tree = ET.parse(datafile) + _root = ET.parse(datafile) + for key, value in data.items(): - for element in tree.findall(".//{}".format(key)): - element.text = str(value) - tree.write(temp_path) + try: + if "/" in key: + if not key.startswith("./"): + key = ".//" + key + + split_key_path = key.split("/") + element_key = split_key_path[-1] + parent_obj_path = "/".join(split_key_path[:-1]) + + parent_obj = _root.find(parent_obj_path) + element_obj = parent_obj.find(element_key) + if not element_obj: + append_element(parent_obj, element_key, value) + else: + finds = _root.findall(".//{}".format(key)) + if not finds: + raise AttributeError + for element in finds: + element.text = str(value) + except AttributeError: + log.warning( + "Cannot create attribute: {}: {}. Skipping".format( + key, value + )) + _root.write(temp_path) return temp_path + + +def append_element(root_element_obj, key, value): + new_element_obj = ET.Element(key) + log.debug("__ new_element_obj: {}".format(new_element_obj)) + new_element_obj.text = str(value) + root_element_obj.insert(0, new_element_obj) From 851df8155f43d4fd6639ee71ab3a3f023e06c7a5 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 13 May 2022 20:11:20 +0200 Subject: [PATCH 388/583] flame: treat thumbnail as poster frame --- .../publish/extract_subset_resources.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py index 9ad3b21687..eea575ea88 100644 --- a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py +++ b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py @@ -168,10 +168,6 @@ class ExtractSubsetResources(openpype.api.Extractor): # add any xml overrides collected form segment.comment modify_xml_data.update(instance.data["xml_overrides"]) - self.log.debug("__ modify_xml_data: {}".format(pformat( - modify_xml_data - ))) - export_kwargs = {} # validate xml preset file is filled if preset_file == "": @@ -198,11 +194,13 @@ class ExtractSubsetResources(openpype.api.Extractor): preset_dir, preset_file )) - preset_path = opfapi.modify_preset_file( - preset_orig_xml_path, staging_dir, modify_xml_data) - # define kwargs based on preset type if "thumbnail" in unique_name: + modify_xml_data.update({ + "video/posterFrame": True, + "video/useFrameAsPoster": 1, + "namePattern": "__thumbnail" + }) thumb_frame_number = int(in_mark + ( source_duration_handles / 2)) @@ -218,6 +216,12 @@ class ExtractSubsetResources(openpype.api.Extractor): "out_mark": out_mark }) + self.log.debug("__ modify_xml_data: {}".format( + pformat(modify_xml_data) + )) + preset_path = opfapi.modify_preset_file( + preset_orig_xml_path, staging_dir, modify_xml_data) + # get and make export dir paths export_dir_path = str(os.path.join( staging_dir, unique_name From d7454044c92f54392b3c8108a466bb6a5758b830 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Marinov?= Date: Fri, 13 May 2022 21:02:44 +0200 Subject: [PATCH 389/583] Nuke: add pointcache and animation to loader --- openpype/hosts/nuke/plugins/load/load_model.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/nuke/plugins/load/load_model.py b/openpype/hosts/nuke/plugins/load/load_model.py index 9788bb25d2..89b58585ef 100644 --- a/openpype/hosts/nuke/plugins/load/load_model.py +++ b/openpype/hosts/nuke/plugins/load/load_model.py @@ -15,13 +15,13 @@ from openpype.hosts.nuke.api import ( class AlembicModelLoader(load.LoaderPlugin): """ - This will load alembic model into script. + This will load alembic model or anim into script. """ - families = ["model"] + families = ["model","pointcache","animation"] representations = ["abc"] - label = "Load Alembic Model" + label = "Load Alembic Model or Anim" icon = "cube" color = "orange" node_color = "0x4ecd91ff" From dfb3e53743b3bc4229b1c82beb1ff0ecbffc7323 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Marinov?= Date: Fri, 13 May 2022 21:07:45 +0200 Subject: [PATCH 390/583] fix missing whitespaces --- openpype/hosts/nuke/plugins/load/load_model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/nuke/plugins/load/load_model.py b/openpype/hosts/nuke/plugins/load/load_model.py index 89b58585ef..8aaa7221eb 100644 --- a/openpype/hosts/nuke/plugins/load/load_model.py +++ b/openpype/hosts/nuke/plugins/load/load_model.py @@ -18,7 +18,7 @@ class AlembicModelLoader(load.LoaderPlugin): This will load alembic model or anim into script. """ - families = ["model","pointcache","animation"] + families = ["model", "pointcache", "animation"] representations = ["abc"] label = "Load Alembic Model or Anim" From 631ccf6318916d9c7ad98830dba3007c073e70db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20David?= Date: Mon, 16 May 2022 10:24:39 +0200 Subject: [PATCH 391/583] Update openpype/modules/kitsu/plugins/publish/integrate_kitsu_review.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- .../kitsu/plugins/publish/integrate_kitsu_review.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_review.py b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_review.py index 57e0286b00..a036f5f9cc 100644 --- a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_review.py +++ b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_review.py @@ -25,12 +25,9 @@ class IntegrateKitsuReview(pyblish.api.InstancePlugin): return # Add review representations as preview of comment - for representation in [ - r - for r in instance.data.get("representations", []) - if "review" in r.get("tags", []) - ]: - + for representation in instance.data.get("representations", []): + if "review" not in r.get("tags", []): + continue review_path = representation.get("published_path") self.log.debug("Found review at: {}".format(review_path)) From 15aa5709ae18c3cca83e73fef9eaa557684a0a5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20David?= Date: Mon, 16 May 2022 10:25:41 +0200 Subject: [PATCH 392/583] cleaning --- .../modules/kitsu/plugins/publish/integrate_kitsu_review.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_review.py b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_review.py index a036f5f9cc..bf80095225 100644 --- a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_review.py +++ b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_review.py @@ -26,8 +26,10 @@ class IntegrateKitsuReview(pyblish.api.InstancePlugin): # Add review representations as preview of comment for representation in instance.data.get("representations", []): - if "review" not in r.get("tags", []): + # Skip if not tagged as review + if "review" not in representation.get("tags", []): continue + review_path = representation.get("published_path") self.log.debug("Found review at: {}".format(review_path)) From 7d13de97f694ee735ffc4de63749c285c898b7e0 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 16 May 2022 11:14:02 +0200 Subject: [PATCH 393/583] Flame: add handles including to settings --- openpype/settings/defaults/project_settings/flame.json | 3 ++- .../schemas/projects_schema/schema_project_flame.json | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/openpype/settings/defaults/project_settings/flame.json b/openpype/settings/defaults/project_settings/flame.json index dd8c05d460..a7836b9c1f 100644 --- a/openpype/settings/defaults/project_settings/flame.json +++ b/openpype/settings/defaults/project_settings/flame.json @@ -16,7 +16,8 @@ "vSyncOn": false, "workfileFrameStart": 1001, "handleStart": 5, - "handleEnd": 5 + "handleEnd": 5, + "includeHandles": false } }, "publish": { diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json b/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json index ace404b47a..ca62679b3d 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json @@ -123,6 +123,11 @@ "type": "number", "key": "handleEnd", "label": "Handle end (tail)" + }, + { + "type": "boolean", + "key": "includeHandles", + "label": "Enable handles including" } ] } From 3895008eae62c2ebba2afe60b0f0320bc7604cc0 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 16 May 2022 11:26:14 +0200 Subject: [PATCH 394/583] flame: implementing handles include switch --- openpype/hosts/flame/api/plugin.py | 3 +++ openpype/version.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/flame/api/plugin.py b/openpype/hosts/flame/api/plugin.py index 11108ba49f..efbabb6a55 100644 --- a/openpype/hosts/flame/api/plugin.py +++ b/openpype/hosts/flame/api/plugin.py @@ -360,6 +360,7 @@ class PublishableClip: driving_layer_default = "" index_from_segment_default = False use_shot_name_default = False + include_handles_default = False def __init__(self, segment, **kwargs): self.rename_index = kwargs["rename_index"] @@ -493,6 +494,8 @@ class PublishableClip: "reviewTrack", {}).get("value") or self.review_track_default self.audio = self.ui_inputs.get( "audio", {}).get("value") or False + self.include_handles = self.ui_inputs.get( + "includeHandles", {}).get("value") or self.include_handles_default # build subset name from layer name if self.subset_name == "[ track name ]": diff --git a/openpype/version.py b/openpype/version.py index 662adf28ca..fe2a90bdd1 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.10.0-nightly.2" +__version__ = "3.10.0-nightly.2-upp220513-1+staging" From 5c4541ca0ae6af1561ecd2e405d840579d4264ae Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 16 May 2022 11:31:06 +0200 Subject: [PATCH 395/583] flame: implementing handles including --- openpype/hosts/flame/api/plugin.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openpype/hosts/flame/api/plugin.py b/openpype/hosts/flame/api/plugin.py index 11108ba49f..efbabb6a55 100644 --- a/openpype/hosts/flame/api/plugin.py +++ b/openpype/hosts/flame/api/plugin.py @@ -360,6 +360,7 @@ class PublishableClip: driving_layer_default = "" index_from_segment_default = False use_shot_name_default = False + include_handles_default = False def __init__(self, segment, **kwargs): self.rename_index = kwargs["rename_index"] @@ -493,6 +494,8 @@ class PublishableClip: "reviewTrack", {}).get("value") or self.review_track_default self.audio = self.ui_inputs.get( "audio", {}).get("value") or False + self.include_handles = self.ui_inputs.get( + "includeHandles", {}).get("value") or self.include_handles_default # build subset name from layer name if self.subset_name == "[ track name ]": From 45c445d438106b1ed3d989224778884f7796974b Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 16 May 2022 11:33:35 +0200 Subject: [PATCH 396/583] removing version change by excident --- openpype/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/version.py b/openpype/version.py index fe2a90bdd1..662adf28ca 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.10.0-nightly.2-upp220513-1+staging" +__version__ = "3.10.0-nightly.2" From e6286166fba934945756357ee08b5d6d89f95cde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20David?= Date: Mon, 16 May 2022 11:33:35 +0200 Subject: [PATCH 397/583] remove default intent effect on review integration --- .../modules/kitsu/plugins/publish/integrate_kitsu_note.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py index 980589365d..9e067a8ecb 100644 --- a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py +++ b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py @@ -11,11 +11,6 @@ class IntegrateKitsuNote(pyblish.api.ContextPlugin): # families = ["kitsu"] def process(self, context): - # Check if work version for user - is_work_version = bool(context.data.get("intent", {}).get("value")) - if is_work_version: - self.log.info("Work version, nothing pushed to Kitsu.") - return # Get comment text body publish_comment = context.data.get("comment") From 5e68cfad86cb3781f3acdc66f681651a11b84945 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Marinov?= Date: Mon, 16 May 2022 11:43:44 +0200 Subject: [PATCH 398/583] More straightforward label --- openpype/hosts/nuke/plugins/load/load_model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/nuke/plugins/load/load_model.py b/openpype/hosts/nuke/plugins/load/load_model.py index 8aaa7221eb..2f54595cb0 100644 --- a/openpype/hosts/nuke/plugins/load/load_model.py +++ b/openpype/hosts/nuke/plugins/load/load_model.py @@ -21,7 +21,7 @@ class AlembicModelLoader(load.LoaderPlugin): families = ["model", "pointcache", "animation"] representations = ["abc"] - label = "Load Alembic Model or Anim" + label = "Load Alembic" icon = "cube" color = "orange" node_color = "0x4ecd91ff" From 3b15167adb602394ad32dcdbeec7f8f0bbb242a3 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 16 May 2022 11:54:29 +0200 Subject: [PATCH 399/583] Flame: adding new attribute to ui includeHandles --- openpype/hosts/flame/plugins/create/create_shot_clip.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/openpype/hosts/flame/plugins/create/create_shot_clip.py b/openpype/hosts/flame/plugins/create/create_shot_clip.py index 11c00dab42..fa239ea420 100644 --- a/openpype/hosts/flame/plugins/create/create_shot_clip.py +++ b/openpype/hosts/flame/plugins/create/create_shot_clip.py @@ -268,6 +268,14 @@ class CreateShotClip(opfapi.Creator): "target": "tag", "toolTip": "Handle at end of clip", # noqa "order": 2 + }, + "includeHandles": { + "value": False, + "type": "QCheckBox", + "label": "Include handles", + "target": "tag", + "toolTip": "By default handles are excluded", # noqa + "order": 3 } } } From 3484d57a7633af9eea4f380b270f361e19eb075d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Mon, 16 May 2022 12:29:34 +0200 Subject: [PATCH 400/583] add support for PxrTexture --- .../maya/plugins/publish/collect_look.py | 27 ++++++++++++------- .../maya/plugins/publish/extract_look.py | 6 +++-- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/collect_look.py b/openpype/hosts/maya/plugins/publish/collect_look.py index b6a76f1e21..70265a160f 100644 --- a/openpype/hosts/maya/plugins/publish/collect_look.py +++ b/openpype/hosts/maya/plugins/publish/collect_look.py @@ -77,9 +77,14 @@ def node_uses_image_sequence(node): node_path = get_file_node_path(node).lower() # The following tokens imply a sequence - patterns = ["", "", "", "u_v", "", "", "", + "u_v", ""] + try: + extension = cmds.getAttr('%s.useFrameExtension' % node) + except ValueError: + extension = None - return (cmds.getAttr('%s.useFrameExtension' % node) or + return (extension or any(pattern in node_path for pattern in patterns)) @@ -165,7 +170,7 @@ def get_file_node_path(node): if any(pattern in lower for pattern in patterns): return texture_pattern - if cmds.nodeType(node) == 'aiImage': + if cmds.nodeType(node) in ['aiImage', 'PxrTexture']: return cmds.getAttr('{0}.filename'.format(node)) if cmds.nodeType(node) == 'RedshiftNormalMap': return cmds.getAttr('{}.tex0'.format(node)) @@ -326,7 +331,10 @@ class CollectLook(pyblish.api.InstancePlugin): "volumeShader", "displacementShader", "aiSurfaceShader", - "aiVolumeShader"] + "aiVolumeShader", + "rman__surface", + "rman__displacement" + ] if look_sets: materials = [] @@ -376,6 +384,7 @@ class CollectLook(pyblish.api.InstancePlugin): files = cmds.ls(history, type="file", long=True) files.extend(cmds.ls(history, type="aiImage", long=True)) + files.extend(cmds.ls(history, type="PxrTexture", long=True)) files.extend(cmds.ls(history, type="RedshiftNormalMap", long=True)) self.log.info("Collected file nodes:\n{}".format(files)) @@ -510,23 +519,21 @@ class CollectLook(pyblish.api.InstancePlugin): Returns: dict """ - self.log.debug("processing: {}".format(node)) - if cmds.nodeType(node) not in ["file", "aiImage", "RedshiftNormalMap"]: + if cmds.nodeType(node) not in [ + "file", "aiImage", "RedshiftNormalMap", "PxrTexture"]: self.log.error( "Unsupported file node: {}".format(cmds.nodeType(node))) raise AssertionError("Unsupported file node") + self.log.debug(" - got {}".format(cmds.nodeType(node))) if cmds.nodeType(node) == 'file': - self.log.debug(" - file node") attribute = "{}.fileTextureName".format(node) computed_attribute = "{}.computedFileTextureNamePattern".format(node) - elif cmds.nodeType(node) == 'aiImage': - self.log.debug("aiImage node") + elif cmds.nodeType(node) in ['aiImage', 'PxrTexture']: attribute = "{}.filename".format(node) computed_attribute = attribute elif cmds.nodeType(node) == 'RedshiftNormalMap': - self.log.debug("RedshiftNormalMap node") attribute = "{}.tex0".format(node) computed_attribute = attribute diff --git a/openpype/hosts/maya/plugins/publish/extract_look.py b/openpype/hosts/maya/plugins/publish/extract_look.py index 881705b92c..81d7c31ae7 100644 --- a/openpype/hosts/maya/plugins/publish/extract_look.py +++ b/openpype/hosts/maya/plugins/publish/extract_look.py @@ -372,10 +372,12 @@ class ExtractLook(openpype.api.Extractor): if mode == COPY: transfers.append((source, destination)) - self.log.info('copying') + self.log.info('file will be copied {} -> {}'.format( + source, destination)) elif mode == HARDLINK: hardlinks.append((source, destination)) - self.log.info('hardlinking') + self.log.info('file will be hardlinked {} -> {}'.format( + source, destination)) # Store the hashes from hash to destination to include in the # database From 3ef846b1623dabfdbcb6cb464c097ec6b19bd473 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 16 May 2022 12:59:55 +0200 Subject: [PATCH 401/583] flame: fixing head and tail calculation --- .../publish/collect_timeline_instances.py | 45 ++++++++++++------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py b/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py index 5174f9db48..306d2da203 100644 --- a/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py +++ b/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py @@ -58,12 +58,16 @@ class CollectTimelineInstances(pyblish.api.ContextPlugin): clip_name = clip_data["segment_name"] self.log.debug("clip_name: {}".format(clip_name)) + # get otio clip data + otio_data = self._get_otio_clip_instance_data(clip_data) or {} + self.log.debug("__ otio_data: {}".format(pformat(otio_data))) + # get file path file_path = clip_data["fpath"] first_frame = opfapi.get_frame_from_filename(file_path) or 0 - head, tail = self._get_head_tail(clip_data, first_frame) + head, tail = self._get_head_tail(clip_data, otio_data["otioClip"]) # solve handles length marker_data["handleStart"] = min( @@ -76,6 +80,9 @@ class CollectTimelineInstances(pyblish.api.ContextPlugin): # add marker data to instance data inst_data = dict(marker_data.items()) + # add ocio_data to instance data + inst_data.update(otio_data) + asset = marker_data["asset"] subset = marker_data["subset"] @@ -105,13 +112,6 @@ class CollectTimelineInstances(pyblish.api.ContextPlugin): task["name"]: {"type": task["type"]} for task in self.add_tasks} }) - - # get otio clip data - otio_data = self._get_otio_clip_instance_data(clip_data) or {} - self.log.debug("__ otio_data: {}".format(pformat(otio_data))) - - # add to instance data - inst_data.update(otio_data) self.log.debug("__ inst_data: {}".format(pformat(inst_data))) # add resolution @@ -236,20 +236,31 @@ class CollectTimelineInstances(pyblish.api.ContextPlugin): return split_comments - def _get_head_tail(self, clip_data, first_frame): + def _get_head_tail(self, clip_data, otio_clip): # calculate head and tail with forward compatibility head = clip_data.get("segment_head") tail = clip_data.get("segment_tail") + self.log.debug("__ head: `{}`".format(head)) + self.log.debug("__ tail: `{}`".format(tail)) # HACK: it is here to serve for versions bellow 2021.1 - if not head: - head = int(clip_data["source_in"]) - int(first_frame) - if not tail: - tail = int( - clip_data["source_duration"] - ( - head + clip_data["record_duration"] - ) - ) + if not any([head, tail]): + otio_source_range = otio_clip.source_range + otio_avalable_range = otio_clip.available_range() + range_convert = openpype.lib.otio_range_to_frame_range + src_start, src_end = range_convert(otio_source_range) + av_start, av_end = range_convert(otio_avalable_range) + av_range = av_end - av_start + av_tail = av_range - src_end + + self.log.debug("__ src_start: `{}`".format(src_start)) + self.log.debug("__ src_end: `{}`".format(src_end)) + self.log.debug("__ av_range: `{}`".format(av_range)) + self.log.debug("__ av_tail: `{}`".format(av_tail)) + + head = src_start + tail = av_tail + return head, tail def _get_resolution_to_data(self, data, context): From 05cb2e4bd938ee1c533bb39625f4d3bbb7b2dad4 Mon Sep 17 00:00:00 2001 From: "clement.hector" Date: Mon, 16 May 2022 13:49:39 +0200 Subject: [PATCH 402/583] waiting approval status can be set in project settings --- .../plugins/publish/integrate_kitsu_note.py | 9 ++++--- .../defaults/project_settings/kitsu.json | 5 ++++ .../projects_schema/schema_project_kitsu.json | 25 +++++++++++++++++++ 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py index 9e067a8ecb..876eb6bf29 100644 --- a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py +++ b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py @@ -9,6 +9,7 @@ class IntegrateKitsuNote(pyblish.api.ContextPlugin): order = pyblish.api.IntegratorOrder label = "Kitsu Note and Status" # families = ["kitsu"] + waiting_for_approval_status = "wfa" def process(self, context): @@ -20,11 +21,13 @@ class IntegrateKitsuNote(pyblish.api.ContextPlugin): self.log.debug("Comment is `{}`".format(publish_comment)) # Get Waiting for Approval status - kitsu_status = gazu.task.get_task_status_by_short_name("wfa") + kitsu_status = gazu.task.get_task_status_by_short_name( + self.waiting_for_approval_status + ) if not kitsu_status: self.log.info( - "Cannot find 'Waiting For Approval' status." - "The status will not be changed" + "Cannot find {} status. The status will not be " + "changed!".format(self.waiting_for_approval_status) ) kitsu_status = context.data["kitsu_task"].get("task_status") self.log.debug("Kitsu status: {}".format(kitsu_status)) diff --git a/openpype/settings/defaults/project_settings/kitsu.json b/openpype/settings/defaults/project_settings/kitsu.json index a37146e1d2..2f1566d89a 100644 --- a/openpype/settings/defaults/project_settings/kitsu.json +++ b/openpype/settings/defaults/project_settings/kitsu.json @@ -7,5 +7,10 @@ "episode": "E##", "sequence": "SQ##", "shot": "SH##" + }, + "publish": { + "IntegrateKitsuNote": { + "waiting_for_approval_status": "wfa" + } } } \ No newline at end of file diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_kitsu.json b/openpype/settings/entities/schemas/projects_schema/schema_project_kitsu.json index 8d71d0ecd6..cffd7ff578 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_kitsu.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_kitsu.json @@ -43,6 +43,31 @@ "label": "Shot:" } ] + }, + { + "type": "dict", + "collapsible": true, + "key": "publish", + "label": "Publish plugins", + "children": [ + { + "type": "label", + "label": "Integrator" + }, + { + "type": "dict", + "collapsible": true, + "key": "IntegrateKitsuNote", + "label": "Integrate Kitsu Note", + "children": [ + { + "type": "text", + "key": "waiting_for_approval_status", + "label": "Waiting for Aproval Status:" + } + ] + } + ] } ] } From 0699906344a8399eeb0f7c10c6b61963ce3eb3e2 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 16 May 2022 15:20:57 +0200 Subject: [PATCH 403/583] Flame: implementing handles inclusion to publishing --- .../plugins/publish/collect_timeline_instances.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py b/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py index 306d2da203..4bca0dcf93 100644 --- a/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py +++ b/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py @@ -36,6 +36,7 @@ class CollectTimelineInstances(pyblish.api.ContextPlugin): for segment in selected_segments: # get openpype tag data marker_data = opfapi.get_segment_data_marker(segment) + self.log.debug("__ marker_data: {}".format( pformat(marker_data))) @@ -75,6 +76,8 @@ class CollectTimelineInstances(pyblish.api.ContextPlugin): marker_data["handleEnd"] = min( marker_data["handleEnd"], abs(tail)) + workfile_start = self._set_workfile_start(marker_data) + with_audio = bool(marker_data.pop("audio")) # add marker data to instance data @@ -105,6 +108,7 @@ class CollectTimelineInstances(pyblish.api.ContextPlugin): "families": families, "publish": marker_data["publish"], "fps": self.fps, + "workfileFrameStart": workfile_start, "sourceFirstFrame": int(first_frame), "path": file_path, "flameAddTasks": self.add_tasks, @@ -145,6 +149,17 @@ class CollectTimelineInstances(pyblish.api.ContextPlugin): if marker_data.get("reviewTrack") is not None: instance.data["reviewAudio"] = True + @staticmethod + def _set_workfile_start(data): + include_handles = data.get("includeHandles") + workfile_start = data["workfileFrameStart"] + handle_start = data["handleStart"] + + if include_handles: + workfile_start += handle_start + + return workfile_start + def _get_comment_attributes(self, segment): comment = segment.comment.get_value() From 0b0a9ca2815251ba423f543e376a38e5805c0aba Mon Sep 17 00:00:00 2001 From: "clement.hector" Date: Mon, 16 May 2022 15:46:47 +0200 Subject: [PATCH 404/583] by default use task status if not specified in config --- .../plugins/publish/integrate_kitsu_note.py | 34 ++++++++++++------- .../defaults/project_settings/kitsu.json | 3 +- .../projects_schema/schema_project_kitsu.json | 9 +++-- 3 files changed, 31 insertions(+), 15 deletions(-) diff --git a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py index 876eb6bf29..ae559e660e 100644 --- a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py +++ b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +from distutils.log import debug import gazu import pyblish.api @@ -9,7 +10,8 @@ class IntegrateKitsuNote(pyblish.api.ContextPlugin): order = pyblish.api.IntegratorOrder label = "Kitsu Note and Status" # families = ["kitsu"] - waiting_for_approval_status = "wfa" + set_status_note = False + note_status_shortname = "wfa" def process(self, context): @@ -20,21 +22,29 @@ class IntegrateKitsuNote(pyblish.api.ContextPlugin): self.log.debug("Comment is `{}`".format(publish_comment)) - # Get Waiting for Approval status - kitsu_status = gazu.task.get_task_status_by_short_name( - self.waiting_for_approval_status - ) - if not kitsu_status: - self.log.info( - "Cannot find {} status. The status will not be " - "changed!".format(self.waiting_for_approval_status) + # Get note status, by default uses the task status for the note + # if it is not specified in the configuration + note_status = context.data["kitsu_task"]["task_status_id"] + if self.set_status_note: + kitsu_status = gazu.task.get_task_status_by_short_name( + self.note_status_shortname ) - kitsu_status = context.data["kitsu_task"].get("task_status") - self.log.debug("Kitsu status: {}".format(kitsu_status)) + if not kitsu_status: + self.log.info( + "Cannot find {} status. The status will not be " + "changed!".format(self.note_status_shortname) + ) + else: + note_status = kitsu_status + self.log.info("Note Kitsu status: {}".format(note_status)) # Add comment to kitsu task + self.log.debug("Add new note in taks id {}".format( + context.data["kitsu_task"]['id'])) kitsu_comment = gazu.task.add_comment( - context.data["kitsu_task"], kitsu_status, comment=publish_comment + context.data["kitsu_task"], + note_status, + comment=publish_comment ) context.data["kitsu_comment"] = kitsu_comment diff --git a/openpype/settings/defaults/project_settings/kitsu.json b/openpype/settings/defaults/project_settings/kitsu.json index 2f1566d89a..ba02d8d259 100644 --- a/openpype/settings/defaults/project_settings/kitsu.json +++ b/openpype/settings/defaults/project_settings/kitsu.json @@ -10,7 +10,8 @@ }, "publish": { "IntegrateKitsuNote": { - "waiting_for_approval_status": "wfa" + "set_status_note": false, + "note_status_shortname": "wfa" } } } \ No newline at end of file diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_kitsu.json b/openpype/settings/entities/schemas/projects_schema/schema_project_kitsu.json index cffd7ff578..014a1b7886 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_kitsu.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_kitsu.json @@ -60,10 +60,15 @@ "key": "IntegrateKitsuNote", "label": "Integrate Kitsu Note", "children": [ + { + "type": "boolean", + "key": "set_status_note", + "label": "Set status on note" + }, { "type": "text", - "key": "waiting_for_approval_status", - "label": "Waiting for Aproval Status:" + "key": "note_status_shortname", + "label": "Note shortname" } ] } From 694c3654764034e1a3f84b4e082b58c0899c8c56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Mon, 16 May 2022 18:26:45 +0200 Subject: [PATCH 405/583] list to sets and var name change --- openpype/hosts/maya/plugins/publish/collect_look.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/collect_look.py b/openpype/hosts/maya/plugins/publish/collect_look.py index 70265a160f..9697d0884f 100644 --- a/openpype/hosts/maya/plugins/publish/collect_look.py +++ b/openpype/hosts/maya/plugins/publish/collect_look.py @@ -60,6 +60,7 @@ def get_look_attrs(node): def node_uses_image_sequence(node): + # type: (str) -> bool """Return whether file node uses an image sequence or single image. Determine if a node uses an image sequence or just a single image, @@ -80,11 +81,11 @@ def node_uses_image_sequence(node): patterns = ["", "", "", "u_v", ""] try: - extension = cmds.getAttr('%s.useFrameExtension' % node) + use_frame_extension = cmds.getAttr('%s.useFrameExtension' % node) except ValueError: - extension = None + use_frame_extension = False - return (extension or + return (use_frame_extension or any(pattern in node_path for pattern in patterns)) @@ -170,7 +171,7 @@ def get_file_node_path(node): if any(pattern in lower for pattern in patterns): return texture_pattern - if cmds.nodeType(node) in ['aiImage', 'PxrTexture']: + if cmds.nodeType(node) in {'aiImage', 'PxrTexture'}: return cmds.getAttr('{0}.filename'.format(node)) if cmds.nodeType(node) == 'RedshiftNormalMap': return cmds.getAttr('{}.tex0'.format(node)) @@ -520,8 +521,8 @@ class CollectLook(pyblish.api.InstancePlugin): dict """ self.log.debug("processing: {}".format(node)) - if cmds.nodeType(node) not in [ - "file", "aiImage", "RedshiftNormalMap", "PxrTexture"]: + if cmds.nodeType(node) not in { + "file", "aiImage", "RedshiftNormalMap", "PxrTexture"}: self.log.error( "Unsupported file node: {}".format(cmds.nodeType(node))) raise AssertionError("Unsupported file node") From 196182f5c5ffdc5bb02d86dedf46fb27b704f64e Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 16 May 2022 20:57:51 +0200 Subject: [PATCH 406/583] general: expose lib editorial function to lib init --- openpype/lib/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openpype/lib/__init__.py b/openpype/lib/__init__.py index 3c1d71ecd5..8d4e733b7d 100644 --- a/openpype/lib/__init__.py +++ b/openpype/lib/__init__.py @@ -203,6 +203,7 @@ from .editorial import ( is_overlapping_otio_ranges, otio_range_to_frame_range, otio_range_with_handles, + get_media_range_with_retimes, convert_to_padded_path, trim_media_range, range_from_frames, @@ -382,6 +383,7 @@ __all__ = [ "otio_range_with_handles", "convert_to_padded_path", "otio_range_to_frame_range", + "get_media_range_with_retimes", "trim_media_range", "range_from_frames", "frames_to_secons", From 6ee42b1d19b890ee583f4a10f8efab81ebec043e Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 16 May 2022 20:58:20 +0200 Subject: [PATCH 407/583] flame: make head and tail with retimed value --- .../publish/collect_timeline_instances.py | 34 +++++++++---------- 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py b/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py index 4bca0dcf93..012cb110ec 100644 --- a/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py +++ b/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py @@ -1,8 +1,8 @@ import re import pyblish -import openpype import openpype.hosts.flame.api as opfapi from openpype.hosts.flame.otio import flame_export +import openpype.lib as oplib # # developer reload modules from pprint import pformat @@ -68,7 +68,12 @@ class CollectTimelineInstances(pyblish.api.ContextPlugin): first_frame = opfapi.get_frame_from_filename(file_path) or 0 - head, tail = self._get_head_tail(clip_data, otio_data["otioClip"]) + head, tail = self._get_head_tail( + clip_data, + otio_data["otioClip"], + marker_data["handleStart"], + marker_data["handleEnd"] + ) # solve handles length marker_data["handleStart"] = min( @@ -251,7 +256,7 @@ class CollectTimelineInstances(pyblish.api.ContextPlugin): return split_comments - def _get_head_tail(self, clip_data, otio_clip): + def _get_head_tail(self, clip_data, otio_clip, handle_start, handle_end): # calculate head and tail with forward compatibility head = clip_data.get("segment_head") tail = clip_data.get("segment_tail") @@ -260,21 +265,14 @@ class CollectTimelineInstances(pyblish.api.ContextPlugin): # HACK: it is here to serve for versions bellow 2021.1 if not any([head, tail]): - otio_source_range = otio_clip.source_range - otio_avalable_range = otio_clip.available_range() - range_convert = openpype.lib.otio_range_to_frame_range - src_start, src_end = range_convert(otio_source_range) - av_start, av_end = range_convert(otio_avalable_range) - av_range = av_end - av_start - av_tail = av_range - src_end + retimed_attributes = oplib.get_media_range_with_retimes( + otio_clip, handle_start, handle_end) + self.log.debug( + ">> retimed_attributes: {}".format(retimed_attributes)) - self.log.debug("__ src_start: `{}`".format(src_start)) - self.log.debug("__ src_end: `{}`".format(src_end)) - self.log.debug("__ av_range: `{}`".format(av_range)) - self.log.debug("__ av_tail: `{}`".format(av_tail)) - - head = src_start - tail = av_tail + # retimed head and tail + head = int(retimed_attributes["handleStart"]) + tail = int(retimed_attributes["handleEnd"]) return head, tail @@ -366,7 +364,7 @@ class CollectTimelineInstances(pyblish.api.ContextPlugin): continue if otio_clip.name not in segment.name.get_value(): continue - if openpype.lib.is_overlapping_otio_ranges( + if oplib.is_overlapping_otio_ranges( parent_range, timeline_range, strict=True): # add pypedata marker to otio_clip metadata From 257c58988181a9dcd39bf85e143a08eb686115a7 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 16 May 2022 20:58:44 +0200 Subject: [PATCH 408/583] flame: change editorial function to lib --- openpype/plugins/publish/collect_otio_subset_resources.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/openpype/plugins/publish/collect_otio_subset_resources.py b/openpype/plugins/publish/collect_otio_subset_resources.py index 7c11462ef0..53d327a51d 100644 --- a/openpype/plugins/publish/collect_otio_subset_resources.py +++ b/openpype/plugins/publish/collect_otio_subset_resources.py @@ -10,8 +10,7 @@ import os import clique import opentimelineio as otio import pyblish.api -import openpype -from openpype.lib import editorial +import openpype.lib as oplib class CollectOtioSubsetResources(pyblish.api.InstancePlugin): @@ -43,7 +42,7 @@ class CollectOtioSubsetResources(pyblish.api.InstancePlugin): available_duration = otio_avalable_range.duration.value # get available range trimmed with processed retimes - retimed_attributes = editorial.get_media_range_with_retimes( + retimed_attributes = oplib.get_media_range_with_retimes( otio_clip, handle_start, handle_end) self.log.debug( ">> retimed_attributes: {}".format(retimed_attributes)) @@ -145,7 +144,7 @@ class CollectOtioSubsetResources(pyblish.api.InstancePlugin): # in case it is file sequence but not new OTIO schema # `ImageSequenceReference` path = media_ref.target_url - collection_data = openpype.lib.make_sequence_collection( + collection_data = oplib.make_sequence_collection( path, trimmed_media_range_h, metadata) self.staging_dir, collection = collection_data From 056b92599ec28afb7de04d5c53021f716f056dfe Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 16 May 2022 21:09:00 +0200 Subject: [PATCH 409/583] global: fixing false editorial namespace --- openpype/plugins/publish/collect_otio_subset_resources.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/plugins/publish/collect_otio_subset_resources.py b/openpype/plugins/publish/collect_otio_subset_resources.py index 53d327a51d..78e2a6428c 100644 --- a/openpype/plugins/publish/collect_otio_subset_resources.py +++ b/openpype/plugins/publish/collect_otio_subset_resources.py @@ -64,7 +64,7 @@ class CollectOtioSubsetResources(pyblish.api.InstancePlugin): a_frame_end_h = media_out + handle_end # create trimmed otio time range - trimmed_media_range_h = editorial.range_from_frames( + trimmed_media_range_h = oplib.range_from_frames( a_frame_start_h, (a_frame_end_h - a_frame_start_h + 1), media_fps ) From 5059c0cedff88d33f7c0044f0020fa03d1cdca48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Hector?= Date: Tue, 17 May 2022 11:43:28 +0200 Subject: [PATCH 410/583] Update openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: FΓ©lix David --- .../modules/kitsu/plugins/publish/integrate_kitsu_note.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py index ae559e660e..78c5170856 100644 --- a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py +++ b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py @@ -29,14 +29,14 @@ class IntegrateKitsuNote(pyblish.api.ContextPlugin): kitsu_status = gazu.task.get_task_status_by_short_name( self.note_status_shortname ) - if not kitsu_status: + if kitsu_status: + note_status = kitsu_status + self.log.info("Note Kitsu status: {}".format(note_status)) + else: self.log.info( "Cannot find {} status. The status will not be " "changed!".format(self.note_status_shortname) ) - else: - note_status = kitsu_status - self.log.info("Note Kitsu status: {}".format(note_status)) # Add comment to kitsu task self.log.debug("Add new note in taks id {}".format( From 667cff319d70ea699ded5d009872588f5d5ffee2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20David?= Date: Tue, 17 May 2022 11:49:54 +0200 Subject: [PATCH 411/583] black --- .../kitsu/plugins/publish/integrate_kitsu_note.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py index 78c5170856..3cd1f450ca 100644 --- a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py +++ b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py @@ -22,7 +22,7 @@ class IntegrateKitsuNote(pyblish.api.ContextPlugin): self.log.debug("Comment is `{}`".format(publish_comment)) - # Get note status, by default uses the task status for the note + # Get note status, by default uses the task status for the note # if it is not specified in the configuration note_status = context.data["kitsu_task"]["task_status_id"] if self.set_status_note: @@ -39,12 +39,13 @@ class IntegrateKitsuNote(pyblish.api.ContextPlugin): ) # Add comment to kitsu task - self.log.debug("Add new note in taks id {}".format( - context.data["kitsu_task"]['id'])) + self.log.debug( + "Add new note in taks id {}".format( + context.data["kitsu_task"]["id"] + ) + ) kitsu_comment = gazu.task.add_comment( - context.data["kitsu_task"], - note_status, - comment=publish_comment + context.data["kitsu_task"], note_status, comment=publish_comment ) context.data["kitsu_comment"] = kitsu_comment From 6976546505590a59072095f675778c9e8a71fe03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20David?= Date: Tue, 17 May 2022 11:51:58 +0200 Subject: [PATCH 412/583] cleaning --- openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py index 3cd1f450ca..ea98e0b7cc 100644 --- a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py +++ b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py @@ -1,5 +1,4 @@ # -*- coding: utf-8 -*- -from distutils.log import debug import gazu import pyblish.api From 34c6cab56fe5046b8ea657c4f241f7533fe10250 Mon Sep 17 00:00:00 2001 From: Petr Dvorak Date: Tue, 17 May 2022 12:09:31 +0200 Subject: [PATCH 413/583] Avalon repo removed from Jobs workflow --- .github/workflows/prerelease.yml | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index 5acd20007c..8f51f27994 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -62,22 +62,6 @@ jobs: - name: "πŸ–¨οΈ Print changelog to console" if: steps.version_type.outputs.type != 'skip' run: cat CHANGELOG.md - - - name: πŸ’Ύ Commit and Tag - id: git_commit - if: steps.version_type.outputs.type != 'skip' - run: | - git config user.email ${{ secrets.CI_EMAIL }} - git config user.name ${{ secrets.CI_USER }} - cd repos/avalon-core - git checkout main - git pull - cd ../.. - git add . - git commit -m "[Automated] Bump version" - tag_name="CI/${{ steps.version.outputs.next_tag }}" - echo $tag_name - git tag -a $tag_name -m "nightly build" - name: Push to protected main branch uses: CasperWA/push-protected@v2.10.0 From ea00dc0c6a41c21d7744b996c40a4eca84e1dcb8 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 17 May 2022 17:07:33 +0200 Subject: [PATCH 414/583] fix support for plugin location --- openpype/hosts/unreal/__init__.py | 3 ++- openpype/hosts/unreal/hooks/pre_workfile_preparation.py | 8 +++++++- openpype/hosts/unreal/lib.py | 6 +++--- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/unreal/__init__.py b/openpype/hosts/unreal/__init__.py index 533f315df3..bedf5a29f7 100644 --- a/openpype/hosts/unreal/__init__.py +++ b/openpype/hosts/unreal/__init__.py @@ -9,7 +9,8 @@ def add_implementation_envs(env, _app): os.path.dirname(os.path.abspath(openpype.hosts.__file__)), "unreal", "integration" ) - env["OPENPYPE_UNREAL_PLUGIN"] = unreal_plugin_path + if not env.get("OPENPYPE_UNREAL_PLUGIN"): + env["OPENPYPE_UNREAL_PLUGIN"] = unreal_plugin_path # Set default environments if are not set via settings defaults = { diff --git a/openpype/hosts/unreal/hooks/pre_workfile_preparation.py b/openpype/hosts/unreal/hooks/pre_workfile_preparation.py index f07e96551c..fa0562a3a0 100644 --- a/openpype/hosts/unreal/hooks/pre_workfile_preparation.py +++ b/openpype/hosts/unreal/hooks/pre_workfile_preparation.py @@ -25,7 +25,7 @@ class UnrealPrelaunchHook(PreLaunchHook): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.signature = "( {} )".format(self.__class__.__name__) + self.signature = f"( {self.__class__.__name__} )" def _get_work_filename(self): # Use last workfile if was found @@ -99,6 +99,7 @@ class UnrealPrelaunchHook(PreLaunchHook): f"character ({unreal_project_name}). Appending 'P'" )) unreal_project_name = f"P{unreal_project_name}" + unreal_project_filename = f'{unreal_project_name}.uproject' project_path = Path(os.path.join(workdir, unreal_project_name)) @@ -138,6 +139,11 @@ class UnrealPrelaunchHook(PreLaunchHook): )) # Set "OPENPYPE_UNREAL_PLUGIN" to current process environment for # execution of `create_unreal_project` + if self.launch_context.env.get("OPENPYPE_UNREAL_PLUGIN"): + self.log.info(( + f"{self.signature} using OpenPype plugin from " + f"{self.launch_context.env.get('OPENPYPE_UNREAL_PLUGIN')}" + )) env_key = "OPENPYPE_UNREAL_PLUGIN" if self.launch_context.env.get(env_key): os.environ[env_key] = self.launch_context.env[env_key] diff --git a/openpype/hosts/unreal/lib.py b/openpype/hosts/unreal/lib.py index 906002b38f..fdf3acb37b 100644 --- a/openpype/hosts/unreal/lib.py +++ b/openpype/hosts/unreal/lib.py @@ -280,7 +280,7 @@ def create_unreal_project(project_name: str, python_path = None if platform.system().lower() == "windows": python_path = engine_path / ("Engine/Binaries/ThirdParty/" - "Python3/Win64/pythonw.exe") + "Python3/Win64/python.exe") if platform.system().lower() == "linux": python_path = engine_path / ("Engine/Binaries/ThirdParty/" @@ -294,8 +294,8 @@ def create_unreal_project(project_name: str, raise NotImplementedError("Unsupported platform") if not python_path.exists(): raise RuntimeError(f"Unreal Python not found at {python_path}") - subprocess.run( - [python_path.as_posix(), "-m", "pip", "install", "pyside2"]) + out = subprocess.check_call( + [python_path.as_posix(), "-m", "pip", "install", "--user", "pyside2"]) if dev_mode or preset["dev_mode"]: _prepare_cpp_project(project_file, engine_path) From e9a855af72ac958b23753e0f71f0fb67ce24871f Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 17 May 2022 18:04:16 +0200 Subject: [PATCH 415/583] added ability to know if project is enabled without prequerying all other projects --- openpype/modules/sync_server/sync_server_module.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/openpype/modules/sync_server/sync_server_module.py b/openpype/modules/sync_server/sync_server_module.py index 45ff8bc4d1..5a1d8467ec 100644 --- a/openpype/modules/sync_server/sync_server_module.py +++ b/openpype/modules/sync_server/sync_server_module.py @@ -921,12 +921,18 @@ class SyncServerModule(OpenPypeModule, ITrayModule): if self.enabled: for project in self.connection.projects(projection={"name": 1}): project_name = project["name"] - project_settings = self.get_sync_project_setting(project_name) - if project_settings and project_settings.get("enabled"): + if self.is_project_enabled(project_name): enabled_projects.append(project_name) return enabled_projects + def is_project_enabled(self, project_name): + if self.enabled: + project_settings = self.get_sync_project_setting(project_name) + if project_settings and project_settings.get("enabled"): + return True + return False + def handle_alternate_site(self, collection, representation, processed_site, file_id, synced_file_id): """ From 96247b4ff636a10cf12fb71221fa3dcb6a4d465e Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 17 May 2022 18:06:02 +0200 Subject: [PATCH 416/583] cache sync server data to avoid refreshing of modules manager all the time --- openpype/tools/loader/model.py | 66 +++++++++++++++++++++++++--------- 1 file changed, 50 insertions(+), 16 deletions(-) diff --git a/openpype/tools/loader/model.py b/openpype/tools/loader/model.py index 8cb8f30013..6f39c428be 100644 --- a/openpype/tools/loader/model.py +++ b/openpype/tools/loader/model.py @@ -1,6 +1,7 @@ import copy import re import math +import time from uuid import uuid4 from Qt import QtCore, QtGui @@ -38,6 +39,14 @@ def is_filtering_recursible(): class BaseRepresentationModel(object): """Methods for SyncServer useful in multiple models""" + # Cheap & hackish way how to avoid refreshing of whole sync server module + # on each selection change + _last_project = None + _modules_manager = None + _last_project_cache = 0 + _last_manager_cache = 0 + _max_project_cache_time = 30 + _max_manager_cache_time = 60 def reset_sync_server(self, project_name=None): """Sets/Resets sync server vars after every change (refresh.)""" @@ -47,28 +56,53 @@ class BaseRepresentationModel(object): remote_site = remote_provider = None if not project_name: - project_name = self.dbcon.Session["AVALON_PROJECT"] + project_name = self.dbcon.Session.get("AVALON_PROJECT") else: self.dbcon.Session["AVALON_PROJECT"] = project_name - if project_name: - manager = ModulesManager() - sync_server = manager.modules_by_name["sync_server"] + if not project_name: + self.repre_icons = repre_icons + self.sync_server = sync_server + self.active_site = active_site + self.active_provider = active_provider + self.remote_site = remote_site + self.remote_provider = remote_provider + return - if project_name in sync_server.get_enabled_projects(): - active_site = sync_server.get_active_site(project_name) - active_provider = sync_server.get_provider_for_site( - project_name, active_site) - if active_site == 'studio': # for studio use explicit icon - active_provider = 'studio' + now_time = time.time() + project_cache_diff = now_time - self._last_project_cache + if project_cache_diff > self._max_project_cache_time: + self._last_project = None - remote_site = sync_server.get_remote_site(project_name) - remote_provider = sync_server.get_provider_for_site( - project_name, remote_site) - if remote_site == 'studio': # for studio use explicit icon - remote_provider = 'studio' + if project_name == self._last_project: + return - repre_icons = lib.get_repre_icons() + self._last_project = project_name + self._last_project_cache = now_time + + manager_cache_diff = now_time - self._last_manager_cache + if manager_cache_diff > self._max_manager_cache_time: + self._modules_manager = None + + if self._modules_manager is None: + self._modules_manager = ModulesManager() + self._last_manager_cache = now_time + + sync_server = self._modules_manager.modules_by_name["sync_server"] + if sync_server.is_project_enabled(project_name): + active_site = sync_server.get_active_site(project_name) + active_provider = sync_server.get_provider_for_site( + project_name, active_site) + if active_site == 'studio': # for studio use explicit icon + active_provider = 'studio' + + remote_site = sync_server.get_remote_site(project_name) + remote_provider = sync_server.get_provider_for_site( + project_name, remote_site) + if remote_site == 'studio': # for studio use explicit icon + remote_provider = 'studio' + + repre_icons = lib.get_repre_icons() self.repre_icons = repre_icons self.sync_server = sync_server From 625b74d5215a3d1cf2e8b256c35c1277d55515fb Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 18 May 2022 09:51:50 +0200 Subject: [PATCH 417/583] Fix - skip collector in PS when automatic testing --- .../hosts/photoshop/plugins/publish/collect_batch_data.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/openpype/hosts/photoshop/plugins/publish/collect_batch_data.py b/openpype/hosts/photoshop/plugins/publish/collect_batch_data.py index 448493d370..736e43de53 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_batch_data.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_batch_data.py @@ -39,6 +39,10 @@ class CollectBatchData(pyblish.api.ContextPlugin): def process(self, context): self.log.info("CollectBatchData") batch_dir = os.environ.get("OPENPYPE_PUBLISH_DATA") + if (os.environ.get("IS_TEST") and + (not batch_dir or not os.path.exists(batch_dir))): + self.log.debug("Automatic testing, no batch data, skipping") + return assert batch_dir, ( "Missing `OPENPYPE_PUBLISH_DATA`") From b2e1c4badec2c833e8d2a75f6a5c5b114faf88cd Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 18 May 2022 09:53:59 +0200 Subject: [PATCH 418/583] Fix - skip collector in PS when automatic testing --- openpype/hosts/photoshop/plugins/publish/collect_batch_data.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/openpype/hosts/photoshop/plugins/publish/collect_batch_data.py b/openpype/hosts/photoshop/plugins/publish/collect_batch_data.py index 736e43de53..2881ef0ea6 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_batch_data.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_batch_data.py @@ -39,8 +39,7 @@ class CollectBatchData(pyblish.api.ContextPlugin): def process(self, context): self.log.info("CollectBatchData") batch_dir = os.environ.get("OPENPYPE_PUBLISH_DATA") - if (os.environ.get("IS_TEST") and - (not batch_dir or not os.path.exists(batch_dir))): + if os.environ.get("IS_TEST"): self.log.debug("Automatic testing, no batch data, skipping") return From 42fd8d637deb7290defb7d98aee0089d0ac4ba07 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 18 May 2022 10:41:58 +0200 Subject: [PATCH 419/583] always create new thumbnail representation in standalone publisher no matter what extension source has --- .../plugins/publish/extract_thumbnail.py | 99 ++++++++----------- 1 file changed, 41 insertions(+), 58 deletions(-) diff --git a/openpype/hosts/standalonepublisher/plugins/publish/extract_thumbnail.py b/openpype/hosts/standalonepublisher/plugins/publish/extract_thumbnail.py index 941a76b05b..3ee2f70809 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/extract_thumbnail.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/extract_thumbnail.py @@ -5,6 +5,7 @@ import openpype.api from openpype.lib import ( get_ffmpeg_tool_path, get_ffprobe_streams, + path_to_subprocess_arg, ) @@ -37,82 +38,69 @@ class ExtractThumbnailSP(pyblish.api.InstancePlugin): if not thumbnail_repre: return + thumbnail_repre.pop("thumbnail") files = thumbnail_repre.get("files") if not files: return if isinstance(files, list): - files_len = len(files) - file = str(files[0]) + first_filename = str(files[0]) else: - files_len = 1 - file = files + first_filename = files staging_dir = None - is_jpeg = False - if file.endswith(".jpeg") or file.endswith(".jpg"): - is_jpeg = True - if is_jpeg and files_len == 1: - # skip if already is single jpeg file - return + # Convert to jpeg if not yet + full_input_path = os.path.join( + thumbnail_repre["stagingDir"], first_filename + ) + self.log.info("input {}".format(full_input_path)) + with tempfile.NamedTemporaryFile(suffix=".jpg") as tmp: + full_thumbnail_path = tmp.name - elif is_jpeg: - # use first frame as thumbnail if is sequence of jpegs - full_thumbnail_path = os.path.join( - thumbnail_repre["stagingDir"], file - ) - self.log.info( - "For thumbnail is used file: {}".format(full_thumbnail_path) - ) + self.log.info("output {}".format(full_thumbnail_path)) - else: - # Convert to jpeg if not yet - full_input_path = os.path.join(thumbnail_repre["stagingDir"], file) - self.log.info("input {}".format(full_input_path)) + instance.context.data["cleanupFullPaths"].append(full_thumbnail_path) - full_thumbnail_path = tempfile.mkstemp(suffix=".jpg")[1] - self.log.info("output {}".format(full_thumbnail_path)) + ffmpeg_path = get_ffmpeg_tool_path("ffmpeg") - ffmpeg_path = get_ffmpeg_tool_path("ffmpeg") + ffmpeg_args = self.ffmpeg_args or {} - ffmpeg_args = self.ffmpeg_args or {} + jpeg_items = [ + path_to_subprocess_arg(ffmpeg_path), + # override file if already exists + "-y" + ] - jpeg_items = [ - "\"{}\"".format(ffmpeg_path), - # override file if already exists - "-y" - ] - - # add input filters from peresets - jpeg_items.extend(ffmpeg_args.get("input") or []) - # input file - jpeg_items.append("-i \"{}\"".format(full_input_path)) + # add input filters from peresets + jpeg_items.extend(ffmpeg_args.get("input") or []) + # input file + jpeg_items.extend([ + "-i", path_to_subprocess_arg(full_input_path), # extract only single file - jpeg_items.append("-frames:v 1") + "-frames:v", "1", # Add black background for transparent images - jpeg_items.append(( - "-filter_complex" - " \"color=black,format=rgb24[c]" + "-filter_complex", ( + "\"color=black,format=rgb24[c]" ";[c][0]scale2ref[c][i]" ";[c][i]overlay=format=auto:shortest=1,setsar=1\"" - )) + ), + ]) - jpeg_items.extend(ffmpeg_args.get("output") or []) + jpeg_items.extend(ffmpeg_args.get("output") or []) - # output file - jpeg_items.append("\"{}\"".format(full_thumbnail_path)) + # output file + jpeg_items.append(path_to_subprocess_arg(full_thumbnail_path)) - subprocess_jpeg = " ".join(jpeg_items) + subprocess_jpeg = " ".join(jpeg_items) - # run subprocess - self.log.debug("Executing: {}".format(subprocess_jpeg)) - openpype.api.run_subprocess( - subprocess_jpeg, shell=True, logger=self.log - ) + # run subprocess + self.log.debug("Executing: {}".format(subprocess_jpeg)) + openpype.api.run_subprocess( + subprocess_jpeg, shell=True, logger=self.log + ) # remove thumbnail key from origin repre - thumbnail_repre.pop("thumbnail") streams = get_ffprobe_streams(full_thumbnail_path) width = height = None for stream in streams: @@ -121,8 +109,7 @@ class ExtractThumbnailSP(pyblish.api.InstancePlugin): height = stream["height"] break - filename = os.path.basename(full_thumbnail_path) - staging_dir = staging_dir or os.path.dirname(full_thumbnail_path) + staging_dir, filename = os.path.split(full_thumbnail_path) # create new thumbnail representation representation = { @@ -130,15 +117,11 @@ class ExtractThumbnailSP(pyblish.api.InstancePlugin): 'ext': 'jpg', 'files': filename, "stagingDir": staging_dir, - "tags": ["thumbnail"], + "tags": ["thumbnail", "delete"], } if width and height: representation["width"] = width representation["height"] = height - # # add Delete tag when temp file was rendered - if not is_jpeg: - representation["tags"].append("delete") - self.log.info(f"New representation {representation}") instance.data["representations"].append(representation) From 20f2fd5904adeb27098e7bcc413494d90e9304c3 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 18 May 2022 10:42:06 +0200 Subject: [PATCH 420/583] remove temp json file after publishing --- openpype/tools/standalonepublish/widgets/widget_components.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/tools/standalonepublish/widgets/widget_components.py b/openpype/tools/standalonepublish/widgets/widget_components.py index fbafc7142a..b3280089c3 100644 --- a/openpype/tools/standalonepublish/widgets/widget_components.py +++ b/openpype/tools/standalonepublish/widgets/widget_components.py @@ -202,6 +202,7 @@ def cli_publish(data, publish_paths, gui=True): if os.path.exists(json_data_path): with open(json_data_path, "r") as f: result = json.load(f) + os.remove(json_data_path) log.info(f"Publish result: {result}") From 99a273c67d950815c4fb40b15506a04f1222159d Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 18 May 2022 12:35:01 +0200 Subject: [PATCH 421/583] OP-3154 - reworked logic Previous one was limiting attribute removal only on shapes, this should remove attribute in more places. --- openpype/hosts/maya/plugins/load/actions.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/openpype/hosts/maya/plugins/load/actions.py b/openpype/hosts/maya/plugins/load/actions.py index 14518ead5d..253dae1e43 100644 --- a/openpype/hosts/maya/plugins/load/actions.py +++ b/openpype/hosts/maya/plugins/load/actions.py @@ -114,8 +114,6 @@ class ImportMayaLoader(load.LoaderPlugin): if choice is False: return - clean_import = data.get("clean_import", False) - asset = context['asset'] namespace = namespace or unique_namespace( @@ -133,13 +131,14 @@ class ImportMayaLoader(load.LoaderPlugin): groupReference=True, groupName="{}:{}".format(namespace, name)) - if clean_import: - shapes = cmds.ls(nodes, shapes=True, long=True) - for shape in shapes: - meshes = cmds.ls('{}.cbId'.format(shape)) - for mesh in meshes: - print("Removing ... " + (mesh)) - cmds.deleteAttr(mesh) + if data.get("clean_import", False): + remove_attributes = ["cbId"] + for node in nodes: + for attr in remove_attributes: + if cmds.attributeQuery(attr, node=node, exists=True): + full_attr = "{}.{}".format(node, attr) + print("Removing {}".format(full_attr)) + cmds.deleteAttr(full_attr) # We do not containerize imported content, it remains unmanaged return From 9fc70adfffeb0569f8c0f32f52eb80c722f923b7 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 18 May 2022 13:07:33 +0200 Subject: [PATCH 422/583] flame: abs number only if not 0 --- .../plugins/publish/collect_timeline_instances.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py b/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py index 012cb110ec..0aca7c38d5 100644 --- a/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py +++ b/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py @@ -75,11 +75,17 @@ class CollectTimelineInstances(pyblish.api.ContextPlugin): marker_data["handleEnd"] ) + # make sure value is absolute + if head != 0: + head = abs(head) + if tail != 0: + tail = abs(tail) + # solve handles length marker_data["handleStart"] = min( - marker_data["handleStart"], abs(head)) + marker_data["handleStart"], head) marker_data["handleEnd"] = min( - marker_data["handleEnd"], abs(tail)) + marker_data["handleEnd"], tail) workfile_start = self._set_workfile_start(marker_data) From 43f7ad57ce3b3d9c289002f311855b031f1f7f05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Wed, 18 May 2022 15:08:09 +0200 Subject: [PATCH 423/583] support for other renderman nodes --- .../maya/plugins/publish/collect_look.py | 39 ++++++++++++++++--- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/collect_look.py b/openpype/hosts/maya/plugins/publish/collect_look.py index 9697d0884f..692ecdcde1 100644 --- a/openpype/hosts/maya/plugins/publish/collect_look.py +++ b/openpype/hosts/maya/plugins/publish/collect_look.py @@ -25,6 +25,32 @@ RENDERER_NODE_TYPES = [ SHAPE_ATTRS = set(SHAPE_ATTRS) +DEFAULT_FILE_NODES = frozenset( + ["file"] +) + +ARNOLD_FILE_NODES = frozenset( + ["aiImage"] +) + +REDSHIFT_FILE_NODES = frozenset( + ["RedshiftNormalMap"] +) + +RENDERMAN_FILE_NODES = frozenset( + [ + "PxrBump", + "PxrNormalMap", + # PxrMultiTexture (need to handle multiple filename0 attrs) + "PxrPtexture", + "PxrTexture", + ] +) + +NODES_WITH_FILENAME = frozenset().union( + DEFAULT_FILE_NODES, ARNOLD_FILE_NODES, RENDERMAN_FILE_NODES +) + def get_look_attrs(node): """Returns attributes of a node that are important for the look. @@ -171,7 +197,7 @@ def get_file_node_path(node): if any(pattern in lower for pattern in patterns): return texture_pattern - if cmds.nodeType(node) in {'aiImage', 'PxrTexture'}: + if cmds.nodeType(node) in NODES_WITH_FILENAME: return cmds.getAttr('{0}.filename'.format(node)) if cmds.nodeType(node) == 'RedshiftNormalMap': return cmds.getAttr('{}.tex0'.format(node)) @@ -383,10 +409,13 @@ class CollectLook(pyblish.api.InstancePlugin): or [] ) - files = cmds.ls(history, type="file", long=True) - files.extend(cmds.ls(history, type="aiImage", long=True)) - files.extend(cmds.ls(history, type="PxrTexture", long=True)) - files.extend(cmds.ls(history, type="RedshiftNormalMap", long=True)) + all_supported_nodes = set().union( + DEFAULT_FILE_NODES, ARNOLD_FILE_NODES, REDSHIFT_FILE_NODES, + RENDERMAN_FILE_NODES + ) + files = [] + for node_type in all_supported_nodes: + files.extend(cmds.ls(history, type=node_type, long=True)) self.log.info("Collected file nodes:\n{}".format(files)) # Collect textures if any file nodes are found From 5ceba8cad4ee59c662fbf172041821d6f4d5fa62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Wed, 18 May 2022 17:52:59 +0200 Subject: [PATCH 424/583] fix supported nodes --- .../maya/plugins/publish/collect_look.py | 27 ++++++++++--------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/collect_look.py b/openpype/hosts/maya/plugins/publish/collect_look.py index 692ecdcde1..123e2637cb 100644 --- a/openpype/hosts/maya/plugins/publish/collect_look.py +++ b/openpype/hosts/maya/plugins/publish/collect_look.py @@ -22,21 +22,16 @@ RENDERER_NODE_TYPES = [ # redshift "RedshiftMeshParameters" ] - SHAPE_ATTRS = set(SHAPE_ATTRS) - DEFAULT_FILE_NODES = frozenset( ["file"] ) - ARNOLD_FILE_NODES = frozenset( ["aiImage"] ) - REDSHIFT_FILE_NODES = frozenset( ["RedshiftNormalMap"] ) - RENDERMAN_FILE_NODES = frozenset( [ "PxrBump", @@ -46,9 +41,14 @@ RENDERMAN_FILE_NODES = frozenset( "PxrTexture", ] ) - +NODES_WITH_FILE = frozenset().union( + DEFAULT_FILE_NODES +) NODES_WITH_FILENAME = frozenset().union( - DEFAULT_FILE_NODES, ARNOLD_FILE_NODES, RENDERMAN_FILE_NODES + ARNOLD_FILE_NODES, RENDERMAN_FILE_NODES +) +NODES_WITH_TEX = frozenset().union( + REDSHIFT_FILE_NODES ) @@ -550,20 +550,23 @@ class CollectLook(pyblish.api.InstancePlugin): dict """ self.log.debug("processing: {}".format(node)) - if cmds.nodeType(node) not in { - "file", "aiImage", "RedshiftNormalMap", "PxrTexture"}: + all_supported_nodes = set().union( + DEFAULT_FILE_NODES, ARNOLD_FILE_NODES, REDSHIFT_FILE_NODES, + RENDERMAN_FILE_NODES + ) + if cmds.nodeType(node) not in all_supported_nodes: self.log.error( "Unsupported file node: {}".format(cmds.nodeType(node))) raise AssertionError("Unsupported file node") self.log.debug(" - got {}".format(cmds.nodeType(node))) - if cmds.nodeType(node) == 'file': + if cmds.nodeType(node) in NODES_WITH_FILE: attribute = "{}.fileTextureName".format(node) computed_attribute = "{}.computedFileTextureNamePattern".format(node) - elif cmds.nodeType(node) in ['aiImage', 'PxrTexture']: + elif cmds.nodeType(node) in NODES_WITH_FILENAME: attribute = "{}.filename".format(node) computed_attribute = attribute - elif cmds.nodeType(node) == 'RedshiftNormalMap': + elif cmds.nodeType(node) in NODES_WITH_TEX: attribute = "{}.tex0".format(node) computed_attribute = attribute From b42382ea184a25aae6024dd9ee0788bba1b49bba Mon Sep 17 00:00:00 2001 From: Milan Kolar Date: Thu, 19 May 2022 11:55:59 +0200 Subject: [PATCH 425/583] commit all changes in CI --- .github/workflows/prerelease.yml | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index 8f51f27994..141dc0696d 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -62,7 +62,19 @@ jobs: - name: "πŸ–¨οΈ Print changelog to console" if: steps.version_type.outputs.type != 'skip' run: cat CHANGELOG.md - + + - name: πŸ’Ύ Commit and Tag + id: git_commit + if: steps.version_type.outputs.type != 'skip' + run: | + git config user.email ${{ secrets.CI_EMAIL }} + git config user.name ${{ secrets.CI_USER }} + git add . + git commit -m "[Automated] Bump version" + tag_name="CI/${{ steps.version.outputs.next_tag }}" + echo $tag_name + git tag -a $tag_name -m "nightly build" + - name: Push to protected main branch uses: CasperWA/push-protected@v2.10.0 with: From f7b7237377d85abdc5856b2a9b0c0aa35e05cecc Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 19 May 2022 11:54:11 +0200 Subject: [PATCH 426/583] fix order of arguments in push hierarchical attributes action --- .../event_handlers_server/action_push_frame_values_to_task.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/ftrack/event_handlers_server/action_push_frame_values_to_task.py b/openpype/modules/ftrack/event_handlers_server/action_push_frame_values_to_task.py index 868bbb8463..1209375f82 100644 --- a/openpype/modules/ftrack/event_handlers_server/action_push_frame_values_to_task.py +++ b/openpype/modules/ftrack/event_handlers_server/action_push_frame_values_to_task.py @@ -356,7 +356,7 @@ class PushHierValuesToNonHier(ServerAction): values_per_entity_id[entity_id][key] = None values = query_custom_attributes( - session, all_ids_with_parents, hier_attr_ids, True + session, hier_attr_ids, all_ids_with_parents, True ) for item in values: entity_id = item["entity_id"] From 3a73e7e7d742ac2a5482238da054a6d6a41c3e9f Mon Sep 17 00:00:00 2001 From: Milan Kolar Date: Thu, 19 May 2022 12:15:33 +0200 Subject: [PATCH 427/583] checkout main in CI --- .github/workflows/prerelease.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index 141dc0696d..bf39f8f956 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -69,6 +69,8 @@ jobs: run: | git config user.email ${{ secrets.CI_EMAIL }} git config user.name ${{ secrets.CI_USER }} + git checkout main + git pull git add . git commit -m "[Automated] Bump version" tag_name="CI/${{ steps.version.outputs.next_tag }}" From 7d0c58c5e2355fd3fb57252e3942a8b0b016ff09 Mon Sep 17 00:00:00 2001 From: OpenPype Date: Thu, 19 May 2022 10:24:57 +0000 Subject: [PATCH 428/583] [Automated] Bump version --- CHANGELOG.md | 256 +++++++++++++++++++++----------------------- openpype/version.py | 2 +- pyproject.toml | 2 +- 3 files changed, 125 insertions(+), 135 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e2ff9f919c..6546ab6139 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,166 +1,156 @@ # Changelog -## [3.10.0-nightly.2](https://github.com/pypeclub/OpenPype/tree/HEAD) +## [3.10.0-nightly.3](https://github.com/pypeclub/OpenPype/tree/HEAD) -[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.9.4...HEAD) +[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.9.8...HEAD) -### πŸ“– Documentation +**πŸ†• New features** -- Docs: add all-contributors config and initial list [\#3094](https://github.com/pypeclub/OpenPype/pull/3094) -- Nuke docs with videos [\#3052](https://github.com/pypeclub/OpenPype/pull/3052) +- General: OpenPype modules publish plugins are registered in host [\#3180](https://github.com/pypeclub/OpenPype/pull/3180) +- General: Creator plugins from addons can be registered [\#3179](https://github.com/pypeclub/OpenPype/pull/3179) +- Ftrack: Single image reviewable [\#3157](https://github.com/pypeclub/OpenPype/pull/3157) +- Nuke: Expose write attributes to settings [\#3123](https://github.com/pypeclub/OpenPype/pull/3123) +- Hiero: Initial frame publish support [\#3106](https://github.com/pypeclub/OpenPype/pull/3106) **πŸš€ Enhancements** -- Standalone publisher: add support for bgeo and vdb [\#3080](https://github.com/pypeclub/OpenPype/pull/3080) -- Update collect\_render.py [\#3055](https://github.com/pypeclub/OpenPype/pull/3055) -- SiteSync: Added compute\_resource\_sync\_sites to sync\_server\_module [\#2983](https://github.com/pypeclub/OpenPype/pull/2983) +- Maya: added clean\_import option to Import loader [\#3181](https://github.com/pypeclub/OpenPype/pull/3181) +- Maya: add maya 2023 to default applications [\#3167](https://github.com/pypeclub/OpenPype/pull/3167) +- Compressed bgeo publishing in SAP and Houdini loader [\#3153](https://github.com/pypeclub/OpenPype/pull/3153) +- General: Add 'dataclasses' to required python modules [\#3149](https://github.com/pypeclub/OpenPype/pull/3149) +- Hooks: Tweak logging grammar [\#3147](https://github.com/pypeclub/OpenPype/pull/3147) +- Nuke: settings for reformat node in CreateWriteRender node [\#3143](https://github.com/pypeclub/OpenPype/pull/3143) +- Houdini: Add loader for alembic through Alembic Archive node [\#3140](https://github.com/pypeclub/OpenPype/pull/3140) +- Publisher: UI Modifications and fixes [\#3139](https://github.com/pypeclub/OpenPype/pull/3139) +- General: Simplified OP modules/addons import [\#3137](https://github.com/pypeclub/OpenPype/pull/3137) +- Terminal: Tweak coloring of TrayModuleManager logging enabled states [\#3133](https://github.com/pypeclub/OpenPype/pull/3133) +- General: Cleanup some Loader docstrings [\#3131](https://github.com/pypeclub/OpenPype/pull/3131) +- Nuke: render instance with subset name filtered overrides [\#3117](https://github.com/pypeclub/OpenPype/pull/3117) +- Unreal: Layout and Camera update and remove functions reimplemented and improvements [\#3116](https://github.com/pypeclub/OpenPype/pull/3116) +- Settings: Remove environment groups from settings [\#3115](https://github.com/pypeclub/OpenPype/pull/3115) +- TVPaint: Match renderlayer key with other hosts [\#3110](https://github.com/pypeclub/OpenPype/pull/3110) +- Ftrack: AssetVersion status on publish [\#3108](https://github.com/pypeclub/OpenPype/pull/3108) +- Tray publisher: Simple families from settings [\#3105](https://github.com/pypeclub/OpenPype/pull/3105) **πŸ› Bug fixes** -- RoyalRender Control Submission - AVALON\_APP\_NAME default [\#3091](https://github.com/pypeclub/OpenPype/pull/3091) -- Ftrack: Update Create Folders action [\#3089](https://github.com/pypeclub/OpenPype/pull/3089) -- Project Manager: Avoid unnecessary updates of asset documents [\#3083](https://github.com/pypeclub/OpenPype/pull/3083) -- Standalone publisher: Fix plugins install [\#3077](https://github.com/pypeclub/OpenPype/pull/3077) -- General: Extract review sequence is not converted with same names [\#3076](https://github.com/pypeclub/OpenPype/pull/3076) -- Webpublisher: Use variant value [\#3068](https://github.com/pypeclub/OpenPype/pull/3068) -- Nuke: Add aov matching even for remainder and prerender [\#3060](https://github.com/pypeclub/OpenPype/pull/3060) +- Standalone Publisher: Always create new representation for thumbnail [\#3203](https://github.com/pypeclub/OpenPype/pull/3203) +- Photoshop: skip collector when automatic testing [\#3202](https://github.com/pypeclub/OpenPype/pull/3202) +- Nuke: render/workfile version sync doesn't work on farm [\#3185](https://github.com/pypeclub/OpenPype/pull/3185) +- Ftrack: Review image only if there are no mp4 reviews [\#3183](https://github.com/pypeclub/OpenPype/pull/3183) +- Ftrack: Locations deepcopy issue [\#3177](https://github.com/pypeclub/OpenPype/pull/3177) +- General: Avoid creating multiple thumbnails [\#3176](https://github.com/pypeclub/OpenPype/pull/3176) +- General/Hiero: better clip duration calculation [\#3169](https://github.com/pypeclub/OpenPype/pull/3169) +- General: Oiio conversion for ffmpeg checks for invalid characters [\#3166](https://github.com/pypeclub/OpenPype/pull/3166) +- Fix for attaching render to subset [\#3164](https://github.com/pypeclub/OpenPype/pull/3164) +- Harmony: fixed missing task name in render instance [\#3163](https://github.com/pypeclub/OpenPype/pull/3163) +- Ftrack: Action delete old versions formatting works [\#3152](https://github.com/pypeclub/OpenPype/pull/3152) +- Deadline: fix the output directory [\#3144](https://github.com/pypeclub/OpenPype/pull/3144) +- General: New Session schema [\#3141](https://github.com/pypeclub/OpenPype/pull/3141) +- TVPaint: Composite layers in reversed order [\#3135](https://github.com/pypeclub/OpenPype/pull/3135) +- Nuke: fixing default settings for workfile builder loaders [\#3120](https://github.com/pypeclub/OpenPype/pull/3120) +- Nuke: fix anatomy imageio regex default [\#3119](https://github.com/pypeclub/OpenPype/pull/3119) +- General: Python 3 compatibility in queries [\#3112](https://github.com/pypeclub/OpenPype/pull/3112) +- General: Collect loaded versions skips not existing representations [\#3095](https://github.com/pypeclub/OpenPype/pull/3095) **πŸ”€ Refactored code** -- General: Move host install [\#3009](https://github.com/pypeclub/OpenPype/pull/3009) +- General: Remove remaining imports from avalon [\#3130](https://github.com/pypeclub/OpenPype/pull/3130) **Merged pull requests:** -- Nuke: added suspend\_publish knob [\#3078](https://github.com/pypeclub/OpenPype/pull/3078) -- Bump async from 2.6.3 to 2.6.4 in /website [\#3065](https://github.com/pypeclub/OpenPype/pull/3065) +- Webpublisher: replace space by underscore in subset names [\#3160](https://github.com/pypeclub/OpenPype/pull/3160) +- StandalonePublisher: removed Extract Background plugins [\#3093](https://github.com/pypeclub/OpenPype/pull/3093) + +## [3.9.8](https://github.com/pypeclub/OpenPype/tree/3.9.8) (2022-05-19) + +[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.9.7...3.9.8) + +**πŸš€ Enhancements** + +- nuke: generate publishing nodes inside render group node [\#3206](https://github.com/pypeclub/OpenPype/pull/3206) +- Backport of fix for attaching renders to subsets [\#3195](https://github.com/pypeclub/OpenPype/pull/3195) + +**πŸ› Bug fixes** + +- Standalone Publisher: Always create new representation for thumbnail [\#3204](https://github.com/pypeclub/OpenPype/pull/3204) +- Nuke: render/workfile version sync doesn't work on farm [\#3184](https://github.com/pypeclub/OpenPype/pull/3184) +- Ftrack: Review image only if there are no mp4 reviews [\#3182](https://github.com/pypeclub/OpenPype/pull/3182) +- Ftrack: Locations deepcopy issue [\#3175](https://github.com/pypeclub/OpenPype/pull/3175) +- General: Avoid creating multiple thumbnails [\#3174](https://github.com/pypeclub/OpenPype/pull/3174) +- General: TemplateResult can be copied [\#3170](https://github.com/pypeclub/OpenPype/pull/3170) + +**Merged pull requests:** + +- hiero: otio p3 compatibility issue - metadata on effect use update [\#3194](https://github.com/pypeclub/OpenPype/pull/3194) + +## [3.9.7](https://github.com/pypeclub/OpenPype/tree/3.9.7) (2022-05-11) + +[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.9.6...3.9.7) + +**πŸ†• New features** + +- Ftrack: Single image reviewable [\#3158](https://github.com/pypeclub/OpenPype/pull/3158) + +**πŸš€ Enhancements** + +- Deadline output dir issue to 3.9x [\#3155](https://github.com/pypeclub/OpenPype/pull/3155) +- nuke: removing redundant code from startup [\#3142](https://github.com/pypeclub/OpenPype/pull/3142) + +**πŸ› Bug fixes** + +- Ftrack: Action delete old versions formatting works [\#3154](https://github.com/pypeclub/OpenPype/pull/3154) +- nuke: adding extract thumbnail settings [\#3148](https://github.com/pypeclub/OpenPype/pull/3148) + +**Merged pull requests:** + +- Webpublisher: replace space by underscore in subset names [\#3159](https://github.com/pypeclub/OpenPype/pull/3159) + +## [3.9.6](https://github.com/pypeclub/OpenPype/tree/3.9.6) (2022-05-03) + +[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.9.5...3.9.6) + +**πŸ†• New features** + +- Nuke: render instance with subset name filtered overrides \(3.9.x\) [\#3125](https://github.com/pypeclub/OpenPype/pull/3125) + +**πŸš€ Enhancements** + +- TVPaint: Match renderlayer key with other hosts [\#3109](https://github.com/pypeclub/OpenPype/pull/3109) + +**πŸ› Bug fixes** + +- General: Missing version on headless mode crash properly [\#3136](https://github.com/pypeclub/OpenPype/pull/3136) +- TVPaint: Composite layers in reversed order [\#3134](https://github.com/pypeclub/OpenPype/pull/3134) +- General: Python 3 compatibility in queries [\#3111](https://github.com/pypeclub/OpenPype/pull/3111) + +**Merged pull requests:** + +- Ftrack: AssetVersion status on publish [\#3114](https://github.com/pypeclub/OpenPype/pull/3114) +- renderman support for 3.9.x [\#3107](https://github.com/pypeclub/OpenPype/pull/3107) + +## [3.9.5](https://github.com/pypeclub/OpenPype/tree/3.9.5) (2022-04-25) + +[Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.10.0-nightly.2...3.9.5) + +**πŸ› Bug fixes** + +- Ftrack: Update Create Folders action [\#3092](https://github.com/pypeclub/OpenPype/pull/3092) +- General: Extract review sequence is not converted with same names [\#3075](https://github.com/pypeclub/OpenPype/pull/3075) +- Webpublisher: Use variant value [\#3072](https://github.com/pypeclub/OpenPype/pull/3072) ## [3.9.4](https://github.com/pypeclub/OpenPype/tree/3.9.4) (2022-04-15) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.9.4-nightly.2...3.9.4) -### πŸ“– Documentation - -- Documentation: more info about Tasks [\#3062](https://github.com/pypeclub/OpenPype/pull/3062) -- Documentation: Python requirements to 3.7.9 [\#3035](https://github.com/pypeclub/OpenPype/pull/3035) -- Website Docs: Remove unused pages [\#2974](https://github.com/pypeclub/OpenPype/pull/2974) - -**πŸ†• New features** - -- General: Local overrides for environment variables [\#3045](https://github.com/pypeclub/OpenPype/pull/3045) - -**πŸš€ Enhancements** - -- TVPaint: Added init file for worker to triggers missing sound file dialog [\#3053](https://github.com/pypeclub/OpenPype/pull/3053) -- Ftrack: Custom attributes can be filled in slate values [\#3036](https://github.com/pypeclub/OpenPype/pull/3036) -- Resolve environment variable in google drive credential path [\#3008](https://github.com/pypeclub/OpenPype/pull/3008) - -**πŸ› Bug fixes** - -- GitHub: Updated push-protected action in github workflow [\#3064](https://github.com/pypeclub/OpenPype/pull/3064) -- Nuke: Typos in imports from Nuke implementation [\#3061](https://github.com/pypeclub/OpenPype/pull/3061) -- Hotfix: fixing deadline job publishing [\#3059](https://github.com/pypeclub/OpenPype/pull/3059) -- General: Extract Review handle invalid characters for ffmpeg [\#3050](https://github.com/pypeclub/OpenPype/pull/3050) -- Slate Review: Support to keep format on slate concatenation [\#3049](https://github.com/pypeclub/OpenPype/pull/3049) -- Webpublisher: fix processing of workfile [\#3048](https://github.com/pypeclub/OpenPype/pull/3048) -- Ftrack: Integrate ftrack api fix [\#3044](https://github.com/pypeclub/OpenPype/pull/3044) -- Webpublisher - removed wrong hardcoded family [\#3043](https://github.com/pypeclub/OpenPype/pull/3043) -- LibraryLoader: Use current project for asset query in families filter [\#3042](https://github.com/pypeclub/OpenPype/pull/3042) -- SiteSync: Providers ignore that site is disabled [\#3041](https://github.com/pypeclub/OpenPype/pull/3041) -- Unreal: Creator import fixes [\#3040](https://github.com/pypeclub/OpenPype/pull/3040) -- Settings UI: Version column can be extended so version are visible [\#3032](https://github.com/pypeclub/OpenPype/pull/3032) -- SiteSync: fix transitive alternate sites, fix dropdown in Local Settings [\#3018](https://github.com/pypeclub/OpenPype/pull/3018) - -**Merged pull requests:** - -- Deadline: reworked pools assignment [\#3051](https://github.com/pypeclub/OpenPype/pull/3051) -- Houdini: Avoid ImportError on `hdefereval` when Houdini runs without UI [\#2987](https://github.com/pypeclub/OpenPype/pull/2987) - ## [3.9.3](https://github.com/pypeclub/OpenPype/tree/3.9.3) (2022-04-07) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.9.3-nightly.2...3.9.3) -### πŸ“– Documentation - -- Website Docs: Manager Ftrack fix broken links [\#2979](https://github.com/pypeclub/OpenPype/pull/2979) - -**πŸ†• New features** - -- Ftrack: Add description integrator [\#3027](https://github.com/pypeclub/OpenPype/pull/3027) -- Publishing textures for Unreal [\#2988](https://github.com/pypeclub/OpenPype/pull/2988) - -**πŸš€ Enhancements** - -- Ftrack: Add more options for note text of integrate ftrack note [\#3025](https://github.com/pypeclub/OpenPype/pull/3025) -- Console Interpreter: Changed how console splitter size are reused on show [\#3016](https://github.com/pypeclub/OpenPype/pull/3016) -- Deadline: Use more suitable name for sequence review logic [\#3015](https://github.com/pypeclub/OpenPype/pull/3015) -- General: default workfile subset name for workfile [\#3011](https://github.com/pypeclub/OpenPype/pull/3011) -- Deadline: priority configurable in Maya jobs [\#2995](https://github.com/pypeclub/OpenPype/pull/2995) - -**πŸ› Bug fixes** - -- Deadline: Fixed default value of use sequence for review [\#3033](https://github.com/pypeclub/OpenPype/pull/3033) -- General: Fix validate asset docs plug-in filename and class name [\#3029](https://github.com/pypeclub/OpenPype/pull/3029) -- General: Fix import after movements [\#3028](https://github.com/pypeclub/OpenPype/pull/3028) -- Harmony: Added creating subset name for workfile from template [\#3024](https://github.com/pypeclub/OpenPype/pull/3024) -- AfterEffects: Added creating subset name for workfile from template [\#3023](https://github.com/pypeclub/OpenPype/pull/3023) -- General: Add example addons to ignored [\#3022](https://github.com/pypeclub/OpenPype/pull/3022) -- Maya: Remove missing import [\#3017](https://github.com/pypeclub/OpenPype/pull/3017) -- Ftrack: multiple reviewable componets [\#3012](https://github.com/pypeclub/OpenPype/pull/3012) -- Tray publisher: Fixes after code movement [\#3010](https://github.com/pypeclub/OpenPype/pull/3010) -- Nuke: fixing unicode type detection in effect loaders [\#3002](https://github.com/pypeclub/OpenPype/pull/3002) -- Nuke: removing redundant Ftrack asset when farm publishing [\#2996](https://github.com/pypeclub/OpenPype/pull/2996) - -**Merged pull requests:** - -- Maya: Allow to select invalid camera contents if no cameras found [\#3030](https://github.com/pypeclub/OpenPype/pull/3030) -- General: adding limitations for pyright [\#2994](https://github.com/pypeclub/OpenPype/pull/2994) - ## [3.9.2](https://github.com/pypeclub/OpenPype/tree/3.9.2) (2022-04-04) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.9.2-nightly.4...3.9.2) -### πŸ“– Documentation - -- Documentation: Added mention of adding My Drive as a root [\#2999](https://github.com/pypeclub/OpenPype/pull/2999) -- Docs: Added MongoDB requirements [\#2951](https://github.com/pypeclub/OpenPype/pull/2951) - -**πŸ†• New features** - -- nuke: bypass baking [\#2992](https://github.com/pypeclub/OpenPype/pull/2992) -- Maya to Unreal: Static and Skeletal Meshes [\#2978](https://github.com/pypeclub/OpenPype/pull/2978) - -**πŸš€ Enhancements** - -- Nuke: add concurrency attr to deadline job [\#3005](https://github.com/pypeclub/OpenPype/pull/3005) -- Photoshop: create image without instance [\#3001](https://github.com/pypeclub/OpenPype/pull/3001) -- TVPaint: Render scene family [\#3000](https://github.com/pypeclub/OpenPype/pull/3000) -- Nuke: ReviewDataMov Read RAW attribute [\#2985](https://github.com/pypeclub/OpenPype/pull/2985) -- General: `METADATA\_KEYS` constant as `frozenset` for optimal immutable lookup [\#2980](https://github.com/pypeclub/OpenPype/pull/2980) -- General: Tools with host filters [\#2975](https://github.com/pypeclub/OpenPype/pull/2975) -- Hero versions: Use custom templates [\#2967](https://github.com/pypeclub/OpenPype/pull/2967) - -**πŸ› Bug fixes** - -- Hosts: Remove path existence checks in 'add\_implementation\_envs' [\#3004](https://github.com/pypeclub/OpenPype/pull/3004) -- Fix - remove doubled dot in workfile created from template [\#2998](https://github.com/pypeclub/OpenPype/pull/2998) -- PS: fix renaming subset incorrectly in PS [\#2991](https://github.com/pypeclub/OpenPype/pull/2991) -- Fix: Disable setuptools auto discovery [\#2990](https://github.com/pypeclub/OpenPype/pull/2990) -- AEL: fix opening existing workfile if no scene opened [\#2989](https://github.com/pypeclub/OpenPype/pull/2989) -- Maya: Don't do hardlinks on windows for look publishing [\#2986](https://github.com/pypeclub/OpenPype/pull/2986) -- Settings UI: Fix version completer on linux [\#2981](https://github.com/pypeclub/OpenPype/pull/2981) -- Photoshop: Fix creation of subset names in PS review and workfile [\#2969](https://github.com/pypeclub/OpenPype/pull/2969) -- Slack: Added default for review\_upload\_limit for Slack [\#2965](https://github.com/pypeclub/OpenPype/pull/2965) -- General: OIIO conversion for ffmeg can handle sequences [\#2958](https://github.com/pypeclub/OpenPype/pull/2958) -- Settings: Conditional dictionary avoid invalid logs [\#2956](https://github.com/pypeclub/OpenPype/pull/2956) -- General: Smaller fixes and typos [\#2950](https://github.com/pypeclub/OpenPype/pull/2950) - -**Merged pull requests:** - -- Bump paramiko from 2.9.2 to 2.10.1 [\#2973](https://github.com/pypeclub/OpenPype/pull/2973) -- Bump minimist from 1.2.5 to 1.2.6 in /website [\#2954](https://github.com/pypeclub/OpenPype/pull/2954) -- Bump node-forge from 1.2.1 to 1.3.0 in /website [\#2953](https://github.com/pypeclub/OpenPype/pull/2953) -- Maya - added transparency into review creator [\#2952](https://github.com/pypeclub/OpenPype/pull/2952) - ## [3.9.1](https://github.com/pypeclub/OpenPype/tree/3.9.1) (2022-03-18) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.9.1-nightly.3...3.9.1) diff --git a/openpype/version.py b/openpype/version.py index 662adf28ca..1db666efec 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.10.0-nightly.2" +__version__ = "3.10.0-nightly.3" diff --git a/pyproject.toml b/pyproject.toml index f32e385e80..4b7972c227 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "OpenPype" -version = "3.10.0-nightly.2" # OpenPype +version = "3.10.0-nightly.3" # OpenPype description = "Open VFX and Animation pipeline with support." authors = ["OpenPype Team "] license = "MIT License" From e38793f258fa8dd0c6195ea3d7fac24e526725d9 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Thu, 19 May 2022 14:29:29 +0200 Subject: [PATCH 429/583] fix copy --- openpype/modules/deadline/plugins/publish/submit_publish_job.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py index 782f85c9d2..78ab935e42 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -466,7 +466,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): if instance_data.get("multipartExr"): preview = True - new_instance = copy(instance_data) + new_instance = deepcopy(instance_data) new_instance["subset"] = subset_name new_instance["subsetGroup"] = group_name if preview: From f734b59305c465ea11a63dec041e4b38a04836a8 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 19 May 2022 15:08:39 +0200 Subject: [PATCH 430/583] block model signals during set project --- openpype/tools/project_manager/project_manager/model.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 871704e13c..b7cb0ec9ed 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -264,6 +264,8 @@ class HierarchyModel(QtCore.QAbstractItemModel): if not project_doc: return + self.blockSignals(True) + # Create project item project_item = ProjectItem(project_doc) self.add_item(project_item) @@ -377,6 +379,8 @@ class HierarchyModel(QtCore.QAbstractItemModel): self.add_items(task_items, asset_item) + self.blockSignals(False) + # Emit that project was successfully changed self.project_changed.emit() From b8e978e518c12fbc18361c45217fbba2d38eff65 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 19 May 2022 15:08:56 +0200 Subject: [PATCH 431/583] refresh of projects with force refresh of current project --- openpype/tools/project_manager/project_manager/view.py | 4 ++-- openpype/tools/project_manager/project_manager/window.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/view.py b/openpype/tools/project_manager/project_manager/view.py index 74f5a06b71..25174232bc 100644 --- a/openpype/tools/project_manager/project_manager/view.py +++ b/openpype/tools/project_manager/project_manager/view.py @@ -195,13 +195,13 @@ class HierarchyView(QtWidgets.QTreeView): for idx, width in widths_by_idx.items(): self.setColumnWidth(idx, width) - def set_project(self, project_name): + def set_project(self, project_name, force=False): # Trigger helpers first self._project_doc_cache.set_project(project_name) self._tools_cache.refresh() # Trigger update of model after all data for delegates are filled - self._source_model.set_project(project_name) + self._source_model.set_project(project_name, force) def _on_project_reset(self): self.header_init() diff --git a/openpype/tools/project_manager/project_manager/window.py b/openpype/tools/project_manager/project_manager/window.py index c281479d4f..8cc3939713 100644 --- a/openpype/tools/project_manager/project_manager/window.py +++ b/openpype/tools/project_manager/project_manager/window.py @@ -191,7 +191,7 @@ class ProjectManagerWindow(QtWidgets.QWidget): self._add_task_btn.setEnabled(project_name is not None) self._save_btn.setEnabled(project_name is not None) self._project_proxy_model.set_filter_default(project_name is not None) - self.hierarchy_view.set_project(project_name) + self.hierarchy_view.set_project(project_name, True) def _current_project(self): row = self._project_combobox.currentIndex() From 6b9983fdede5e7e086e62798abddabdec3afcb85 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Thu, 19 May 2022 14:19:30 +0100 Subject: [PATCH 432/583] Added support for both UE4 and 5 Plugin won't compile in UE4 yet. UE5 needs different modules, not available in UE4. --- .../unreal/hooks/pre_workfile_preparation.py | 12 ++--- openpype/hosts/unreal/lib.py | 48 ++++++++++++------- 2 files changed, 36 insertions(+), 24 deletions(-) diff --git a/openpype/hosts/unreal/hooks/pre_workfile_preparation.py b/openpype/hosts/unreal/hooks/pre_workfile_preparation.py index fa0562a3a0..5be04fc841 100644 --- a/openpype/hosts/unreal/hooks/pre_workfile_preparation.py +++ b/openpype/hosts/unreal/hooks/pre_workfile_preparation.py @@ -71,7 +71,7 @@ class UnrealPrelaunchHook(PreLaunchHook): if int(engine_version.split(".")[0]) < 4 and \ int(engine_version.split(".")[1]) < 26: raise ApplicationLaunchFailed(( - f"{self.signature} Old unsupported version of UE4 " + f"{self.signature} Old unsupported version of UE " f"detected - {engine_version}")) except ValueError: # there can be string in minor version and in that case @@ -104,14 +104,14 @@ class UnrealPrelaunchHook(PreLaunchHook): project_path = Path(os.path.join(workdir, unreal_project_name)) self.log.info(( - f"{self.signature} requested UE4 version: " + f"{self.signature} requested UE version: " f"[ {engine_version} ]" )) detected = unreal_lib.get_engine_versions(self.launch_context.env) detected_str = ', '.join(detected.keys()) or 'none' self.log.info(( - f"{self.signature} detected UE4 versions: " + f"{self.signature} detected UE versions: " f"[ {detected_str} ]" )) if not detected: @@ -124,10 +124,10 @@ class UnrealPrelaunchHook(PreLaunchHook): f"detected [ {engine_version} ]" )) - ue4_path = unreal_lib.get_editor_executable_path( - Path(detected[engine_version])) + ue_path = unreal_lib.get_editor_executable_path( + Path(detected[engine_version]), engine_version) - self.launch_context.launch_args = [ue4_path.as_posix()] + self.launch_context.launch_args = [ue_path.as_posix()] project_path.mkdir(parents=True, exist_ok=True) project_file = project_path / unreal_project_filename diff --git a/openpype/hosts/unreal/lib.py b/openpype/hosts/unreal/lib.py index fdf3acb37b..f220d8dedf 100644 --- a/openpype/hosts/unreal/lib.py +++ b/openpype/hosts/unreal/lib.py @@ -70,19 +70,22 @@ def get_engine_versions(env=None): return OrderedDict() -def get_editor_executable_path(engine_path: Path) -> Path: - """Get UE4 Editor executable path.""" - ue4_path = engine_path / "Engine/Binaries" +def get_editor_executable_path(engine_path: Path, engine_version: str) -> Path: + """Get UE Editor executable path.""" + ue_path = engine_path / "Engine/Binaries" if platform.system().lower() == "windows": - ue4_path /= "Win64/UnrealEditor.exe" + if engine_version.split(".")[0] == "4": + ue_path /= "Win64/UE4Editor.exe" + elif engine_version.split(".")[0] == "5": + ue_path /= "Win64/UnrealEditor.exe" elif platform.system().lower() == "linux": - ue4_path /= "Linux/UE4Editor" + ue_path /= "Linux/UE4Editor" elif platform.system().lower() == "darwin": - ue4_path /= "Mac/UE4Editor" + ue_path /= "Mac/UE4Editor" - return ue4_path + return ue_path def _win_get_engine_versions(): @@ -208,22 +211,26 @@ def create_unreal_project(project_name: str, # created in different UE4 version. When user convert such project # to his UE4 version, Engine ID is replaced in uproject file. If some # other user tries to open it, it will present him with similar error. - ue4_modules = Path() + ue_modules = Path() if platform.system().lower() == "windows": - ue4_modules = Path(os.path.join(engine_path, "Engine", "Binaries", - "Win64", "UE4Editor.modules")) + ue_modules_path = engine_path / "Engine/Binaries/Win64" + if ue_version.split(".")[0] == "4": + ue_modules_path /= "UE4Editor.modules" + elif ue_version.split(".")[0] == "5": + ue_modules_path /= "UnrealEditor.modules" + ue_modules = Path(ue_modules_path) if platform.system().lower() == "linux": - ue4_modules = Path(os.path.join(engine_path, "Engine", "Binaries", + ue_modules = Path(os.path.join(engine_path, "Engine", "Binaries", "Linux", "UE4Editor.modules")) if platform.system().lower() == "darwin": - ue4_modules = Path(os.path.join(engine_path, "Engine", "Binaries", + ue_modules = Path(os.path.join(engine_path, "Engine", "Binaries", "Mac", "UE4Editor.modules")) - if ue4_modules.exists(): + if ue_modules.exists(): print("--- Loading Engine ID from modules file ...") - with open(ue4_modules, "r") as mp: + with open(ue_modules, "r") as mp: loaded_modules = json.load(mp) if loaded_modules.get("BuildId"): @@ -298,10 +305,11 @@ def create_unreal_project(project_name: str, [python_path.as_posix(), "-m", "pip", "install", "--user", "pyside2"]) if dev_mode or preset["dev_mode"]: - _prepare_cpp_project(project_file, engine_path) + _prepare_cpp_project(project_file, engine_path, ue_version) -def _prepare_cpp_project(project_file: Path, engine_path: Path) -> None: +def _prepare_cpp_project( + project_file: Path, engine_path: Path, ue_version: str) -> None: """Prepare CPP Unreal Project. This function will add source files needed for project to be @@ -420,8 +428,12 @@ class {1}_API A{0}GameModeBase : public AGameModeBase with open(sources_dir / f"{project_name}GameModeBase.h", mode="w") as f: f.write(game_mode_h) - u_build_tool = Path( - engine_path / "Engine/Binaries/DotNET/UnrealBuildTool/UnrealBuildTool.exe") + u_build_tool_path = engine_path / "Engine/Binaries/DotNET" + if ue_version.split(".")[0] == "4": + u_build_tool_path /= "UnrealBuildTool.exe" + elif ue_version.split(".")[0] == "5": + u_build_tool_path /= "UnrealBuildTool/UnrealBuildTool.exe" + u_build_tool = Path(u_build_tool_path) u_header_tool = None arch = "Win64" From 13b4b18d162ed4307408e9d2f64869403c740724 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 19 May 2022 17:05:55 +0200 Subject: [PATCH 433/583] OP-2787 - WIP implementation --- openpype/api.py | 2 + openpype/hosts/maya/api/pipeline.py | 7 + .../maya/plugins/create/create_animation.py | 4 + .../maya/plugins/create/create_pointcache.py | 4 + .../maya/plugins/publish/collect_animation.py | 3 + .../plugins/publish/collect_pointcache.py | 14 ++ .../maya/plugins/publish/extract_animation.py | 8 + .../plugins/publish/extract_pointcache.py | 8 + openpype/lib/remote_publish.py | 2 +- .../submit_maya_remote_publish_deadline.py | 137 ++++++++++++++++++ openpype/plugin.py | 12 ++ openpype/plugins/publish/integrate_new.py | 3 + openpype/scripts/remote_publish.py | 11 ++ 13 files changed, 214 insertions(+), 1 deletion(-) create mode 100644 openpype/hosts/maya/plugins/publish/collect_pointcache.py create mode 100644 openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py create mode 100644 openpype/scripts/remote_publish.py diff --git a/openpype/api.py b/openpype/api.py index 9ce745b653..e049a683c6 100644 --- a/openpype/api.py +++ b/openpype/api.py @@ -44,6 +44,7 @@ from . import resources from .plugin import ( Extractor, + Integrator, ValidatePipelineOrder, ValidateContentsOrder, @@ -86,6 +87,7 @@ __all__ = [ # plugin classes "Extractor", + "Integrator", # ordering "ValidatePipelineOrder", "ValidateContentsOrder", diff --git a/openpype/hosts/maya/api/pipeline.py b/openpype/hosts/maya/api/pipeline.py index b0e8fac635..b75af29523 100644 --- a/openpype/hosts/maya/api/pipeline.py +++ b/openpype/hosts/maya/api/pipeline.py @@ -71,8 +71,15 @@ def install(): if lib.IS_HEADLESS: log.info(("Running in headless mode, skipping Maya " "save/open/new callback installation..")) + + # Register default "local" target + print("Registering pyblish target: farm") + pyblish.api.register_target("farm") return + print("Registering pyblish target: local") + pyblish.api.register_target("local") + _set_project() _register_callbacks() diff --git a/openpype/hosts/maya/plugins/create/create_animation.py b/openpype/hosts/maya/plugins/create/create_animation.py index 11a668cfc8..5cd1f7090a 100644 --- a/openpype/hosts/maya/plugins/create/create_animation.py +++ b/openpype/hosts/maya/plugins/create/create_animation.py @@ -38,3 +38,7 @@ class CreateAnimation(plugin.Creator): # Default to exporting world-space self.data["worldSpace"] = True + + # Default to not send to farm. + self.data["farm"] = False + self.data["priority"] = 50 diff --git a/openpype/hosts/maya/plugins/create/create_pointcache.py b/openpype/hosts/maya/plugins/create/create_pointcache.py index ede152f1ef..e876015adb 100644 --- a/openpype/hosts/maya/plugins/create/create_pointcache.py +++ b/openpype/hosts/maya/plugins/create/create_pointcache.py @@ -28,3 +28,7 @@ class CreatePointCache(plugin.Creator): # Add options for custom attributes self.data["attr"] = "" self.data["attrPrefix"] = "" + + # Default to not send to farm. + self.data["farm"] = False + self.data["priority"] = 50 diff --git a/openpype/hosts/maya/plugins/publish/collect_animation.py b/openpype/hosts/maya/plugins/publish/collect_animation.py index 9b1e38fd0a..b442113fbc 100644 --- a/openpype/hosts/maya/plugins/publish/collect_animation.py +++ b/openpype/hosts/maya/plugins/publish/collect_animation.py @@ -55,3 +55,6 @@ class CollectAnimationOutputGeometry(pyblish.api.InstancePlugin): # Store data in the instance for the validator instance.data["out_hierarchy"] = hierarchy + + if instance.data.get("farm"): + instance.data["families"].append("deadline") diff --git a/openpype/hosts/maya/plugins/publish/collect_pointcache.py b/openpype/hosts/maya/plugins/publish/collect_pointcache.py new file mode 100644 index 0000000000..b55babe372 --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/collect_pointcache.py @@ -0,0 +1,14 @@ +import pyblish.api + + +class CollectPointcache(pyblish.api.InstancePlugin): + """Collect pointcache data for instance.""" + + order = pyblish.api.CollectorOrder + 0.4 + families = ["pointcache"] + label = "Collect Pointcache" + hosts = ["maya"] + + def process(self, instance): + if instance.data.get("farm"): + instance.data["families"].append("deadline") \ No newline at end of file diff --git a/openpype/hosts/maya/plugins/publish/extract_animation.py b/openpype/hosts/maya/plugins/publish/extract_animation.py index 8a8bd67cd8..87f2d35192 100644 --- a/openpype/hosts/maya/plugins/publish/extract_animation.py +++ b/openpype/hosts/maya/plugins/publish/extract_animation.py @@ -23,6 +23,14 @@ class ExtractAnimation(openpype.api.Extractor): families = ["animation"] def process(self, instance): + if instance.data.get("farm"): + path = os.path.join( + os.path.dirname(instance.context.data["currentFile"]), + "cache", + instance.data["name"] + ".abc" + ) + instance.data["expectedFiles"] = [os.path.normpath(path)] + return # Collect the out set nodes out_sets = [node for node in instance if node.endswith("out_SET")] diff --git a/openpype/hosts/maya/plugins/publish/extract_pointcache.py b/openpype/hosts/maya/plugins/publish/extract_pointcache.py index 60502fdde1..7ad4c6dfa9 100644 --- a/openpype/hosts/maya/plugins/publish/extract_pointcache.py +++ b/openpype/hosts/maya/plugins/publish/extract_pointcache.py @@ -25,6 +25,14 @@ class ExtractAlembic(openpype.api.Extractor): "vrayproxy"] def process(self, instance): + if instance.data.get("farm"): + path = os.path.join( + os.path.dirname(instance.context.data["currentFile"]), + "cache", + instance.data["name"] + ".abc" + ) + instance.data["expectedFiles"] = [os.path.normpath(path)] + return nodes = instance[:] diff --git a/openpype/lib/remote_publish.py b/openpype/lib/remote_publish.py index 8a42daf4e9..da2497e1a5 100644 --- a/openpype/lib/remote_publish.py +++ b/openpype/lib/remote_publish.py @@ -228,7 +228,7 @@ def _get_close_plugin(close_plugin_name, log): if plugin.__name__ == close_plugin_name: return plugin - log.warning("Close plugin not found, app might not close.") + log.debug("Close plugin not found, app might not close.") def get_task_data(batch_dir): diff --git a/openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py b/openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py new file mode 100644 index 0000000000..761bc8cc95 --- /dev/null +++ b/openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py @@ -0,0 +1,137 @@ +import os +import requests + +from maya import cmds + +from openpype.pipeline import legacy_io + +import pyblish.api + + +class MayaSubmitRemotePublishDeadline(pyblish.api.ContextPlugin): + """Submit Maya scene to perform a local publish in Deadline. + + Publishing in Deadline can be helpful for scenes that publish very slow. + This way it can process in the background on another machine without the + Artist having to wait for the publish to finish on their local machine. + + Submission is done through the Deadline Web Service. + + Different from `ProcessSubmittedJobOnFarm` which creates publish job + depending on metadata json containing context and instance data of + rendered files. + """ + + label = "Submit Scene to Deadline" + order = pyblish.api.IntegratorOrder + hosts = ["maya"] + families = ["deadline"] + + # custom deadline attributes + deadline_department = "" + deadline_pool = "" + deadline_pool_secondary = "" + deadline_group = "" + deadline_chunk_size = 1 + deadline_priority = 50 + + def process(self, context): + + # Ensure no errors so far + assert all(result["success"] for result in context.data["results"]), ( + "Errors found, aborting integration..") + + # Note that `publish` data member might change in the future. + # See: https://github.com/pyblish/pyblish-base/issues/307 + actives = [i for i in context if i.data["publish"]] + instance_names = sorted(instance.name for instance in actives) + + if not instance_names: + self.log.warning("No active instances found. " + "Skipping submission..") + return + + scene = context.data["currentFile"] + scenename = os.path.basename(scene) + + # Get project code + project_name = legacy_io.Session["AVALON_PROJECT"] + + job_name = "{scene} [PUBLISH]".format(scene=scenename) + batch_name = "{code} - {scene}".format(code=project_name, + scene=scenename) + + # Generate the payload for Deadline submission + payload = { + "JobInfo": { + "Plugin": "MayaBatch", + "BatchName": batch_name, + "Priority": 50, + "Name": job_name, + "UserName": context.data["user"], + # "Comment": instance.context.data.get("comment", ""), + # "InitialStatus": state + "Department": self.deadline_department, + "ChunkSize": self.deadline_chunk_size, + "Priority": self.deadline_priority, + + "Group": self.deadline_group, + + }, + "PluginInfo": { + + "Build": None, # Don't force build + "StrictErrorChecking": True, + "ScriptJob": True, + + # Inputs + "SceneFile": scene, + "ScriptFilename": "{OPENPYPE_ROOT}/scripts/remote_publish.py", + + # Mandatory for Deadline + "Version": cmds.about(version=True), + + # Resolve relative references + "ProjectPath": cmds.workspace(query=True, + rootDirectory=True), + + }, + + # Mandatory for Deadline, may be empty + "AuxFiles": [] + } + + # Include critical environment variables with submission + api.Session + keys = [ + "FTRACK_API_USER", + "FTRACK_API_KEY", + "FTRACK_SERVER" + ] + environment = dict({key: os.environ[key] for key in keys + if key in os.environ}, **legacy_io.Session) + + # TODO replace legacy_io with context.data ? + environment["AVALON_PROJECT"] = legacy_io.Session["AVALON_PROJECT"] + environment["AVALON_ASSET"] = legacy_io.Session["AVALON_ASSET"] + environment["AVALON_TASK"] = legacy_io.Session["AVALON_TASK"] + environment["AVALON_APP_NAME"] = os.environ.get("AVALON_APP_NAME") + environment["OPENPYPE_LOG_NO_COLORS"] = "1" + environment["OPENPYPE_USERNAME"] = context.data["user"] + environment["OPENPYPE_PUBLISH_JOB"] = "1" + environment["OPENPYPE_RENDER_JOB"] = "0" + environment["PYBLISH_ACTIVE_INSTANCES"] = ",".join(instance_names) + + payload["JobInfo"].update({ + "EnvironmentKeyValue%d" % index: "{key}={value}".format( + key=key, + value=environment[key] + ) for index, key in enumerate(environment) + }) + + self.log.info("Submitting Deadline job ...") + deadline_url = context.data["defaultDeadline"] + assert deadline_url, "Requires Deadline Webservice URL" + url = "{}/api/jobs".format(deadline_url) + response = requests.post(url, json=payload, timeout=10) + if not response.ok: + raise Exception(response.text) diff --git a/openpype/plugin.py b/openpype/plugin.py index bb9bc2ff85..f1ee626ffb 100644 --- a/openpype/plugin.py +++ b/openpype/plugin.py @@ -18,6 +18,16 @@ class InstancePlugin(pyblish.api.InstancePlugin): super(InstancePlugin, cls).process(cls, *args, **kwargs) +class Integrator(InstancePlugin): + """Integrator base class. + + Wraps pyblish instance plugin. Targets set to "local" which means all + integrators should run on "local" publishes, by default. + "farm" targets could be used for integrators that should run on a farm. + """ + targets = ["local"] + + class Extractor(InstancePlugin): """Extractor base class. @@ -28,6 +38,8 @@ class Extractor(InstancePlugin): """ + targets = ["local"] + order = 2.0 def staging_dir(self, instance): diff --git a/openpype/plugins/publish/integrate_new.py b/openpype/plugins/publish/integrate_new.py index bf13a4050e..1a4112107a 100644 --- a/openpype/plugins/publish/integrate_new.py +++ b/openpype/plugins/publish/integrate_new.py @@ -139,6 +139,9 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): ef, instance.data["family"], instance.data["families"])) return + if "deadline" in instance.data["families"]: + return + self.integrated_file_sizes = {} try: self.register(instance) diff --git a/openpype/scripts/remote_publish.py b/openpype/scripts/remote_publish.py new file mode 100644 index 0000000000..b54c8d931b --- /dev/null +++ b/openpype/scripts/remote_publish.py @@ -0,0 +1,11 @@ +try: + from openpype.api import Logger + import openpype.lib.remote_publish +except ImportError as exc: + # Ensure Deadline fails by output an error that contains "Fatal Error:" + raise ImportError("Fatal Error: %s" % exc) + +if __name__ == "__main__": + # Perform remote publish with thorough error checking + log = Logger.get_logger(__name__) + openpype.lib.remote_publish.publish(log) From afc1fa9a1390f6864f4d39031d8248aafa4a053e Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 20 May 2022 10:19:12 +0200 Subject: [PATCH 434/583] fix persistent editors on project change --- .../project_manager/project_manager/view.py | 28 +++++++++++++++++++ .../project_manager/project_manager/window.py | 8 +++--- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/view.py b/openpype/tools/project_manager/project_manager/view.py index 25174232bc..2c2a17d712 100644 --- a/openpype/tools/project_manager/project_manager/view.py +++ b/openpype/tools/project_manager/project_manager/view.py @@ -213,6 +213,34 @@ class HierarchyView(QtWidgets.QTreeView): index = self._source_model.index_for_item(project_item) self.expand(index) + self._open_persistent_editors_on_project_refresh() + + def _open_persistent_editors_on_project_refresh(self): + model = self._source_model + parent_index = QtCore.QModelIndex() + persistent_queue = collections.deque() + persistent_queue.append((parent_index, model.rowCount())) + while persistent_queue: + item = persistent_queue.popleft() + parent_index, rows = item + if not rows: + continue + + for row in range(rows): + row_index = model.index(row, 0, parent_index) + persistent_queue.append( + (row_index, model.rowCount(row_index)) + ) + for key, column in self._column_key_to_index.items(): + if key not in self.persistent_columns: + continue + col_index = model.index(row, column, parent_index) + if bool( + model.flags(col_index) + & QtCore.Qt.ItemIsEditable + ): + self.openPersistentEditor(col_index) + def _on_rows_moved(self, index): parent_index = index.parent() if not self.isExpanded(parent_index): diff --git a/openpype/tools/project_manager/project_manager/window.py b/openpype/tools/project_manager/project_manager/window.py index 8cc3939713..6a2bc29fd1 100644 --- a/openpype/tools/project_manager/project_manager/window.py +++ b/openpype/tools/project_manager/project_manager/window.py @@ -184,14 +184,14 @@ class ProjectManagerWindow(QtWidgets.QWidget): self.resize(1200, 600) self.setStyleSheet(load_stylesheet()) - def _set_project(self, project_name=None): + def _set_project(self, project_name=None, force=False): self._create_folders_btn.setEnabled(project_name is not None) self._remove_projects_btn.setEnabled(project_name is not None) self._add_asset_btn.setEnabled(project_name is not None) self._add_task_btn.setEnabled(project_name is not None) self._save_btn.setEnabled(project_name is not None) self._project_proxy_model.set_filter_default(project_name is not None) - self.hierarchy_view.set_project(project_name, True) + self.hierarchy_view.set_project(project_name, force) def _current_project(self): row = self._project_combobox.currentIndex() @@ -229,11 +229,11 @@ class ProjectManagerWindow(QtWidgets.QWidget): self._project_combobox.setCurrentIndex(row) selected_project = self._current_project() - self._set_project(selected_project) + self._set_project(selected_project, True) def _on_project_change(self): selected_project = self._current_project() - self._set_project(selected_project) + self._set_project(selected_project, False) def _on_project_refresh(self): self.refresh_projects() From d46c919281145a7d07ffb5aca1ef16f7407c6e46 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 12 May 2022 11:46:55 +0200 Subject: [PATCH 435/583] general: calculation of duration should not exclude one frame From ddfee503677c3ad6e653c9346cd742520667d0d9 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 12 May 2022 11:47:23 +0200 Subject: [PATCH 436/583] hiero: fitting new duration calculation From f4ebcdb27856888794c59c142c43806b863cb61b Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 19 May 2022 12:22:35 +0200 Subject: [PATCH 437/583] Hiero: small bugs - track name was not equal and was catching similar names too - publish action in timeline submenu was broken - parse_container was returning false data even it should not --- openpype/hosts/hiero/api/lib.py | 7 ++++--- openpype/hosts/hiero/api/pipeline.py | 5 +++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/hiero/api/lib.py b/openpype/hosts/hiero/api/lib.py index 2a4cd03b76..be02c7c793 100644 --- a/openpype/hosts/hiero/api/lib.py +++ b/openpype/hosts/hiero/api/lib.py @@ -118,7 +118,7 @@ def get_current_track(sequence, name, audio=False): # get track by name track = None for _track in tracks: - if _track.name() in name: + if _track.name() == name: track = _track if not track: @@ -126,6 +126,7 @@ def get_current_track(sequence, name, audio=False): track = hiero.core.VideoTrack(name) else: track = hiero.core.AudioTrack(name) + sequence.addTrack(track) return track @@ -497,7 +498,7 @@ class PyblishSubmission(hiero.exporters.FnSubmission.Submission): from . import publish # Add submission to Hiero module for retrieval in plugins. hiero.submission = self - publish() + publish(hiero.ui.mainWindow()) def add_submission(): @@ -527,7 +528,7 @@ class PublishAction(QtWidgets.QAction): # from getting picked up when not using the "Export" dialog. if hasattr(hiero, "submission"): del hiero.submission - publish() + publish(hiero.ui.mainWindow()) def eventHandler(self, event): # Add the Menu to the right-click menu diff --git a/openpype/hosts/hiero/api/pipeline.py b/openpype/hosts/hiero/api/pipeline.py index 8025ebff05..9b628ec70b 100644 --- a/openpype/hosts/hiero/api/pipeline.py +++ b/openpype/hosts/hiero/api/pipeline.py @@ -143,6 +143,11 @@ def parse_container(track_item, validate=True): """ # convert tag metadata to normal keys names data = lib.get_track_item_pype_data(track_item) + if ( + not data + or data.get("id") != "pyblish.avalon.container" + ): + return if validate and data and data.get("schema"): schema.validate(data) From c09038984190085b6182cf075455d503692e10ca Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 19 May 2022 19:46:34 +0200 Subject: [PATCH 438/583] Hiero: add new `get_timeline_selection` function --- openpype/hosts/hiero/api/__init__.py | 2 ++ openpype/hosts/hiero/api/lib.py | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/openpype/hosts/hiero/api/__init__.py b/openpype/hosts/hiero/api/__init__.py index f3c32b268c..fc2d017f04 100644 --- a/openpype/hosts/hiero/api/__init__.py +++ b/openpype/hosts/hiero/api/__init__.py @@ -27,6 +27,7 @@ from .lib import ( get_track_items, get_current_project, get_current_sequence, + get_timeline_selection, get_current_track, get_track_item_pype_tag, set_track_item_pype_tag, @@ -80,6 +81,7 @@ __all__ = [ "get_track_items", "get_current_project", "get_current_sequence", + "get_timeline_selection", "get_current_track", "get_track_item_pype_tag", "set_track_item_pype_tag", diff --git a/openpype/hosts/hiero/api/lib.py b/openpype/hosts/hiero/api/lib.py index be02c7c793..115a926d84 100644 --- a/openpype/hosts/hiero/api/lib.py +++ b/openpype/hosts/hiero/api/lib.py @@ -96,6 +96,12 @@ def get_current_sequence(name=None, new=False): return sequence +def get_timeline_selection(): + active_sequence = hiero.ui.activeSequence() + timeline_editor = hiero.ui.getTimelineEditor(active_sequence) + return list(timeline_editor.selection()) + + def get_current_track(sequence, name, audio=False): """ Get current track in context of active project. From 9dd13425c36511873aeefb46f88f721381d91cc6 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 19 May 2022 19:47:15 +0200 Subject: [PATCH 439/583] Hiero: removing event slowing down work with timeline --- openpype/hosts/hiero/api/events.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/hiero/api/events.py b/openpype/hosts/hiero/api/events.py index 7fab3edfc8..59fd278a81 100644 --- a/openpype/hosts/hiero/api/events.py +++ b/openpype/hosts/hiero/api/events.py @@ -109,8 +109,9 @@ def register_hiero_events(): # hiero.core.events.registerInterest("kShutdown", shutDown) # hiero.core.events.registerInterest("kStartup", startupCompleted) - hiero.core.events.registerInterest( - ("kSelectionChanged", "kTimeline"), selection_changed_timeline) + # INFO: was disabled because it was slowing down timeline operations + # hiero.core.events.registerInterest( + # ("kSelectionChanged", "kTimeline"), selection_changed_timeline) # workfiles try: From c70c6d99110dce5f7d880cc0d285e2a03dfbf583 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 19 May 2022 19:48:12 +0200 Subject: [PATCH 440/583] Hiero: fixing one frame difference otio clip and media --- openpype/hosts/hiero/api/otio/hiero_export.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/hiero/api/otio/hiero_export.py b/openpype/hosts/hiero/api/otio/hiero_export.py index 64fb81aed4..46e1204324 100644 --- a/openpype/hosts/hiero/api/otio/hiero_export.py +++ b/openpype/hosts/hiero/api/otio/hiero_export.py @@ -151,7 +151,7 @@ def create_otio_reference(clip): padding = media_source.filenamePadding() file_head = media_source.filenameHead() is_sequence = not media_source.singleFile() - frame_duration = media_source.duration() - 1 + frame_duration = media_source.duration() fps = utils.get_rate(clip) or self.project_fps extension = os.path.splitext(path)[-1] @@ -277,7 +277,7 @@ def create_otio_clip(track_item): # flip if speed is in minus source_in = track_item.sourceIn() if speed > 0 else track_item.sourceOut() - duration = int(track_item.duration()) + duration = int(track_item.duration()) - 1 fps = utils.get_rate(track_item) or self.project_fps name = track_item.name() From cf6ea949acca41fcd04a0155134252c21816939a Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 19 May 2022 19:48:40 +0200 Subject: [PATCH 441/583] global: hierarchy fps should be taken from instance.data --- openpype/plugins/publish/collect_hierarchy.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/plugins/publish/collect_hierarchy.py b/openpype/plugins/publish/collect_hierarchy.py index a96d444be6..8398a2815a 100644 --- a/openpype/plugins/publish/collect_hierarchy.py +++ b/openpype/plugins/publish/collect_hierarchy.py @@ -62,7 +62,7 @@ class CollectHierarchy(pyblish.api.ContextPlugin): "frameEnd": instance.data["frameEnd"], "clipIn": instance.data["clipIn"], "clipOut": instance.data["clipOut"], - 'fps': instance.context.data["fps"], + "fps": instance.data["fps"], "resolutionWidth": instance.data["resolutionWidth"], "resolutionHeight": instance.data["resolutionHeight"], "pixelAspect": instance.data["pixelAspect"] From 191444167c025f0f1bf20b6d15dbd480eff1243e Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 19 May 2022 19:49:52 +0200 Subject: [PATCH 442/583] Hiero: moving order bit lower under core plugins --- openpype/hosts/hiero/plugins/publish/precollect_workfile.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/hiero/plugins/publish/precollect_workfile.py b/openpype/hosts/hiero/plugins/publish/precollect_workfile.py index b9f58c15f6..c9bfb86810 100644 --- a/openpype/hosts/hiero/plugins/publish/precollect_workfile.py +++ b/openpype/hosts/hiero/plugins/publish/precollect_workfile.py @@ -16,7 +16,7 @@ class PrecollectWorkfile(pyblish.api.ContextPlugin): """Inject the current working file into context""" label = "Precollect Workfile" - order = pyblish.api.CollectorOrder - 0.5 + order = pyblish.api.CollectorOrder - 0.491 def process(self, context): @@ -84,6 +84,7 @@ class PrecollectWorkfile(pyblish.api.ContextPlugin): "colorspace": self.get_colorspace(project), "fps": fps } + self.log.debug("__ context_data: {}".format(pformat(context_data))) context.data.update(context_data) self.log.info("Creating instance: {}".format(instance)) From b55bf81d352790c46ba748a7781bc75cda88afb1 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 19 May 2022 19:50:50 +0200 Subject: [PATCH 443/583] Hiero: adding timeline selected to precollector --- .../hosts/hiero/plugins/publish/precollect_instances.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/hiero/plugins/publish/precollect_instances.py b/openpype/hosts/hiero/plugins/publish/precollect_instances.py index 46f0b2440e..1ef7e5f538 100644 --- a/openpype/hosts/hiero/plugins/publish/precollect_instances.py +++ b/openpype/hosts/hiero/plugins/publish/precollect_instances.py @@ -19,9 +19,12 @@ class PrecollectInstances(pyblish.api.ContextPlugin): def process(self, context): self.otio_timeline = context.data["otioTimeline"] - + timeline_selection = phiero.get_timeline_selection() selected_timeline_items = phiero.get_track_items( - selected=True, check_tagged=True, check_enabled=True) + selection=timeline_selection, + check_tagged=True, + check_enabled=True + ) # only return enabled track items if not selected_timeline_items: From 68439301dc5f24372ccd69202bcea3a3040ce733 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 19 May 2022 19:51:21 +0200 Subject: [PATCH 444/583] Hiero: one frame diff fix, with code improvements --- .../hosts/hiero/plugins/publish/precollect_instances.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/hiero/plugins/publish/precollect_instances.py b/openpype/hosts/hiero/plugins/publish/precollect_instances.py index 1ef7e5f538..e54d050f0d 100644 --- a/openpype/hosts/hiero/plugins/publish/precollect_instances.py +++ b/openpype/hosts/hiero/plugins/publish/precollect_instances.py @@ -295,9 +295,9 @@ class PrecollectInstances(pyblish.api.ContextPlugin): for otio_clip in self.otio_timeline.each_clip(): track_name = otio_clip.parent().name parent_range = otio_clip.range_in_parent() - if ti_track_name not in track_name: + if ti_track_name != track_name: continue - if otio_clip.name not in track_item.name(): + if otio_clip.name != track_item.name(): continue self.log.debug("__ parent_range: {}".format(parent_range)) self.log.debug("__ timeline_range: {}".format(timeline_range)) @@ -317,7 +317,7 @@ class PrecollectInstances(pyblish.api.ContextPlugin): speed = track_item.playbackSpeed() timeline = phiero.get_current_sequence() frame_start = int(track_item.timelineIn()) - frame_duration = int(track_item.sourceDuration() / speed) + frame_duration = int((track_item.duration() - 1) / speed) fps = timeline.framerate().toFloat() return hiero_export.create_otio_time_range( From 2798469e36c3f915692cb0cfdea5e0cb5f16900a Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 19 May 2022 19:54:17 +0200 Subject: [PATCH 445/583] Hiero: refactory of get_track_items with better validation --- openpype/hosts/hiero/api/lib.py | 140 ++++++++++++++++++++------------ 1 file changed, 89 insertions(+), 51 deletions(-) diff --git a/openpype/hosts/hiero/api/lib.py b/openpype/hosts/hiero/api/lib.py index 115a926d84..15142daa09 100644 --- a/openpype/hosts/hiero/api/lib.py +++ b/openpype/hosts/hiero/api/lib.py @@ -1,7 +1,10 @@ """ Host specific functions where host api is connected """ + +import contextlib import os +from pprint import pformat import re import sys import platform @@ -139,7 +142,7 @@ def get_current_track(sequence, name, audio=False): def get_track_items( - selected=False, + selection=False, sequence_name=None, track_item_name=None, track_name=None, @@ -150,7 +153,7 @@ def get_track_items( """Get all available current timeline track items. Attribute: - selected (bool)[optional]: return only selected items on timeline + selection (list)[optional]: list of selected track items sequence_name (str)[optional]: return only clips from input sequence track_item_name (str)[optional]: return only item with input name track_name (str)[optional]: return only items from track name @@ -162,32 +165,33 @@ def get_track_items( Return: list or hiero.core.TrackItem: list of track items or single track item """ - return_list = list() - track_items = list() + track_type = track_type or "video" + selection = selection or [] + return_list = [] # get selected track items or all in active sequence - if selected: - try: - selected_items = list(hiero.selection) - for item in selected_items: - if track_name and track_name in item.parent().name(): - # filter only items fitting input track name - track_items.append(item) - elif not track_name: - # or add all if no track_name was defined - track_items.append(item) - except AttributeError: - pass + if selection: + with contextlib.suppress(AttributeError): + for track_item in selection: + log.info("___ track_item: {}".format(track_item)) + # make sure only trackitems are selected + if not isinstance(track_item, hiero.core.TrackItem): + continue + + if _validate_all_atrributes( + track_item, + track_item_name, + track_name, + track_type, + check_enabled, + check_tagged + ): + log.info("___ valid trackitem: {}".format(track_item)) + return_list.append(track_item) - # check if any collected track items are - # `core.Hiero.Python.TrackItem` instance - if track_items: - any_track_item = track_items[0] - if not isinstance(any_track_item, hiero.core.TrackItem): - selected_items = [] # collect all available active sequence track items - if not track_items: + if not return_list: sequence = get_current_sequence(name=sequence_name) # get all available tracks from sequence tracks = list(sequence.audioTracks()) + list(sequence.videoTracks()) @@ -198,42 +202,76 @@ def get_track_items( if check_enabled and not track.isEnabled(): continue # and all items in track - for item in track.items(): - if check_tagged and not item.tags(): + for track_item in track.items(): + # make sure no subtrackitem is also track items + if not isinstance(track_item, hiero.core.TrackItem): continue - # check if track item is enabled - if check_enabled: - if not item.isEnabled(): - continue - if track_item_name: - if track_item_name in item.name(): - return item - # make sure only track items with correct track names are added - if track_name and track_name in track.name(): - # filter out only defined track_name items - track_items.append(item) - elif not track_name: - # or add all if no track_name is defined - track_items.append(item) + if not _validate_all_atrributes( + track_item, + track_item_name, + track_name, + track_type, + check_enabled, + check_tagged + ): + return_list.append(track_item) - # filter out only track items with defined track_type - for track_item in track_items: - if track_type and track_type == "video" and isinstance( + return return_list + + +def _validate_all_atrributes( + track_item, + track_item_name, + track_name, + track_type, + check_enabled, + check_tagged +): + def _validate_correct_name_track_item(): + if track_item_name and track_item_name in track_item.name(): + return True + elif not track_item_name: + return True + + def _validate_tagged_track_item(): + if check_tagged and track_item.tags(): + return True + elif not check_tagged: + return True + + def _validate_enabled_track_item(): + if check_enabled and track_item.isEnabled(): + return True + elif not check_enabled: + return True + + def _validate_parent_track_item(): + if track_name and track_name in track_item.parent().name(): + # filter only items fitting input track name + return True + elif not track_name: + # or add all if no track_name was defined + return True + + def _validate_type_track_item(): + if track_type == "video" and isinstance( track_item.parent(), hiero.core.VideoTrack): # only video track items are allowed - return_list.append(track_item) - elif track_type and track_type == "audio" and isinstance( + return True + elif track_type == "audio" and isinstance( track_item.parent(), hiero.core.AudioTrack): # only audio track items are allowed - return_list.append(track_item) - elif not track_type: - # add all if no track_type is defined - return_list.append(track_item) + return True - # return output list but make sure all items are TrackItems - return [_i for _i in return_list - if type(_i) == hiero.core.TrackItem] + # check if track item is enabled + return all([ + _validate_enabled_track_item(), + _validate_type_track_item(), + _validate_tagged_track_item(), + _validate_parent_track_item(), + _validate_correct_name_track_item() + ]) def get_track_item_pype_tag(track_item): From 13599837176687e49a57762414aad97e56e9125a Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 19 May 2022 19:55:16 +0200 Subject: [PATCH 446/583] Hiero: fixing events --- openpype/hosts/hiero/api/lib.py | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/openpype/hosts/hiero/api/lib.py b/openpype/hosts/hiero/api/lib.py index 15142daa09..d3e6441705 100644 --- a/openpype/hosts/hiero/api/lib.py +++ b/openpype/hosts/hiero/api/lib.py @@ -1027,7 +1027,7 @@ def sync_clip_name_to_data_asset(track_items_list): print("asset was changed in clip: {}".format(ti_name)) -def check_inventory_versions(): +def check_inventory_versions(track_items=None): """ Actual version color idetifier of Loaded containers @@ -1038,14 +1038,15 @@ def check_inventory_versions(): """ from . import parse_container + track_item = track_items or get_track_items() # presets clip_color_last = "green" clip_color = "red" # get all track items from current timeline - for track_item in get_track_items(): + for track_item in track_item: container = parse_container(track_item) - + log.info("___> container: {}".format(pformat(container))) if container: # get representation from io representation = legacy_io.find_one({ @@ -1083,29 +1084,31 @@ def selection_changed_timeline(event): timeline_editor = event.sender selection = timeline_editor.selection() - selection = [ti for ti in selection - if isinstance(ti, hiero.core.TrackItem)] + track_items = get_track_items( + selection=selection, + track_type="video", + check_enabled=True, + check_locked=True, + check_tagged=True + ) # run checking function - sync_clip_name_to_data_asset(selection) - - # also mark old versions of loaded containers - check_inventory_versions() + sync_clip_name_to_data_asset(track_items) def before_project_save(event): track_items = get_track_items( - selected=False, track_type="video", check_enabled=True, check_locked=True, - check_tagged=True) + check_tagged=True + ) # run checking function sync_clip_name_to_data_asset(track_items) # also mark old versions of loaded containers - check_inventory_versions() + check_inventory_versions(track_items) def get_main_window(): From 733ad125b5798d01fe9dac9d3d24e6256d614386 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 19 May 2022 19:56:15 +0200 Subject: [PATCH 447/583] Hiero: one frame diff during loading --- openpype/hosts/hiero/api/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/hiero/api/plugin.py b/openpype/hosts/hiero/api/plugin.py index 54e66bf99a..35e9d54810 100644 --- a/openpype/hosts/hiero/api/plugin.py +++ b/openpype/hosts/hiero/api/plugin.py @@ -500,7 +500,7 @@ class ClipLoader: track_item.setSource(clip) track_item.setSourceIn(self.handle_start) track_item.setTimelineIn(self.timeline_in) - track_item.setSourceOut(self.media_duration - self.handle_end) + track_item.setSourceOut((self.media_duration + 1) - self.handle_end) track_item.setTimelineOut(self.timeline_out) track_item.setPlaybackSpeed(1) self.active_track.addTrackItem(track_item) From 91af28612e367d412e70a20ece03481ed4b976e9 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 20 May 2022 11:21:16 +0200 Subject: [PATCH 448/583] Hiero: poping found clip --- openpype/hosts/hiero/plugins/load/load_clip.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/hiero/plugins/load/load_clip.py b/openpype/hosts/hiero/plugins/load/load_clip.py index da4326c8c1..a3365253b3 100644 --- a/openpype/hosts/hiero/plugins/load/load_clip.py +++ b/openpype/hosts/hiero/plugins/load/load_clip.py @@ -3,10 +3,6 @@ from openpype.pipeline import ( get_representation_path, ) import openpype.hosts.hiero.api as phiero -# from openpype.hosts.hiero.api import plugin, lib -# reload(lib) -# reload(plugin) -# reload(phiero) class LoadClip(phiero.SequenceLoader): @@ -106,7 +102,7 @@ class LoadClip(phiero.SequenceLoader): name = container['name'] namespace = container['namespace'] track_item = phiero.get_track_items( - track_item_name=namespace) + track_item_name=namespace).pop() version = legacy_io.find_one({ "type": "version", "_id": representation["parent"] @@ -157,7 +153,7 @@ class LoadClip(phiero.SequenceLoader): # load clip to timeline and get main variables namespace = container['namespace'] track_item = phiero.get_track_items( - track_item_name=namespace) + track_item_name=namespace).pop() track = track_item.parent() # remove track item from track From 053287bc616f1370602383f2aa4891fe80358599 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 19 May 2022 20:00:01 +0200 Subject: [PATCH 449/583] Hiero: better logging and improving code --- openpype/hosts/hiero/api/plugin.py | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/hiero/api/plugin.py b/openpype/hosts/hiero/api/plugin.py index 35e9d54810..174a25102f 100644 --- a/openpype/hosts/hiero/api/plugin.py +++ b/openpype/hosts/hiero/api/plugin.py @@ -1,4 +1,5 @@ import os +from pprint import pformat import re from copy import deepcopy @@ -400,7 +401,8 @@ class ClipLoader: # inject asset data to representation dict self._get_asset_data() - log.debug("__init__ self.data: `{}`".format(self.data)) + log.info("__init__ self.data: `{}`".format(pformat(self.data))) + log.info("__init__ options: `{}`".format(pformat(options))) # add active components to class if self.new_sequence: @@ -482,7 +484,9 @@ class ClipLoader: """ asset_name = self.context["representation"]["context"]["asset"] - self.data["assetData"] = openpype.get_asset(asset_name)["data"] + asset_doc = openpype.get_asset(asset_name) + log.debug("__ asset_doc: {}".format(pformat(asset_doc))) + self.data["assetData"] = asset_doc["data"] def _make_track_item(self, source_bin_item, audio=False): """ Create track item with """ @@ -527,7 +531,8 @@ class ClipLoader: if self.sequencial_load: last_track_item = lib.get_track_items( sequence_name=self.active_sequence.name(), - track_name=self.active_track.name()) + track_name=self.active_track.name() + ) if len(last_track_item) == 0: last_timeline_out = 0 else: @@ -541,6 +546,8 @@ class ClipLoader: self.timeline_in = int(self.data["assetData"]["clipIn"]) self.timeline_out = int(self.data["assetData"]["clipOut"]) + log.debug("__ self.timeline_in: {}".format(self.timeline_in)) + log.debug("__ self.timeline_out: {}".format(self.timeline_out)) # check if slate is included # either in version data families or by calculating frame diff slate_on = next( @@ -553,6 +560,7 @@ class ClipLoader: (self.timeline_out - self.timeline_in + 1) + self.handle_start + self.handle_end) < self.media_duration) + log.debug("__ slate_on: {}".format(slate_on)) # if slate is on then remove the slate frame from beginning if slate_on: self.media_duration -= 1 @@ -599,8 +607,8 @@ class Creator(LegacyCreator): rename_index = None def __init__(self, *args, **kwargs): - import openpype.hosts.hiero.api as phiero super(Creator, self).__init__(*args, **kwargs) + import openpype.hosts.hiero.api as phiero self.presets = openpype.get_current_project_settings()[ "hiero"]["create"].get(self.__class__.__name__, {}) @@ -609,7 +617,10 @@ class Creator(LegacyCreator): self.sequence = phiero.get_current_sequence() if (self.options or {}).get("useSelection"): - self.selected = phiero.get_track_items(selected=True) + timeline_selection = phiero.get_timeline_selection() + self.selected = phiero.get_track_items( + selection=timeline_selection + ) else: self.selected = phiero.get_track_items() @@ -716,6 +727,10 @@ class PublishClip: else: self.tag_data.update({"reviewTrack": None}) + log.debug("___ self.tag_data: {}".format( + pformat(self.tag_data) + )) + # create pype tag on track_item and add data lib.imprint(self.track_item, self.tag_data) From d27448e7e36d751f16b458bde328065706378092 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 19 May 2022 20:42:29 +0200 Subject: [PATCH 450/583] Hiero: handles and slate detection - handles should be int - slate only if in families on version --- openpype/hosts/hiero/api/plugin.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/hiero/api/plugin.py b/openpype/hosts/hiero/api/plugin.py index 174a25102f..e3ec6f3cf1 100644 --- a/openpype/hosts/hiero/api/plugin.py +++ b/openpype/hosts/hiero/api/plugin.py @@ -524,9 +524,12 @@ class ClipLoader: self.handle_start = self.data["versionData"].get("handleStart") self.handle_end = self.data["versionData"].get("handleEnd") if self.handle_start is None: - self.handle_start = int(self.data["assetData"]["handleStart"]) + self.handle_start = self.data["assetData"]["handleStart"] if self.handle_end is None: - self.handle_end = int(self.data["assetData"]["handleEnd"]) + self.handle_end = self.data["assetData"]["handleEnd"] + + self.handle_start = int(self.handle_start) + self.handle_end = int(self.handle_end) if self.sequencial_load: last_track_item = lib.get_track_items( @@ -556,9 +559,7 @@ class ClipLoader: if "slate" in f), # if nothing was found then use default None # so other bool could be used - None) or bool(int( - (self.timeline_out - self.timeline_in + 1) - + self.handle_start + self.handle_end) < self.media_duration) + None) log.debug("__ slate_on: {}".format(slate_on)) # if slate is on then remove the slate frame from beginning From cac79e69031f5c9a0e082ad0b4117ce3b5339a78 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 19 May 2022 20:42:53 +0200 Subject: [PATCH 451/583] Hiero: fix timewarp lookup to be list --- openpype/lib/editorial.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/lib/editorial.py b/openpype/lib/editorial.py index 1ee21deedc..5fe498bf6a 100644 --- a/openpype/lib/editorial.py +++ b/openpype/lib/editorial.py @@ -218,6 +218,7 @@ def get_media_range_with_retimes(otio_clip, handle_start, handle_end): "name": name } tw_node.update(metadata) + tw_node["lookup"] = list(lookup) # get first and last frame offsets offset_in += lookup[0] From ffe1bff6599e0de5c0e07b30b4878714bd26e91a Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 19 May 2022 21:06:11 +0200 Subject: [PATCH 452/583] Hiero: fix by reversing validation --- openpype/hosts/hiero/api/lib.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/openpype/hosts/hiero/api/lib.py b/openpype/hosts/hiero/api/lib.py index d3e6441705..4dc8d26c2a 100644 --- a/openpype/hosts/hiero/api/lib.py +++ b/openpype/hosts/hiero/api/lib.py @@ -207,7 +207,7 @@ def get_track_items( if not isinstance(track_item, hiero.core.TrackItem): continue - if not _validate_all_atrributes( + if _validate_all_atrributes( track_item, track_item_name, track_name, @@ -1046,7 +1046,6 @@ def check_inventory_versions(track_items=None): # get all track items from current timeline for track_item in track_item: container = parse_container(track_item) - log.info("___> container: {}".format(pformat(container))) if container: # get representation from io representation = legacy_io.find_one({ From 15726cfef92bb465a794d7ccd3acb8cdc3e828bd Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 19 May 2022 21:06:42 +0200 Subject: [PATCH 453/583] Hiero: simplify code for slate detection --- openpype/hosts/hiero/api/plugin.py | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/openpype/hosts/hiero/api/plugin.py b/openpype/hosts/hiero/api/plugin.py index e3ec6f3cf1..8c61baa04b 100644 --- a/openpype/hosts/hiero/api/plugin.py +++ b/openpype/hosts/hiero/api/plugin.py @@ -551,17 +551,11 @@ class ClipLoader: log.debug("__ self.timeline_in: {}".format(self.timeline_in)) log.debug("__ self.timeline_out: {}".format(self.timeline_out)) - # check if slate is included - # either in version data families or by calculating frame diff - slate_on = next( - # check iterate if slate is in families - (f for f in self.context["version"]["data"]["families"] - if "slate" in f), - # if nothing was found then use default None - # so other bool could be used - None) + # check if slate is included + slate_on = "slate" in self.context["version"]["data"]["families"] log.debug("__ slate_on: {}".format(slate_on)) + # if slate is on then remove the slate frame from beginning if slate_on: self.media_duration -= 1 @@ -581,7 +575,7 @@ class ClipLoader: # there were some cases were hiero was not creating it source_bin_item = None for item in self.active_bin.items(): - if self.data["clip_name"] in item.name(): + if self.data["clip_name"] == item.name(): source_bin_item = item if not source_bin_item: log.warning("Problem with created Source clip: `{}`".format( From 38d4c3fa67effbc25847ee8706c7a1714cfa54a8 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 19 May 2022 21:17:14 +0200 Subject: [PATCH 454/583] Hiero: lib code refactory --- openpype/hosts/hiero/api/lib.py | 37 +++++++++++++-------------------- 1 file changed, 15 insertions(+), 22 deletions(-) diff --git a/openpype/hosts/hiero/api/lib.py b/openpype/hosts/hiero/api/lib.py index 4dc8d26c2a..758df43968 100644 --- a/openpype/hosts/hiero/api/lib.py +++ b/openpype/hosts/hiero/api/lib.py @@ -4,7 +4,6 @@ Host specific functions where host api is connected import contextlib import os -from pprint import pformat import re import sys import platform @@ -92,7 +91,7 @@ def get_current_sequence(name=None, new=False): if not sequence: # if nothing found create new with input name sequence = get_current_sequence(name, True) - elif not name and not new: + else: # if name is none and new is False then return current open sequence sequence = hiero.ui.activeSequence() @@ -189,7 +188,6 @@ def get_track_items( log.info("___ valid trackitem: {}".format(track_item)) return_list.append(track_item) - # collect all available active sequence track items if not return_list: sequence = get_current_sequence(name=sequence_name) @@ -311,7 +309,7 @@ def set_track_item_pype_tag(track_item, data=None): "editable": "0", "note": "OpenPype data container", "icon": "openpype_icon.png", - "metadata": {k: v for k, v in data.items()} + "metadata": dict(data.items()) } # get available pype tag if any _tag = get_track_item_pype_tag(track_item) @@ -369,7 +367,7 @@ def get_track_item_pype_data(track_item): log.warning(msg) value = v - data.update({key: value}) + data[key] = value return data @@ -938,32 +936,32 @@ def apply_colorspace_clips(): def is_overlapping(ti_test, ti_original, strict=False): - covering_exp = bool( + covering_exp = ( (ti_test.timelineIn() <= ti_original.timelineIn()) and (ti_test.timelineOut() >= ti_original.timelineOut()) ) - inside_exp = bool( + inside_exp = ( (ti_test.timelineIn() >= ti_original.timelineIn()) and (ti_test.timelineOut() <= ti_original.timelineOut()) ) - overlaying_right_exp = bool( + overlaying_right_exp = ( (ti_test.timelineIn() < ti_original.timelineOut()) and (ti_test.timelineOut() >= ti_original.timelineOut()) ) - overlaying_left_exp = bool( + overlaying_left_exp = ( (ti_test.timelineOut() > ti_original.timelineIn()) and (ti_test.timelineIn() <= ti_original.timelineIn()) ) - if not strict: + if strict: + return covering_exp + else: return any(( covering_exp, inside_exp, overlaying_right_exp, overlaying_left_exp )) - else: - return covering_exp def get_sequence_pattern_and_padding(file): @@ -981,17 +979,12 @@ def get_sequence_pattern_and_padding(file): """ foundall = re.findall( r"(#+)|(%\d+d)|(?<=[^a-zA-Z0-9])(\d+)(?=\.\w+$)", file) - if foundall: - found = sorted(list(set(foundall[0])))[-1] - - if "%" in found: - padding = int(re.findall(r"\d+", found)[-1]) - else: - padding = len(found) - - return found, padding - else: + if not foundall: return None, None + found = sorted(list(set(foundall[0])))[-1] + + padding = int(re.findall(r"\d+", found)[-1]) if "%" in found else len(found) + return found, padding def sync_clip_name_to_data_asset(track_items_list): From 90948931b08dba9e4698ffc45f6a3be307996853 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 19 May 2022 19:49:08 +0200 Subject: [PATCH 455/583] Hiero: removing old code From b9278efdfd0233208e33b0e02036188ead29fd64 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 20 May 2022 12:30:00 +0200 Subject: [PATCH 456/583] Added jpg extension to filter for Maya Image plane loader --- openpype/hosts/maya/plugins/load/load_image_plane.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/load/load_image_plane.py b/openpype/hosts/maya/plugins/load/load_image_plane.py index b67c2cb209..5e44917f28 100644 --- a/openpype/hosts/maya/plugins/load/load_image_plane.py +++ b/openpype/hosts/maya/plugins/load/load_image_plane.py @@ -83,7 +83,7 @@ class ImagePlaneLoader(load.LoaderPlugin): families = ["image", "plate", "render"] label = "Load imagePlane" - representations = ["mov", "exr", "preview", "png"] + representations = ["mov", "exr", "preview", "png", "jpg"] icon = "image" color = "orange" From f02f11750ca394adb60ace7e9ab9164d778bd7e9 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 20 May 2022 12:33:30 +0200 Subject: [PATCH 457/583] reversed block signals --- .../project_manager/project_manager/model.py | 4 --- .../project_manager/project_manager/view.py | 28 ------------------- 2 files changed, 32 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index b7cb0ec9ed..871704e13c 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -264,8 +264,6 @@ class HierarchyModel(QtCore.QAbstractItemModel): if not project_doc: return - self.blockSignals(True) - # Create project item project_item = ProjectItem(project_doc) self.add_item(project_item) @@ -379,8 +377,6 @@ class HierarchyModel(QtCore.QAbstractItemModel): self.add_items(task_items, asset_item) - self.blockSignals(False) - # Emit that project was successfully changed self.project_changed.emit() diff --git a/openpype/tools/project_manager/project_manager/view.py b/openpype/tools/project_manager/project_manager/view.py index 2c2a17d712..25174232bc 100644 --- a/openpype/tools/project_manager/project_manager/view.py +++ b/openpype/tools/project_manager/project_manager/view.py @@ -213,34 +213,6 @@ class HierarchyView(QtWidgets.QTreeView): index = self._source_model.index_for_item(project_item) self.expand(index) - self._open_persistent_editors_on_project_refresh() - - def _open_persistent_editors_on_project_refresh(self): - model = self._source_model - parent_index = QtCore.QModelIndex() - persistent_queue = collections.deque() - persistent_queue.append((parent_index, model.rowCount())) - while persistent_queue: - item = persistent_queue.popleft() - parent_index, rows = item - if not rows: - continue - - for row in range(rows): - row_index = model.index(row, 0, parent_index) - persistent_queue.append( - (row_index, model.rowCount(row_index)) - ) - for key, column in self._column_key_to_index.items(): - if key not in self.persistent_columns: - continue - col_index = model.index(row, column, parent_index) - if bool( - model.flags(col_index) - & QtCore.Qt.ItemIsEditable - ): - self.openPersistentEditor(col_index) - def _on_rows_moved(self, index): parent_index = index.parent() if not self.isExpanded(parent_index): From 61be6857603c8061283bcbed938f07c5bd525eee Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 20 May 2022 12:35:46 +0200 Subject: [PATCH 458/583] replaced persistent editors with added ability of any edit trigger --- .../tools/project_manager/project_manager/view.py | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/view.py b/openpype/tools/project_manager/project_manager/view.py index 25174232bc..6f5b9dc3f7 100644 --- a/openpype/tools/project_manager/project_manager/view.py +++ b/openpype/tools/project_manager/project_manager/view.py @@ -139,6 +139,7 @@ class HierarchyView(QtWidgets.QTreeView): self.setAlternatingRowColors(True) self.setSelectionMode(HierarchyView.ExtendedSelection) self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + self.setEditTriggers(HierarchyView.AllEditTriggers) column_delegates = {} column_key_to_index = {} @@ -301,16 +302,6 @@ class HierarchyView(QtWidgets.QTreeView): def rowsInserted(self, parent_index, start, end): super(HierarchyView, self).rowsInserted(parent_index, start, end) - for row in range(start, end + 1): - for key, column in self._column_key_to_index.items(): - if key not in self.persistent_columns: - continue - col_index = self._source_model.index(row, column, parent_index) - if bool( - self._source_model.flags(col_index) - & QtCore.Qt.ItemIsEditable - ): - self.openPersistentEditor(col_index) # Expand parent on insert if not self.isExpanded(parent_index): From 7c3fdcc633f80b73084b0fbf4ba6f51d4c8c8a71 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 20 May 2022 12:59:38 +0200 Subject: [PATCH 459/583] Hiero: tag should use deepcopy for metadata --- openpype/hosts/hiero/api/lib.py | 7 ++++--- openpype/hosts/hiero/api/tags.py | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/hiero/api/lib.py b/openpype/hosts/hiero/api/lib.py index 758df43968..5b2f6c814d 100644 --- a/openpype/hosts/hiero/api/lib.py +++ b/openpype/hosts/hiero/api/lib.py @@ -3,6 +3,7 @@ Host specific functions where host api is connected """ import contextlib +from copy import deepcopy import os import re import sys @@ -288,7 +289,7 @@ def get_track_item_pype_tag(track_item): return None for tag in _tags: # return only correct tag defined by global name - if tag.name() in self.pype_tag_name: + if tag.name() == self.pype_tag_name: return tag @@ -344,9 +345,9 @@ def get_track_item_pype_data(track_item): return None # get tag metadata attribute - tag_data = tag.metadata() + tag_data = deepcopy(dict(tag.metadata())) # convert tag metadata to normal keys names and values to correct types - for k, v in dict(tag_data).items(): + for k, v in tag_data.items(): key = k.replace("tag.", "") try: diff --git a/openpype/hosts/hiero/api/tags.py b/openpype/hosts/hiero/api/tags.py index 8877b92b9d..8c6ff2a77b 100644 --- a/openpype/hosts/hiero/api/tags.py +++ b/openpype/hosts/hiero/api/tags.py @@ -86,7 +86,7 @@ def update_tag(tag, data): # due to hiero bug we have to make sure keys which are not existent in # data are cleared of value by `None` - for _mk in mtd.keys(): + for _mk in mtd.dict().keys(): if _mk.replace("tag.", "") not in data_mtd.keys(): mtd.setValue(_mk, str(None)) From be9f6cf5c5ddd7e833f5dd5c31e690706767e493 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 20 May 2022 15:08:56 +0200 Subject: [PATCH 460/583] Hiero: frame difference issue --- openpype/hosts/hiero/api/otio/hiero_export.py | 2 +- openpype/hosts/hiero/api/plugin.py | 2 +- openpype/lib/editorial.py | 2 +- openpype/plugins/publish/collect_otio_frame_ranges.py | 8 ++++---- openpype/plugins/publish/collect_otio_subset_resources.py | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/openpype/hosts/hiero/api/otio/hiero_export.py b/openpype/hosts/hiero/api/otio/hiero_export.py index 46e1204324..1e4088d9c0 100644 --- a/openpype/hosts/hiero/api/otio/hiero_export.py +++ b/openpype/hosts/hiero/api/otio/hiero_export.py @@ -277,7 +277,7 @@ def create_otio_clip(track_item): # flip if speed is in minus source_in = track_item.sourceIn() if speed > 0 else track_item.sourceOut() - duration = int(track_item.duration()) - 1 + duration = int(track_item.duration()) fps = utils.get_rate(track_item) or self.project_fps name = track_item.name() diff --git a/openpype/hosts/hiero/api/plugin.py b/openpype/hosts/hiero/api/plugin.py index 8c61baa04b..add416d04e 100644 --- a/openpype/hosts/hiero/api/plugin.py +++ b/openpype/hosts/hiero/api/plugin.py @@ -504,7 +504,7 @@ class ClipLoader: track_item.setSource(clip) track_item.setSourceIn(self.handle_start) track_item.setTimelineIn(self.timeline_in) - track_item.setSourceOut((self.media_duration + 1) - self.handle_end) + track_item.setSourceOut((self.media_duration) - self.handle_end) track_item.setTimelineOut(self.timeline_out) track_item.setPlaybackSpeed(1) self.active_track.addTrackItem(track_item) diff --git a/openpype/lib/editorial.py b/openpype/lib/editorial.py index 5fe498bf6a..2c877b9d0d 100644 --- a/openpype/lib/editorial.py +++ b/openpype/lib/editorial.py @@ -255,7 +255,7 @@ def get_media_range_with_retimes(otio_clip, handle_start, handle_end): media_in + source_in + offset_in) media_out_trimmed = ( media_in + source_in + ( - (source_range.duration.value * abs( + ((source_range.duration.value - 1) * abs( time_scalar)) + offset_out)) # calculate available handles diff --git a/openpype/plugins/publish/collect_otio_frame_ranges.py b/openpype/plugins/publish/collect_otio_frame_ranges.py index ee7b7957ad..8eaf9d6f29 100644 --- a/openpype/plugins/publish/collect_otio_frame_ranges.py +++ b/openpype/plugins/publish/collect_otio_frame_ranges.py @@ -55,13 +55,13 @@ class CollectOtioFrameRanges(pyblish.api.InstancePlugin): "frameStart": frame_start, "frameEnd": frame_end, "clipIn": tl_start, - "clipOut": tl_end, + "clipOut": tl_end - 1, "clipInH": tl_start_h, - "clipOutH": tl_end_h, + "clipOutH": tl_end_h - 1, "sourceStart": src_starting_from + src_start, - "sourceEnd": src_starting_from + src_end, + "sourceEnd": src_starting_from + src_end - 1, "sourceStartH": src_starting_from + src_start_h, - "sourceEndH": src_starting_from + src_end_h, + "sourceEndH": src_starting_from + src_end_h - 1, } instance.data.update(data) self.log.debug( diff --git a/openpype/plugins/publish/collect_otio_subset_resources.py b/openpype/plugins/publish/collect_otio_subset_resources.py index 7c11462ef0..b89a076a44 100644 --- a/openpype/plugins/publish/collect_otio_subset_resources.py +++ b/openpype/plugins/publish/collect_otio_subset_resources.py @@ -66,7 +66,7 @@ class CollectOtioSubsetResources(pyblish.api.InstancePlugin): # create trimmed otio time range trimmed_media_range_h = editorial.range_from_frames( - a_frame_start_h, (a_frame_end_h - a_frame_start_h + 1), + a_frame_start_h, (a_frame_end_h - a_frame_start_h) + 1, media_fps ) trimmed_duration = trimmed_media_range_h.duration.value From 4c4c824d8084247c2b0ed550579849fe9b503247 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 20 May 2022 15:22:23 +0200 Subject: [PATCH 461/583] :recycle: refactored work with attributes --- .../maya/plugins/publish/collect_look.py | 153 +++++++++--------- 1 file changed, 81 insertions(+), 72 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/collect_look.py b/openpype/hosts/maya/plugins/publish/collect_look.py index 123e2637cb..fb2ce04cad 100644 --- a/openpype/hosts/maya/plugins/publish/collect_look.py +++ b/openpype/hosts/maya/plugins/publish/collect_look.py @@ -23,33 +23,43 @@ RENDERER_NODE_TYPES = [ "RedshiftMeshParameters" ] SHAPE_ATTRS = set(SHAPE_ATTRS) -DEFAULT_FILE_NODES = frozenset( - ["file"] -) -ARNOLD_FILE_NODES = frozenset( - ["aiImage"] -) -REDSHIFT_FILE_NODES = frozenset( - ["RedshiftNormalMap"] -) -RENDERMAN_FILE_NODES = frozenset( - [ - "PxrBump", - "PxrNormalMap", - # PxrMultiTexture (need to handle multiple filename0 attrs) - "PxrPtexture", - "PxrTexture", - ] -) -NODES_WITH_FILE = frozenset().union( - DEFAULT_FILE_NODES -) -NODES_WITH_FILENAME = frozenset().union( - ARNOLD_FILE_NODES, RENDERMAN_FILE_NODES -) -NODES_WITH_TEX = frozenset().union( - REDSHIFT_FILE_NODES -) + + +def get_pxr_multitexture_file_attrs(node): + attrs = [] + for i in range(9): + if cmds.attributeQuery("filename{}".format(i), node): + file = cmds.getAttr("{}.filename{}".format(node, i)) + if file: + attrs.append("filename{}".format(i)) + return attrs + + +FILE_NODES = { + "file": "fileTextureName", + + "aiImage": "filename", + + "RedshiftNormalMap": "text0", + + "PxrBump": "filename", + "PxrNormalMap": "filename", + "PxrMultiTexture": get_pxr_multitexture_file_attrs, + "PxrPtexture": "filename", + "PxrTexture": "filename" +} + + +def get_attributes(dictionary, attr): + # type: (dict, str) -> list + if callable(dictionary[attr]): + val = dictionary[attr]() + else: + val = dictionary.get(attr, []) + + if not isinstance(val, list): + return [val] + return val def get_look_attrs(node): @@ -77,15 +87,13 @@ def get_look_attrs(node): if cmds.objectType(node, isAType="shape"): attrs = cmds.listAttr(node, changedSinceFileOpen=True) or [] for attr in attrs: - if attr in SHAPE_ATTRS: + if attr in SHAPE_ATTRS or \ + attr not in SHAPE_ATTRS and attr.startswith('ai'): result.append(attr) - elif attr.startswith('ai'): - result.append(attr) - return result -def node_uses_image_sequence(node): +def node_uses_image_sequence(node, node_path): # type: (str) -> bool """Return whether file node uses an image sequence or single image. @@ -101,8 +109,6 @@ def node_uses_image_sequence(node): """ # useFrameExtension indicates an explicit image sequence - node_path = get_file_node_path(node).lower() - # The following tokens imply a sequence patterns = ["", "", "", "u_v", ""] @@ -169,14 +175,15 @@ def seq_to_glob(path): return path -def get_file_node_path(node): +def get_file_node_paths(node): + # type: (str) -> list """Get the file path used by a Maya file node. Args: node (str): Name of the Maya file node Returns: - str: the file path in use + list: the file paths in use """ # if the path appears to be sequence, use computedFileTextureNamePattern, @@ -195,15 +202,19 @@ def get_file_node_path(node): ""] lower = texture_pattern.lower() if any(pattern in lower for pattern in patterns): - return texture_pattern + return [texture_pattern] - if cmds.nodeType(node) in NODES_WITH_FILENAME: - return cmds.getAttr('{0}.filename'.format(node)) - if cmds.nodeType(node) == 'RedshiftNormalMap': - return cmds.getAttr('{}.tex0'.format(node)) + try: + file_attributes = get_attributes(FILE_NODES, cmds.nodeType(node)) + except AttributeError: + file_attributes = "fileTextureName" - # otherwise use fileTextureName - return cmds.getAttr('{0}.fileTextureName'.format(node)) + files = [] + for file_attr in file_attributes: + if cmds.attributeQuery(file_attr, node=node, exists=True): + files.append(cmds.getAttr("{}.{}".format(node, file_attr))) + + return files def get_file_node_files(node): @@ -217,16 +228,21 @@ def get_file_node_files(node): list: List of full file paths. """ + paths = get_file_node_paths(node) + sequences = [] + replaces = [] + for index, path in enumerate(paths): + if node_uses_image_sequence(node, path): + glob_pattern = seq_to_glob(path) + sequences.extend(glob.glob(glob_pattern)) + replaces.append(index) - path = get_file_node_path(node) - path = cmds.workspace(expandName=path) - if node_uses_image_sequence(node): - glob_pattern = seq_to_glob(path) - return glob.glob(glob_pattern) - elif os.path.exists(path): - return [path] - else: - return [] + for index in replaces: + paths.pop(index) + + paths.extend(sequences) + + return [p for p in paths if os.path.exists(p)] class CollectLook(pyblish.api.InstancePlugin): @@ -270,13 +286,13 @@ class CollectLook(pyblish.api.InstancePlugin): "for %s" % instance.data['name']) # Discover related object sets - self.log.info("Gathering sets..") + self.log.info("Gathering sets ...") sets = self.collect_sets(instance) # Lookup set (optimization) instance_lookup = set(cmds.ls(instance, long=True)) - self.log.info("Gathering set relations..") + self.log.info("Gathering set relations ...") # Ensure iteration happen in a list so we can remove keys from the # dict within the loop @@ -409,10 +425,7 @@ class CollectLook(pyblish.api.InstancePlugin): or [] ) - all_supported_nodes = set().union( - DEFAULT_FILE_NODES, ARNOLD_FILE_NODES, REDSHIFT_FILE_NODES, - RENDERMAN_FILE_NODES - ) + all_supported_nodes = FILE_NODES.keys() files = [] for node_type in all_supported_nodes: files.extend(cmds.ls(history, type=node_type, long=True)) @@ -550,27 +563,23 @@ class CollectLook(pyblish.api.InstancePlugin): dict """ self.log.debug("processing: {}".format(node)) - all_supported_nodes = set().union( - DEFAULT_FILE_NODES, ARNOLD_FILE_NODES, REDSHIFT_FILE_NODES, - RENDERMAN_FILE_NODES - ) + all_supported_nodes = FILE_NODES.keys() if cmds.nodeType(node) not in all_supported_nodes: self.log.error( "Unsupported file node: {}".format(cmds.nodeType(node))) raise AssertionError("Unsupported file node") self.log.debug(" - got {}".format(cmds.nodeType(node))) - if cmds.nodeType(node) in NODES_WITH_FILE: - attribute = "{}.fileTextureName".format(node) - computed_attribute = "{}.computedFileTextureNamePattern".format(node) - elif cmds.nodeType(node) in NODES_WITH_FILENAME: - attribute = "{}.filename".format(node) - computed_attribute = attribute - elif cmds.nodeType(node) in NODES_WITH_TEX: - attribute = "{}.tex0".format(node) - computed_attribute = attribute - source = cmds.getAttr(attribute) + attribute = FILE_NODES.get(cmds.nodeType(node)) + source = cmds.getAttr("{}.{}".format( + node, + attribute + )) + computed_attribute = "{}.{}".format(node, attribute) + if attribute == "fileTextureName": + computed_attribute = node + ".computedFileTextureNamePattern" + self.log.info(" - file source: {}".format(source)) color_space_attr = "{}.colorSpace".format(node) try: From f35079aaed7eb74ccca74645c800d20d8f225801 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 20 May 2022 16:18:39 +0200 Subject: [PATCH 462/583] global: remove exclude family for `clip` --- openpype/plugins/publish/integrate_new.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/plugins/publish/integrate_new.py b/openpype/plugins/publish/integrate_new.py index bf13a4050e..353314fff2 100644 --- a/openpype/plugins/publish/integrate_new.py +++ b/openpype/plugins/publish/integrate_new.py @@ -113,7 +113,7 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): "usdOverride", "simpleUnrealTexture" ] - exclude_families = ["clip", "render.farm"] + exclude_families = ["render.farm"] db_representation_context_keys = [ "project", "asset", "task", "subset", "version", "representation", "family", "hierarchy", "task", "username" From 87182f5a3e66cbae791f49c2f8cc0692f8dff3bf Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 20 May 2022 17:00:48 +0200 Subject: [PATCH 463/583] global: otio duration is one frame longer --- openpype/plugins/publish/extract_otio_trimming_video.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/plugins/publish/extract_otio_trimming_video.py b/openpype/plugins/publish/extract_otio_trimming_video.py index 30b57e2c69..e8e2994f36 100644 --- a/openpype/plugins/publish/extract_otio_trimming_video.py +++ b/openpype/plugins/publish/extract_otio_trimming_video.py @@ -80,7 +80,7 @@ class ExtractOTIOTrimmingVideo(openpype.api.Extractor): video_path = input_file_path frame_start = otio_range.start_time.value input_fps = otio_range.start_time.rate - frame_duration = (otio_range.duration.value + 1) + frame_duration = otio_range.duration.value - 1 sec_start = openpype.lib.frames_to_secons(frame_start, input_fps) sec_duration = openpype.lib.frames_to_secons(frame_duration, input_fps) From 7b65184389640165d7268996dc069600722fe60f Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 20 May 2022 17:45:15 +0200 Subject: [PATCH 464/583] OP-2787 - replaced target farm with remote Target farm is being used for rendering, this should better differentiate it. --- openpype/hosts/maya/api/pipeline.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/maya/api/pipeline.py b/openpype/hosts/maya/api/pipeline.py index b75af29523..c2fe8a95a5 100644 --- a/openpype/hosts/maya/api/pipeline.py +++ b/openpype/hosts/maya/api/pipeline.py @@ -73,8 +73,8 @@ def install(): "save/open/new callback installation..")) # Register default "local" target - print("Registering pyblish target: farm") - pyblish.api.register_target("farm") + print("Registering pyblish target: remote") + pyblish.api.register_target("remote") return print("Registering pyblish target: local") From c3e13a9e198a7082439588d6b3f6550bbdf98675 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 20 May 2022 18:07:26 +0200 Subject: [PATCH 465/583] modules have ability to modify environments before launch --- openpype/lib/applications.py | 33 ++++++++++++++++++++++++++++----- openpype/modules/base.py | 19 +++++++++++++++++++ 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/openpype/lib/applications.py b/openpype/lib/applications.py index 6ade33b59c..a81bdeca0f 100644 --- a/openpype/lib/applications.py +++ b/openpype/lib/applications.py @@ -1282,7 +1282,13 @@ class EnvironmentPrepData(dict): def get_app_environments_for_context( - project_name, asset_name, task_name, app_name, env_group=None, env=None + project_name, + asset_name, + task_name, + app_name, + env_group=None, + env=None, + modules_manager=None ): """Prepare environment variables by context. Args: @@ -1293,10 +1299,12 @@ def get_app_environments_for_context( by ApplicationManager. env (dict): Initial environment variables. `os.environ` is used when not passed. + modules_manager (ModulesManager): Initialized modules manager. Returns: dict: Environments for passed context and application. """ + from openpype.pipeline import AvalonMongoDB # Avalon database connection @@ -1311,6 +1319,11 @@ def get_app_environments_for_context( "name": asset_name }) + if modules_manager is None: + from openpype.modules import ModulesManager + + modules_manager = ModulesManager() + # Prepare app object which can be obtained only from ApplciationManager app_manager = ApplicationManager() app = app_manager.applications[app_name] @@ -1334,7 +1347,7 @@ def get_app_environments_for_context( "env": env }) - prepare_app_environments(data, env_group) + prepare_app_environments(data, env_group, modules_manager) prepare_context_environments(data, env_group) # Discard avalon connection @@ -1355,9 +1368,12 @@ def _merge_env(env, current_env): return result -def _add_python_version_paths(app, env, logger): +def _add_python_version_paths(app, env, logger, modules_manager): """Add vendor packages specific for a Python version.""" + for module in modules_manager.get_enabled_modules(): + module.modify_application_launch_arguments(app, env) + # Skip adding if host name is not set if not app.host_name: return @@ -1390,7 +1406,9 @@ def _add_python_version_paths(app, env, logger): env["PYTHONPATH"] = os.pathsep.join(python_paths) -def prepare_app_environments(data, env_group=None, implementation_envs=True): +def prepare_app_environments( + data, env_group=None, implementation_envs=True, modules_manager=None +): """Modify launch environments based on launched app and context. Args: @@ -1403,7 +1421,12 @@ def prepare_app_environments(data, env_group=None, implementation_envs=True): log = data["log"] source_env = data["env"].copy() - _add_python_version_paths(app, source_env, log) + if modules_manager is None: + from openpype.modules import ModulesManager + + modules_manager = ModulesManager() + + _add_python_version_paths(app, source_env, log, modules_manager) # Use environments from local settings filtered_local_envs = {} diff --git a/openpype/modules/base.py b/openpype/modules/base.py index 5b49649359..d591df6768 100644 --- a/openpype/modules/base.py +++ b/openpype/modules/base.py @@ -370,6 +370,7 @@ def _load_modules(): class _OpenPypeInterfaceMeta(ABCMeta): """OpenPypeInterface meta class to print proper string.""" + def __str__(self): return "<'OpenPypeInterface.{}'>".format(self.__name__) @@ -388,6 +389,7 @@ class OpenPypeInterface: OpenPype modules which means they have to have implemented methods defined in the interface. By default interface does not have any abstract parts. """ + pass @@ -432,10 +434,12 @@ class OpenPypeModule: It is not recommended to override __init__ that's why specific method was implemented. """ + pass def connect_with_modules(self, enabled_modules): """Connect with other enabled modules.""" + pass def get_global_environments(self): @@ -443,8 +447,22 @@ class OpenPypeModule: Environment variables that can be get only from system settings. """ + return {} + def modify_application_launch_arguments(self, app, env): + """Give option to modify launch environments before application launch. + + Implementation is optional. To change environments modify passed + dictionary of environments. + + Args: + app (Application): Application that is launcher. + env (dict): Current environemnt variables. + """ + + pass + def cli(self, module_click_group): """Add commands to click group. @@ -465,6 +483,7 @@ class OpenPypeModule: def mycommand(): print("my_command") """ + pass From 6fc240734c799a37c349f7a6e8945f7feea50ab5 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 20 May 2022 18:10:40 +0200 Subject: [PATCH 466/583] ftrack module modify application launch environments in module instead of in prelaunch hook --- openpype/modules/base.py | 4 +- openpype/modules/ftrack/ftrack_module.py | 34 +++++++++++++++ .../ftrack/launch_hooks/pre_python2_vendor.py | 43 ------------------- 3 files changed, 36 insertions(+), 45 deletions(-) delete mode 100644 openpype/modules/ftrack/launch_hooks/pre_python2_vendor.py diff --git a/openpype/modules/base.py b/openpype/modules/base.py index d591df6768..96c1b84a54 100644 --- a/openpype/modules/base.py +++ b/openpype/modules/base.py @@ -450,14 +450,14 @@ class OpenPypeModule: return {} - def modify_application_launch_arguments(self, app, env): + def modify_application_launch_arguments(self, application, env): """Give option to modify launch environments before application launch. Implementation is optional. To change environments modify passed dictionary of environments. Args: - app (Application): Application that is launcher. + application (Application): Application that is launched. env (dict): Current environemnt variables. """ diff --git a/openpype/modules/ftrack/ftrack_module.py b/openpype/modules/ftrack/ftrack_module.py index 5c38df2e03..f99e189082 100644 --- a/openpype/modules/ftrack/ftrack_module.py +++ b/openpype/modules/ftrack/ftrack_module.py @@ -88,6 +88,40 @@ class FtrackModule( """Implementation of `ILaunchHookPaths`.""" return os.path.join(FTRACK_MODULE_DIR, "launch_hooks") + def modify_application_launch_arguments(self, application, env): + if not application.use_python_2: + return + + self.log.info("Adding Ftrack Python 2 packages to PYTHONPATH.") + + # Prepare vendor dir path + python_2_vendor = os.path.join(FTRACK_MODULE_DIR, "python2_vendor") + + # Add Python 2 modules + python_paths = [ + # `python-ftrack-api` + os.path.join(python_2_vendor, "ftrack-python-api", "source"), + # `arrow` + os.path.join(python_2_vendor, "arrow"), + # `builtins` from `python-future` + # - `python-future` is strict Python 2 module that cause crashes + # of Python 3 scripts executed through OpenPype + # (burnin script etc.) + os.path.join(python_2_vendor, "builtins"), + # `backports.functools_lru_cache` + os.path.join( + python_2_vendor, "backports.functools_lru_cache" + ) + ] + + # Load PYTHONPATH from current launch context + python_path = env.get("PYTHONPATH") + if python_path: + python_paths.append(python_path) + + # Set new PYTHONPATH to launch context environments + env["PYTHONPATH"] = os.pathsep.join(python_paths) + def connect_with_modules(self, enabled_modules): for module in enabled_modules: if not hasattr(module, "get_ftrack_event_handler_paths"): diff --git a/openpype/modules/ftrack/launch_hooks/pre_python2_vendor.py b/openpype/modules/ftrack/launch_hooks/pre_python2_vendor.py deleted file mode 100644 index 0dd894bebf..0000000000 --- a/openpype/modules/ftrack/launch_hooks/pre_python2_vendor.py +++ /dev/null @@ -1,43 +0,0 @@ -import os -from openpype.lib import PreLaunchHook -from openpype_modules.ftrack import FTRACK_MODULE_DIR - - -class PrePython2Support(PreLaunchHook): - """Add python ftrack api module for Python 2 to PYTHONPATH. - - Path to vendor modules is added to the beggining of PYTHONPATH. - """ - - def execute(self): - if not self.application.use_python_2: - return - - self.log.info("Adding Ftrack Python 2 packages to PYTHONPATH.") - - # Prepare vendor dir path - python_2_vendor = os.path.join(FTRACK_MODULE_DIR, "python2_vendor") - - # Add Python 2 modules - python_paths = [ - # `python-ftrack-api` - os.path.join(python_2_vendor, "ftrack-python-api", "source"), - # `arrow` - os.path.join(python_2_vendor, "arrow"), - # `builtins` from `python-future` - # - `python-future` is strict Python 2 module that cause crashes - # of Python 3 scripts executed through OpenPype (burnin script etc.) - os.path.join(python_2_vendor, "builtins"), - # `backports.functools_lru_cache` - os.path.join( - python_2_vendor, "backports.functools_lru_cache" - ) - ] - - # Load PYTHONPATH from current launch context - python_path = self.launch_context.env.get("PYTHONPATH") - if python_path: - python_paths.append(python_path) - - # Set new PYTHONPATH to launch context environments - self.launch_context.env["PYTHONPATH"] = os.pathsep.join(python_paths) From 38d58182fc9f7d908eca61ab573b7562d1e8af97 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 20 May 2022 18:44:11 +0200 Subject: [PATCH 467/583] OP-2787 - added updating of script url Remote publish requires path to script which is known only on DL node. Injection of env var is required for remote publish. --- .../repository/custom/plugins/GlobalJobPreLoad.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py b/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py index eeb1f7744c..bcd853f374 100644 --- a/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py +++ b/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py @@ -87,6 +87,13 @@ def inject_openpype_environment(deadlinePlugin): for key, value in contents.items(): deadlinePlugin.SetProcessEnvironmentVariable(key, value) + script_url = job.GetJobPluginInfoKeyValue("ScriptFilename") + if script_url: + + script_url = script_url.format(**contents).replace("\\", "/") + print(">>> Setting script path {}".format(script_url)) + job.SetJobPluginInfoKeyValue("ScriptFilename", script_url) + print(">>> Removing temporary file") os.remove(export_url) @@ -196,16 +203,19 @@ def __main__(deadlinePlugin): job.GetJobEnvironmentKeyValue('OPENPYPE_RENDER_JOB') or '0' openpype_publish_job = \ job.GetJobEnvironmentKeyValue('OPENPYPE_PUBLISH_JOB') or '0' + openpype_remote_job = \ + job.GetJobEnvironmentKeyValue('OPENPYPE_REMOTE_JOB') or '0' print("--- Job type - render {}".format(openpype_render_job)) print("--- Job type - publish {}".format(openpype_publish_job)) + print("--- Job type - remote {}".format(openpype_remote_job)) if openpype_publish_job == '1' and openpype_render_job == '1': raise RuntimeError("Misconfiguration. Job couldn't be both " + "render and publish.") if openpype_publish_job == '1': inject_render_job_id(deadlinePlugin) - elif openpype_render_job == '1': + elif openpype_render_job == '1' or openpype_remote_job == '1': inject_openpype_environment(deadlinePlugin) else: pype(deadlinePlugin) # backward compatibility with Pype2 From 529c31c4f912d66bece87a17eb597c1ec5dd86ad Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 20 May 2022 19:02:17 +0200 Subject: [PATCH 468/583] OP-2787 - updated validator Checks for exactly 1 out set. --- .../hosts/maya/plugins/publish/validate_animation_content.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/openpype/hosts/maya/plugins/publish/validate_animation_content.py b/openpype/hosts/maya/plugins/publish/validate_animation_content.py index bcea761a01..7638c44b87 100644 --- a/openpype/hosts/maya/plugins/publish/validate_animation_content.py +++ b/openpype/hosts/maya/plugins/publish/validate_animation_content.py @@ -30,6 +30,10 @@ class ValidateAnimationContent(pyblish.api.InstancePlugin): assert 'out_hierarchy' in instance.data, "Missing `out_hierarchy` data" + out_sets = [node for node in instance if node.endswith("out_SET")] + msg = "Couldn't find exactly one out_SET: {0}".format(out_sets) + assert len(out_sets) == 1, msg + # All nodes in the `out_hierarchy` must be among the nodes that are # in the instance. The nodes in the instance are found from the top # group, as such this tests whether all nodes are under that top group. From 62ac633da95772488f94ea4b9d53c2c5a74e9b9c Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 20 May 2022 19:05:35 +0200 Subject: [PATCH 469/583] OP-2787 - used settings from ProcessSubmittedJobOnFarm --- .../submit_maya_remote_publish_deadline.py | 67 +++++++++---------- 1 file changed, 32 insertions(+), 35 deletions(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py b/openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py index 761bc8cc95..b11698f8e8 100644 --- a/openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py @@ -4,18 +4,22 @@ import requests from maya import cmds from openpype.pipeline import legacy_io +from openpype.settings import get_project_settings import pyblish.api -class MayaSubmitRemotePublishDeadline(pyblish.api.ContextPlugin): +class MayaSubmitRemotePublishDeadline(pyblish.api.InstancePlugin): """Submit Maya scene to perform a local publish in Deadline. Publishing in Deadline can be helpful for scenes that publish very slow. This way it can process in the background on another machine without the Artist having to wait for the publish to finish on their local machine. - Submission is done through the Deadline Web Service. + Submission is done through the Deadline Web Service. DL then triggers + `openpype/scripts/remote_publish.py`. + + Each publishable instance creates its own full publish job. Different from `ProcessSubmittedJobOnFarm` which creates publish job depending on metadata json containing context and instance data of @@ -27,31 +31,24 @@ class MayaSubmitRemotePublishDeadline(pyblish.api.ContextPlugin): hosts = ["maya"] families = ["deadline"] - # custom deadline attributes - deadline_department = "" - deadline_pool = "" - deadline_pool_secondary = "" - deadline_group = "" - deadline_chunk_size = 1 - deadline_priority = 50 - - def process(self, context): + def process(self, instance): + settings = get_project_settings(os.getenv("AVALON_PROJECT")) + # use setting for publish job on farm, no reason to have it separately + deadline_publish_job_sett = (settings["deadline"] + ["publish"] + ["ProcessSubmittedJobOnFarm"]) # Ensure no errors so far - assert all(result["success"] for result in context.data["results"]), ( - "Errors found, aborting integration..") + assert (all(result["success"] + for result in instance.context.data["results"]), + ("Errors found, aborting integration..")) - # Note that `publish` data member might change in the future. - # See: https://github.com/pyblish/pyblish-base/issues/307 - actives = [i for i in context if i.data["publish"]] - instance_names = sorted(instance.name for instance in actives) - - if not instance_names: + if not instance.data["publish"]: self.log.warning("No active instances found. " "Skipping submission..") return - scene = context.data["currentFile"] + scene = instance.context.data["currentFile"] scenename = os.path.basename(scene) # Get project code @@ -66,17 +63,15 @@ class MayaSubmitRemotePublishDeadline(pyblish.api.ContextPlugin): "JobInfo": { "Plugin": "MayaBatch", "BatchName": batch_name, - "Priority": 50, "Name": job_name, - "UserName": context.data["user"], - # "Comment": instance.context.data.get("comment", ""), + "UserName": instance.context.data["user"], + "Comment": instance.context.data.get("comment", ""), # "InitialStatus": state - "Department": self.deadline_department, - "ChunkSize": self.deadline_chunk_size, - "Priority": self.deadline_priority, - - "Group": self.deadline_group, - + "Department": deadline_publish_job_sett["deadline_department"], + "ChunkSize": deadline_publish_job_sett["deadline_chunk_size"], + "Priority": deadline_publish_job_sett["deadline_priority"], + "Group": deadline_publish_job_sett["deadline_group"], + "Pool": deadline_publish_job_sett["deadline_pool"], }, "PluginInfo": { @@ -86,7 +81,7 @@ class MayaSubmitRemotePublishDeadline(pyblish.api.ContextPlugin): # Inputs "SceneFile": scene, - "ScriptFilename": "{OPENPYPE_ROOT}/scripts/remote_publish.py", + "ScriptFilename": "{OPENPYPE_REPOS_ROOT}/openpype/scripts/remote_publish.py", # noqa # Mandatory for Deadline "Version": cmds.about(version=True), @@ -116,10 +111,9 @@ class MayaSubmitRemotePublishDeadline(pyblish.api.ContextPlugin): environment["AVALON_TASK"] = legacy_io.Session["AVALON_TASK"] environment["AVALON_APP_NAME"] = os.environ.get("AVALON_APP_NAME") environment["OPENPYPE_LOG_NO_COLORS"] = "1" - environment["OPENPYPE_USERNAME"] = context.data["user"] - environment["OPENPYPE_PUBLISH_JOB"] = "1" - environment["OPENPYPE_RENDER_JOB"] = "0" - environment["PYBLISH_ACTIVE_INSTANCES"] = ",".join(instance_names) + environment["OPENPYPE_REMOTE_JOB"] = "1" + environment["OPENPYPE_USERNAME"] = instance.context.data["user"] + environment["OPENPYPE_PUBLISH_SUBSET"] = instance.data["subset"] payload["JobInfo"].update({ "EnvironmentKeyValue%d" % index: "{key}={value}".format( @@ -129,7 +123,10 @@ class MayaSubmitRemotePublishDeadline(pyblish.api.ContextPlugin): }) self.log.info("Submitting Deadline job ...") - deadline_url = context.data["defaultDeadline"] + deadline_url = instance.context.data["defaultDeadline"] + # if custom one is set in instance, use that + if instance.data.get("deadlineUrl"): + deadline_url = instance.data.get("deadlineUrl") assert deadline_url, "Requires Deadline Webservice URL" url = "{}/api/jobs".format(deadline_url) response = requests.post(url, json=payload, timeout=10) From bf75d18a7b6852415cbb71b69a8a6a05c0ea3754 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 20 May 2022 19:07:21 +0200 Subject: [PATCH 470/583] OP-2787 - added collector for remote publishable instances Filters instances from a workfile and marks only these that should be published on a farm. --- .../publish/collect_publishable_instances.py | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 openpype/modules/deadline/plugins/publish/collect_publishable_instances.py diff --git a/openpype/modules/deadline/plugins/publish/collect_publishable_instances.py b/openpype/modules/deadline/plugins/publish/collect_publishable_instances.py new file mode 100644 index 0000000000..9a467428fd --- /dev/null +++ b/openpype/modules/deadline/plugins/publish/collect_publishable_instances.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +"""Collect instances that should be processed and published on DL. + +""" +import os + +import pyblish.api + + +class CollectDeadlinePublishableInstances(pyblish.api.InstancePlugin): + """Collect instances that should be processed and published on DL. + + Some long running publishes (not just renders) could be offloaded to DL, + this plugin compares theirs name against env variable, marks only + publishable by farm. + + Triggered only when running only in headless mode, eg on a farm. + """ + + order = pyblish.api.CollectorOrder + 0.499 + label = "Collect Deadline Publishable Instance" + targets = ["remote"] + + def process(self, instance): + self.log.debug("CollectDeadlinePublishableInstances") + publish_inst = os.environ.get("OPENPYPE_PUBLISH_SUBSET", '') + assert (publish_inst, + "OPENPYPE_PUBLISH_SUBSET env var required for " + "remote publishing") + + subset_name = instance.data["subset"] + if subset_name == publish_inst: + self.log.debug("Publish {}".format(subset_name)) + instance.data["publish"] = True + instance.data["farm"] = False + instance.data["families"].remove("deadline") + else: + self.log.debug("Skipping {}".format(subset_name)) + instance.data["publish"] = False From 72d8633266048ba19b78d425c879aa0325ba042b Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 20 May 2022 19:09:35 +0200 Subject: [PATCH 471/583] OP-2787 - changed flag from family to farm It probably makes more sense to check specific flag than a family. --- openpype/plugins/publish/integrate_new.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/plugins/publish/integrate_new.py b/openpype/plugins/publish/integrate_new.py index 1a4112107a..b5a7f11904 100644 --- a/openpype/plugins/publish/integrate_new.py +++ b/openpype/plugins/publish/integrate_new.py @@ -139,7 +139,8 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): ef, instance.data["family"], instance.data["families"])) return - if "deadline" in instance.data["families"]: + # instance should be published on a farm + if instance.data["farm"]: return self.integrated_file_sizes = {} From 6b71ff1909c3d32e5bebc0595580f1dcde1c1180 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 20 May 2022 19:25:55 +0200 Subject: [PATCH 472/583] OP-2787 - removed deadline family deadline family is not used anymore anywhere, filtering on integrate is being done on instance.data["farm"] flag. --- .../maya/plugins/publish/collect_animation.py | 3 --- .../maya/plugins/publish/collect_pointcache.py | 14 -------------- 2 files changed, 17 deletions(-) delete mode 100644 openpype/hosts/maya/plugins/publish/collect_pointcache.py diff --git a/openpype/hosts/maya/plugins/publish/collect_animation.py b/openpype/hosts/maya/plugins/publish/collect_animation.py index b442113fbc..9b1e38fd0a 100644 --- a/openpype/hosts/maya/plugins/publish/collect_animation.py +++ b/openpype/hosts/maya/plugins/publish/collect_animation.py @@ -55,6 +55,3 @@ class CollectAnimationOutputGeometry(pyblish.api.InstancePlugin): # Store data in the instance for the validator instance.data["out_hierarchy"] = hierarchy - - if instance.data.get("farm"): - instance.data["families"].append("deadline") diff --git a/openpype/hosts/maya/plugins/publish/collect_pointcache.py b/openpype/hosts/maya/plugins/publish/collect_pointcache.py deleted file mode 100644 index b55babe372..0000000000 --- a/openpype/hosts/maya/plugins/publish/collect_pointcache.py +++ /dev/null @@ -1,14 +0,0 @@ -import pyblish.api - - -class CollectPointcache(pyblish.api.InstancePlugin): - """Collect pointcache data for instance.""" - - order = pyblish.api.CollectorOrder + 0.4 - families = ["pointcache"] - label = "Collect Pointcache" - hosts = ["maya"] - - def process(self, instance): - if instance.data.get("farm"): - instance.data["families"].append("deadline") \ No newline at end of file From 939f2187291ccc6c69767ffa74472d054c3dde2f Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 20 May 2022 20:44:57 +0200 Subject: [PATCH 473/583] Allow to paste Tasks into multiple assets at the same time --- .../project_manager/project_manager/model.py | 26 ++++++++++++++----- .../project_manager/project_manager/view.py | 4 +-- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index b7cb0ec9ed..2721297578 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -1476,12 +1476,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): mimedata.setData("application/copy_task", encoded_data) return mimedata - def paste_mime_data(self, index, mime_data): - if not index.isValid(): - return - - item_id = index.data(IDENTIFIER_ROLE) - item = self._items_by_id[item_id] + def paste_mime_data(self, item, mime_data): if not isinstance(item, (AssetItem, TaskItem)): return @@ -1515,6 +1510,25 @@ class HierarchyModel(QtCore.QAbstractItemModel): task_item = TaskItem(task_data, True) self.add_item(task_item, parent) + def paste(self, indices, mime_data): + + # Get the selected Assets uniquely + items = set() + for index in indices: + if not index.isValid(): + return + item_id = index.data(IDENTIFIER_ROLE) + item = self._items_by_id[item_id] + + # Do not copy into the Task Item so get parent Asset instead + if isinstance(item, TaskItem): + item = item.parent() + + items.add(item) + + for item in items: + self.paste_mime_data(item, mime_data) + class BaseItem: """Base item for HierarchyModel. diff --git a/openpype/tools/project_manager/project_manager/view.py b/openpype/tools/project_manager/project_manager/view.py index 25174232bc..4279cb7468 100644 --- a/openpype/tools/project_manager/project_manager/view.py +++ b/openpype/tools/project_manager/project_manager/view.py @@ -376,9 +376,9 @@ class HierarchyView(QtWidgets.QTreeView): self._show_message(str(exc)) def _paste_items(self): - index = self.currentIndex() mime_data = QtWidgets.QApplication.clipboard().mimeData() - self._source_model.paste_mime_data(index, mime_data) + rows = self.selectionModel().selectedRows() + self._source_model.paste(rows, mime_data) def _delete_items(self, indexes=None): if indexes is None: From 413917672a0486643f424bddf77cdcee881c1746 Mon Sep 17 00:00:00 2001 From: OpenPype Date: Sat, 21 May 2022 03:41:16 +0000 Subject: [PATCH 474/583] [Automated] Bump version --- CHANGELOG.md | 13 ++++++++----- openpype/version.py | 2 +- pyproject.toml | 2 +- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6546ab6139..b8cec29df7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [3.10.0-nightly.3](https://github.com/pypeclub/OpenPype/tree/HEAD) +## [3.10.0-nightly.4](https://github.com/pypeclub/OpenPype/tree/HEAD) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.9.8...HEAD) @@ -14,13 +14,12 @@ **πŸš€ Enhancements** +- Project manager: Sped up project load [\#3216](https://github.com/pypeclub/OpenPype/pull/3216) - Maya: added clean\_import option to Import loader [\#3181](https://github.com/pypeclub/OpenPype/pull/3181) - Maya: add maya 2023 to default applications [\#3167](https://github.com/pypeclub/OpenPype/pull/3167) -- Compressed bgeo publishing in SAP and Houdini loader [\#3153](https://github.com/pypeclub/OpenPype/pull/3153) - General: Add 'dataclasses' to required python modules [\#3149](https://github.com/pypeclub/OpenPype/pull/3149) - Hooks: Tweak logging grammar [\#3147](https://github.com/pypeclub/OpenPype/pull/3147) - Nuke: settings for reformat node in CreateWriteRender node [\#3143](https://github.com/pypeclub/OpenPype/pull/3143) -- Houdini: Add loader for alembic through Alembic Archive node [\#3140](https://github.com/pypeclub/OpenPype/pull/3140) - Publisher: UI Modifications and fixes [\#3139](https://github.com/pypeclub/OpenPype/pull/3139) - General: Simplified OP modules/addons import [\#3137](https://github.com/pypeclub/OpenPype/pull/3137) - Terminal: Tweak coloring of TrayModuleManager logging enabled states [\#3133](https://github.com/pypeclub/OpenPype/pull/3133) @@ -34,6 +33,8 @@ **πŸ› Bug fixes** +- Deadline: instance data overwrite fix [\#3214](https://github.com/pypeclub/OpenPype/pull/3214) +- Ftrack: Push hierarchical attributes action works [\#3210](https://github.com/pypeclub/OpenPype/pull/3210) - Standalone Publisher: Always create new representation for thumbnail [\#3203](https://github.com/pypeclub/OpenPype/pull/3203) - Photoshop: skip collector when automatic testing [\#3202](https://github.com/pypeclub/OpenPype/pull/3202) - Nuke: render/workfile version sync doesn't work on farm [\#3185](https://github.com/pypeclub/OpenPype/pull/3185) @@ -47,6 +48,7 @@ - Ftrack: Action delete old versions formatting works [\#3152](https://github.com/pypeclub/OpenPype/pull/3152) - Deadline: fix the output directory [\#3144](https://github.com/pypeclub/OpenPype/pull/3144) - General: New Session schema [\#3141](https://github.com/pypeclub/OpenPype/pull/3141) +- General: Missing version on headless mode crash properly [\#3136](https://github.com/pypeclub/OpenPype/pull/3136) - TVPaint: Composite layers in reversed order [\#3135](https://github.com/pypeclub/OpenPype/pull/3135) - Nuke: fixing default settings for workfile builder loaders [\#3120](https://github.com/pypeclub/OpenPype/pull/3120) - Nuke: fix anatomy imageio regex default [\#3119](https://github.com/pypeclub/OpenPype/pull/3119) @@ -59,6 +61,7 @@ **Merged pull requests:** +- Maya: added jpg to filter for Image Plane Loader [\#3223](https://github.com/pypeclub/OpenPype/pull/3223) - Webpublisher: replace space by underscore in subset names [\#3160](https://github.com/pypeclub/OpenPype/pull/3160) - StandalonePublisher: removed Extract Background plugins [\#3093](https://github.com/pypeclub/OpenPype/pull/3093) @@ -95,7 +98,9 @@ **πŸš€ Enhancements** - Deadline output dir issue to 3.9x [\#3155](https://github.com/pypeclub/OpenPype/pull/3155) +- Compressed bgeo publishing in SAP and Houdini loader [\#3153](https://github.com/pypeclub/OpenPype/pull/3153) - nuke: removing redundant code from startup [\#3142](https://github.com/pypeclub/OpenPype/pull/3142) +- Houdini: Add loader for alembic through Alembic Archive node [\#3140](https://github.com/pypeclub/OpenPype/pull/3140) **πŸ› Bug fixes** @@ -120,7 +125,6 @@ **πŸ› Bug fixes** -- General: Missing version on headless mode crash properly [\#3136](https://github.com/pypeclub/OpenPype/pull/3136) - TVPaint: Composite layers in reversed order [\#3134](https://github.com/pypeclub/OpenPype/pull/3134) - General: Python 3 compatibility in queries [\#3111](https://github.com/pypeclub/OpenPype/pull/3111) @@ -137,7 +141,6 @@ - Ftrack: Update Create Folders action [\#3092](https://github.com/pypeclub/OpenPype/pull/3092) - General: Extract review sequence is not converted with same names [\#3075](https://github.com/pypeclub/OpenPype/pull/3075) -- Webpublisher: Use variant value [\#3072](https://github.com/pypeclub/OpenPype/pull/3072) ## [3.9.4](https://github.com/pypeclub/OpenPype/tree/3.9.4) (2022-04-15) diff --git a/openpype/version.py b/openpype/version.py index 1db666efec..1cc854cfd1 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.10.0-nightly.3" +__version__ = "3.10.0-nightly.4" diff --git a/pyproject.toml b/pyproject.toml index 4b7972c227..a2614b24b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "OpenPype" -version = "3.10.0-nightly.3" # OpenPype +version = "3.10.0-nightly.4" # OpenPype description = "Open VFX and Animation pipeline with support." authors = ["OpenPype Team "] license = "MIT License" From 8177a44b5d0261f7bdd683a190796ccbd52d57f0 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 23 May 2022 10:57:56 +0200 Subject: [PATCH 475/583] set empty mime data on failed copy --- openpype/tools/project_manager/project_manager/view.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/openpype/tools/project_manager/project_manager/view.py b/openpype/tools/project_manager/project_manager/view.py index 4279cb7468..7d9f1a7323 100644 --- a/openpype/tools/project_manager/project_manager/view.py +++ b/openpype/tools/project_manager/project_manager/view.py @@ -365,14 +365,18 @@ class HierarchyView(QtWidgets.QTreeView): event.accept() def _copy_items(self, indexes=None): + clipboard = QtWidgets.QApplication.clipboard() try: if indexes is None: indexes = self.selectedIndexes() mime_data = self._source_model.copy_mime_data(indexes) - QtWidgets.QApplication.clipboard().setMimeData(mime_data) + clipboard.setMimeData(mime_data) self._show_message("Tasks copied") except ValueError as exc: + # Change clipboard to contain empty data + empty_mime_data = QtCore.QMimeData() + clipboard.setMimeData(empty_mime_data) self._show_message(str(exc)) def _paste_items(self): From 264f3bb9278436a3a07f3d0460667d48ba72342f Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 23 May 2022 11:49:09 +0200 Subject: [PATCH 476/583] loop over 100 groups instead of only 12 --- openpype/hosts/tvpaint/api/lib.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/tvpaint/api/lib.py b/openpype/hosts/tvpaint/api/lib.py index 0c63dbe5be..b81685dbd7 100644 --- a/openpype/hosts/tvpaint/api/lib.py +++ b/openpype/hosts/tvpaint/api/lib.py @@ -165,12 +165,12 @@ def parse_group_data(data): if not group_raw: continue - parts = group_raw.split(" ") + parts = group_raw.split("|") # Check for length and concatenate 2 last items until length match # - this happens if name contain spaces while len(parts) > 6: last_item = parts.pop(-1) - parts[-1] = " ".join([parts[-1], last_item]) + parts[-1] = "|".join([parts[-1], last_item]) clip_id, group_id, red, green, blue, name = parts group = { @@ -201,11 +201,16 @@ def get_groups_data(communicator=None): george_script_lines = ( # Variable containing full path to output file "output_path = \"{}\"".format(output_filepath), - "loop = 1", - "FOR idx = 1 TO 12", + "empty = 0", + # Loop over 100 groups + "FOR idx = 1 TO 100", + # Receive information about groups "tv_layercolor \"getcolor\" 0 idx", - "tv_writetextfile \"strict\" \"append\" '\"'output_path'\"' result", - "END" + "PARSE result clip_id group_index c_red c_green c_blue group_name", + # Create and add line to output file + "line = group_index'|'c_red'|'c_green'|'c_blue'|'group_name", + "tv_writetextfile \"strict\" \"append\" '\"'output_path'\"' line", + "END", ) george_script = "\n".join(george_script_lines) execute_george_through_file(george_script, communicator) From 8079654f38dfa3644357dbcc6ea44554eef48ade Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 23 May 2022 12:33:40 +0200 Subject: [PATCH 477/583] Code cosmetics from code review Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- openpype/tools/project_manager/project_manager/model.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 2721297578..a69ef4a232 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -1476,7 +1476,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): mimedata.setData("application/copy_task", encoded_data) return mimedata - def paste_mime_data(self, item, mime_data): + def _paste_mime_data(self, item, mime_data): if not isinstance(item, (AssetItem, TaskItem)): return @@ -1510,11 +1510,11 @@ class HierarchyModel(QtCore.QAbstractItemModel): task_item = TaskItem(task_data, True) self.add_item(task_item, parent) - def paste(self, indices, mime_data): + def paste(self, indexes, mime_data): # Get the selected Assets uniquely items = set() - for index in indices: + for index in indexes: if not index.isValid(): return item_id = index.data(IDENTIFIER_ROLE) @@ -1527,7 +1527,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): items.add(item) for item in items: - self.paste_mime_data(item, mime_data) + self._paste_mime_data(item, mime_data) class BaseItem: From a176228396083c4eefc1de798c579c67ff65fd4d Mon Sep 17 00:00:00 2001 From: Jiri Sindelar <45896205+jrsndl@users.noreply.github.com> Date: Mon, 23 May 2022 13:19:53 +0200 Subject: [PATCH 478/583] Update openpype/plugins/publish/extract_review_slate.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- openpype/plugins/publish/extract_review_slate.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openpype/plugins/publish/extract_review_slate.py b/openpype/plugins/publish/extract_review_slate.py index 832799601c..52d988a6b0 100644 --- a/openpype/plugins/publish/extract_review_slate.py +++ b/openpype/plugins/publish/extract_review_slate.py @@ -130,8 +130,9 @@ class ExtractReviewSlate(openpype.api.Extractor): input_audio = True break # Get duration of one frame in micro seconds - one_frame_duration = "40000us" - if input_frame_rate: + if not input_frame_rate: + one_frame_duration = "40000us" + else: items = input_frame_rate.split("/") if len(items) == 1: one_frame_duration = float(1.0) / float(items[0]) From bf80eee17816bf1876d32d0d37c486ec9db54aa3 Mon Sep 17 00:00:00 2001 From: Jiri Sindelar <45896205+jrsndl@users.noreply.github.com> Date: Mon, 23 May 2022 13:20:10 +0200 Subject: [PATCH 479/583] Update openpype/plugins/publish/extract_review_slate.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- openpype/plugins/publish/extract_review_slate.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openpype/plugins/publish/extract_review_slate.py b/openpype/plugins/publish/extract_review_slate.py index 52d988a6b0..3bf7d00f7b 100644 --- a/openpype/plugins/publish/extract_review_slate.py +++ b/openpype/plugins/publish/extract_review_slate.py @@ -138,6 +138,8 @@ class ExtractReviewSlate(openpype.api.Extractor): one_frame_duration = float(1.0) / float(items[0]) elif len(items) == 2: one_frame_duration = float(items[1]) / float(items[0]) + else: + one_frame_duration = 0 one_frame_duration *= 1000000 one_frame_duration = str(int(one_frame_duration)) + "us" self.log.debug( From de2ea81928d15a2536aca8ba8ab12c50248f40ee Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 23 May 2022 13:34:24 +0200 Subject: [PATCH 480/583] fix missing clip id --- openpype/hosts/tvpaint/api/lib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/tvpaint/api/lib.py b/openpype/hosts/tvpaint/api/lib.py index b81685dbd7..a341f48859 100644 --- a/openpype/hosts/tvpaint/api/lib.py +++ b/openpype/hosts/tvpaint/api/lib.py @@ -208,7 +208,7 @@ def get_groups_data(communicator=None): "tv_layercolor \"getcolor\" 0 idx", "PARSE result clip_id group_index c_red c_green c_blue group_name", # Create and add line to output file - "line = group_index'|'c_red'|'c_green'|'c_blue'|'group_name", + "line = clip_id'|'group_index'|'c_red'|'c_green'|'c_blue'|'group_name", "tv_writetextfile \"strict\" \"append\" '\"'output_path'\"' line", "END", ) From 790a7468d7a4ece5d767b2b016cf71183ebbd567 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 23 May 2022 16:48:03 +0200 Subject: [PATCH 481/583] remove usage of unused attribute _items_with_color_by_id --- openpype/tools/utils/assets_widget.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/openpype/tools/utils/assets_widget.py b/openpype/tools/utils/assets_widget.py index d1df1193d2..82bdcd63a2 100644 --- a/openpype/tools/utils/assets_widget.py +++ b/openpype/tools/utils/assets_widget.py @@ -494,8 +494,6 @@ class AssetModel(QtGui.QStandardItemModel): # Remove cache of removed items for asset_id in removed_asset_ids: self._items_by_asset_id.pop(asset_id) - if asset_id in self._items_with_color_by_id: - self._items_with_color_by_id.pop(asset_id) # Refresh data # - all items refresh all data except id From 9244389b585b654f92dbf76a8e5ed2692ac4cb3b Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 23 May 2022 17:16:44 +0200 Subject: [PATCH 482/583] OP-2790 - safer querying of farm flag --- openpype/plugins/publish/integrate_new.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/plugins/publish/integrate_new.py b/openpype/plugins/publish/integrate_new.py index b5a7f11904..fa0582c65a 100644 --- a/openpype/plugins/publish/integrate_new.py +++ b/openpype/plugins/publish/integrate_new.py @@ -140,7 +140,7 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): return # instance should be published on a farm - if instance.data["farm"]: + if instance.data.get("farm"): return self.integrated_file_sizes = {} From 712d226e922f31fea0ebf5559f8625be8d140d26 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 23 May 2022 17:33:10 +0200 Subject: [PATCH 483/583] Fix - use clean layer name to create subset name Clean name is without publishing highlights denoting that layer has created OP instance, eg. should be published. --- .../photoshop/plugins/publish/collect_color_coded_instances.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/photoshop/plugins/publish/collect_color_coded_instances.py b/openpype/hosts/photoshop/plugins/publish/collect_color_coded_instances.py index ae025fc61d..71bd2cd854 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_color_coded_instances.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_color_coded_instances.py @@ -84,7 +84,7 @@ class CollectColorCodedInstances(pyblish.api.ContextPlugin): "variant": variant, "family": resolved_family, "task": task_name, - "layer": layer.name + "layer": layer.clean_name } subset = resolved_subset_template.format( From 5d6ce5592dfcb02444e9d7f86e9feab6eaebf53d Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 23 May 2022 17:47:54 +0200 Subject: [PATCH 484/583] Hiero: rolled back py3 compatible code make it py27 working --- openpype/hosts/hiero/api/lib.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/hiero/api/lib.py b/openpype/hosts/hiero/api/lib.py index 5b2f6c814d..ae0aef9e9b 100644 --- a/openpype/hosts/hiero/api/lib.py +++ b/openpype/hosts/hiero/api/lib.py @@ -171,7 +171,7 @@ def get_track_items( # get selected track items or all in active sequence if selection: - with contextlib.suppress(AttributeError): + try: for track_item in selection: log.info("___ track_item: {}".format(track_item)) # make sure only trackitems are selected @@ -188,6 +188,8 @@ def get_track_items( ): log.info("___ valid trackitem: {}".format(track_item)) return_list.append(track_item) + except AttributeError: + pass # collect all available active sequence track items if not return_list: From 2e5acf6221e5063517870c93046979e79b243e49 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 23 May 2022 17:50:09 +0200 Subject: [PATCH 485/583] hound --- openpype/hosts/hiero/api/lib.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/hiero/api/lib.py b/openpype/hosts/hiero/api/lib.py index ae0aef9e9b..d19cefd2da 100644 --- a/openpype/hosts/hiero/api/lib.py +++ b/openpype/hosts/hiero/api/lib.py @@ -2,7 +2,6 @@ Host specific functions where host api is connected """ -import contextlib from copy import deepcopy import os import re @@ -986,7 +985,8 @@ def get_sequence_pattern_and_padding(file): return None, None found = sorted(list(set(foundall[0])))[-1] - padding = int(re.findall(r"\d+", found)[-1]) if "%" in found else len(found) + padding = int( + re.findall(r"\d+", found)[-1]) if "%" in found else len(found) return found, padding From d4b6d6552caf6ebaef179a1f1519731746dad28e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Mon, 23 May 2022 18:34:03 +0200 Subject: [PATCH 486/583] :bug: get resolution from overrides --- .../hosts/maya/plugins/publish/collect_render.py | 12 +++++++++--- .../hosts/maya/plugins/publish/collect_vrayscene.py | 12 +++++++++--- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/collect_render.py b/openpype/hosts/maya/plugins/publish/collect_render.py index e66983780e..b19572ab37 100644 --- a/openpype/hosts/maya/plugins/publish/collect_render.py +++ b/openpype/hosts/maya/plugins/publish/collect_render.py @@ -339,9 +339,15 @@ class CollectMayaRender(pyblish.api.ContextPlugin): "source": filepath, "expectedFiles": full_exp_files, "publishRenderMetadataFolder": common_publish_meta_path, - "resolutionWidth": cmds.getAttr("defaultResolution.width"), - "resolutionHeight": cmds.getAttr("defaultResolution.height"), - "pixelAspect": cmds.getAttr("defaultResolution.pixelAspect"), + "resolutionWidth": lib.get_attr_in_layer( + "defaultResolution.height", layer=layer + ), + "resolutionHeight": lib.get_attr_in_layer( + "defaultResolution.width", layer=layer + ), + "pixelAspect": lib.get_attr_in_layer( + "defaultResolution.pixelAspect", layer=layer + ), "tileRendering": render_instance.data.get("tileRendering") or False, # noqa: E501 "tilesX": render_instance.data.get("tilesX") or 2, "tilesY": render_instance.data.get("tilesY") or 2, diff --git a/openpype/hosts/maya/plugins/publish/collect_vrayscene.py b/openpype/hosts/maya/plugins/publish/collect_vrayscene.py index afdb570cbc..6a0c2332fe 100644 --- a/openpype/hosts/maya/plugins/publish/collect_vrayscene.py +++ b/openpype/hosts/maya/plugins/publish/collect_vrayscene.py @@ -124,9 +124,15 @@ class CollectVrayScene(pyblish.api.InstancePlugin): # Add source to allow tracing back to the scene from # which was submitted originally "source": context.data["currentFile"].replace("\\", "/"), - "resolutionWidth": cmds.getAttr("defaultResolution.width"), - "resolutionHeight": cmds.getAttr("defaultResolution.height"), - "pixelAspect": cmds.getAttr("defaultResolution.pixelAspect"), + "resolutionWidth": lib.get_attr_in_layer( + "defaultResolution.height", layer=layer + ), + "resolutionHeight": lib.get_attr_in_layer( + "defaultResolution.width", layer=layer + ), + "pixelAspect": lib.get_attr_in_layer( + "defaultResolution.pixelAspect", layer=layer + ), "priority": instance.data.get("priority"), "useMultipleSceneFiles": instance.data.get( "vraySceneMultipleFiles") From d02af55c0589a3f50f1855a01b02b531a671e2dc Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 23 May 2022 19:04:34 +0200 Subject: [PATCH 487/583] validate that the user exists on ftrack when ftrack credentials are checked --- openpype/modules/ftrack/lib/credentials.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/openpype/modules/ftrack/lib/credentials.py b/openpype/modules/ftrack/lib/credentials.py index 4e29e66382..2eb64254d1 100644 --- a/openpype/modules/ftrack/lib/credentials.py +++ b/openpype/modules/ftrack/lib/credentials.py @@ -92,14 +92,18 @@ def check_credentials(username, api_key, ftrack_server=None): if not ftrack_server or not username or not api_key: return False + user_exists = False try: session = ftrack_api.Session( server_url=ftrack_server, api_key=api_key, api_user=username ) + # Validated that the username actually exists + user = session.query("User where username is \"{}\"".format(username)) + user_exists = user is not None session.close() except Exception: - return False - return True + pass + return user_exists From c0ee519dba5f24c8040bc08f910c7024697b621d Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 24 May 2022 12:59:33 +0200 Subject: [PATCH 488/583] Hound --- openpype/hosts/flame/api/lib.py | 12 +++++++----- openpype/hosts/hiero/api/lib.py | 3 ++- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py index 6dc7d3d887..d59308ad6c 100644 --- a/openpype/hosts/flame/api/lib.py +++ b/openpype/hosts/flame/api/lib.py @@ -779,7 +779,6 @@ class MediaInfoFile(object): feed_dir = os.path.dirname(path) feed_ext = os.path.splitext(feed_basename)[1][1:].lower() - with maintained_temp_file_path(".clip") as tmp_path: self.log.info("Temp File: {}".format(tmp_path)) self._generate_media_info_file(tmp_path, feed_ext, feed_dir) @@ -827,9 +826,11 @@ class MediaInfoFile(object): # make sure partial input basename is having correct extensoon if not partialname: - raise AttributeError("Wrong input attributes. Basename - {}, Ext - {}".format( - feed_basename, feed_ext - )) + raise AttributeError( + "Wrong input attributes. Basename - {}, Ext - {}".format( + feed_basename, feed_ext + ) + ) # get all related files files = [ @@ -860,7 +861,8 @@ class MediaInfoFile(object): # convert to multiple collections _continues_colls = collection.separate() for _coll in _continues_colls: - coll_to_text = self._format_collection(_coll, len(number_from_path)) + coll_to_text = self._format_collection( + _coll, len(number_from_path)) self.log.debug("__ coll_to_text: {}".format(coll_to_text)) if search_number_pattern in coll_to_text: return coll_to_text diff --git a/openpype/hosts/hiero/api/lib.py b/openpype/hosts/hiero/api/lib.py index 5b2f6c814d..5a9f38bf92 100644 --- a/openpype/hosts/hiero/api/lib.py +++ b/openpype/hosts/hiero/api/lib.py @@ -984,7 +984,8 @@ def get_sequence_pattern_and_padding(file): return None, None found = sorted(list(set(foundall[0])))[-1] - padding = int(re.findall(r"\d+", found)[-1]) if "%" in found else len(found) + padding = int( + re.findall(r"\d+", found)[-1]) if "%" in found else len(found) return found, padding From 0e0bbd56992a8ee8e7c32d90e56606623bb3781c Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Tue, 24 May 2022 15:31:01 +0100 Subject: [PATCH 489/583] Fix camera in UE5 --- .../hosts/unreal/plugins/load/load_camera.py | 31 +++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/unreal/plugins/load/load_camera.py b/openpype/hosts/unreal/plugins/load/load_camera.py index b33e45b6e9..0072dd9e73 100644 --- a/openpype/hosts/unreal/plugins/load/load_camera.py +++ b/openpype/hosts/unreal/plugins/load/load_camera.py @@ -57,6 +57,33 @@ class CameraLoader(plugin.Loader): min_frame_j, max_frame_j + 1) + def _import_camera( + self, world, sequence, bindings, import_fbx_settings, import_filename + ): + ue_version = unreal.SystemLibrary.get_engine_version().split('.') + ue_major = int(ue_version[0]) + ue_minor = int(ue_version[1]) + + if ue_major == 4 and ue_minor <= 26: + unreal.SequencerTools.import_fbx( + world, + sequence, + bindings, + import_fbx_settings, + import_filename + ) + elif (ue_major == 4 and ue_minor >= 27) or ue_major == 5: + unreal.SequencerTools.import_level_sequence_fbx( + world, + sequence, + bindings, + import_fbx_settings, + import_filename + ) + else: + raise NotImplementedError( + f"Unreal version {ue_major} not supported") + def load(self, context, name, namespace, data): """ Load and containerise representation into Content Browser. @@ -228,7 +255,7 @@ class CameraLoader(plugin.Loader): settings.set_editor_property('reduce_keys', False) if cam_seq: - unreal.SequencerTools.import_fbx( + self._import_camera( EditorLevelLibrary.get_editor_world(), cam_seq, cam_seq.get_bindings(), @@ -388,7 +415,7 @@ class CameraLoader(plugin.Loader): sub_scene.set_sequence(new_sequence) - unreal.SequencerTools.import_fbx( + self._import_camera( EditorLevelLibrary.get_editor_world(), new_sequence, new_sequence.get_bindings(), From 9c72873a9a8ac462abf463cc86c1c04dcfecaf8b Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Tue, 24 May 2022 15:32:29 +0100 Subject: [PATCH 490/583] Fix animations in UE5 --- openpype/hosts/unreal/plugins/load/load_animation.py | 12 ++++++++---- openpype/hosts/unreal/plugins/load/load_layout.py | 6 ++++-- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/unreal/plugins/load/load_animation.py b/openpype/hosts/unreal/plugins/load/load_animation.py index 60c1526d3d..54b43c500c 100644 --- a/openpype/hosts/unreal/plugins/load/load_animation.py +++ b/openpype/hosts/unreal/plugins/load/load_animation.py @@ -77,13 +77,15 @@ class AnimationFBXLoader(plugin.Loader): task.options.anim_sequence_import_data.set_editor_property( 'import_meshes_in_bone_hierarchy', False) task.options.anim_sequence_import_data.set_editor_property( - 'use_default_sample_rate', True) + 'use_default_sample_rate', False) + task.options.anim_sequence_import_data.set_editor_property( + 'custom_sample_rate', 25.0) # TODO: get from database task.options.anim_sequence_import_data.set_editor_property( 'import_custom_attribute', True) task.options.anim_sequence_import_data.set_editor_property( 'import_bone_tracks', True) task.options.anim_sequence_import_data.set_editor_property( - 'remove_redundant_keys', True) + 'remove_redundant_keys', False) task.options.anim_sequence_import_data.set_editor_property( 'convert_scene', True) @@ -279,13 +281,15 @@ class AnimationFBXLoader(plugin.Loader): task.options.anim_sequence_import_data.set_editor_property( 'import_meshes_in_bone_hierarchy', False) task.options.anim_sequence_import_data.set_editor_property( - 'use_default_sample_rate', True) + 'use_default_sample_rate', False) + task.options.anim_sequence_import_data.set_editor_property( + 'custom_sample_rate', 25.0) # TODO: get from database task.options.anim_sequence_import_data.set_editor_property( 'import_custom_attribute', True) task.options.anim_sequence_import_data.set_editor_property( 'import_bone_tracks', True) task.options.anim_sequence_import_data.set_editor_property( - 'remove_redundant_keys', True) + 'remove_redundant_keys', False) task.options.anim_sequence_import_data.set_editor_property( 'convert_scene', True) diff --git a/openpype/hosts/unreal/plugins/load/load_layout.py b/openpype/hosts/unreal/plugins/load/load_layout.py index 412f77e3a9..49611c6c05 100644 --- a/openpype/hosts/unreal/plugins/load/load_layout.py +++ b/openpype/hosts/unreal/plugins/load/load_layout.py @@ -262,13 +262,15 @@ class LayoutLoader(plugin.Loader): task.options.anim_sequence_import_data.set_editor_property( 'import_meshes_in_bone_hierarchy', False) task.options.anim_sequence_import_data.set_editor_property( - 'use_default_sample_rate', True) + 'use_default_sample_rate', False) + task.options.anim_sequence_import_data.set_editor_property( + 'custom_sample_rate', 25.0) # TODO: get from database task.options.anim_sequence_import_data.set_editor_property( 'import_custom_attribute', True) task.options.anim_sequence_import_data.set_editor_property( 'import_bone_tracks', True) task.options.anim_sequence_import_data.set_editor_property( - 'remove_redundant_keys', True) + 'remove_redundant_keys', False) task.options.anim_sequence_import_data.set_editor_property( 'convert_scene', True) From 1b76f86d6691b0106ce2ae9022666979103cf57d Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Tue, 24 May 2022 15:35:47 +0100 Subject: [PATCH 491/583] Fix render create in UE5 --- openpype/hosts/unreal/plugins/create/create_render.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/unreal/plugins/create/create_render.py b/openpype/hosts/unreal/plugins/create/create_render.py index 3b6c7a9f1e..a3e125a94e 100644 --- a/openpype/hosts/unreal/plugins/create/create_render.py +++ b/openpype/hosts/unreal/plugins/create/create_render.py @@ -22,17 +22,24 @@ class CreateRender(Creator): ar = unreal.AssetRegistryHelpers.get_asset_registry() + # The asset name is the the third element of the path which contains + # the map. + # The index of the split path is 3 because the first element is an + # empty string, as the path begins with "/Content". + a = unreal.EditorUtilityLibrary.get_selected_assets()[0] + asset_name = a.get_path_name().split("/")[3] + # Get the master sequence and the master level. # There should be only one sequence and one level in the directory. filter = unreal.ARFilter( class_names=["LevelSequence"], - package_paths=[f"/Game/OpenPype/{self.data['asset']}"], + package_paths=[f"/Game/OpenPype/{asset_name}"], recursive_paths=False) sequences = ar.get_assets(filter) ms = sequences[0].get_editor_property('object_path') filter = unreal.ARFilter( class_names=["World"], - package_paths=[f"/Game/OpenPype/{self.data['asset']}"], + package_paths=[f"/Game/OpenPype/{asset_name}"], recursive_paths=False) levels = ar.get_assets(filter) ml = levels[0].get_editor_property('object_path') From f57c22e4203b9d6441a05edabeda19742e4eda91 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 24 May 2022 22:20:00 +0200 Subject: [PATCH 492/583] :bug: fix layer names --- openpype/hosts/maya/plugins/publish/collect_render.py | 6 +++--- openpype/hosts/maya/plugins/publish/collect_vrayscene.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/collect_render.py b/openpype/hosts/maya/plugins/publish/collect_render.py index b19572ab37..fbd2e81279 100644 --- a/openpype/hosts/maya/plugins/publish/collect_render.py +++ b/openpype/hosts/maya/plugins/publish/collect_render.py @@ -340,13 +340,13 @@ class CollectMayaRender(pyblish.api.ContextPlugin): "expectedFiles": full_exp_files, "publishRenderMetadataFolder": common_publish_meta_path, "resolutionWidth": lib.get_attr_in_layer( - "defaultResolution.height", layer=layer + "defaultResolution.height", layer=layer_name ), "resolutionHeight": lib.get_attr_in_layer( - "defaultResolution.width", layer=layer + "defaultResolution.width", layer=layer_name ), "pixelAspect": lib.get_attr_in_layer( - "defaultResolution.pixelAspect", layer=layer + "defaultResolution.pixelAspect", layer=layer_name ), "tileRendering": render_instance.data.get("tileRendering") or False, # noqa: E501 "tilesX": render_instance.data.get("tilesX") or 2, diff --git a/openpype/hosts/maya/plugins/publish/collect_vrayscene.py b/openpype/hosts/maya/plugins/publish/collect_vrayscene.py index 6a0c2332fe..0bae9656f3 100644 --- a/openpype/hosts/maya/plugins/publish/collect_vrayscene.py +++ b/openpype/hosts/maya/plugins/publish/collect_vrayscene.py @@ -125,13 +125,13 @@ class CollectVrayScene(pyblish.api.InstancePlugin): # which was submitted originally "source": context.data["currentFile"].replace("\\", "/"), "resolutionWidth": lib.get_attr_in_layer( - "defaultResolution.height", layer=layer + "defaultResolution.height", layer=layer_name ), "resolutionHeight": lib.get_attr_in_layer( - "defaultResolution.width", layer=layer + "defaultResolution.width", layer=layer_name ), "pixelAspect": lib.get_attr_in_layer( - "defaultResolution.pixelAspect", layer=layer + "defaultResolution.pixelAspect", layer=layer_name ), "priority": instance.data.get("priority"), "useMultipleSceneFiles": instance.data.get( From ac79f31a279fcbd50d04a571b2b3eb8270b761d7 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 24 May 2022 22:39:50 +0200 Subject: [PATCH 493/583] :bug: filter out display types without file output don't process renderman display type that are not producing any file output (like `d_it`) --- openpype/hosts/maya/api/lib_renderproducts.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/openpype/hosts/maya/api/lib_renderproducts.py b/openpype/hosts/maya/api/lib_renderproducts.py index ff04fa7aa2..2d3bda5245 100644 --- a/openpype/hosts/maya/api/lib_renderproducts.py +++ b/openpype/hosts/maya/api/lib_renderproducts.py @@ -1093,6 +1093,11 @@ class RenderProductsRenderman(ARenderProducts): if not enabled: continue + # Skip display types not producing any file output. + # Is there a better way to do it? + if not display_types.get(display["driverNode"]["type"]): + continue + aov_name = name if aov_name == "rmanDefaultDisplay": aov_name = "beauty" From d019e5d7c22ace4d64bdbd8f73d09592b912e315 Mon Sep 17 00:00:00 2001 From: OpenPype Date: Wed, 25 May 2022 03:48:50 +0000 Subject: [PATCH 494/583] [Automated] Bump version --- CHANGELOG.md | 23 +++++++++++------------ openpype/version.py | 2 +- pyproject.toml | 2 +- 3 files changed, 13 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b8cec29df7..3dd410391a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [3.10.0-nightly.4](https://github.com/pypeclub/OpenPype/tree/HEAD) +## [3.10.0-nightly.5](https://github.com/pypeclub/OpenPype/tree/HEAD) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.9.8...HEAD) @@ -14,12 +14,16 @@ **πŸš€ Enhancements** +- Project Manager: Allow to paste Tasks into multiple assets at the same time [\#3226](https://github.com/pypeclub/OpenPype/pull/3226) - Project manager: Sped up project load [\#3216](https://github.com/pypeclub/OpenPype/pull/3216) +- Loader UI: Speed issues of loader with sync server [\#3199](https://github.com/pypeclub/OpenPype/pull/3199) - Maya: added clean\_import option to Import loader [\#3181](https://github.com/pypeclub/OpenPype/pull/3181) - Maya: add maya 2023 to default applications [\#3167](https://github.com/pypeclub/OpenPype/pull/3167) +- Compressed bgeo publishing in SAP and Houdini loader [\#3153](https://github.com/pypeclub/OpenPype/pull/3153) - General: Add 'dataclasses' to required python modules [\#3149](https://github.com/pypeclub/OpenPype/pull/3149) - Hooks: Tweak logging grammar [\#3147](https://github.com/pypeclub/OpenPype/pull/3147) - Nuke: settings for reformat node in CreateWriteRender node [\#3143](https://github.com/pypeclub/OpenPype/pull/3143) +- Houdini: Add loader for alembic through Alembic Archive node [\#3140](https://github.com/pypeclub/OpenPype/pull/3140) - Publisher: UI Modifications and fixes [\#3139](https://github.com/pypeclub/OpenPype/pull/3139) - General: Simplified OP modules/addons import [\#3137](https://github.com/pypeclub/OpenPype/pull/3137) - Terminal: Tweak coloring of TrayModuleManager logging enabled states [\#3133](https://github.com/pypeclub/OpenPype/pull/3133) @@ -28,18 +32,19 @@ - Unreal: Layout and Camera update and remove functions reimplemented and improvements [\#3116](https://github.com/pypeclub/OpenPype/pull/3116) - Settings: Remove environment groups from settings [\#3115](https://github.com/pypeclub/OpenPype/pull/3115) - TVPaint: Match renderlayer key with other hosts [\#3110](https://github.com/pypeclub/OpenPype/pull/3110) -- Ftrack: AssetVersion status on publish [\#3108](https://github.com/pypeclub/OpenPype/pull/3108) - Tray publisher: Simple families from settings [\#3105](https://github.com/pypeclub/OpenPype/pull/3105) **πŸ› Bug fixes** +- Ftrack: Validate that the user exists on ftrack [\#3237](https://github.com/pypeclub/OpenPype/pull/3237) +- TVPaint: Look for more groups than 12 [\#3228](https://github.com/pypeclub/OpenPype/pull/3228) +- Project Manager: Fix persistent editors on project change [\#3218](https://github.com/pypeclub/OpenPype/pull/3218) - Deadline: instance data overwrite fix [\#3214](https://github.com/pypeclub/OpenPype/pull/3214) - Ftrack: Push hierarchical attributes action works [\#3210](https://github.com/pypeclub/OpenPype/pull/3210) - Standalone Publisher: Always create new representation for thumbnail [\#3203](https://github.com/pypeclub/OpenPype/pull/3203) - Photoshop: skip collector when automatic testing [\#3202](https://github.com/pypeclub/OpenPype/pull/3202) - Nuke: render/workfile version sync doesn't work on farm [\#3185](https://github.com/pypeclub/OpenPype/pull/3185) - Ftrack: Review image only if there are no mp4 reviews [\#3183](https://github.com/pypeclub/OpenPype/pull/3183) -- Ftrack: Locations deepcopy issue [\#3177](https://github.com/pypeclub/OpenPype/pull/3177) - General: Avoid creating multiple thumbnails [\#3176](https://github.com/pypeclub/OpenPype/pull/3176) - General/Hiero: better clip duration calculation [\#3169](https://github.com/pypeclub/OpenPype/pull/3169) - General: Oiio conversion for ffmpeg checks for invalid characters [\#3166](https://github.com/pypeclub/OpenPype/pull/3166) @@ -52,11 +57,10 @@ - TVPaint: Composite layers in reversed order [\#3135](https://github.com/pypeclub/OpenPype/pull/3135) - Nuke: fixing default settings for workfile builder loaders [\#3120](https://github.com/pypeclub/OpenPype/pull/3120) - Nuke: fix anatomy imageio regex default [\#3119](https://github.com/pypeclub/OpenPype/pull/3119) -- General: Python 3 compatibility in queries [\#3112](https://github.com/pypeclub/OpenPype/pull/3112) -- General: Collect loaded versions skips not existing representations [\#3095](https://github.com/pypeclub/OpenPype/pull/3095) **πŸ”€ Refactored code** +- Avalon repo removed from Jobs workflow [\#3193](https://github.com/pypeclub/OpenPype/pull/3193) - General: Remove remaining imports from avalon [\#3130](https://github.com/pypeclub/OpenPype/pull/3130) **Merged pull requests:** @@ -72,6 +76,7 @@ **πŸš€ Enhancements** - nuke: generate publishing nodes inside render group node [\#3206](https://github.com/pypeclub/OpenPype/pull/3206) +- Loader UI: Speed issues of loader with sync server [\#3200](https://github.com/pypeclub/OpenPype/pull/3200) - Backport of fix for attaching renders to subsets [\#3195](https://github.com/pypeclub/OpenPype/pull/3195) **πŸ› Bug fixes** @@ -79,6 +84,7 @@ - Standalone Publisher: Always create new representation for thumbnail [\#3204](https://github.com/pypeclub/OpenPype/pull/3204) - Nuke: render/workfile version sync doesn't work on farm [\#3184](https://github.com/pypeclub/OpenPype/pull/3184) - Ftrack: Review image only if there are no mp4 reviews [\#3182](https://github.com/pypeclub/OpenPype/pull/3182) +- Ftrack: Locations deepcopy issue [\#3177](https://github.com/pypeclub/OpenPype/pull/3177) - Ftrack: Locations deepcopy issue [\#3175](https://github.com/pypeclub/OpenPype/pull/3175) - General: Avoid creating multiple thumbnails [\#3174](https://github.com/pypeclub/OpenPype/pull/3174) - General: TemplateResult can be copied [\#3170](https://github.com/pypeclub/OpenPype/pull/3170) @@ -98,9 +104,7 @@ **πŸš€ Enhancements** - Deadline output dir issue to 3.9x [\#3155](https://github.com/pypeclub/OpenPype/pull/3155) -- Compressed bgeo publishing in SAP and Houdini loader [\#3153](https://github.com/pypeclub/OpenPype/pull/3153) - nuke: removing redundant code from startup [\#3142](https://github.com/pypeclub/OpenPype/pull/3142) -- Houdini: Add loader for alembic through Alembic Archive node [\#3140](https://github.com/pypeclub/OpenPype/pull/3140) **πŸ› Bug fixes** @@ -137,11 +141,6 @@ [Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.10.0-nightly.2...3.9.5) -**πŸ› Bug fixes** - -- Ftrack: Update Create Folders action [\#3092](https://github.com/pypeclub/OpenPype/pull/3092) -- General: Extract review sequence is not converted with same names [\#3075](https://github.com/pypeclub/OpenPype/pull/3075) - ## [3.9.4](https://github.com/pypeclub/OpenPype/tree/3.9.4) (2022-04-15) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.9.4-nightly.2...3.9.4) diff --git a/openpype/version.py b/openpype/version.py index 1cc854cfd1..eee776fd2c 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.10.0-nightly.4" +__version__ = "3.10.0-nightly.5" diff --git a/pyproject.toml b/pyproject.toml index a2614b24b5..50cdefe1bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "OpenPype" -version = "3.10.0-nightly.4" # OpenPype +version = "3.10.0-nightly.5" # OpenPype description = "Open VFX and Animation pipeline with support." authors = ["OpenPype Team "] license = "MIT License" From 73a0d6fc70c847ab6114b1985dd0013282fb9a9a Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 25 May 2022 12:08:01 +0200 Subject: [PATCH 495/583] extracted some functionality into separated functions --- .../plugins/publish/extract_review_slate.py | 275 +++++++++++------- 1 file changed, 171 insertions(+), 104 deletions(-) diff --git a/openpype/plugins/publish/extract_review_slate.py b/openpype/plugins/publish/extract_review_slate.py index 7b2df4dc5f..a42fbbbfe1 100644 --- a/openpype/plugins/publish/extract_review_slate.py +++ b/openpype/plugins/publish/extract_review_slate.py @@ -78,76 +78,26 @@ class ExtractReviewSlate(openpype.api.Extractor): input_path, self.log ) # Get video metadata - for stream in streams: - input_timecode = "" - input_width = None - input_height = None - input_frame_rate = None - if "codec_type" in stream: - if stream["codec_type"] == "video": - self.log.debug("__Ffprobe Video: {}".format(stream)) - tags = stream.get("tags") or {} - input_timecode = tags.get("timecode") or "" - if "width" in stream and "height" in stream: - input_width = int(stream.get("width")) - input_height = int(stream.get("height")) - if "r_frame_rate" in stream: - # get frame rate in a form of - # x/y, like 24000/1001 for 23.976 - input_frame_rate = stream.get("r_frame_rate") - if ( - input_width - and input_height - and input_frame_rate - ): - input_frame_rate = str(input_frame_rate) - break + ( + input_width, + input_height, + input_timecode, + input_frame_rate + ) = self._get_video_metadata(streams) + # Raise exception of any stream didn't define input resolution if input_width is None: raise AssertionError(( "FFprobe couldn't read resolution from input file: \"{}\"" ).format(input_path)) - # Get audio metadata - for stream in streams: - audio_channels = None - audio_sample_rate = None - audio_channel_layout = None - input_audio = False - if stream["codec_type"] == "audio": - self.log.debug("__Ffprobe Audio: {}".format(stream)) - if stream["channels"]: - audio_channels = str(stream.get("channels")) - if stream["sample_rate"]: - audio_sample_rate = str(stream.get("sample_rate")) - if stream["channel_layout"]: - audio_channel_layout = str( - stream.get("channel_layout")) - if stream["codec_name"]: - audio_codec = str( - stream.get("codec_name")) - if ( - audio_channels - and audio_sample_rate - and audio_channel_layout - and audio_codec - ): - input_audio = str(input_audio) - audio_sample_rate = str(audio_sample_rate) - audio_channel_layout = str(audio_channel_layout) - audio_codec = str(audio_codec) - break - # Get duration of one frame in micro seconds - one_frame_duration = "40000us" - if input_frame_rate: - items = input_frame_rate.split("/") - if len(items) == 1: - one_frame_duration = float(1.0) / float(items[0]) - elif len(items) == 2: - one_frame_duration = float(items[1]) / float(items[0]) - one_frame_duration *= 1000000 - one_frame_duration = str(int(one_frame_duration)) + "us" - self.log.debug( - "One frame duration is {}".format(one_frame_duration)) + + ( + audio_codec, + audio_channels, + audio_sample_rate, + audio_channel_layout, + input_audio + ) = self._get_audio_metadata(streams) # values are set in ExtractReview if use_legacy_code: @@ -205,14 +155,16 @@ class ExtractReviewSlate(openpype.api.Extractor): else: input_args.extend(repre["outputDef"].get('input', [])) - input_args.append("-loop 1 -i {}".format( - openpype.lib.path_to_subprocess_arg(slate_path))) - input_args.extend(["-r {}".format(input_frame_rate)]) - input_args.extend(["-frames:v 1"]) + input_args.extend([ + "-loop", "1", + "-i", openpype.lib.path_to_subprocess_arg(slate_path), + "-r", str(input_frame_rate), + "-frames:v", "1", + ]) + # add timecode from source to the slate, substract one frame offset_timecode = "" if input_timecode: - offset_timecode = str(input_timecode) offset_timecode = self._tc_offset( str(input_timecode), framerate=fps, @@ -221,7 +173,8 @@ class ExtractReviewSlate(openpype.api.Extractor): self.log.debug("Slate Timecode: `{}`".format( offset_timecode )) - input_args.extend(["-timecode {}".format(offset_timecode)]) + input_args.extend(["-timecode", str(offset_timecode)]) + if use_legacy_code: format_args = [] codec_args = repre["_profile"].get('codec', []) @@ -236,10 +189,10 @@ class ExtractReviewSlate(openpype.api.Extractor): # make sure colors are correct output_args.extend([ - "-vf scale=out_color_matrix=bt709", - "-color_primaries bt709", - "-color_trc bt709", - "-colorspace bt709" + "-vf", "scale=out_color_matrix=bt709", + "-color_primaries", "bt709", + "-color_trc", "bt709", + "-colorspace", "bt709", ]) # scaling none square pixels and 1920 width @@ -275,13 +228,20 @@ class ExtractReviewSlate(openpype.api.Extractor): "__ height_half_pad: `{}`".format(height_half_pad) ) - scaling_arg = ("scale={0}x{1}:flags=lanczos," - "pad={2}:{3}:{4}:{5}:black,setsar=1").format( - width_scale, height_scale, to_width, to_height, - width_half_pad, height_half_pad + scaling_arg = ( + "scale={0}x{1}:flags=lanczos" + ",pad={2}:{3}:{4}:{5}:black" + ",setsar=1" + ",fps={6}" + ).format( + width_scale, + height_scale, + to_width, + to_height, + width_half_pad, + height_half_pad, + input_frame_rate ) - # add output frame rate as a filter, just in case - scaling_arg += ",fps={}".format(input_frame_rate) vf_back = self.add_video_filter_args(output_args, scaling_arg) # add it to output_args @@ -317,30 +277,14 @@ class ExtractReviewSlate(openpype.api.Extractor): slate_silent_path = "_silent".join( os.path.splitext(slate_v_path)) _remove_at_end.append(slate_silent_path) - - slate_silent_args = [ + self._create_silent_slate( ffmpeg_path, - "-i", slate_v_path, - "-f", "lavfi", "-i", - "anullsrc=r={}:cl={}:d={}".format( - audio_sample_rate, - audio_channel_layout, - one_frame_duration - ), - "-c:v", "copy", - "-c:a", audio_codec, - "-map", "0:v", - "-map", "1:a", - "-shortest", - "-y", - slate_silent_path - ] - # run slate generation subprocess - self.log.debug( - "Silent Slate Executing: {}".format( - " ".join(slate_silent_args))) - openpype.api.run_subprocess( - slate_silent_args, logger=self.log + slate_v_path, + slate_silent_path, + audio_codec, + audio_channels, + audio_sample_rate, + audio_channel_layout, ) # replace slate with silent slate for concat @@ -426,6 +370,129 @@ class ExtractReviewSlate(openpype.api.Extractor): self.log.debug(inst_data["representations"]) + def _get_video_metadata(self, streams): + input_timecode = "" + input_width = None + input_height = None + input_frame_rate = None + for stream in streams: + if stream.get("codec_type") != "video": + continue + self.log.debug("FFprobe Video: {}".format(stream)) + + if "width" not in stream or "height" not in stream: + continue + width = int(stream["width"]) + height = int(stream["height"]) + if not width or not height: + continue + + # Make sure that width and height are captured even if frame rate + # is not available + input_width = width + input_height = height + + tags = stream.get("tags") or {} + input_timecode = tags.get("timecode") or "" + + input_frame_rate = stream.get("r_frame_rate") + if input_frame_rate is not None: + break + return ( + input_width, + input_height, + input_timecode, + input_frame_rate + ) + + def _get_audio_metadata(self, streams): + # Get audio metadata + audio_codec = None + audio_channels = None + audio_sample_rate = None + audio_channel_layout = None + input_audio = False + + for stream in streams: + if stream.get("codec_type") != "audio": + continue + self.log.debug("__Ffprobe Audio: {}".format(stream)) + + if all( + stream.get(key) + for key in ( + "codec_name", + "channels", + "sample_rate", + "channel_layout", + ) + ): + audio_codec = stream["codec_name"] + audio_channels = stream["channels"] + audio_sample_rate = stream["sample_rate"] + audio_channel_layout = stream["channel_layout"] + input_audio = True + break + + return ( + audio_codec, + audio_channels, + audio_sample_rate, + audio_channel_layout, + input_audio, + ) + + def _create_silent_slate( + self, + ffmpeg_path, + src_path, + dst_path, + audio_codec, + audio_channels, + audio_sample_rate, + audio_channel_layout, + ): + # Get duration of one frame in micro seconds + items = audio_sample_rate.split("/") + if len(items) == 1: + one_frame_duration = 1.0 / float(items[0]) + elif len(items) == 2: + one_frame_duration = float(items[1]) / float(items[0]) + else: + one_frame_duration = None + + if one_frame_duration is None: + one_frame_duration = "40000us" + else: + one_frame_duration *= 1000000 + one_frame_duration = str(int(one_frame_duration)) + "us" + self.log.debug("One frame duration is {}".format(one_frame_duration)) + + slate_silent_args = [ + ffmpeg_path, + "-i", src_path, + "-f", "lavfi", "-i", + "anullsrc=r={}:cl={}:d={}".format( + audio_sample_rate, + audio_channel_layout, + one_frame_duration + ), + "-c:v", "copy", + "-c:a", audio_codec, + "-map", "0:v", + "-map", "1:a", + "-shortest", + "-y", + dst_path + ] + # run slate generation subprocess + self.log.debug("Silent Slate Executing: {}".format( + " ".join(slate_silent_args) + )) + openpype.api.run_subprocess( + slate_silent_args, logger=self.log + ) + def add_video_filter_args(self, args, inserting_arg): """ Fixing video filter argumets to be one long string From 4c3914c6c263efe68d466a542c26908a1e12739d Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 25 May 2022 12:12:46 +0200 Subject: [PATCH 496/583] fix double space --- openpype/plugins/publish/extract_review_slate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/plugins/publish/extract_review_slate.py b/openpype/plugins/publish/extract_review_slate.py index a42fbbbfe1..a2cbc1b704 100644 --- a/openpype/plugins/publish/extract_review_slate.py +++ b/openpype/plugins/publish/extract_review_slate.py @@ -159,7 +159,7 @@ class ExtractReviewSlate(openpype.api.Extractor): "-loop", "1", "-i", openpype.lib.path_to_subprocess_arg(slate_path), "-r", str(input_frame_rate), - "-frames:v", "1", + "-frames:v", "1", ]) # add timecode from source to the slate, substract one frame From c9b0bb0e54cbd3390f06ee8ea3135ffd5e9413e5 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 25 May 2022 13:32:45 +0200 Subject: [PATCH 497/583] make sure chunk size is at least 1 --- openpype/modules/ftrack/lib/avalon_sync.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/openpype/modules/ftrack/lib/avalon_sync.py b/openpype/modules/ftrack/lib/avalon_sync.py index 124787e467..e4ba651bfd 100644 --- a/openpype/modules/ftrack/lib/avalon_sync.py +++ b/openpype/modules/ftrack/lib/avalon_sync.py @@ -143,14 +143,17 @@ def create_chunks(iterable, chunk_size=None): list: Chunked items. """ chunks = [] - if not iterable: - return chunks tupled_iterable = tuple(iterable) + if not tupled_iterable: + return chunks iterable_size = len(tupled_iterable) if chunk_size is None: chunk_size = 200 + if chunk_size < 1: + chunk_size = 1 + for idx in range(0, iterable_size, chunk_size): chunks.append(tupled_iterable[idx:idx + chunk_size]) return chunks From 93c4e3403aa0a26717ab47815940cb269e279c76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Wed, 25 May 2022 14:06:25 +0200 Subject: [PATCH 498/583] :bug: fix node attribute name --- .../hosts/maya/plugins/publish/collect_look.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/collect_look.py b/openpype/hosts/maya/plugins/publish/collect_look.py index fb2ce04cad..d295492f9a 100644 --- a/openpype/hosts/maya/plugins/publish/collect_look.py +++ b/openpype/hosts/maya/plugins/publish/collect_look.py @@ -616,11 +616,15 @@ class CollectLook(pyblish.api.InstancePlugin): self.log.info(" - color space: {}".format(color_space)) # Define the resource - return {"node": node, - "attribute": attribute, - "source": source, # required for resources - "files": files, - "color_space": color_space} # required for resources + return { + "node": node, + # here we are passing not only attribute, but with node again + # this should be simplified and changed extractor. + "attribute": "{}.{}".format(node, attribute), + "source": source, # required for resources + "files": files, + "color_space": color_space + } # required for resources class CollectModelRenderSets(CollectLook): From ce882641e7baec3c7edca957e2024331943ab9af Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 25 May 2022 17:27:40 +0200 Subject: [PATCH 499/583] Vendor: updating scriptmenu to 1.5.2 --- openpype/vendor/python/common/scriptsmenu/action.py | 3 ++- openpype/vendor/python/common/scriptsmenu/launchfornuke.py | 7 ++----- openpype/vendor/python/common/scriptsmenu/scriptsmenu.py | 3 +-- openpype/vendor/python/common/scriptsmenu/version.py | 2 +- 4 files changed, 6 insertions(+), 9 deletions(-) diff --git a/openpype/vendor/python/common/scriptsmenu/action.py b/openpype/vendor/python/common/scriptsmenu/action.py index dc4d775f6a..5e68628406 100644 --- a/openpype/vendor/python/common/scriptsmenu/action.py +++ b/openpype/vendor/python/common/scriptsmenu/action.py @@ -119,7 +119,8 @@ module.{module_name}()""" """ # get the current application and its linked keyboard modifiers - modifiers = QtWidgets.QApplication.keyboardModifiers() + app = QtWidgets.QApplication.instance() + modifiers = app.keyboardModifiers() # If the menu has a callback registered for the current modifier # we run the callback instead of the action itself. diff --git a/openpype/vendor/python/common/scriptsmenu/launchfornuke.py b/openpype/vendor/python/common/scriptsmenu/launchfornuke.py index 23e4ed1b4d..72302a79a6 100644 --- a/openpype/vendor/python/common/scriptsmenu/launchfornuke.py +++ b/openpype/vendor/python/common/scriptsmenu/launchfornuke.py @@ -8,7 +8,7 @@ def _nuke_main_window(): if (obj.inherits('QMainWindow') and obj.metaObject().className() == 'Foundry::UI::DockMainWindow'): return obj - raise RuntimeError('Could not find Nuke MainWindow instance') + raise RuntimeError('Could not find Nuke MainWindow instance') def _nuke_main_menubar(): @@ -22,9 +22,6 @@ def _nuke_main_menubar(): def main(title="Scripts"): - # Register control + shift callback to add to shelf (Nuke behavior) - # modifiers = QtCore.Qt.ControlModifier | QtCore.Qt.ShiftModifier - # menu.register_callback(modifiers, to_shelf) nuke_main_bar = _nuke_main_menubar() for nuke_bar in nuke_main_bar.children(): if isinstance(nuke_bar, scriptsmenu.ScriptsMenu): @@ -33,4 +30,4 @@ def main(title="Scripts"): return menu menu = scriptsmenu.ScriptsMenu(title=title, parent=nuke_main_bar) - return menu \ No newline at end of file + return menu diff --git a/openpype/vendor/python/common/scriptsmenu/scriptsmenu.py b/openpype/vendor/python/common/scriptsmenu/scriptsmenu.py index e2b7ff96c7..9e7c094902 100644 --- a/openpype/vendor/python/common/scriptsmenu/scriptsmenu.py +++ b/openpype/vendor/python/common/scriptsmenu/scriptsmenu.py @@ -264,8 +264,7 @@ class ScriptsMenu(QtWidgets.QMenu): action.setVisible(True) else: for action in self._script_actions: - if not action.has_tag(search.lower()): - action.setVisible(False) + action.setVisible(action.has_tag(search.lower())) # Set visibility for all submenus for action in self.actions(): diff --git a/openpype/vendor/python/common/scriptsmenu/version.py b/openpype/vendor/python/common/scriptsmenu/version.py index 73f9426c2d..52ec49c845 100644 --- a/openpype/vendor/python/common/scriptsmenu/version.py +++ b/openpype/vendor/python/common/scriptsmenu/version.py @@ -1,6 +1,6 @@ VERSION_MAJOR = 1 VERSION_MINOR = 5 -VERSION_PATCH = 1 +VERSION_PATCH = 2 version = '{}.{}.{}'.format(VERSION_MAJOR, VERSION_MINOR, VERSION_PATCH) From d17ae6d6a588be1c700be664f3e746975661034a Mon Sep 17 00:00:00 2001 From: "clement.hector" Date: Wed, 25 May 2022 19:10:32 +0200 Subject: [PATCH 500/583] Change icon path and add nukestudio icon. --- openpype/resources/app_icons/nukestudio.png | Bin 0 -> 46080 bytes .../defaults/system_settings/applications.json | 4 ++-- 2 files changed, 2 insertions(+), 2 deletions(-) create mode 100644 openpype/resources/app_icons/nukestudio.png diff --git a/openpype/resources/app_icons/nukestudio.png b/openpype/resources/app_icons/nukestudio.png new file mode 100644 index 0000000000000000000000000000000000000000..99c95f59ff858d67fb92263f594f4b71bda87a66 GIT binary patch literal 46080 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 HIsgA3TR*cj literal 0 HcmV?d00001 diff --git a/openpype/settings/defaults/system_settings/applications.json b/openpype/settings/defaults/system_settings/applications.json index 2b0de44fa9..29c8a1cccc 100644 --- a/openpype/settings/defaults/system_settings/applications.json +++ b/openpype/settings/defaults/system_settings/applications.json @@ -292,7 +292,7 @@ "nukex": { "enabled": true, "label": "Nuke X", - "icon": "{}/app_icons/nuke.png", + "icon": "{}/app_icons/nukex.png", "host_name": "nuke", "environment": { "NUKE_PATH": [ @@ -431,7 +431,7 @@ "nukestudio": { "enabled": true, "label": "Nuke Studio", - "icon": "{}/app_icons/nuke.png", + "icon": "{}/app_icons/nukestudio.png", "host_name": "hiero", "environment": { "WORKFILES_STARTUP": "0", From d9a9981fefacc42a9e9466f6af6769f1605d4396 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 25 May 2022 19:37:14 +0200 Subject: [PATCH 501/583] Fix - Harmony 21.1 messed up Javascript Qt API QDataStream is missing, different way to get codec used. QApplication.activeWindow() also returned null, replaced by topLevelWidgets --- openpype/hosts/harmony/api/TB_sceneOpened.js | 31 +++++++++++--------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/openpype/hosts/harmony/api/TB_sceneOpened.js b/openpype/hosts/harmony/api/TB_sceneOpened.js index 6a403fa65e..29473bcc93 100644 --- a/openpype/hosts/harmony/api/TB_sceneOpened.js +++ b/openpype/hosts/harmony/api/TB_sceneOpened.js @@ -279,19 +279,13 @@ function Client() { }; self._send = function(message) { - var data = new QByteArray(); - var outstr = new QDataStream(data, QIODevice.WriteOnly); - outstr.writeInt(0); - data.append('UTF-8'); - outstr.device().seek(0); - outstr.writeInt(data.size() - 4); - var codec = QTextCodec.codecForUtfText(data); - var msg = codec.fromUnicode(message); - var l = msg.size(); - var coded = new QByteArray('AH').append(self.pack(l)); - coded = coded.append(msg); - self.socket.write(new QByteArray(coded)); - self.logDebug('Sent.'); + var codec_name = new QByteArray().append("ISO-8859-1"); + var codec = QTextCodec.codecForName(codec_name); + var msg = codec.fromUnicode(message); + var l = msg.size(); + var coded = new QByteArray().append('AH').append(self.pack(l)).append(msg); + self.socket.write(new QByteArray(coded)); + self.logDebug('Sent.'); }; self.waitForLock = function() { @@ -343,6 +337,7 @@ function start() { var host = '127.0.0.1'; /** port of the server */ var port = parseInt(System.getenv('AVALON_HARMONY_PORT')); + MessageLog.trace("port " + port.toString()); // Attach the client to the QApplication to preserve. var app = QCoreApplication.instance(); @@ -351,7 +346,15 @@ function start() { app.avalonClient = new Client(); app.avalonClient.socket.connectToHost(host, port); } - var menuBar = QApplication.activeWindow().menuBar(); + var mainWindow = null; + var widgets = QApplication.topLevelWidgets(); + for (var i = 0 ; i < widgets.length; i++) { + if (widgets[i] instanceof QMainWindow){ + MessageLog.trace('(DEBUG): START Main window '); + mainWindow = widgets[i]; + } + } + var menuBar = mainWindow.menuBar(); var actions = menuBar.actions(); app.avalonMenu = null; From f213a33f130d336ca8345ab64bbc6d1105c3a379 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 25 May 2022 19:40:40 +0200 Subject: [PATCH 502/583] Fix - Harmony 21.1 messed up Javascript Qt API Removed missed logging --- openpype/hosts/harmony/api/TB_sceneOpened.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/openpype/hosts/harmony/api/TB_sceneOpened.js b/openpype/hosts/harmony/api/TB_sceneOpened.js index 29473bcc93..610b0a73bb 100644 --- a/openpype/hosts/harmony/api/TB_sceneOpened.js +++ b/openpype/hosts/harmony/api/TB_sceneOpened.js @@ -337,7 +337,6 @@ function start() { var host = '127.0.0.1'; /** port of the server */ var port = parseInt(System.getenv('AVALON_HARMONY_PORT')); - MessageLog.trace("port " + port.toString()); // Attach the client to the QApplication to preserve. var app = QCoreApplication.instance(); @@ -350,7 +349,6 @@ function start() { var widgets = QApplication.topLevelWidgets(); for (var i = 0 ; i < widgets.length; i++) { if (widgets[i] instanceof QMainWindow){ - MessageLog.trace('(DEBUG): START Main window '); mainWindow = widgets[i]; } } From 78ddd548287c00eb99aaeb99624d380314172052 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 26 May 2022 10:42:31 +0200 Subject: [PATCH 503/583] init file for tvpaint worker also has set path to not existing file in guidelines --- openpype/hosts/tvpaint/worker/init_file.tvpp | Bin 59333 -> 59973 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/openpype/hosts/tvpaint/worker/init_file.tvpp b/openpype/hosts/tvpaint/worker/init_file.tvpp index 572d278fdb49c619d3cf040f59238377d1062d6a..22170b45bc728d3d46193a35fa5216e5c739e8cf 100644 GIT binary patch delta 627 zcmZ`!&r94u6n-1o7WW`Zp|ppVoa$-UWMbA$V4$EB5B?|x5xnSZcA6b`HWPLdQ4j>h zOHVDi6%=}`g_aV$mEuM4=0(uIz|#H|eTnu`^ufFz-+SNt=AE7oon8#RfBgJU-PvFA zf4m&KJluxSm2XF1ptOhE7+!pzoHw4j-~)jsm|%g2xl?)qUjETIFe*;gsvbWYhFK7x zApr#g*n&FP*<97TgFJ;RY#`ZTb@2`sxrbE<>gYp z2h-361`&jaR)FL)4}MD{{hCeaH6xwNKj_^`O`pA5d@~QAPx5E!yS6drWS=k3t1J~A zmF-(*}bzUpvFib`vt6KY`^C@ER2Ikj&JHK3Mhla`6R;vxE0inyHZ ziKs)T5Rt6SC0$U7$v1J4h(c5JS;!mfaagof(A(jilGA5{5SKR*R_%2jpz5vFi};`W z*Td43?b!*s;r^1=F%9nU9TO){MXT^5Br`*cmR*T2%g@OG;2 delta 199 zcmX?lh56`t<_ThQ&(|8ahFa}$5WGIGhCv`|{i@XrV8A8p>0If=z3$r*`x>8X Date: Thu, 26 May 2022 10:42:37 +0200 Subject: [PATCH 504/583] OP-2787 - changed assert to PublishXmlValidationError --- .../help/submit_maya_remote_publish_deadline.xml | 16 ++++++++++++++++ .../submit_maya_remote_publish_deadline.py | 8 ++++---- 2 files changed, 20 insertions(+), 4 deletions(-) create mode 100644 openpype/hosts/maya/plugins/publish/help/submit_maya_remote_publish_deadline.xml diff --git a/openpype/hosts/maya/plugins/publish/help/submit_maya_remote_publish_deadline.xml b/openpype/hosts/maya/plugins/publish/help/submit_maya_remote_publish_deadline.xml new file mode 100644 index 0000000000..e92320ccdc --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/help/submit_maya_remote_publish_deadline.xml @@ -0,0 +1,16 @@ + + + +Errors found + +## Publish process has errors + +At least one plugin failed before this plugin, job won't be sent to Deadline for processing before all issues are fixed. + +### How to repair? + +Check all failing plugins (should be highlighted in red) and fix issues if possible. + + + + \ No newline at end of file diff --git a/openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py b/openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py index b11698f8e8..be8c50d7b3 100644 --- a/openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py @@ -3,7 +3,7 @@ import requests from maya import cmds -from openpype.pipeline import legacy_io +from openpype.pipeline import legacy_io, PublishXmlValidationError from openpype.settings import get_project_settings import pyblish.api @@ -39,9 +39,9 @@ class MayaSubmitRemotePublishDeadline(pyblish.api.InstancePlugin): ["ProcessSubmittedJobOnFarm"]) # Ensure no errors so far - assert (all(result["success"] - for result in instance.context.data["results"]), - ("Errors found, aborting integration..")) + if not (all(result["success"] + for result in instance.context.data["results"])): + raise PublishXmlValidationError("Publish process has errors") if not instance.data["publish"]: self.log.warning("No active instances found. " From 4ca419f0b63c2782d8955b8012674ca131a57ad9 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 26 May 2022 11:36:49 +0200 Subject: [PATCH 505/583] Revert "OP-2787 - removed deadline family" This reverts commit 6b71ff19 --- .../maya/plugins/publish/collect_animation.py | 3 +++ .../maya/plugins/publish/collect_pointcache.py | 14 ++++++++++++++ 2 files changed, 17 insertions(+) create mode 100644 openpype/hosts/maya/plugins/publish/collect_pointcache.py diff --git a/openpype/hosts/maya/plugins/publish/collect_animation.py b/openpype/hosts/maya/plugins/publish/collect_animation.py index 9b1e38fd0a..b442113fbc 100644 --- a/openpype/hosts/maya/plugins/publish/collect_animation.py +++ b/openpype/hosts/maya/plugins/publish/collect_animation.py @@ -55,3 +55,6 @@ class CollectAnimationOutputGeometry(pyblish.api.InstancePlugin): # Store data in the instance for the validator instance.data["out_hierarchy"] = hierarchy + + if instance.data.get("farm"): + instance.data["families"].append("deadline") diff --git a/openpype/hosts/maya/plugins/publish/collect_pointcache.py b/openpype/hosts/maya/plugins/publish/collect_pointcache.py new file mode 100644 index 0000000000..b55babe372 --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/collect_pointcache.py @@ -0,0 +1,14 @@ +import pyblish.api + + +class CollectPointcache(pyblish.api.InstancePlugin): + """Collect pointcache data for instance.""" + + order = pyblish.api.CollectorOrder + 0.4 + families = ["pointcache"] + label = "Collect Pointcache" + hosts = ["maya"] + + def process(self, instance): + if instance.data.get("farm"): + instance.data["families"].append("deadline") \ No newline at end of file From c96ea856425550835c7c5dfc42b1965a54ca0902 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 26 May 2022 11:40:19 +0200 Subject: [PATCH 506/583] OP-2787 - change families to more generic Not only Deadline could be used for remote publish. DL plugin will be picked only if DL module is enabled. --- openpype/hosts/maya/plugins/publish/collect_animation.py | 2 +- openpype/hosts/maya/plugins/publish/collect_pointcache.py | 2 +- .../plugins/publish/submit_maya_remote_publish_deadline.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/collect_animation.py b/openpype/hosts/maya/plugins/publish/collect_animation.py index b442113fbc..549098863f 100644 --- a/openpype/hosts/maya/plugins/publish/collect_animation.py +++ b/openpype/hosts/maya/plugins/publish/collect_animation.py @@ -57,4 +57,4 @@ class CollectAnimationOutputGeometry(pyblish.api.InstancePlugin): instance.data["out_hierarchy"] = hierarchy if instance.data.get("farm"): - instance.data["families"].append("deadline") + instance.data["families"].append("publish.farm") diff --git a/openpype/hosts/maya/plugins/publish/collect_pointcache.py b/openpype/hosts/maya/plugins/publish/collect_pointcache.py index b55babe372..a841341f72 100644 --- a/openpype/hosts/maya/plugins/publish/collect_pointcache.py +++ b/openpype/hosts/maya/plugins/publish/collect_pointcache.py @@ -11,4 +11,4 @@ class CollectPointcache(pyblish.api.InstancePlugin): def process(self, instance): if instance.data.get("farm"): - instance.data["families"].append("deadline") \ No newline at end of file + instance.data["families"].append("publish.farm") diff --git a/openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py b/openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py index be8c50d7b3..210fefb520 100644 --- a/openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py @@ -29,7 +29,7 @@ class MayaSubmitRemotePublishDeadline(pyblish.api.InstancePlugin): label = "Submit Scene to Deadline" order = pyblish.api.IntegratorOrder hosts = ["maya"] - families = ["deadline"] + families = ["publish.farm"] def process(self, instance): settings = get_project_settings(os.getenv("AVALON_PROJECT")) From 0d03e3e2f836476310b899a21aec5b49dbd2a7c8 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 26 May 2022 11:40:50 +0200 Subject: [PATCH 507/583] OP-2787 - removed obsolete part of code Doesn't do anything. --- openpype/hosts/maya/plugins/publish/extract_animation.py | 7 +------ openpype/hosts/maya/plugins/publish/extract_pointcache.py | 7 +------ 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_animation.py b/openpype/hosts/maya/plugins/publish/extract_animation.py index 87f2d35192..1ccc8f5cfe 100644 --- a/openpype/hosts/maya/plugins/publish/extract_animation.py +++ b/openpype/hosts/maya/plugins/publish/extract_animation.py @@ -24,12 +24,7 @@ class ExtractAnimation(openpype.api.Extractor): def process(self, instance): if instance.data.get("farm"): - path = os.path.join( - os.path.dirname(instance.context.data["currentFile"]), - "cache", - instance.data["name"] + ".abc" - ) - instance.data["expectedFiles"] = [os.path.normpath(path)] + self.log.debug("Should be processed on farm, skipping.") return # Collect the out set nodes diff --git a/openpype/hosts/maya/plugins/publish/extract_pointcache.py b/openpype/hosts/maya/plugins/publish/extract_pointcache.py index 7ad4c6dfa9..ff3d97ded1 100644 --- a/openpype/hosts/maya/plugins/publish/extract_pointcache.py +++ b/openpype/hosts/maya/plugins/publish/extract_pointcache.py @@ -26,12 +26,7 @@ class ExtractAlembic(openpype.api.Extractor): def process(self, instance): if instance.data.get("farm"): - path = os.path.join( - os.path.dirname(instance.context.data["currentFile"]), - "cache", - instance.data["name"] + ".abc" - ) - instance.data["expectedFiles"] = [os.path.normpath(path)] + self.log.debug("Should be processed on farm, skipping.") return nodes = instance[:] From de161da68b4035c4fde2e376dc8da1ab2b79d093 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 26 May 2022 11:45:29 +0200 Subject: [PATCH 508/583] OP-2787 - created explicit env var HEADLESS_PUBLISH Env var created to differentiate launch of Maya on the farm. lib.IS_HEADLESS might be triggered locally, it is not precise enough. Added same env var to all commands to standardize it a bit. --- openpype/hosts/maya/api/pipeline.py | 5 ++++- .../submit_maya_remote_publish_deadline.py | 2 ++ openpype/pype_commands.py | 18 +++++++++++++----- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/maya/api/pipeline.py b/openpype/hosts/maya/api/pipeline.py index c2fe8a95a5..6fc93e864f 100644 --- a/openpype/hosts/maya/api/pipeline.py +++ b/openpype/hosts/maya/api/pipeline.py @@ -72,7 +72,10 @@ def install(): log.info(("Running in headless mode, skipping Maya " "save/open/new callback installation..")) - # Register default "local" target + return + + if os.environ.get("HEADLESS_PUBLISH"): + # Maya launched on farm, lib.IS_HEADLESS might be triggered locally too print("Registering pyblish target: remote") pyblish.api.register_target("remote") return diff --git a/openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py b/openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py index 210fefb520..8f50878db4 100644 --- a/openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py @@ -114,6 +114,8 @@ class MayaSubmitRemotePublishDeadline(pyblish.api.InstancePlugin): environment["OPENPYPE_REMOTE_JOB"] = "1" environment["OPENPYPE_USERNAME"] = instance.context.data["user"] environment["OPENPYPE_PUBLISH_SUBSET"] = instance.data["subset"] + environment["HEADLESS_PUBLISH"] = "1" + payload["JobInfo"].update({ "EnvironmentKeyValue%d" % index: "{key}={value}".format( diff --git a/openpype/pype_commands.py b/openpype/pype_commands.py index d945a1f697..90c582a319 100644 --- a/openpype/pype_commands.py +++ b/openpype/pype_commands.py @@ -144,6 +144,7 @@ class PypeCommands: pyblish.api.register_target("farm") os.environ["OPENPYPE_PUBLISH_DATA"] = os.pathsep.join(paths) + os.environ["HEADLESS_PUBLISH"] = 'true' # to use in app lib log.info("Running publish ...") @@ -173,9 +174,11 @@ class PypeCommands: user_email, targets=None): """Opens installed variant of 'host' and run remote publish there. + Eventually should be yanked out to Webpublisher cli. + Currently implemented and tested for Photoshop where customer wants to process uploaded .psd file and publish collected layers - from there. + from there. Triggered by Webpublisher. Checks if no other batches are running (status =='in_progress). If so, it sleeps for SLEEP (this is separate process), @@ -273,7 +276,8 @@ class PypeCommands: def remotepublish(project, batch_path, user_email, targets=None): """Start headless publishing. - Used to publish rendered assets, workfiles etc. + Used to publish rendered assets, workfiles etc via Webpublisher. + Eventually should be yanked out to Webpublisher cli. Publish use json from passed paths argument. @@ -309,6 +313,7 @@ class PypeCommands: os.environ["AVALON_PROJECT"] = project os.environ["AVALON_APP"] = host_name os.environ["USER_EMAIL"] = user_email + os.environ["HEADLESS_PUBLISH"] = 'true' # to use in app lib pyblish.api.register_host(host_name) @@ -331,9 +336,12 @@ class PypeCommands: log.info("Publish finished.") @staticmethod - def extractenvironments( - output_json_path, project, asset, task, app, env_group - ): + def extractenvironments(output_json_path, project, asset, task, app, + env_group): + """Produces json file with environment based on project and app. + + Called by Deadline plugin to propagate environment into render jobs. + """ if all((project, asset, task, app)): from openpype.api import get_app_environments_for_context env = get_app_environments_for_context( From 8cda0ebbeef23ceccc7f1a9d9963eecc93219012 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 26 May 2022 12:09:13 +0200 Subject: [PATCH 509/583] OP-2787 - Hound --- .../plugins/publish/collect_publishable_instances.py | 7 ++++--- .../plugins/publish/submit_maya_remote_publish_deadline.py | 1 - 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/openpype/modules/deadline/plugins/publish/collect_publishable_instances.py b/openpype/modules/deadline/plugins/publish/collect_publishable_instances.py index 9a467428fd..741a2a5af8 100644 --- a/openpype/modules/deadline/plugins/publish/collect_publishable_instances.py +++ b/openpype/modules/deadline/plugins/publish/collect_publishable_instances.py @@ -5,6 +5,7 @@ import os import pyblish.api +from openpype.pipeline import PublishValidationError class CollectDeadlinePublishableInstances(pyblish.api.InstancePlugin): @@ -24,9 +25,9 @@ class CollectDeadlinePublishableInstances(pyblish.api.InstancePlugin): def process(self, instance): self.log.debug("CollectDeadlinePublishableInstances") publish_inst = os.environ.get("OPENPYPE_PUBLISH_SUBSET", '') - assert (publish_inst, - "OPENPYPE_PUBLISH_SUBSET env var required for " - "remote publishing") + if not publish_inst: + raise PublishValidationError("OPENPYPE_PUBLISH_SUBSET env var " + "required for remote publishing") subset_name = instance.data["subset"] if subset_name == publish_inst: diff --git a/openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py b/openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py index 8f50878db4..196adc5906 100644 --- a/openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py @@ -116,7 +116,6 @@ class MayaSubmitRemotePublishDeadline(pyblish.api.InstancePlugin): environment["OPENPYPE_PUBLISH_SUBSET"] = instance.data["subset"] environment["HEADLESS_PUBLISH"] = "1" - payload["JobInfo"].update({ "EnvironmentKeyValue%d" % index: "{key}={value}".format( key=key, From 15822591793cb94ea730941358abb0a06dac211c Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Thu, 26 May 2022 14:07:30 +0200 Subject: [PATCH 510/583] :truck: split versions of integration plugin --- openpype/hosts/unreal/__init__.py | 10 +- .../integration/{ => UE_4.7}/.gitignore | 0 .../Content/Python/init_unreal.py | 0 .../integration/{ => UE_4.7}/OpenPype.uplugin | 0 .../unreal/integration/{ => UE_4.7}/README.md | 2 +- .../{ => UE_4.7}/Resources/openpype128.png | Bin .../{ => UE_4.7}/Resources/openpype40.png | Bin .../{ => UE_4.7}/Resources/openpype512.png | Bin .../UE_4.7/Source/OpenPype/OpenPype.Build.cs | 57 +++++++++ .../OpenPype/Private/AssetContainer.cpp | 0 .../Private/AssetContainerFactory.cpp | 0 .../Source/OpenPype/Private/OpenPype.cpp | 103 ++++++++++++++++ .../Source/OpenPype/Private/OpenPypeLib.cpp | 0 .../Private/OpenPypePublishInstance.cpp | 0 .../OpenPypePublishInstanceFactory.cpp | 0 .../OpenPype/Private/OpenPypePythonBridge.cpp | 0 .../Source/OpenPype/Private/OpenPypeStyle.cpp | 70 +++++++++++ .../Source/OpenPype/Public/AssetContainer.h | 0 .../OpenPype/Public/AssetContainerFactory.h | 0 .../UE_4.7/Source/OpenPype/Public/OpenPype.h | 21 ++++ .../Source/OpenPype/Public/OpenPypeLib.h | 0 .../OpenPype/Public/OpenPypePublishInstance.h | 0 .../Public/OpenPypePublishInstanceFactory.h | 0 .../OpenPype/Public/OpenPypePythonBridge.h | 0 .../Source/OpenPype/Public/OpenPypeStyle.h | 22 ++++ .../unreal/integration/UE_5.0/.gitignore | 35 ++++++ .../UE_5.0/Content/Python/__init__.py | 0 .../UE_5.0/Content/Python/init_unreal.py | 28 +++++ .../integration/UE_5.0/Content/__init__.py | 0 .../integration/UE_5.0/OpenPype.uplugin | 24 ++++ .../hosts/unreal/integration/UE_5.0/README.md | 11 ++ .../UE_5.0/Resources/openpype128.png | Bin 0 -> 14594 bytes .../UE_5.0/Resources/openpype40.png | Bin 0 -> 4884 bytes .../UE_5.0/Resources/openpype512.png | Bin 0 -> 85856 bytes .../Source/OpenPype/OpenPype.Build.cs | 0 .../OpenPype/Private/AssetContainer.cpp | 115 ++++++++++++++++++ .../Private/AssetContainerFactory.cpp | 20 +++ .../Source/OpenPype/Private/OpenPype.cpp | 0 .../OpenPype/Private/OpenPypeCommands.cpp | 0 .../Source/OpenPype/Private/OpenPypeLib.cpp | 48 ++++++++ .../Private/OpenPypePublishInstance.cpp | 108 ++++++++++++++++ .../OpenPypePublishInstanceFactory.cpp | 20 +++ .../OpenPype/Private/OpenPypePythonBridge.cpp | 13 ++ .../Source/OpenPype/Private/OpenPypeStyle.cpp | 0 .../Source/OpenPype/Public/AssetContainer.h | 39 ++++++ .../OpenPype/Public/AssetContainerFactory.h | 21 ++++ .../Source/OpenPype/Public/OpenPype.h | 0 .../Source/OpenPype/Public/OpenPypeCommands.h | 0 .../Source/OpenPype/Public/OpenPypeLib.h | 19 +++ .../OpenPype/Public/OpenPypePublishInstance.h | 21 ++++ .../Public/OpenPypePublishInstanceFactory.h | 19 +++ .../OpenPype/Public/OpenPypePythonBridge.h | 20 +++ .../Source/OpenPype/Public/OpenPypeStyle.h | 0 .../system_settings/applications.json | 12 +- repos/avalon-core | 1 - 55 files changed, 854 insertions(+), 5 deletions(-) rename openpype/hosts/unreal/integration/{ => UE_4.7}/.gitignore (100%) rename openpype/hosts/unreal/integration/{ => UE_4.7}/Content/Python/init_unreal.py (100%) rename openpype/hosts/unreal/integration/{ => UE_4.7}/OpenPype.uplugin (100%) rename openpype/hosts/unreal/integration/{ => UE_4.7}/README.md (91%) rename openpype/hosts/unreal/integration/{ => UE_4.7}/Resources/openpype128.png (100%) rename openpype/hosts/unreal/integration/{ => UE_4.7}/Resources/openpype40.png (100%) rename openpype/hosts/unreal/integration/{ => UE_4.7}/Resources/openpype512.png (100%) create mode 100644 openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/OpenPype.Build.cs rename openpype/hosts/unreal/integration/{ => UE_4.7}/Source/OpenPype/Private/AssetContainer.cpp (100%) rename openpype/hosts/unreal/integration/{ => UE_4.7}/Source/OpenPype/Private/AssetContainerFactory.cpp (100%) create mode 100644 openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Private/OpenPype.cpp rename openpype/hosts/unreal/integration/{ => UE_4.7}/Source/OpenPype/Private/OpenPypeLib.cpp (100%) rename openpype/hosts/unreal/integration/{ => UE_4.7}/Source/OpenPype/Private/OpenPypePublishInstance.cpp (100%) rename openpype/hosts/unreal/integration/{ => UE_4.7}/Source/OpenPype/Private/OpenPypePublishInstanceFactory.cpp (100%) rename openpype/hosts/unreal/integration/{ => UE_4.7}/Source/OpenPype/Private/OpenPypePythonBridge.cpp (100%) create mode 100644 openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Private/OpenPypeStyle.cpp rename openpype/hosts/unreal/integration/{ => UE_4.7}/Source/OpenPype/Public/AssetContainer.h (100%) rename openpype/hosts/unreal/integration/{ => UE_4.7}/Source/OpenPype/Public/AssetContainerFactory.h (100%) create mode 100644 openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Public/OpenPype.h rename openpype/hosts/unreal/integration/{ => UE_4.7}/Source/OpenPype/Public/OpenPypeLib.h (100%) rename openpype/hosts/unreal/integration/{ => UE_4.7}/Source/OpenPype/Public/OpenPypePublishInstance.h (100%) rename openpype/hosts/unreal/integration/{ => UE_4.7}/Source/OpenPype/Public/OpenPypePublishInstanceFactory.h (100%) rename openpype/hosts/unreal/integration/{ => UE_4.7}/Source/OpenPype/Public/OpenPypePythonBridge.h (100%) create mode 100644 openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Public/OpenPypeStyle.h create mode 100644 openpype/hosts/unreal/integration/UE_5.0/.gitignore create mode 100644 openpype/hosts/unreal/integration/UE_5.0/Content/Python/__init__.py create mode 100644 openpype/hosts/unreal/integration/UE_5.0/Content/Python/init_unreal.py create mode 100644 openpype/hosts/unreal/integration/UE_5.0/Content/__init__.py create mode 100644 openpype/hosts/unreal/integration/UE_5.0/OpenPype.uplugin create mode 100644 openpype/hosts/unreal/integration/UE_5.0/README.md create mode 100644 openpype/hosts/unreal/integration/UE_5.0/Resources/openpype128.png create mode 100644 openpype/hosts/unreal/integration/UE_5.0/Resources/openpype40.png create mode 100644 openpype/hosts/unreal/integration/UE_5.0/Resources/openpype512.png rename openpype/hosts/unreal/integration/{ => UE_5.0}/Source/OpenPype/OpenPype.Build.cs (100%) create mode 100644 openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/AssetContainer.cpp create mode 100644 openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/AssetContainerFactory.cpp rename openpype/hosts/unreal/integration/{ => UE_5.0}/Source/OpenPype/Private/OpenPype.cpp (100%) rename openpype/hosts/unreal/integration/{ => UE_5.0}/Source/OpenPype/Private/OpenPypeCommands.cpp (100%) create mode 100644 openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/OpenPypeLib.cpp create mode 100644 openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/OpenPypePublishInstance.cpp create mode 100644 openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/OpenPypePublishInstanceFactory.cpp create mode 100644 openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/OpenPypePythonBridge.cpp rename openpype/hosts/unreal/integration/{ => UE_5.0}/Source/OpenPype/Private/OpenPypeStyle.cpp (100%) create mode 100644 openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/AssetContainer.h create mode 100644 openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/AssetContainerFactory.h rename openpype/hosts/unreal/integration/{ => UE_5.0}/Source/OpenPype/Public/OpenPype.h (100%) rename openpype/hosts/unreal/integration/{ => UE_5.0}/Source/OpenPype/Public/OpenPypeCommands.h (100%) create mode 100644 openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/OpenPypeLib.h create mode 100644 openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/OpenPypePublishInstance.h create mode 100644 openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/OpenPypePublishInstanceFactory.h create mode 100644 openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/OpenPypePythonBridge.h rename openpype/hosts/unreal/integration/{ => UE_5.0}/Source/OpenPype/Public/OpenPypeStyle.h (100%) delete mode 160000 repos/avalon-core diff --git a/openpype/hosts/unreal/__init__.py b/openpype/hosts/unreal/__init__.py index bedf5a29f7..ae9b113acd 100644 --- a/openpype/hosts/unreal/__init__.py +++ b/openpype/hosts/unreal/__init__.py @@ -1,13 +1,19 @@ import os import openpype.hosts +from openpype.lib.applications import Application -def add_implementation_envs(env, _app): +def add_implementation_envs(env: dict, _app: Application) -> None: """Modify environments to contain all required for implementation.""" # Set OPENPYPE_UNREAL_PLUGIN required for Unreal implementation + + engine_version = _app.name.split("/")[-1].replace("-", ".") + major_version = int(engine_version.split(".")[0]) + + ue_plugin = "UE_4.7" if major_version == 4 else "UE_5.0" unreal_plugin_path = os.path.join( os.path.dirname(os.path.abspath(openpype.hosts.__file__)), - "unreal", "integration" + "unreal", "integration", ue_plugin ) if not env.get("OPENPYPE_UNREAL_PLUGIN"): env["OPENPYPE_UNREAL_PLUGIN"] = unreal_plugin_path diff --git a/openpype/hosts/unreal/integration/.gitignore b/openpype/hosts/unreal/integration/UE_4.7/.gitignore similarity index 100% rename from openpype/hosts/unreal/integration/.gitignore rename to openpype/hosts/unreal/integration/UE_4.7/.gitignore diff --git a/openpype/hosts/unreal/integration/Content/Python/init_unreal.py b/openpype/hosts/unreal/integration/UE_4.7/Content/Python/init_unreal.py similarity index 100% rename from openpype/hosts/unreal/integration/Content/Python/init_unreal.py rename to openpype/hosts/unreal/integration/UE_4.7/Content/Python/init_unreal.py diff --git a/openpype/hosts/unreal/integration/OpenPype.uplugin b/openpype/hosts/unreal/integration/UE_4.7/OpenPype.uplugin similarity index 100% rename from openpype/hosts/unreal/integration/OpenPype.uplugin rename to openpype/hosts/unreal/integration/UE_4.7/OpenPype.uplugin diff --git a/openpype/hosts/unreal/integration/README.md b/openpype/hosts/unreal/integration/UE_4.7/README.md similarity index 91% rename from openpype/hosts/unreal/integration/README.md rename to openpype/hosts/unreal/integration/UE_4.7/README.md index a32d89aab8..a08c1ada39 100644 --- a/openpype/hosts/unreal/integration/README.md +++ b/openpype/hosts/unreal/integration/UE_4.7/README.md @@ -1,4 +1,4 @@ -# OpenPype Unreal Integration plugin +# OpenPype Unreal Integration plugin - UE 4.x This is plugin for Unreal Editor, creating menu for [OpenPype](https://github.com/getavalon) tools to run. diff --git a/openpype/hosts/unreal/integration/Resources/openpype128.png b/openpype/hosts/unreal/integration/UE_4.7/Resources/openpype128.png similarity index 100% rename from openpype/hosts/unreal/integration/Resources/openpype128.png rename to openpype/hosts/unreal/integration/UE_4.7/Resources/openpype128.png diff --git a/openpype/hosts/unreal/integration/Resources/openpype40.png b/openpype/hosts/unreal/integration/UE_4.7/Resources/openpype40.png similarity index 100% rename from openpype/hosts/unreal/integration/Resources/openpype40.png rename to openpype/hosts/unreal/integration/UE_4.7/Resources/openpype40.png diff --git a/openpype/hosts/unreal/integration/Resources/openpype512.png b/openpype/hosts/unreal/integration/UE_4.7/Resources/openpype512.png similarity index 100% rename from openpype/hosts/unreal/integration/Resources/openpype512.png rename to openpype/hosts/unreal/integration/UE_4.7/Resources/openpype512.png diff --git a/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/OpenPype.Build.cs b/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/OpenPype.Build.cs new file mode 100644 index 0000000000..c30835b63d --- /dev/null +++ b/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/OpenPype.Build.cs @@ -0,0 +1,57 @@ +// Copyright 1998-2019 Epic Games, Inc. All Rights Reserved. + +using UnrealBuildTool; + +public class OpenPype : ModuleRules +{ + public OpenPype(ReadOnlyTargetRules Target) : base(Target) + { + PCHUsage = ModuleRules.PCHUsageMode.UseExplicitOrSharedPCHs; + + PublicIncludePaths.AddRange( + new string[] { + // ... add public include paths required here ... + } + ); + + + PrivateIncludePaths.AddRange( + new string[] { + // ... add other private include paths required here ... + } + ); + + + PublicDependencyModuleNames.AddRange( + new string[] + { + "Core", + // ... add other public dependencies that you statically link with here ... + } + ); + + + PrivateDependencyModuleNames.AddRange( + new string[] + { + "Projects", + "InputCore", + "UnrealEd", + "LevelEditor", + "CoreUObject", + "Engine", + "Slate", + "SlateCore", + // ... add private dependencies that you statically link with here ... + } + ); + + + DynamicallyLoadedModuleNames.AddRange( + new string[] + { + // ... add any modules that your module loads dynamically here ... + } + ); + } +} diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Private/AssetContainer.cpp b/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Private/AssetContainer.cpp similarity index 100% rename from openpype/hosts/unreal/integration/Source/OpenPype/Private/AssetContainer.cpp rename to openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Private/AssetContainer.cpp diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Private/AssetContainerFactory.cpp b/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Private/AssetContainerFactory.cpp similarity index 100% rename from openpype/hosts/unreal/integration/Source/OpenPype/Private/AssetContainerFactory.cpp rename to openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Private/AssetContainerFactory.cpp diff --git a/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Private/OpenPype.cpp b/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Private/OpenPype.cpp new file mode 100644 index 0000000000..15c46b3862 --- /dev/null +++ b/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Private/OpenPype.cpp @@ -0,0 +1,103 @@ +#include "OpenPype.h" +#include "LevelEditor.h" +#include "OpenPypePythonBridge.h" +#include "OpenPypeStyle.h" + + +static const FName OpenPypeTabName("OpenPype"); + +#define LOCTEXT_NAMESPACE "FOpenPypeModule" + +// This function is triggered when the plugin is staring up +void FOpenPypeModule::StartupModule() +{ + + FOpenPypeStyle::Initialize(); + FOpenPypeStyle::SetIcon("Logo", "openpype40"); + + // Create the Extender that will add content to the menu + FLevelEditorModule& LevelEditorModule = FModuleManager::LoadModuleChecked("LevelEditor"); + + TSharedPtr MenuExtender = MakeShareable(new FExtender()); + TSharedPtr ToolbarExtender = MakeShareable(new FExtender()); + + MenuExtender->AddMenuExtension( + "LevelEditor", + EExtensionHook::After, + NULL, + FMenuExtensionDelegate::CreateRaw(this, &FOpenPypeModule::AddMenuEntry) + ); + ToolbarExtender->AddToolBarExtension( + "Settings", + EExtensionHook::After, + NULL, + FToolBarExtensionDelegate::CreateRaw(this, &FOpenPypeModule::AddToobarEntry)); + + + LevelEditorModule.GetMenuExtensibilityManager()->AddExtender(MenuExtender); + LevelEditorModule.GetToolBarExtensibilityManager()->AddExtender(ToolbarExtender); + +} + +void FOpenPypeModule::ShutdownModule() +{ + FOpenPypeStyle::Shutdown(); +} + + +void FOpenPypeModule::AddMenuEntry(FMenuBuilder& MenuBuilder) +{ + // Create Section + MenuBuilder.BeginSection("OpenPype", TAttribute(FText::FromString("OpenPype"))); + { + // Create a Submenu inside of the Section + MenuBuilder.AddMenuEntry( + FText::FromString("Tools..."), + FText::FromString("Pipeline tools"), + FSlateIcon(FOpenPypeStyle::GetStyleSetName(), "OpenPype.Logo"), + FUIAction(FExecuteAction::CreateRaw(this, &FOpenPypeModule::MenuPopup)) + ); + + MenuBuilder.AddMenuEntry( + FText::FromString("Tools dialog..."), + FText::FromString("Pipeline tools dialog"), + FSlateIcon(FOpenPypeStyle::GetStyleSetName(), "OpenPype.Logo"), + FUIAction(FExecuteAction::CreateRaw(this, &FOpenPypeModule::MenuDialog)) + ); + + } + MenuBuilder.EndSection(); +} + +void FOpenPypeModule::AddToobarEntry(FToolBarBuilder& ToolbarBuilder) +{ + ToolbarBuilder.BeginSection(TEXT("OpenPype")); + { + ToolbarBuilder.AddToolBarButton( + FUIAction( + FExecuteAction::CreateRaw(this, &FOpenPypeModule::MenuPopup), + NULL, + FIsActionChecked() + + ), + NAME_None, + LOCTEXT("OpenPype_label", "OpenPype"), + LOCTEXT("OpenPype_tooltip", "OpenPype Tools"), + FSlateIcon(FOpenPypeStyle::GetStyleSetName(), "OpenPype.Logo") + ); + } + ToolbarBuilder.EndSection(); +} + + +void FOpenPypeModule::MenuPopup() { + UOpenPypePythonBridge* bridge = UOpenPypePythonBridge::Get(); + bridge->RunInPython_Popup(); +} + +void FOpenPypeModule::MenuDialog() { + UOpenPypePythonBridge* bridge = UOpenPypePythonBridge::Get(); + bridge->RunInPython_Dialog(); +} + +IMPLEMENT_MODULE(FOpenPypeModule, OpenPype) diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypeLib.cpp b/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Private/OpenPypeLib.cpp similarity index 100% rename from openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypeLib.cpp rename to openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Private/OpenPypeLib.cpp diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypePublishInstance.cpp b/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Private/OpenPypePublishInstance.cpp similarity index 100% rename from openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypePublishInstance.cpp rename to openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Private/OpenPypePublishInstance.cpp diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypePublishInstanceFactory.cpp b/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Private/OpenPypePublishInstanceFactory.cpp similarity index 100% rename from openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypePublishInstanceFactory.cpp rename to openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Private/OpenPypePublishInstanceFactory.cpp diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypePythonBridge.cpp b/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Private/OpenPypePythonBridge.cpp similarity index 100% rename from openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypePythonBridge.cpp rename to openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Private/OpenPypePythonBridge.cpp diff --git a/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Private/OpenPypeStyle.cpp b/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Private/OpenPypeStyle.cpp new file mode 100644 index 0000000000..a51c2d6aa5 --- /dev/null +++ b/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Private/OpenPypeStyle.cpp @@ -0,0 +1,70 @@ +#include "OpenPypeStyle.h" +#include "Framework/Application/SlateApplication.h" +#include "Styling/SlateStyle.h" +#include "Styling/SlateStyleRegistry.h" + + +TUniquePtr< FSlateStyleSet > FOpenPypeStyle::OpenPypeStyleInstance = nullptr; + +void FOpenPypeStyle::Initialize() +{ + if (!OpenPypeStyleInstance.IsValid()) + { + OpenPypeStyleInstance = Create(); + FSlateStyleRegistry::RegisterSlateStyle(*OpenPypeStyleInstance); + } +} + +void FOpenPypeStyle::Shutdown() +{ + if (OpenPypeStyleInstance.IsValid()) + { + FSlateStyleRegistry::UnRegisterSlateStyle(*OpenPypeStyleInstance); + OpenPypeStyleInstance.Reset(); + } +} + +FName FOpenPypeStyle::GetStyleSetName() +{ + static FName StyleSetName(TEXT("OpenPypeStyle")); + return StyleSetName; +} + +FName FOpenPypeStyle::GetContextName() +{ + static FName ContextName(TEXT("OpenPype")); + return ContextName; +} + +#define IMAGE_BRUSH(RelativePath, ...) FSlateImageBrush( Style->RootToContentDir( RelativePath, TEXT(".png") ), __VA_ARGS__ ) + +const FVector2D Icon40x40(40.0f, 40.0f); + +TUniquePtr< FSlateStyleSet > FOpenPypeStyle::Create() +{ + TUniquePtr< FSlateStyleSet > Style = MakeUnique(GetStyleSetName()); + Style->SetContentRoot(FPaths::ProjectPluginsDir() / TEXT("OpenPype/Resources")); + + return Style; +} + +void FOpenPypeStyle::SetIcon(const FString& StyleName, const FString& ResourcePath) +{ + FSlateStyleSet* Style = OpenPypeStyleInstance.Get(); + + FString Name(GetContextName().ToString()); + Name = Name + "." + StyleName; + Style->Set(*Name, new FSlateImageBrush(Style->RootToContentDir(ResourcePath, TEXT(".png")), Icon40x40)); + + + FSlateApplication::Get().GetRenderer()->ReloadTextureResources(); +} + +#undef IMAGE_BRUSH + +const ISlateStyle& FOpenPypeStyle::Get() +{ + check(OpenPypeStyleInstance); + return *OpenPypeStyleInstance; + return *OpenPypeStyleInstance; +} diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Public/AssetContainer.h b/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Public/AssetContainer.h similarity index 100% rename from openpype/hosts/unreal/integration/Source/OpenPype/Public/AssetContainer.h rename to openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Public/AssetContainer.h diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Public/AssetContainerFactory.h b/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Public/AssetContainerFactory.h similarity index 100% rename from openpype/hosts/unreal/integration/Source/OpenPype/Public/AssetContainerFactory.h rename to openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Public/AssetContainerFactory.h diff --git a/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Public/OpenPype.h b/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Public/OpenPype.h new file mode 100644 index 0000000000..db3f299354 --- /dev/null +++ b/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Public/OpenPype.h @@ -0,0 +1,21 @@ +// Copyright 1998-2019 Epic Games, Inc. All Rights Reserved. + +#pragma once + +#include "Engine.h" + + +class FOpenPypeModule : public IModuleInterface +{ +public: + virtual void StartupModule() override; + virtual void ShutdownModule() override; + +private: + + void AddMenuEntry(FMenuBuilder& MenuBuilder); + void AddToobarEntry(FToolBarBuilder& ToolbarBuilder); + void MenuPopup(); + void MenuDialog(); + +}; diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypeLib.h b/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Public/OpenPypeLib.h similarity index 100% rename from openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypeLib.h rename to openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Public/OpenPypeLib.h diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypePublishInstance.h b/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Public/OpenPypePublishInstance.h similarity index 100% rename from openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypePublishInstance.h rename to openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Public/OpenPypePublishInstance.h diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypePublishInstanceFactory.h b/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Public/OpenPypePublishInstanceFactory.h similarity index 100% rename from openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypePublishInstanceFactory.h rename to openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Public/OpenPypePublishInstanceFactory.h diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypePythonBridge.h b/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Public/OpenPypePythonBridge.h similarity index 100% rename from openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypePythonBridge.h rename to openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Public/OpenPypePythonBridge.h diff --git a/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Public/OpenPypeStyle.h b/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Public/OpenPypeStyle.h new file mode 100644 index 0000000000..fbc8bcdd5b --- /dev/null +++ b/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Public/OpenPypeStyle.h @@ -0,0 +1,22 @@ +#pragma once +#include "CoreMinimal.h" + +class FSlateStyleSet; +class ISlateStyle; + + +class FOpenPypeStyle +{ +public: + static void Initialize(); + static void Shutdown(); + static const ISlateStyle& Get(); + static FName GetStyleSetName(); + static FName GetContextName(); + + static void SetIcon(const FString& StyleName, const FString& ResourcePath); + +private: + static TUniquePtr< FSlateStyleSet > Create(); + static TUniquePtr< FSlateStyleSet > OpenPypeStyleInstance; +}; \ No newline at end of file diff --git a/openpype/hosts/unreal/integration/UE_5.0/.gitignore b/openpype/hosts/unreal/integration/UE_5.0/.gitignore new file mode 100644 index 0000000000..b32a6f55e5 --- /dev/null +++ b/openpype/hosts/unreal/integration/UE_5.0/.gitignore @@ -0,0 +1,35 @@ +# Prerequisites +*.d + +# Compiled Object files +*.slo +*.lo +*.o +*.obj + +# Precompiled Headers +*.gch +*.pch + +# Compiled Dynamic libraries +*.so +*.dylib +*.dll + +# Fortran module files +*.mod +*.smod + +# Compiled Static libraries +*.lai +*.la +*.a +*.lib + +# Executables +*.exe +*.out +*.app + +/Binaries +/Intermediate diff --git a/openpype/hosts/unreal/integration/UE_5.0/Content/Python/__init__.py b/openpype/hosts/unreal/integration/UE_5.0/Content/Python/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openpype/hosts/unreal/integration/UE_5.0/Content/Python/init_unreal.py b/openpype/hosts/unreal/integration/UE_5.0/Content/Python/init_unreal.py new file mode 100644 index 0000000000..4bb03b07ed --- /dev/null +++ b/openpype/hosts/unreal/integration/UE_5.0/Content/Python/init_unreal.py @@ -0,0 +1,28 @@ +import unreal + +openpype_detected = True +try: + from openpype.pipeline import install_host + from openpype.hosts.unreal import api as openpype_host +except ImportError as exc: + openpype_host = None + openpype_detected = False + unreal.log_error("OpenPype: cannot load OpenPype [ {} ]".format(exc)) + +if openpype_detected: + install_host(openpype_host) + + +@unreal.uclass() +class OpenPypeIntegration(unreal.OpenPypePythonBridge): + @unreal.ufunction(override=True) + def RunInPython_Popup(self): + unreal.log_warning("OpenPype: showing tools popup") + if openpype_detected: + openpype_host.show_tools_popup() + + @unreal.ufunction(override=True) + def RunInPython_Dialog(self): + unreal.log_warning("OpenPype: showing tools dialog") + if openpype_detected: + openpype_host.show_tools_dialog() diff --git a/openpype/hosts/unreal/integration/UE_5.0/Content/__init__.py b/openpype/hosts/unreal/integration/UE_5.0/Content/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openpype/hosts/unreal/integration/UE_5.0/OpenPype.uplugin b/openpype/hosts/unreal/integration/UE_5.0/OpenPype.uplugin new file mode 100644 index 0000000000..4c7a74403c --- /dev/null +++ b/openpype/hosts/unreal/integration/UE_5.0/OpenPype.uplugin @@ -0,0 +1,24 @@ +{ + "FileVersion": 3, + "Version": 1, + "VersionName": "1.0", + "FriendlyName": "OpenPype", + "Description": "OpenPype Integration", + "Category": "OpenPype.Integration", + "CreatedBy": "Ondrej Samohel", + "CreatedByURL": "https://openpype.io", + "DocsURL": "https://openpype.io/docs/artist_hosts_unreal", + "MarketplaceURL": "", + "SupportURL": "https://pype.club/", + "CanContainContent": true, + "IsBetaVersion": true, + "IsExperimentalVersion": false, + "Installed": false, + "Modules": [ + { + "Name": "OpenPype", + "Type": "Editor", + "LoadingPhase": "Default" + } + ] +} \ No newline at end of file diff --git a/openpype/hosts/unreal/integration/UE_5.0/README.md b/openpype/hosts/unreal/integration/UE_5.0/README.md new file mode 100644 index 0000000000..cf0aa622c2 --- /dev/null +++ b/openpype/hosts/unreal/integration/UE_5.0/README.md @@ -0,0 +1,11 @@ +# OpenPype Unreal Integration plugin - UE 5.x + +This is plugin for Unreal Editor, creating menu for [OpenPype](https://github.com/getavalon) tools to run. + +## How does this work + +Plugin is creating basic menu items in **Window/OpenPype** section of Unreal Editor main menu and a button +on the main toolbar with associated menu. Clicking on those menu items is calling callbacks that are +declared in C++ but needs to be implemented during Unreal Editor +startup in `Plugins/OpenPype/Content/Python/init_unreal.py` - this should be executed by Unreal Editor +automatically. diff --git a/openpype/hosts/unreal/integration/UE_5.0/Resources/openpype128.png b/openpype/hosts/unreal/integration/UE_5.0/Resources/openpype128.png new file mode 100644 index 0000000000000000000000000000000000000000..abe8a807ef40f00b75d7446d020a2437732c7583 GIT binary patch literal 14594 zcmbWe1y~$i7A@MiTY%sJnh>C&k;dKKU4y&3ySo!KXmAhi9xTD#B?Nc(%Rlqayt(hq zmGAY}RbA)QI%}`9_ddJp>#B}WkP}BkCPW4R0BDjDB1&(c{(o(V@NfG*K7&yJ0LsTg zSXjYHNnD6bQdF3YiIa^D454QN0H_mOCc3PYp>PJy$E88Im1>MZ5@BH~c-j`Uu%C&Q z>XB#3W8y_puUPq!?+3hSlyu`D&PpNz?zBTfyc>1-q)HvIG*sP zj*=@|ihngW4wZAm#KS{2Xi-8F?;=canoy*&Qk?2)cg{0be|{kIcbh&gNjmDh)jQTJ zrNZjb&5C+B_ul}%w?ln^32Yk@tz1IxagrI>kxJR%bsQCb3Dt2L@{5m>9`JyKVY0cM zU7*KmIfVU0{ltCTV1AebFuFdOr6leP7MTnToS@wOHJa(7rn^?pS^V?6v@bk%)gF?l z=fm898fycp3{s`!ObDT&i;K;f8 zw(mFRqyhF7zwQY5?fF+|A5yckvvW%Ow|F4gOK3U)04UghZBT%WEPMa}?!rPv!&yUC zhRev#hTg!~&d`M3-R3Ve0KmiVZf{^@W#UX`Xkunz%L_bh>jIKl81n+vS!Eez?S)Ou zEhIc0O_V+5RE#{Wj5v*f{Cs3Q?p$vKHYUynWbQWBwoY8`yug3(a=jh@)y)7T`v=6? ziWeyOmq9WOSp_m-J4X{Tc6uhT5hEib89OJviLn91klB=u48jOuVqkiEvw)c(T+EDI zED*B4U%)qWj>e{3N+M!^8+&W<0?nPB?YS5j+}zyg-I(d^9L*S*I5{~P7$FQ02>1;F zcJi=wHgE^qI#K+KLBzz#$kD>y*}~42>@P+GLpv8|Uf`S5f6l?i{@=8=PJjF9&0`Gi z2KEe0^o)Pa=^sF2qkrSCdy=?%;DZ>+t!owJ>jx!wPQ`roJj zCj)Q3m6iRsjsL2}#^&E9oSa2n-=^`mL;fq;NyWq7gh9!~$m$VAljO(w-(v$5wA zb~G_?wsTamv$OtJq!j)onGC{A&qPM8ZeeR|=jKH79|KH844h4PfqzBqEnZ*n(7s?6izbT#StWgv#0(TbO$MS11b?1oA&Y-*U#-z}evc2sSq2GPQHGF?gG>g^huk z34^_@8IbJXZsZcSv$k`5GyJBG`9J$5-|Ca2ovDTO+ll{Ao%)AdSy?VgTPJ4&TO$)m z5nkY%bLcHBjJcQ$m{|?k3=P0+Y^F?LP7W3pFbAh8E7*j|(1_ibjgy(l5c03_B6dbD zf2F`*v;8K+2x4FYW@TqF1{)eMn!Gic-x{ojoJ@?6zta96 znZzYw;q(?`kG~g^vWdgrN7fc(|41G#1Eaqd1uxL(uWT?e2L9b`@n8J$e`Wda@owfO zZ>0a5EcvH(Cp%MTHv>l#L9;jC{U5WC;eRFG$-wo0Fa7^6l>gN9U#0(N*8cyI{iR~M;<6D_7 zkEi?%05E|iMFdscvyQ)dG=tSuH~g&BzdD_C*hyUIzC%Pp!b&6q#(z;14E2YVERZ2g zDT%3%gxNpQ?EnWZGNroX3a|1i`wJh^%)q30G;t%N+xk^_wAf0G9s{~i>j^q6?l;I z=tbyp`GD$gE6yP-@BuoWDF^T~kJ zOCS4@e|utesBRKbd-+=hs0NewLInhsSpyfiOZ}V#L4wjI?LHc2{jv7yl>V3g;xKrK z7ZReUP;wU{A7OyGApcl@d6+lbNGan{dnw551B5UJDwAGt;n!l$;;7k4)eTpuEpb@aOuyp0K|vfPm}lqWsnlwCM}Jt9t90}U^AY>O z+M}NdZ4~<}sSpox)U9I$jNYPcFerLp=j*lA5iY}PQEO@SBSYA5GAT{XbKgb&f@TIy z2qi=mZ1lXC2xN)u0S2_m;xnCY6@Ml^a#51Lny4ZwYt4%Nd!D`EuBYC*65n+ zka7_&AQP#1S1>=A&p%u}h6Su6wA3F8khowD+<*~rh$sivpiB#Lb&f^pn>vue*6lUW zWs;}Dz>}&wo?g(&*hhaeK($bdx@`MkEf!`6@tqIWy%#SI)vZKO;Fri7ItZ4h7eX?E zSK4)=VS%%b4Y7f_0ZD!wRpvLi6IAGCqBFu|()S-V2PQ+X1?({0Ye9ByFzz_h#Ne~Zh%`mpMxJy>`YH$sUs>g zxBl~q5=GfiS zm@kCX#WT%DHPtZuP~PsQXaRjbSVnMcvLfGAib8+0ET^qV>^fTXezrQ34QiA>wraf9 z#*F<0)sA_mcV7J`x#j{rm{fc*4MZUH4OxRRDgVbW^u^8(+vm1ILNKu~yPNKz1tNe4 z!uZ`^!)IR2-SQ;u9AX2a<1-AaX1`RJ-P;Ci8jz6Y80n@_wg( z^w$rAQW`6pXxuN@i{enRbWqf%u-TtpFc1>6dqcWSly_ij#?qqf@euR9X}c3X`n!?y zG*mfR;d^tO+1bE-O&gDVDqiC=wRd-lz>L2(-6$1s9&eu~cH|8t7{UnErGCt@x86KM z5^zWBts}I!AHM~>E@|CV`QH(Y4KyJWS0TGYF^(_!5!-Tp3DRjFC!MJaIE6idp@T{i zw%fu%j6z%wxrF=HyXU*X7W~|-ribBOH|x`Z(wm3vfA&o6$2f@hsENxr(_jC)C&R)W z@yszeN^32Zto{3O!?j1q&HmiJ3p~B%w`>IOtR8}I^wwzI$GLjYRWQ%6XIRM$`pbvB z*?BzuLw^XMUng{SbI5NR5UcILoGACbP66{So{#Np>{zQU=mwr69%4plZCD=)^W%E= zDCrHYyDIj?O$=WmrTEBFzWJaHGNlbkYPaz%pcX*q)sY&8Y9ZFygYA(%C#=K0-#;Q4 zx3ZFbg4CO)b8wT;AMVCTjTMYL9Z6lwyDtAm=B{CYl1sF#>TjFwnem$5*@|gy@IpV} zSOsLWT)#ivZL+CUXyV&=B{t>v=Jqy{VeSCiX?sQ?YuxEdqGPge|>YnDG%*DO=b zFHk-n<{$AWNjJx`Bx_&5Bu6RuG(HL^wql>Z9e$DSzjJaJmbf%Z0;+Es+7N!B>^)vtS^369%XAdqPJ()ZbofwL{(Km0lA7n zjQN?Mlb*^}b+})VKbBk0&g9vOgiuvNJ^N&MoDwn)Iaudls&}0uqlVY(dv>m$!Q%}< zhfh#WVr`44*G)57V5K{h9h@g~_G;wWy6KlI6g}+?VqDik{$;U`nRhhO)F@Zof=AiQ zlMQJ2F^V`yLn4sh2p>)N;W4{5D1b{8>&;!Fu+irp17KxZ9ZlA)H52<_oA7bO@_N-o zByuwBT|t#)D*37}WenH1==Ah+qqoa1U%P5rj_OC$JfMe^50zVAelShfX^%>p-Zfx% zTQtlgS|q?{WE3F>L1{#FyucqFyZNhrTMrWKDtfURienR$!(22Wv`9E)?q!@vMg(O` zzJ3$ME(KBqp_hH`YqQgtTV}pH?SScEUs<#@T0GyegX@~H%s}Krdg}X`czHV` zr%BfGH5b~D_hQhf%&L0ugNEoA_;99ctg&Srk@kL(M9U!*7QOZU&|Ohh*%wrY zmqn3A{m}-YYJ@qZ&rT$+E@+yX7kB1F0mPgyrvk88ZW*N^V*BYUX|t1r79&er%R5Sx zPT4G`NfFxNtsW0cq?wWq(sp{UUmMN1&-1lL%?Bqc9W#hK_o!VukvRn8*KOdvzLVcs ziFC2lIn%$GA^88{VKz6YnfD?2{8?D-i(nrVmW)+(_jBIbgY4+K4Zd9hw4hS|gZ7AM zVa_5QYj4Rb7&{2(%dFE)r_ucq2?h=N`<${bRCHQS8WO{2-(2RuQINsCFZEY10Vye4 zHjKSq{7mHRqYKPJym<7*SZyQi@L`}sD{AiKR8xkb-`bKmN<{`dgf^?E+F;#k#bSv?wMNJd3KN%Zd{!s=GX{Gw5ST~0StR{O*!!E@pul=|=%Y4IMYuIaUG35TEp~VP>E#;+hbRnBv zX;)7;{xV>CIn+jt%pBot`klIAA~dTlF&~0=en-ivI;J0rg}&G)ioK;)l(VZ5i)GqQ z>L;n2=^7aCV0t$Grkjf=meXRi&Y~Xva(UK(_eWp!1w?T-?VhA|8|u*86R^yJc|+@R z58|lNZ>Z;`-EO+Qm9-Q-5!d6Evy_80lLWhP9`-!rEicZ*o%Cp`l$|I=T!l2~S@TnD z!CWgpz3;@HH`(p^?)F#c{Fn(junDESiW<$a&rzbYY8FK-@WGm#P|S31m9RKYoU@aR!b|5oh(RFdq?IZGTUtTLURBguou%<__ah4ppo>He4=Rwe? z#a}p|jV&_FzO;%@O0l%O8zQa$2KRvo_pJ)KRX4_vn$5*rUYhAd7OpH8YtT;GGIQmK zH#c+2fsYEuh&Cc7Ulk8>vno3{=G}ClNR7Ue4hUC zvk%?UgVk0L8WuQ;1XN>EZTdJ5IYLCg7y5y}K0Z>!$D$vK z`b#w=cn+3`%+B~%nboko51sKrk*>2beh!Og1!e{ z+HrDnZDI89^8D{IpwhxOjk*JFu%vk-o==N%v@FdJlSIbkk4G?hSLX;01P)wGU0N!@ zK;CK@Q!=(c7Ls;Ticeqvyeoy}E{VRmcG%G(Wd@n)LTISfyoNN)Q}xjl?itUvJ(R$h z-}d5)$ReG-_EzM)zWSi(y(_xpst!w9oC1nNIF;Gr$Fd$x&6_1*slz3Q*h4cOY!8K% z8)gKQiI|Cs=%roCB%V~eyX|iuLcW7eYo(nko+t(Ba5CX$r9$X*xWT75JMVB$pjHsC z70y-_KCC6%Rxuwg@*)@KC~-HLUqu`fqF zqcQcQ0pg{k>w@jyrDO|h@~Mnm=rF<0f@Z;F$2gO7<;lc6%lpJ1E3C>Sd(g|oC72F< zG*c@C#o(;V32jYjy(gHj>-^P)e0aM<$Yu;*@5pLK9f^9o7(9h5?ZRL)xDuT-=)c2A z_@t*x1Yv$B?Zy%?v70+TyS{qxkLX8(FGKzsZNLM|v~Kn#Rqx~T8n#xZ=JZZW=V(oK z1M_wHKJwgS72y4mz&LX)4&lFI211F8mZv3@E>CodrbVuA$fl;=ppzsp9$c34xk?uHpCLzBu6Vt5SHnr8UN@vq|I{! zqPcjZROj<}YB`%+jpjw>*EQE5q4rE{N41Ds1TnWrWtZ3L$|1;*-zWY#B2wasEpuDC z83&L*BWe1y{e3Wmn9y?r4fOgD2d>~M_^LnT(t2?rD>kx}01Vsrz(E!AiP3Y9Uk&p< zNT`?{Qit%mD_seo-o$F_=2?~7UjUod0fzVh!K}OU*Yl=7YuAV0yw@6rid>2j#7c9o$Fqb{L>L<$IcgB zOEaPtoQK|jmIpm%u`7H)^QJ5D7nkyd78dbL15Ck8Y+lim@3&Oia>wk#6(Qlx%atCw z{@MbNGwprNq9{fbRCt0Of4WE%@Z=@_0Z!(ne!eWEOp{S_f!vp99`LK}f{`sqO6?zeJ3fl5hQGd6Yu@`tJU7%{&zuU{=OT9 z=AI6o6xQ@e^l)v~;>F==X3YdX5QP=?Jp@0k zF5PisiE}3`1Qa$k=~;JOh?Lnlh3X3a2yJ*gE?(m6v8MmsZU@ZWyUia)YuCeh)mlHx zqt2h|Y)FQ_f7)%Uzs)0K-pZ72&p@6G2daH9oXPXOEjQhD=H zZ1GxA_?$5#q?j~P&y>@rNo1UmZ&ulydbK*`3`B^+g8O?;AHw5ZKf4jQZh{m&nj^E- zf!;)WRT&kDp`prr>v>CyFztZAdGhZXKpYHrrcq0L~|BJEwmEIVLv-1zL&DHw^O_I&?rv5p=5+BhQo zPN|koQDQ1$TE^c#h+j=mu{$&Q$vmX$gS}wh+J^1Gi85*ExwJ!Z@9_QG<>Ppv=*=4b z{LsU4)l*zWT?Lk}$o^K5NzZ-2{<;5p@>3K5a0Pg}l0&ZB1}ATTK%A08-ma;&V4Q^jyR5_}O1yyi(#QbL!bYn5ysb*_cmze;ooe&};Mc>|u@Q=38 zGzY55wsk+}!{6@~j`4Zvw(VmSQznkexeuLW`M;V7bSqy*Da6ACS^~+oE3$lzTfL`k z8R1QMD%dcT!t;IjQ>$>zR~C`xfNG?a%W~c=$sL2cS?dSzVkc-rzXrT4#eiEn?*x|b zq$o>j+0{m|t?hcs#%qwhUe38WWxToN=vAHFI=_qA6d~&fcTJT_^;D}aL*LGGC6q}z zL9Oj&aGePjjZlV;$FC*BqDvOz(_~wWDA7E^Gl70k7abLNV#s(D(WS)U(wxd0B)Z#r z+*Ys^_KQtWOBG05PjCC9Ko1Z3@03}3d{?NjvZ$4w?Tv9Iknt{;17o?u`D9i?%YOo_ zOq7l=cEgRO9S~BNUVr<*h_uz{B0rd%=EfcoPiTY5Fe&4}HA?k_W`Yq3%>-u{U57FA zuCbuyhlYsI6{D3vvXqv%9DSussCN!KVJOQ8YgiBUj;VvsYxmTzD1PpOp?>`)O$}qZ zPxz0YP^Bj7ySOl%2%*YLhAO|7MH3h8yV2$vU$D-khxur8aE+!GOFzRi;BD#nYekib z&Nwuet+Rbv`=D?*hBS1DXt1qgybG(AnTpsJA(WwBwKZ@Zcs@C0$TlLMeAp2pNY?Ea zAw&Go`U~NzJ-Kou*v;i}?a&DtLBVEKiDeae{my<}5inx}dWlB z?$fdo?!1nt=2sk11=*?wbNsRnCyZg6k#A2O+NG(qq4IqCbxg`mVm%i*8cLhFrw^sB z9!RfUmNKl?7{kyeR`vxEPE3*30Z!vCFqMp5Xb=WVlvg}K?aJAI2}D}CUHM5#J7GPa zC>-nOnvN8c#UWr!{qQO>;0*b0X(VU(K$HHn0gfC zav$CpXwZs*rIoGkZ-LhOhQXnizvD4vL<}sFBRS5+t$buzB^eOAr*E^yYnw(+K0zte zUW%wZLg%GXEfMKBwOGwHVGcy#o~MZ%V%cGUWk_7DoJki?!$KHRf%|psAM@xf7|z6} zbxh~C3)R1LU_uc!JkbqUnV{qznK#Kj)&u|5S)@#nkx_}#v z11Z-~N?*_C(2Wa00?!tDn2@yDIUzPnGk_|%B6e?;wb&iT_&8>|h83nZ57J+ad;#jV zy2E#Kv^6u{;ldKF%~=oonAAMG%&Z)q&H72#Q(YRjdTUe0fMOtBaaP* z2YB_)R&=t?C-W1f*zHDFFRnavKrZr<{Bst9wklM7X*KF(b?=?^@M zl^Hr_$lre@y>=>vaEr7fD>(GjP{Y+t0opIfo>>X00^Z z`n`-RMrot0&O$S{H&OqOV{iabkzse|J*bXtcidLv7Hk0Z?lmM$VyqtVAW#Te` z+%M{6IQ)|Y{4Jat`*blIrmq-m4=yCHGjwlUb!vu3u#)?0E}=?V&`x0o@AG;oJoYQe z7F>u*d;o;c;rBS!!jwNhnS_d{9UWH3*zwDGjhW1K*#GqZ(El@T3R<(IBUnXDwy*^u zyR9+>y)3U7X)tVli`iz)L{V3`spc+V8L0Di)zIhUwbfU{sKNygMP@s@Hqtw&!j_t? zxmW`I5#Dn}{=-I|E9q0j(zbx0Z((BD^(E9AiUNw3yK0go&hSAh!E+f&U}eMYSUp-l zoOk5xh4OM|?WI>Vc!F_<+inlmcx#JdHUfd^2D85M?DI($G~DK4VXwNVhOl$yq5N4b zZoG~5zz#VIh+OO?jBVGstpB3aCI=!?|98JQ+N-B9yy^uD9gA)EY^+HO8 z8#c}DpP$el^tNZ@YPDWmUT+GcMB%iTk_~zm(j|NTyxmBV#{OwKh%10JCcw$wDSrQ5 zP(yulMBw>^J+fAr%kFe!#BRUrYg={jv#aIId^(3)YczjIfBYPID5iBd5cve>Od*vD zP<{|Cc#1Vs`&)bne+hMzaK^OG6jMrqHmjHB_EuN-gjA;@1Y)R4R&s^0$Fh4dl z>c|i#iWq+QH7hY>w6Z2{&HmP5^<9a~?-**oh)ALw z@WL$A&jm)!?3xxY5@z}*f@tlkKTx^F#bSqD0$jj9b)Z&IuWOSC4`yIi(Vp$bO~|Yl zlX~#6ItkHUbXV)1Ox7u9GgfSw=&hsr1eJUT*GK$reAGfJm2E!ryb}@Z+GY$z#Fx`W zm{mT-g85wUFOe89sWje{V9Q0Wt%!l(O`5|6A)3$_3hUOs=~b;T{VOF3e14fqX^>UQV2HM#PnkO2 zO=hdO3gzMAVuNKcXddv5veXw2gO-eQh#ElAn5&pO=x_sj^QzaL$ySP0P~$t`V?G?l zg22rav>(f1I0X>IE^rWXYB13R)6fpUq;Y>ksX=G22=GxIZWnU6+{FoNv7hSXa?-qI zh9%shLt^YI;4SZ~$KGa{NIqJwse7DmwK&^m-26%gp{Mqu9b3bRdLK#C6PZVi^35*y z`#y1VMjQnsh z9cs<)C8h!W(iNB2%SDe7DH{+6#~TalO)?=Rfj_GJa8jQ1yPYT$MvS-aBv4 zhih81>PwSQX$wiblZ!yMh4I*hOy$b8(nkgEo@8m#;CI|*8ZEo&{szb8^Lar?s1Mra zH88j?#m;K*%=C=H%C8b%6f178D@#|_d5L`dz_e`YY|CLHB#NrfWrwyoniDX-Jc!PA z-(ElC*Dv7-{%mbYJ-8^n*{8Vhk(p`$za*I3a++!IfJ6k%UVjST`bVx~7H-cS(FHdpGB@T$R;=GXt z11F5;_(VT^B=t#KOpX#03E9`!0J15?_O-{6j4FtM? z>E=%6V64->{?f!MmAkrGm#+d@4MuCo8J6*JqJYrB<3heE!&5uIHeIi5;TP7QtK+mf zx9(;~5dllQe4OpXwFm#TABo(9cAlUUasS(`&Vnls+zy?PYH}Tvb}09~JVOUsHW%N# z_A+{-ogkKv9?N*bYZdvbofC?ikKDglG3gwRePtU~C`Qb`pAfC@sph<=awALG-p^brk&zX{8dwjIKvyJ2KBuXKERLV);nCpFP%w8^JAfYuP_)X5p*HGLt6*@-xi_ zHlm-m1WwH?0jC?U<_7oYdj$-?g`e}ED%de!fCtJ{bMgouW=d;&=l(!{%z+^*YH{9b z?f$)%5dYa-XlVOI5KR=XZtR}Cqmm>VbVLJplT9hAI;BmdV0**VhAM3)3R1ni zzJCf}S1}UaWsEKATid zHqd${Ub@%TQ19H4k2gVb&mZGxnz0CztX{vv%6!nQ8zL=i_5^s^Lp4kf)zo3zmI}!A z-e8%2?dN=M1z}brL+Ho8NqmUO6dQ^j)fj=HgqTk1JZlFO?)$c3#j(j6g(}Cu704?V zjPK72tjs}5F;-#bMV30LgC*_gv@F${dT214sS%zyj9KV96ET4I_FCJKNt9Fg zj0M@3yuDE(n3A}#V{-X;j)p@^?@VdUfa|PX_$kwkLL_4doz;uG8#ZhmaYZ;6&)%{c zn2=jgWm2%gU>hdE1u?wGL=+%eZe%ym130~N0g(vfo`H}qKMFxVi z<>DuXIhpqMROG?)o~H3L8CIXp@<2&>ztETx_-s}A*=CgV=-maAOqZp5I~!t$s|S#7 z{;~9!86ivA-|e^Ox^zHl!HGz`8j-#(LqK)eS&{JyP4_7sHTIPpNeeUDkCmnQkDS3j z%CfBiPHp7d){u|$D>4P?_31td6qans-MmFg18(g%ZuB9`Q`bKi<=U5-6)JA`j!E@H z3zuJu`|S{<+F@6ApCUb(f3qp-a$>+01!>-+AZ8S?UBBCyxu0|1`T++LIMB)ZoM*jR zMLlKJ^HsB7U`|?(Z4u5iPb&ihuClflwGC?+OkBRtIif#TW3@T=#Bi($uNbKqAQKsW zRueyN5KksFI6R$T3HjdNsuGe=eRChETjm&7Cg3YK&-8rx6p>q zF5C3XPp12~<|2PwWVK(_8I<%$)YhJCm<1hAE@{r!qD+Rvq2X#TvdP8}N3U7d~-TH4k5qF9PmPmdANfJKmDQ{8|+*v+Is8%@q!DHl?wz<7dtcX@p&PJSS@&6~=Z5x3e=~x}uGqs>$XiqnQR%fI5sC1g z8N?|-selA=5MRtt>={AhX2RZ@$1lh zd6b|2i+Fh4$A}R(X!Uy2CZPrvgL<3av5Zz>2#DVF;Y`B_6_fX+1qxOsQ|l+44L{W# zqIAq|#&5vfU5@Xho2uxz3T`dBxi4YCGxsNj4~3sk=HF+0iN=lm!lhy)FSy#uBlyI` z5W17;s(^}8Rw%g#=sCWgx;4XkPGXeFyG&2!N#Sf1#a=23+T9a_yBhAMM)7R-o6`qL zdDO_;b)3fXAQ1r%vTi+ab#Xa-LHQ)>70^f zf2OaM6-p%;E?(LQF-uAWbM%1e@CZA}#c^)NAMe}1b)kFg!TUw8cRbiB%UV7w=>fTw zeY&{WX*zTr+-JAHWR&g)lAxb`U>*?Q9eh@B!aMP6?IhM2$71*mwQR5Fi<8p76D5Me ze{5isOftA;=4TTwXr4)TzecZy1J@28sVO^}D~(C*12pc_qDh!aYOm1IHCh}H21>eb zv=i+7yP)q`a|gCDMLf{XV(C5kyCS`KU#S)YNj4a&unk~-BkA^M(+)`n%a3{%48}xm zudHd2mZB!5FpY@}A!q=67&-$O zJ9bUC-uH#sk5-)SRDW?D#oQccdT<6`MIqAv(9?K$OeI*U-TJ1=zgM|*4G6{n@)UVM zv>N@+x4SW1EUd8`O<`D2`t-t6az4U;a%Va#+j|hO&C>q?H=ApC`5O&yuVfKUF+aIm zSzrurN4J|T8R_@i9lGNDjT?X5ZZP$H_yv>-A9k~So(^7GMKs4bO7VBcX;<+I?Vv{( zJadcjMlt!^38ID?^+loe>cS39l&E9PBmF>`Xo~s0-;DwON%PkJnofk92+<+jvec0IK=%%%_n(1@} zr}xtHd#m!KjJ4GtKPo1kBmhoRVw2t^H>3XGER|x_&9rBi+!9%NRlV&AKRdK^)Um3{ zD0bwp#6s;RgCB6Nj1gkGp?9YWlj;;alO1})5oDwM&?hm@Zw)x~0eJgkiKaN!&2{GA z9fWr&nmg9xKsAj{6QZ}gv97*g)(u-q>;t$oiZnn3K?ch%RQ6k`a;}+7O>wo)KTR#p zrMSApRz6`U8tgaPRCrIfc}$x@5pFd3(~9gw*i4F+U_DX<5H*(FUX~vRHESbGikECU z8SdzeTXrEhei|8XGMN|3Q-b&U!qk_zIefU#>0-4J2S=@+tChhqfB46SZoU4iV@KSv zynUFd6oh*A)uvDm5#?i0Bn6q^9?6li?G0l=uVm1asUeO(DL@x@RmG0#egbC`V7E>t z;$G29h2cgWiwad@4UHjsl>~B(y)E*UORlpg$ppH*TBy9OjJJEBnZ;F1i+rx-D(H4p zKeJr7F!@U`bGzIkQZk#Sm)QkS3bhE@c-TfsVFkiD%33+R$0Tin$(l)ov4hfah88wl z;Z-MBuXgC)9)@%je9{&6J&7+u-fluv%?{oRi|dakCV;lD(F=J6_U=UTDd1pwK+5!B zTY}jvachDi4K->metnI0vxTfNnSO`3D%Oq=rK(O_aj?_`Az$0>Pub93-GT!I8hG+d zn;QjhWKd*jozrYHOPaBHgxWf0*Zswz8KAPWq;baA6A-_ntwMbWyX1*QB>s+tpNAEV zNlQ6`AacKk340#&2vpG};R!S*ZvCz#sT0Ax$Y$DZ^BYeTq@}Vj)$x<$YEMrsq{H2J zK*l67WzX#>p0!%%w9b1JIcPam@bf}y^9l&1{Kxp{6eADw+^<=IdR!urB{_vS1YQ5V zq)X0N8tHX}gU^q+`nZ#W<(t?7kx|5-h|!=Co9wV?`QL->cPVuuFQ64WbtnJ~QlbOL zVMM7X8^HXBO&_YafTZCQqRlW^6 zv;DVB{!tECePL`7KT4fmbIgGd)qauFI~YWHD6TB>HW!YJcimuZq3nnYwqnAMjx8GZ zppj~%3w2f`Ow647m*L`S{v=SiPl(hC`f9r@IScWU+V)+vQCujjLN;2ve+_prT2UaE z^2o_+e}S>>{Dxh?WBJ)WBj+}^FKKWSOpZ{?odReK!(?I#&hcUtTslT~ zP=GPo%>d97bf>tC#}M%vS~t@jgg-Otj*3+ih9O3Q2HZjNF@7l4OP|BXNBZK0zOg|0 z!gFlmH|$IFx8{&lH2@p~hi4mQ|_)s;3jS$KmmLHI^j+;V7#m<=7Ka5GIRwdR#oZ^SOgH4vh z4G(MYmyCeIgSxZX9pu6j_SUPllW?cDG&{%^AW@#yn{9TOd%PX zk&21*+cTMVU<5DV?N<_<6gxmuenkP~L;%36kF?{gGKRx}!GXQwOMMH|qW=A??xhh6 zDsPPA-Sjq2KkQ0mCX1KJ$O~yYK&iznd?ZTo!#j4qh2ZA2b+#D+-gVFj2;>E8D^Ac> zlAzt-Z>Zp!oAn#7)>?QG>k!!|f3kW-YaB_S1WU+%S35|kx01SA^iDW=cIlEk0uXCT zwx_T`^3p0H>ZiNWdxmAj@|q}lHo%N3;oV8HfbQvrX2=Vq8M3^Eh}Lm*dVRU#_>dXP zXxJLRwk*JB%u>*hQ3d;g<4)1LghfzF0Xw*avWM)4!6UiZlUuFY2kxYJuwcN_5j7KOx*g?FD&ATLk>>bOQMY5jaWO2lZD9AH` zY#E6v4eOimSj&rg!Os#3Cb>2)brxUd$!&gEEThllU*VLxJGPCQ2hvYB?d1@;#q8U$ z1^m%gB8ctaTxlOfuv!3DlVrDYi|zR1ah_CUGv!Eek2-#4#)&iz!@;7|BFCA8*@!2A z7lJz~9n9Ub0%llogcA^KeBW_1wPtwoSBwXR_%Mnr3{UzY$B~n3cL9x$(6XN21Th6a ziv%(5y5k)`5}yE${rW1MN{F1@$eU^331IUbR?mAKda>K6@)cMy5jw_^mhHk^; zt&KIvP|_!9hM8R!wC%e;U??F}5OC%*}lO>d0lqC^w zwiXqyU6$-Zmc;*P``+*W-}n2z<2(L2=9zi!`#$gMItVs-B)1+uZO77_YCVj?_|DhoWFe02JrlTIpfIv!iyV z#aLN9#~N|^Xk?_N#0lzw4UW?0g~Dxe7h`c(=FmFAOSJ*}%^mpPg@ujuKhRy{b>w%| zHQS-vxxrOm0@@;XW@n!3GHVih<%OJuyI~5M9C3`hRYF3T@W@=$uu>|H-FjTnaFetJx;CgGQHo zv8)$*s}TusmlYJcew96kG}4SdCi#>YHxWwN*@l0(VUGRuPfGAik&&}*!RfIq|g`ogr1{1P0!%@dnAdPEXIsGl6;tF^}4@ zL7+|B*DoH>wd=b;Ac0D%r7g$S)C5Cf&|m~IgGhn-($>)+&_NwvCV}KZ;ed%0S1KI~ znJTY@fT?6G#0G7OFlFjd+^9$WSriNZW0oX;50VxcqH_p*&=&(3piwvkurJM%&c^s+ zA>Zs`fcy1nI0XC+!tuaDbk`k%ZB5O4*m%jLqjsxSu26^_)>(t;yU za1;s(AfRkNI)~s3rL*_`w1A_qNh~UpLuJsx>lO(_hBpTb0jPeDfyVr0md^f>Cm>^R zUjh@3gdx^r`UWJEe&LwjEYEMw$s{<%lR~4=Icxxn{Doz@F*ppi8{=@MKW(7k2pkF)vR*ZyUQiUu5~+#-3WwG>fIwmpi0@ES z2AS&O_m@yL3|jS{pnzt`1PSf29$l$M9sZ0LK73 z)j!YUf&Ro|xKKTTh5ys1zR@)`#o*~|4uMXh;Bi<8kQ^A5O2#0FWHL!pod{JUqBNlh zvKk2r=!^hWC#Y%>C`2_E5?bTuejJ13y)J?E{ojuRnLz?<{DYnvihxijB1uq^3mFY1 zlhja9O${Vq4MilYp%5A*3R>-_wcl7&;6xHU|7>-g6&bLoPIe)yYq_AIBou)HMQf;$ zp+o|L0t{7w0h*|VM4;AX|4m7lqf|CfW4|8<$%5kb(eSn*uuf`t7f03NZNfRHD#c_wr5h30Llj_x32l> zMpfhFk6PWmcG#Z5kCuB@*LrQ2P#$&Py-Nm5r<0sj&J{;MeDTV!c%^b$caHFPY=%xW zY8AkH{AJf{d%B0Oe-tUsB~wy^Di{yuvxlExr(W$|8`5dl*$~Tny7Q0_6YpT+Gx0i3 zr+@ml@rz=W_`uZcKmm~$(yq5bnm!OLndU|#{5)F5X81Brd8&Z^k)1~>k z&n%&{Pfq)dYtts{Iu&O`Q&%@ha_hou1@e?`irqz5KqA<{hD5=tC%ZyVR5ev;C-4UD zu7;{_*-Mgqa1|OsM0IdQb+y`$56K|mv4vdaXAm=CuFGnEyxBH)zc^6sZiK8HT6ab3 z69co7ZOj*OMkMbK4WdD8yS)8|c-Qod=`=lRAz?y5&sFhl!;sxBruHlnK(k=-CF3|j0#0i=90f8-#yj#-3ymHzF1`W$5?yC zTo$~&_>8M#+O?;3bWW|nO968$rm*nVm{^QE4L6e$Vbg~zknC=kll7Ax+N8uIW+tr6 zU&b4B*oi6YyA2_l#IwE9M{_jB<9Kj-$i1~YeD`!Nq(;<4&2CXiy|5@e9sE0w>Kw?# z|6r;`<@8p+Ph>vDB1!kSfVzt!>h@s*wkMgRiEWQwl+@*e+#A{-#$*rk9F*|d%lks( z(LU?qGs&-Qa9IaF#Q)aUwvEQ<+qzkohkvh`x&p&Fh`Cu|sJj3Cyb9i%tgL4DQBEFa6PDl z^^7o4(N7s{d5JtOkf*|#4dW$ap&U6TrStt;f|@xnf5=tluF_XP@i(nzv*|THAoZZbkkRq7Au?_q8P|EAT1W z#Ju5Qn=>^wn5w>kaGjDFtvYk@DE1D)-J$7CW~FfWjGFV?TSR8J;rN9)(`mI3WL@ov z9F<}$IcK;gX%i+AE_d-rYDvnMn9(5TWXaC=qf$zYT_R_X4$Y>E^L!=LAN#>f>ZM|jIXs7tA-mpVX)bT*(X_Gp6v-y*DQl{gZM{b`=xOR2#L+nel zp4^5H)Js=9zSKW>{V`0zo7lL)C5m^~{ZIjH)~&n_TUWvbz`o<&d;wOa;18R4| z^o=#m&d{9naPf?d$|pE~lu+}r3e$0C&%V@Gvj-cd#k-|RX?Il~Z`)x$a}u_JypUoX zVPWR${`x@axl1Zq27Mc`omF#t57&YA^BBE4@Dz`G_bTqaF}AwCL#bgIcYo2X@rBSr z_eSfqYq{Y!TKk&&+TPb+?=ZU@(9~q`!M*e50O<53rkKGS`~B+C?_XRXJ-gF+7wAO$ z6`s3J3G{hOe3uw%V z%x@4V0V9tHkMLKObSiqf3+7jl?@;EqBlgM5SV7ZHhmw?=ZAby)?ZLw>;Qf9h6XL zp<~o`dyhy=R<>FN2n!nT(Y^IMEb?%l~BEQ|6cF>c|c=6c_ zKHxiP$yq}I0e78rl;tSc6;3RK5Ajhp3#Rp49jPK z7l+qIp0+(3{PJ~608wcQ7uZ~zTD0={;sNnH9SKqHH?3~UnYDd+xEhz`7ghB6NxOV_ z2qnkfAxiPw7xmrsMxT2>N=Z+E)B3wVu=KI0Dt(Kxu02+SB>}?4WVpe`Zl{*DF+NnNRkJDic{q zOi9qE@|JW}uT0)UiO0(~9100Z63H&M0dcgq6UCLh#fD<=K}Ryzj-R((*c9HAP;1ef z<6-(1lXt#6yFJD^y%&GUk<{cd%k@(-cJaw^d~YMC_(hLBSI|&)EA^nZ$01C3!m?tK z`=@*8I|0}me$9^Ez5Ra}o`2bTJNMWaseSnL?%1wF1q=A%$|>%ZmbQ;o53$+zPl=V} zKKNwW)_8R#Q>9o|J970|I40BLS*3H3;802&X3u`e{Nv;^It{RpeDczzrV@@e_O9)o z6dUiAq%8XLGU9l$h4pb|WNNgPgGap9ee%puv%n0!)$;x#f6i0sPfm!DdwNdVMqJjB zE-9Gcq3kzYxz%e?Tcp7&S&=%yTZymToX%MtydzhUo*g#cxUwP>p?9EASt3CSf^XYs zmw9TSbnpz__37t<$d8psT4sif!1>c%V$SoJ-sX(r z;+Js);?i?1O(Qjf zOYRyx_IbX*r|dr zzSg!aUF;!2N$99~#^^39*(fQUJ@>-|f(#_NS);*LL3Wwjf8TYqI0567X>+N}WT?p2 zrplh}=BNt%%ou~oHa1U$1vhR^kCE^<{A$&!xV7i1e2+|YN$7)p?PIUhubduRo7f(2 zx_AgSd;FOoa-p&+$Y~C`7Q$C~c2WL=tU{^O9AB@EGwiOUuYSJSh>+qIp`Mue{k?mf eS0M`nAw10wMiL+(J>&JiOYr)~aJfgE!~O>^B0mQJ literal 0 HcmV?d00001 diff --git a/openpype/hosts/unreal/integration/UE_5.0/Resources/openpype512.png b/openpype/hosts/unreal/integration/UE_5.0/Resources/openpype512.png new file mode 100644 index 0000000000000000000000000000000000000000..97c4d4326bc16ba6dfb45d35c4362d8bc15900ae GIT binary patch literal 85856 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 bCJFu@j}?W0K?Ok;00000NkvXXu0mjf9`4Bp literal 0 HcmV?d00001 diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/OpenPype.Build.cs b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/OpenPype.Build.cs similarity index 100% rename from openpype/hosts/unreal/integration/Source/OpenPype/OpenPype.Build.cs rename to openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/OpenPype.Build.cs diff --git a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/AssetContainer.cpp b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/AssetContainer.cpp new file mode 100644 index 0000000000..c766f87a8e --- /dev/null +++ b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/AssetContainer.cpp @@ -0,0 +1,115 @@ +// Fill out your copyright notice in the Description page of Project Settings. + +#include "AssetContainer.h" +#include "AssetRegistryModule.h" +#include "Misc/PackageName.h" +#include "Engine.h" +#include "Containers/UnrealString.h" + +UAssetContainer::UAssetContainer(const FObjectInitializer& ObjectInitializer) +: UAssetUserData(ObjectInitializer) +{ + FAssetRegistryModule& AssetRegistryModule = FModuleManager::LoadModuleChecked("AssetRegistry"); + FString path = UAssetContainer::GetPathName(); + UE_LOG(LogTemp, Warning, TEXT("UAssetContainer %s"), *path); + FARFilter Filter; + Filter.PackagePaths.Add(FName(*path)); + + AssetRegistryModule.Get().OnAssetAdded().AddUObject(this, &UAssetContainer::OnAssetAdded); + AssetRegistryModule.Get().OnAssetRemoved().AddUObject(this, &UAssetContainer::OnAssetRemoved); + AssetRegistryModule.Get().OnAssetRenamed().AddUObject(this, &UAssetContainer::OnAssetRenamed); +} + +void UAssetContainer::OnAssetAdded(const FAssetData& AssetData) +{ + TArray split; + + // get directory of current container + FString selfFullPath = UAssetContainer::GetPathName(); + FString selfDir = FPackageName::GetLongPackagePath(*selfFullPath); + + // get asset path and class + FString assetPath = AssetData.GetFullName(); + FString assetFName = AssetData.AssetClass.ToString(); + + // split path + assetPath.ParseIntoArray(split, TEXT(" "), true); + + FString assetDir = FPackageName::GetLongPackagePath(*split[1]); + + // take interest only in paths starting with path of current container + if (assetDir.StartsWith(*selfDir)) + { + // exclude self + if (assetFName != "AssetContainer") + { + assets.Add(assetPath); + assetsData.Add(AssetData); + UE_LOG(LogTemp, Log, TEXT("%s: asset added to %s"), *selfFullPath, *selfDir); + } + } +} + +void UAssetContainer::OnAssetRemoved(const FAssetData& AssetData) +{ + TArray split; + + // get directory of current container + FString selfFullPath = UAssetContainer::GetPathName(); + FString selfDir = FPackageName::GetLongPackagePath(*selfFullPath); + + // get asset path and class + FString assetPath = AssetData.GetFullName(); + FString assetFName = AssetData.AssetClass.ToString(); + + // split path + assetPath.ParseIntoArray(split, TEXT(" "), true); + + FString assetDir = FPackageName::GetLongPackagePath(*split[1]); + + // take interest only in paths starting with path of current container + FString path = UAssetContainer::GetPathName(); + FString lpp = FPackageName::GetLongPackagePath(*path); + + if (assetDir.StartsWith(*selfDir)) + { + // exclude self + if (assetFName != "AssetContainer") + { + // UE_LOG(LogTemp, Warning, TEXT("%s: asset removed"), *lpp); + assets.Remove(assetPath); + assetsData.Remove(AssetData); + } + } +} + +void UAssetContainer::OnAssetRenamed(const FAssetData& AssetData, const FString& str) +{ + TArray split; + + // get directory of current container + FString selfFullPath = UAssetContainer::GetPathName(); + FString selfDir = FPackageName::GetLongPackagePath(*selfFullPath); + + // get asset path and class + FString assetPath = AssetData.GetFullName(); + FString assetFName = AssetData.AssetClass.ToString(); + + // split path + assetPath.ParseIntoArray(split, TEXT(" "), true); + + FString assetDir = FPackageName::GetLongPackagePath(*split[1]); + if (assetDir.StartsWith(*selfDir)) + { + // exclude self + if (assetFName != "AssetContainer") + { + + assets.Remove(str); + assets.Add(assetPath); + assetsData.Remove(AssetData); + // UE_LOG(LogTemp, Warning, TEXT("%s: asset renamed %s"), *lpp, *str); + } + } +} + diff --git a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/AssetContainerFactory.cpp b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/AssetContainerFactory.cpp new file mode 100644 index 0000000000..b943150bdd --- /dev/null +++ b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/AssetContainerFactory.cpp @@ -0,0 +1,20 @@ +#include "AssetContainerFactory.h" +#include "AssetContainer.h" + +UAssetContainerFactory::UAssetContainerFactory(const FObjectInitializer& ObjectInitializer) + : UFactory(ObjectInitializer) +{ + SupportedClass = UAssetContainer::StaticClass(); + bCreateNew = false; + bEditorImport = true; +} + +UObject* UAssetContainerFactory::FactoryCreateNew(UClass* Class, UObject* InParent, FName Name, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn) +{ + UAssetContainer* AssetContainer = NewObject(InParent, Class, Name, Flags); + return AssetContainer; +} + +bool UAssetContainerFactory::ShouldShowInNewMenu() const { + return false; +} diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPype.cpp b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/OpenPype.cpp similarity index 100% rename from openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPype.cpp rename to openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/OpenPype.cpp diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypeCommands.cpp b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/OpenPypeCommands.cpp similarity index 100% rename from openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypeCommands.cpp rename to openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/OpenPypeCommands.cpp diff --git a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/OpenPypeLib.cpp b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/OpenPypeLib.cpp new file mode 100644 index 0000000000..5facab7b8b --- /dev/null +++ b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/OpenPypeLib.cpp @@ -0,0 +1,48 @@ +#include "OpenPypeLib.h" +#include "Misc/Paths.h" +#include "Misc/ConfigCacheIni.h" +#include "UObject/UnrealType.h" + +/** + * Sets color on folder icon on given path + * @param InPath - path to folder + * @param InFolderColor - color of the folder + * @warning This color will appear only after Editor restart. Is there a better way? + */ + +void UOpenPypeLib::CSetFolderColor(FString FolderPath, FLinearColor FolderColor, bool bForceAdd) +{ + auto SaveColorInternal = [](FString InPath, FLinearColor InFolderColor) + { + // Saves the color of the folder to the config + if (FPaths::FileExists(GEditorPerProjectIni)) + { + GConfig->SetString(TEXT("PathColor"), *InPath, *InFolderColor.ToString(), GEditorPerProjectIni); + } + + }; + + SaveColorInternal(FolderPath, FolderColor); + +} +/** + * Returns all poperties on given object + * @param cls - class + * @return TArray of properties + */ +TArray UOpenPypeLib::GetAllProperties(UClass* cls) +{ + TArray Ret; + if (cls != nullptr) + { + for (TFieldIterator It(cls); It; ++It) + { + FProperty* Property = *It; + if (Property->HasAnyPropertyFlags(EPropertyFlags::CPF_Edit)) + { + Ret.Add(Property->GetName()); + } + } + } + return Ret; +} diff --git a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/OpenPypePublishInstance.cpp b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/OpenPypePublishInstance.cpp new file mode 100644 index 0000000000..4f1e846c0b --- /dev/null +++ b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/OpenPypePublishInstance.cpp @@ -0,0 +1,108 @@ +#pragma once + +#include "OpenPypePublishInstance.h" +#include "AssetRegistryModule.h" + + +UOpenPypePublishInstance::UOpenPypePublishInstance(const FObjectInitializer& ObjectInitializer) + : UObject(ObjectInitializer) +{ + FAssetRegistryModule& AssetRegistryModule = FModuleManager::LoadModuleChecked("AssetRegistry"); + FString path = UOpenPypePublishInstance::GetPathName(); + FARFilter Filter; + Filter.PackagePaths.Add(FName(*path)); + + AssetRegistryModule.Get().OnAssetAdded().AddUObject(this, &UOpenPypePublishInstance::OnAssetAdded); + AssetRegistryModule.Get().OnAssetRemoved().AddUObject(this, &UOpenPypePublishInstance::OnAssetRemoved); + AssetRegistryModule.Get().OnAssetRenamed().AddUObject(this, &UOpenPypePublishInstance::OnAssetRenamed); +} + +void UOpenPypePublishInstance::OnAssetAdded(const FAssetData& AssetData) +{ + TArray split; + + // get directory of current container + FString selfFullPath = UOpenPypePublishInstance::GetPathName(); + FString selfDir = FPackageName::GetLongPackagePath(*selfFullPath); + + // get asset path and class + FString assetPath = AssetData.GetFullName(); + FString assetFName = AssetData.AssetClass.ToString(); + + // split path + assetPath.ParseIntoArray(split, TEXT(" "), true); + + FString assetDir = FPackageName::GetLongPackagePath(*split[1]); + + // take interest only in paths starting with path of current container + if (assetDir.StartsWith(*selfDir)) + { + // exclude self + if (assetFName != "OpenPypePublishInstance") + { + assets.Add(assetPath); + UE_LOG(LogTemp, Log, TEXT("%s: asset added to %s"), *selfFullPath, *selfDir); + } + } +} + +void UOpenPypePublishInstance::OnAssetRemoved(const FAssetData& AssetData) +{ + TArray split; + + // get directory of current container + FString selfFullPath = UOpenPypePublishInstance::GetPathName(); + FString selfDir = FPackageName::GetLongPackagePath(*selfFullPath); + + // get asset path and class + FString assetPath = AssetData.GetFullName(); + FString assetFName = AssetData.AssetClass.ToString(); + + // split path + assetPath.ParseIntoArray(split, TEXT(" "), true); + + FString assetDir = FPackageName::GetLongPackagePath(*split[1]); + + // take interest only in paths starting with path of current container + FString path = UOpenPypePublishInstance::GetPathName(); + FString lpp = FPackageName::GetLongPackagePath(*path); + + if (assetDir.StartsWith(*selfDir)) + { + // exclude self + if (assetFName != "OpenPypePublishInstance") + { + // UE_LOG(LogTemp, Warning, TEXT("%s: asset removed"), *lpp); + assets.Remove(assetPath); + } + } +} + +void UOpenPypePublishInstance::OnAssetRenamed(const FAssetData& AssetData, const FString& str) +{ + TArray split; + + // get directory of current container + FString selfFullPath = UOpenPypePublishInstance::GetPathName(); + FString selfDir = FPackageName::GetLongPackagePath(*selfFullPath); + + // get asset path and class + FString assetPath = AssetData.GetFullName(); + FString assetFName = AssetData.AssetClass.ToString(); + + // split path + assetPath.ParseIntoArray(split, TEXT(" "), true); + + FString assetDir = FPackageName::GetLongPackagePath(*split[1]); + if (assetDir.StartsWith(*selfDir)) + { + // exclude self + if (assetFName != "AssetContainer") + { + + assets.Remove(str); + assets.Add(assetPath); + // UE_LOG(LogTemp, Warning, TEXT("%s: asset renamed %s"), *lpp, *str); + } + } +} diff --git a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/OpenPypePublishInstanceFactory.cpp b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/OpenPypePublishInstanceFactory.cpp new file mode 100644 index 0000000000..e61964c689 --- /dev/null +++ b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/OpenPypePublishInstanceFactory.cpp @@ -0,0 +1,20 @@ +#include "OpenPypePublishInstanceFactory.h" +#include "OpenPypePublishInstance.h" + +UOpenPypePublishInstanceFactory::UOpenPypePublishInstanceFactory(const FObjectInitializer& ObjectInitializer) + : UFactory(ObjectInitializer) +{ + SupportedClass = UOpenPypePublishInstance::StaticClass(); + bCreateNew = false; + bEditorImport = true; +} + +UObject* UOpenPypePublishInstanceFactory::FactoryCreateNew(UClass* Class, UObject* InParent, FName Name, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn) +{ + UOpenPypePublishInstance* OpenPypePublishInstance = NewObject(InParent, Class, Name, Flags); + return OpenPypePublishInstance; +} + +bool UOpenPypePublishInstanceFactory::ShouldShowInNewMenu() const { + return false; +} diff --git a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/OpenPypePythonBridge.cpp b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/OpenPypePythonBridge.cpp new file mode 100644 index 0000000000..8113231503 --- /dev/null +++ b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/OpenPypePythonBridge.cpp @@ -0,0 +1,13 @@ +#include "OpenPypePythonBridge.h" + +UOpenPypePythonBridge* UOpenPypePythonBridge::Get() +{ + TArray OpenPypePythonBridgeClasses; + GetDerivedClasses(UOpenPypePythonBridge::StaticClass(), OpenPypePythonBridgeClasses); + int32 NumClasses = OpenPypePythonBridgeClasses.Num(); + if (NumClasses > 0) + { + return Cast(OpenPypePythonBridgeClasses[NumClasses - 1]->GetDefaultObject()); + } + return nullptr; +}; \ No newline at end of file diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypeStyle.cpp b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/OpenPypeStyle.cpp similarity index 100% rename from openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypeStyle.cpp rename to openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/OpenPypeStyle.cpp diff --git a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/AssetContainer.h b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/AssetContainer.h new file mode 100644 index 0000000000..3c2a360c78 --- /dev/null +++ b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/AssetContainer.h @@ -0,0 +1,39 @@ +// Fill out your copyright notice in the Description page of Project Settings. + +#pragma once + +#include "CoreMinimal.h" +#include "UObject/NoExportTypes.h" +#include "Engine/AssetUserData.h" +#include "AssetData.h" +#include "AssetContainer.generated.h" + +/** + * + */ +UCLASS(Blueprintable) +class OPENPYPE_API UAssetContainer : public UAssetUserData +{ + GENERATED_BODY() + +public: + + UAssetContainer(const FObjectInitializer& ObjectInitalizer); + // ~UAssetContainer(); + + UPROPERTY(EditAnywhere, BlueprintReadOnly) + TArray assets; + + // There seems to be no reflection option to expose array of FAssetData + /* + UPROPERTY(Transient, BlueprintReadOnly, Category = "Python", meta=(DisplayName="Assets Data")) + TArray assetsData; + */ +private: + TArray assetsData; + void OnAssetAdded(const FAssetData& AssetData); + void OnAssetRemoved(const FAssetData& AssetData); + void OnAssetRenamed(const FAssetData& AssetData, const FString& str); +}; + + diff --git a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/AssetContainerFactory.h b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/AssetContainerFactory.h new file mode 100644 index 0000000000..331ce6bb50 --- /dev/null +++ b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/AssetContainerFactory.h @@ -0,0 +1,21 @@ +// Fill out your copyright notice in the Description page of Project Settings. + +#pragma once + +#include "CoreMinimal.h" +#include "Factories/Factory.h" +#include "AssetContainerFactory.generated.h" + +/** + * + */ +UCLASS() +class OPENPYPE_API UAssetContainerFactory : public UFactory +{ + GENERATED_BODY() + +public: + UAssetContainerFactory(const FObjectInitializer& ObjectInitializer); + virtual UObject* FactoryCreateNew(UClass* Class, UObject* InParent, FName Name, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn) override; + virtual bool ShouldShowInNewMenu() const override; +}; \ No newline at end of file diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPype.h b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/OpenPype.h similarity index 100% rename from openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPype.h rename to openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/OpenPype.h diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypeCommands.h b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/OpenPypeCommands.h similarity index 100% rename from openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypeCommands.h rename to openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/OpenPypeCommands.h diff --git a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/OpenPypeLib.h b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/OpenPypeLib.h new file mode 100644 index 0000000000..59e9c8bd76 --- /dev/null +++ b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/OpenPypeLib.h @@ -0,0 +1,19 @@ +#pragma once + +#include "Engine.h" +#include "OpenPypeLib.generated.h" + + +UCLASS(Blueprintable) +class OPENPYPE_API UOpenPypeLib : public UObject +{ + + GENERATED_BODY() + +public: + UFUNCTION(BlueprintCallable, Category = Python) + static void CSetFolderColor(FString FolderPath, FLinearColor FolderColor, bool bForceAdd); + + UFUNCTION(BlueprintCallable, Category = Python) + static TArray GetAllProperties(UClass* cls); +}; \ No newline at end of file diff --git a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/OpenPypePublishInstance.h b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/OpenPypePublishInstance.h new file mode 100644 index 0000000000..0a27a078d7 --- /dev/null +++ b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/OpenPypePublishInstance.h @@ -0,0 +1,21 @@ +#pragma once + +#include "Engine.h" +#include "OpenPypePublishInstance.generated.h" + + +UCLASS(Blueprintable) +class OPENPYPE_API UOpenPypePublishInstance : public UObject +{ + GENERATED_BODY() + +public: + UOpenPypePublishInstance(const FObjectInitializer& ObjectInitalizer); + + UPROPERTY(EditAnywhere, BlueprintReadOnly) + TArray assets; +private: + void OnAssetAdded(const FAssetData& AssetData); + void OnAssetRemoved(const FAssetData& AssetData); + void OnAssetRenamed(const FAssetData& AssetData, const FString& str); +}; \ No newline at end of file diff --git a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/OpenPypePublishInstanceFactory.h b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/OpenPypePublishInstanceFactory.h new file mode 100644 index 0000000000..a2b3abe13e --- /dev/null +++ b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/OpenPypePublishInstanceFactory.h @@ -0,0 +1,19 @@ +#pragma once + +#include "CoreMinimal.h" +#include "Factories/Factory.h" +#include "OpenPypePublishInstanceFactory.generated.h" + +/** + * + */ +UCLASS() +class OPENPYPE_API UOpenPypePublishInstanceFactory : public UFactory +{ + GENERATED_BODY() + +public: + UOpenPypePublishInstanceFactory(const FObjectInitializer& ObjectInitializer); + virtual UObject* FactoryCreateNew(UClass* Class, UObject* InParent, FName Name, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn) override; + virtual bool ShouldShowInNewMenu() const override; +}; \ No newline at end of file diff --git a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/OpenPypePythonBridge.h b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/OpenPypePythonBridge.h new file mode 100644 index 0000000000..692aab2e5e --- /dev/null +++ b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/OpenPypePythonBridge.h @@ -0,0 +1,20 @@ +#pragma once +#include "Engine.h" +#include "OpenPypePythonBridge.generated.h" + +UCLASS(Blueprintable) +class UOpenPypePythonBridge : public UObject +{ + GENERATED_BODY() + +public: + UFUNCTION(BlueprintCallable, Category = Python) + static UOpenPypePythonBridge* Get(); + + UFUNCTION(BlueprintImplementableEvent, Category = Python) + void RunInPython_Popup() const; + + UFUNCTION(BlueprintImplementableEvent, Category = Python) + void RunInPython_Dialog() const; + +}; diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypeStyle.h b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/OpenPypeStyle.h similarity index 100% rename from openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypeStyle.h rename to openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/OpenPypeStyle.h diff --git a/openpype/settings/defaults/system_settings/applications.json b/openpype/settings/defaults/system_settings/applications.json index 2b0de44fa9..d17674ea2c 100644 --- a/openpype/settings/defaults/system_settings/applications.json +++ b/openpype/settings/defaults/system_settings/applications.json @@ -1243,9 +1243,19 @@ "host_name": "unreal", "environment": {}, "variants": { - "4-26": { + "4-27": { "use_python_2": false, "environment": {} + }, + "5-0": { + "use_python_2": false, + "environment": { + "UE_PYTHONPATH": "{PYTHONPATH}" + } + }, + "__dynamic_keys_labels__": { + "4-27": "4.27", + "5-0": "5.0" } } }, diff --git a/repos/avalon-core b/repos/avalon-core deleted file mode 160000 index 2fa14cea6f..0000000000 --- a/repos/avalon-core +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 2fa14cea6f6a9d86eec70bbb96860cbe4c75c8eb From 2b1079be32264ae78d4cb70a10e9c10960db7d6e Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Thu, 26 May 2022 14:10:46 +0200 Subject: [PATCH 511/583] :recycle: simplify version determination --- openpype/hosts/unreal/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/unreal/__init__.py b/openpype/hosts/unreal/__init__.py index ae9b113acd..9c0768b78e 100644 --- a/openpype/hosts/unreal/__init__.py +++ b/openpype/hosts/unreal/__init__.py @@ -10,7 +10,7 @@ def add_implementation_envs(env: dict, _app: Application) -> None: engine_version = _app.name.split("/")[-1].replace("-", ".") major_version = int(engine_version.split(".")[0]) - ue_plugin = "UE_4.7" if major_version == 4 else "UE_5.0" + ue_plugin = "UE_5.0" if _app.name[:1] == "5" else "UE_4.7" unreal_plugin_path = os.path.join( os.path.dirname(os.path.abspath(openpype.hosts.__file__)), "unreal", "integration", ue_plugin From 067058f5d344a9ab940cd33ff0f3ab4436e74dbb Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Thu, 26 May 2022 14:13:25 +0200 Subject: [PATCH 512/583] :recycle: hound fixes --- openpype/hosts/unreal/__init__.py | 1 - openpype/hosts/unreal/lib.py | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/unreal/__init__.py b/openpype/hosts/unreal/__init__.py index 9c0768b78e..e0e1f0bc3d 100644 --- a/openpype/hosts/unreal/__init__.py +++ b/openpype/hosts/unreal/__init__.py @@ -8,7 +8,6 @@ def add_implementation_envs(env: dict, _app: Application) -> None: # Set OPENPYPE_UNREAL_PLUGIN required for Unreal implementation engine_version = _app.name.split("/")[-1].replace("-", ".") - major_version = int(engine_version.split(".")[0]) ue_plugin = "UE_5.0" if _app.name[:1] == "5" else "UE_4.7" unreal_plugin_path = os.path.join( diff --git a/openpype/hosts/unreal/lib.py b/openpype/hosts/unreal/lib.py index f220d8dedf..8c453b38b9 100644 --- a/openpype/hosts/unreal/lib.py +++ b/openpype/hosts/unreal/lib.py @@ -301,8 +301,8 @@ def create_unreal_project(project_name: str, raise NotImplementedError("Unsupported platform") if not python_path.exists(): raise RuntimeError(f"Unreal Python not found at {python_path}") - out = subprocess.check_call( - [python_path.as_posix(), "-m", "pip", "install", "--user", "pyside2"]) + subprocess.check_call( + [python_path.as_posix(), "-m", "pip", "install", "pyside2"]) if dev_mode or preset["dev_mode"]: _prepare_cpp_project(project_file, engine_path, ue_version) From 9a5dce42af1d1d9befc021ca1326f2e2a3a2fdc7 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Thu, 26 May 2022 14:17:41 +0200 Subject: [PATCH 513/583] :recycle: hound fixes #2 :dog: --- openpype/hosts/unreal/__init__.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/openpype/hosts/unreal/__init__.py b/openpype/hosts/unreal/__init__.py index e0e1f0bc3d..10e9c5100e 100644 --- a/openpype/hosts/unreal/__init__.py +++ b/openpype/hosts/unreal/__init__.py @@ -7,8 +7,6 @@ def add_implementation_envs(env: dict, _app: Application) -> None: """Modify environments to contain all required for implementation.""" # Set OPENPYPE_UNREAL_PLUGIN required for Unreal implementation - engine_version = _app.name.split("/")[-1].replace("-", ".") - ue_plugin = "UE_5.0" if _app.name[:1] == "5" else "UE_4.7" unreal_plugin_path = os.path.join( os.path.dirname(os.path.abspath(openpype.hosts.__file__)), From e4878eac8aecf8993b5b690401539fba88bb182c Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 26 May 2022 14:25:51 +0200 Subject: [PATCH 514/583] Flame: make sure repre name is first segment from tokenizable str --- .../flame/plugins/publish/extract_subset_resources.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py index eea575ea88..1bfe980a01 100644 --- a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py +++ b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py @@ -232,10 +232,14 @@ class ExtractSubsetResources(openpype.api.Extractor): opfapi.export_clip( export_dir_path, exporting_clip, preset_path, **export_kwargs) + # make sure only first segment is used if underscore in name + # HACK: `ftrackreview_withLUT` will result only in `ftrackreview` + repr_name = unique_name.split("_")[0] + # create representation data representation_data = { - "name": unique_name, - "outputName": unique_name, + "name": repr_name, + "outputName": repr_name, "ext": extension, "stagingDir": export_dir_path, "tags": repre_tags, From 4fbdefdb6c95b461cbe43e70a0e3de6c6ec7992e Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Thu, 26 May 2022 14:43:48 +0200 Subject: [PATCH 515/583] :recycle: fps from asset, few style changes --- .../unreal/plugins/load/load_animation.py | 25 +++++------- .../hosts/unreal/plugins/load/load_layout.py | 39 +++++++++---------- 2 files changed, 29 insertions(+), 35 deletions(-) diff --git a/openpype/hosts/unreal/plugins/load/load_animation.py b/openpype/hosts/unreal/plugins/load/load_animation.py index 54b43c500c..da2830bc52 100644 --- a/openpype/hosts/unreal/plugins/load/load_animation.py +++ b/openpype/hosts/unreal/plugins/load/load_animation.py @@ -14,6 +14,7 @@ from openpype.pipeline import ( ) from openpype.hosts.unreal.api import plugin from openpype.hosts.unreal.api import pipeline as unreal_pipeline +from openpype.api import get_asset class AnimationFBXLoader(plugin.Loader): @@ -79,7 +80,7 @@ class AnimationFBXLoader(plugin.Loader): task.options.anim_sequence_import_data.set_editor_property( 'use_default_sample_rate', False) task.options.anim_sequence_import_data.set_editor_property( - 'custom_sample_rate', 25.0) # TODO: get from database + 'custom_sample_rate', get_asset()["data"].get("fps")) task.options.anim_sequence_import_data.set_editor_property( 'import_custom_attribute', True) task.options.anim_sequence_import_data.set_editor_property( @@ -141,22 +142,18 @@ class AnimationFBXLoader(plugin.Loader): root = "/Game/OpenPype" asset = context.get('asset').get('name') suffix = "_CON" - if asset: - asset_name = "{}_{}".format(asset, name) - else: - asset_name = "{}".format(name) - + asset_name = f"{asset}_{name}" if asset else f"{name}" tools = unreal.AssetToolsHelpers().get_asset_tools() asset_dir, container_name = tools.create_unique_asset_name( f"{root}/Animations/{asset}/{name}", suffix="") ar = unreal.AssetRegistryHelpers.get_asset_registry() - filter = unreal.ARFilter( + _filter = unreal.ARFilter( class_names=["World"], package_paths=[f"{root}/{hierarchy[0]}"], recursive_paths=False) - levels = ar.get_assets(filter) + levels = ar.get_assets(_filter) master_level = levels[0].get_editor_property('object_path') hierarchy_dir = root @@ -164,11 +161,11 @@ class AnimationFBXLoader(plugin.Loader): hierarchy_dir = f"{hierarchy_dir}/{h}" hierarchy_dir = f"{hierarchy_dir}/{asset}" - filter = unreal.ARFilter( + _filter = unreal.ARFilter( class_names=["World"], package_paths=[f"{hierarchy_dir}/"], recursive_paths=True) - levels = ar.get_assets(filter) + levels = ar.get_assets(_filter) level = levels[0].get_editor_property('object_path') unreal.EditorLevelLibrary.save_all_dirty_levels() @@ -235,8 +232,7 @@ class AnimationFBXLoader(plugin.Loader): "parent": context["representation"]["parent"], "family": context["representation"]["context"]["family"] } - unreal_pipeline.imprint( - "{}/{}".format(asset_dir, container_name), data) + unreal_pipeline.imprint(f"{asset_dir}/{container_name}", data) imported_content = EditorAssetLibrary.list_assets( asset_dir, recursive=True, include_folder=False) @@ -283,7 +279,7 @@ class AnimationFBXLoader(plugin.Loader): task.options.anim_sequence_import_data.set_editor_property( 'use_default_sample_rate', False) task.options.anim_sequence_import_data.set_editor_property( - 'custom_sample_rate', 25.0) # TODO: get from database + 'custom_sample_rate', get_asset()["data"].get("fps")) task.options.anim_sequence_import_data.set_editor_property( 'import_custom_attribute', True) task.options.anim_sequence_import_data.set_editor_property( @@ -300,8 +296,7 @@ class AnimationFBXLoader(plugin.Loader): # do import fbx and replace existing data unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) - container_path = "{}/{}".format(container["namespace"], - container["objectName"]) + container_path = f'{container["namespace"]}/{container["objectName"]}' # update metadata unreal_pipeline.imprint( container_path, diff --git a/openpype/hosts/unreal/plugins/load/load_layout.py b/openpype/hosts/unreal/plugins/load/load_layout.py index 49611c6c05..0632c3c0b5 100644 --- a/openpype/hosts/unreal/plugins/load/load_layout.py +++ b/openpype/hosts/unreal/plugins/load/load_layout.py @@ -20,6 +20,7 @@ from openpype.pipeline import ( AVALON_CONTAINER_ID, legacy_io, ) +from openpype.api import get_asset from openpype.hosts.unreal.api import plugin from openpype.hosts.unreal.api import pipeline as unreal_pipeline @@ -87,7 +88,8 @@ class LayoutLoader(plugin.Loader): return None - def _get_data(self, asset_name): + @staticmethod + def _get_data(asset_name): asset_doc = legacy_io.find_one({ "type": "asset", "name": asset_name @@ -95,8 +97,9 @@ class LayoutLoader(plugin.Loader): return asset_doc.get("data") + @staticmethod def _set_sequence_hierarchy( - self, seq_i, seq_j, max_frame_i, min_frame_j, max_frame_j, map_paths + seq_i, seq_j, max_frame_i, min_frame_j, max_frame_j, map_paths ): # Get existing sequencer tracks or create them if they don't exist tracks = seq_i.get_master_tracks() @@ -165,8 +168,9 @@ class LayoutLoader(plugin.Loader): hid_section.set_row_index(index) hid_section.set_level_names(maps) + @staticmethod def _process_family( - self, assets, class_name, transform, sequence, inst_name=None + assets, class_name, transform, sequence, inst_name=None ): ar = unreal.AssetRegistryHelpers.get_asset_registry() @@ -264,7 +268,7 @@ class LayoutLoader(plugin.Loader): task.options.anim_sequence_import_data.set_editor_property( 'use_default_sample_rate', False) task.options.anim_sequence_import_data.set_editor_property( - 'custom_sample_rate', 25.0) # TODO: get from database + 'custom_sample_rate', get_asset()["data"].get("fps")) task.options.anim_sequence_import_data.set_editor_property( 'import_custom_attribute', True) task.options.anim_sequence_import_data.set_editor_property( @@ -313,11 +317,8 @@ class LayoutLoader(plugin.Loader): for binding in bindings: tracks = binding.get_tracks() track = None - if not tracks: - track = binding.add_track( - unreal.MovieSceneSkeletalAnimationTrack) - else: - track = tracks[0] + track = tracks[0] if tracks else binding.add_track( + unreal.MovieSceneSkeletalAnimationTrack) sections = track.get_sections() section = None @@ -337,11 +338,11 @@ class LayoutLoader(plugin.Loader): curr_anim.get_path_name()).parent ).replace('\\', '/') - filter = unreal.ARFilter( + _filter = unreal.ARFilter( class_names=["AssetContainer"], package_paths=[anim_path], recursive_paths=False) - containers = ar.get_assets(filter) + containers = ar.get_assets(_filter) if len(containers) > 0: return @@ -352,6 +353,7 @@ class LayoutLoader(plugin.Loader): sec_params = section.get_editor_property('params') sec_params.set_editor_property('animation', animation) + @staticmethod def _generate_sequence(self, h, h_dir): tools = unreal.AssetToolsHelpers().get_asset_tools() @@ -585,10 +587,7 @@ class LayoutLoader(plugin.Loader): hierarchy_dir_list.append(hierarchy_dir) asset = context.get('asset').get('name') suffix = "_CON" - if asset: - asset_name = "{}_{}".format(asset, name) - else: - asset_name = "{}".format(name) + asset_name = f"{asset}_{name}" if asset else asset_name = name tools = unreal.AssetToolsHelpers().get_asset_tools() asset_dir, container_name = tools.create_unique_asset_name( @@ -802,7 +801,7 @@ class LayoutLoader(plugin.Loader): lc for lc in layout_containers if asset in lc.get('loaded_assets')] - if len(layouts) == 0: + if not layouts: EditorAssetLibrary.delete_directory(str(Path(asset).parent)) # Remove the Level Sequence from the parent. @@ -812,17 +811,17 @@ class LayoutLoader(plugin.Loader): namespace = container.get('namespace').replace(f"{root}/", "") ms_asset = namespace.split('/')[0] ar = unreal.AssetRegistryHelpers.get_asset_registry() - filter = unreal.ARFilter( + _filter = unreal.ARFilter( class_names=["LevelSequence"], package_paths=[f"{root}/{ms_asset}"], recursive_paths=False) - sequences = ar.get_assets(filter) + sequences = ar.get_assets(_filter) master_sequence = sequences[0].get_asset() - filter = unreal.ARFilter( + _filter = unreal.ARFilter( class_names=["World"], package_paths=[f"{root}/{ms_asset}"], recursive_paths=False) - levels = ar.get_assets(filter) + levels = ar.get_assets(_filter) master_level = levels[0].get_editor_property('object_path') sequences = [master_sequence] From 0c2d0bdd75961d8a685c810c6cbbf6d36591669d Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Thu, 26 May 2022 14:45:17 +0200 Subject: [PATCH 516/583] :bug: fix wrong assignment --- openpype/hosts/unreal/plugins/load/load_layout.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/unreal/plugins/load/load_layout.py b/openpype/hosts/unreal/plugins/load/load_layout.py index 0632c3c0b5..c65cd25ac8 100644 --- a/openpype/hosts/unreal/plugins/load/load_layout.py +++ b/openpype/hosts/unreal/plugins/load/load_layout.py @@ -587,7 +587,7 @@ class LayoutLoader(plugin.Loader): hierarchy_dir_list.append(hierarchy_dir) asset = context.get('asset').get('name') suffix = "_CON" - asset_name = f"{asset}_{name}" if asset else asset_name = name + asset_name = f"{asset}_{name}" if asset else name tools = unreal.AssetToolsHelpers().get_asset_tools() asset_dir, container_name = tools.create_unique_asset_name( From d3179847d266be44a96ab08e9b9b7fdffe5b3173 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 26 May 2022 14:59:08 +0200 Subject: [PATCH 517/583] General: editorial otio_range in collection was one frame longer --- openpype/lib/editorial.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/lib/editorial.py b/openpype/lib/editorial.py index 2c877b9d0d..7b2d22f738 100644 --- a/openpype/lib/editorial.py +++ b/openpype/lib/editorial.py @@ -168,7 +168,7 @@ def make_sequence_collection(path, otio_range, metadata): first, last = otio_range_to_frame_range(otio_range) collection = clique.Collection( head=head, tail=tail, padding=metadata["padding"]) - collection.indexes.update([i for i in range(first, (last + 1))]) + collection.indexes.update([i for i in range(first, last)]) return dir_path, collection From f96318cddd5e4cee2f66fa01ca85ccfb6bddb769 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Je=C5=BEek?= Date: Thu, 26 May 2022 15:01:56 +0200 Subject: [PATCH 518/583] Update openpype/hosts/flame/otio/flame_export.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- openpype/hosts/flame/otio/flame_export.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/flame/otio/flame_export.py b/openpype/hosts/flame/otio/flame_export.py index ffb82b97c2..9756d0442e 100644 --- a/openpype/hosts/flame/otio/flame_export.py +++ b/openpype/hosts/flame/otio/flame_export.py @@ -94,7 +94,7 @@ def create_otio_time_range(start_frame, frame_duration, fps): def _get_metadata(item): if hasattr(item, 'metadata'): - return dict(dict(item.metadata)) if item.metadata else {} + return dict(item.metadata) if item.metadata else {} return {} From 95f836f41175128e548f9369d5e50c148e180e93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Je=C5=BEek?= Date: Thu, 26 May 2022 15:02:17 +0200 Subject: [PATCH 519/583] Update openpype/hosts/flame/api/render_utils.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- openpype/hosts/flame/api/render_utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/flame/api/render_utils.py b/openpype/hosts/flame/api/render_utils.py index 9957550af9..da22f117a7 100644 --- a/openpype/hosts/flame/api/render_utils.py +++ b/openpype/hosts/flame/api/render_utils.py @@ -1,8 +1,8 @@ import os from xml.etree import ElementTree as ET -import openpype.api as openpype +import openpype.api import Logger -log = openpype.Logger.get_logger(__name__) +log = Logger.get_logger(__name__) def export_clip(export_path, clip, preset_path, **kwargs): From 2d1f7b9873022ece16f822690a4269a15fb979c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Je=C5=BEek?= Date: Thu, 26 May 2022 15:02:41 +0200 Subject: [PATCH 520/583] Update openpype/hosts/flame/otio/flame_export.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- openpype/hosts/flame/otio/flame_export.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/openpype/hosts/flame/otio/flame_export.py b/openpype/hosts/flame/otio/flame_export.py index 9756d0442e..1e4ef866ed 100644 --- a/openpype/hosts/flame/otio/flame_export.py +++ b/openpype/hosts/flame/otio/flame_export.py @@ -280,9 +280,7 @@ def create_otio_clip(clip_data): segment = clip_data["PySegment"] # calculate source in - media_info = MediaInfoFile(clip_data["fpath"], **{ - "logger": log - }) + media_info = MediaInfoFile(clip_data["fpath"], logger=log) media_timecode_start = media_info.start_frame media_fps = media_info.fps From db5d85080a418ed8e78908bb72339a0a41011b5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Je=C5=BEek?= Date: Thu, 26 May 2022 15:03:29 +0200 Subject: [PATCH 521/583] Update openpype/hosts/flame/plugins/publish/extract_subset_resources.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- .../hosts/flame/plugins/publish/extract_subset_resources.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py index 1bfe980a01..6319f4b041 100644 --- a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py +++ b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py @@ -425,9 +425,7 @@ class ExtractSubsetResources(openpype.api.Extractor): Import clip from path """ dir_path = os.path.dirname(path) - media_info = MediaInfoFile(path, **{ - "logger": self.log - }) + media_info = MediaInfoFile(path, logger=self.log) file_pattern = media_info.file_pattern self.log.debug("__ file_pattern: {}".format(file_pattern)) From 024874b4f653725e56223fba8a2a7f47404c30f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Je=C5=BEek?= Date: Thu, 26 May 2022 15:06:16 +0200 Subject: [PATCH 522/583] Update openpype/hosts/flame/plugins/publish/extract_subset_resources.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- .../hosts/flame/plugins/publish/extract_subset_resources.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py index 6319f4b041..9265d3517c 100644 --- a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py +++ b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py @@ -60,7 +60,8 @@ class ExtractSubsetResources(openpype.api.Extractor): export_presets_mapping = {} def process(self, instance): - self._make_representation_data(instance) + if "representations" not in instance.data: + instance.data["representations"] = [] # flame objects segment = instance.data["item"] From a2289429a850061484300e81eaed99413a87ef38 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Thu, 26 May 2022 15:57:52 +0200 Subject: [PATCH 523/583] :sparkles: added collector for FBX camera export --- .../plugins/publish/collect_fbx_camera.py | 20 +++++++++++++++++++ .../defaults/project_settings/maya.json | 3 +++ .../schemas/schema_maya_publish.json | 14 +++++++++++++ 3 files changed, 37 insertions(+) create mode 100644 openpype/hosts/maya/plugins/publish/collect_fbx_camera.py diff --git a/openpype/hosts/maya/plugins/publish/collect_fbx_camera.py b/openpype/hosts/maya/plugins/publish/collect_fbx_camera.py new file mode 100644 index 0000000000..bfa5bccbb9 --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/collect_fbx_camera.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +from maya import cmds # noqa +import pyblish.api + + +class CollectFbxCamera(pyblish.api.InstancePlugin): + """Collect Camera for FBX export.""" + + order = pyblish.api.CollectorOrder + 0.2 + label = "Collect Camera for FBX export" + families = ["camera"] + + def process(self, instance): + if not instance.data.get("families"): + instance.data["families"] = [] + + if "fbx" not in instance.data["families"]: + instance.data["families"].append("fbx") + + instance.data["cameras"] = True diff --git a/openpype/settings/defaults/project_settings/maya.json b/openpype/settings/defaults/project_settings/maya.json index 4cdfe1ca5d..e03bdcecc3 100644 --- a/openpype/settings/defaults/project_settings/maya.json +++ b/openpype/settings/defaults/project_settings/maya.json @@ -165,6 +165,9 @@ "CollectMayaRender": { "sync_workfile_version": false }, + "CollectFbxCamera": { + "enabled": false + }, "ValidateInstanceInContext": { "enabled": true, "optional": true, diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json index 2e5bc64e1c..9877b5ff0d 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json @@ -21,6 +21,20 @@ } ] }, + { + "type": "dict", + "collapsible": true, + "key": "CollectFbxCamera", + "label": "Collect Camera for FBX export", + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + } + ] + }, { "type": "splitter" }, From ab8068858cf62422b6838b7e526d723f1b81230a Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 26 May 2022 16:32:25 +0200 Subject: [PATCH 524/583] flame: removing unneeded code --- .../flame/plugins/publish/extract_subset_resources.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py index 9265d3517c..b91aec15c8 100644 --- a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py +++ b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py @@ -51,7 +51,6 @@ class ExtractSubsetResources(openpype.api.Extractor): "path_regex": ".*" } } - keep_original_representation = False # hide publisher during exporting hide_ui_on_process = True @@ -329,14 +328,6 @@ class ExtractSubsetResources(openpype.api.Extractor): ): return True - def _make_representation_data(self, instance): - if ( - self.keep_original_representation - and "representations" not in instance.data - or not self.keep_original_representation - ): - instance.data["representations"] = [] - def _unfolds_nested_folders(self, stage_dir, files_list, ext): """Unfolds nested folders From 24c289a0d5088c708d9e6754dd8ebbc033e0e491 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 26 May 2022 17:18:00 +0200 Subject: [PATCH 525/583] nuke: use framerange used as list but it is bool --- openpype/hosts/nuke/api/lib.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index ba8aa7a8db..f40425eefc 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -373,7 +373,7 @@ def add_write_node_legacy(name, **kwarg): Returns: node (obj): nuke write node """ - frame_range = kwarg.get("use_range_limit", None) + use_range_limit = kwarg.get("use_range_limit", None) w = nuke.createNode( "Write", @@ -391,10 +391,10 @@ def add_write_node_legacy(name, **kwarg): log.debug(e) continue - if frame_range: + if use_range_limit: w["use_limit"].setValue(True) - w["first"].setValue(frame_range[0]) - w["last"].setValue(frame_range[1]) + w["first"].setValue(kwarg["frame_range"][0]) + w["last"].setValue(kwarg["frame_range"][1]) return w @@ -409,7 +409,7 @@ def add_write_node(name, file_path, knobs, **kwarg): Returns: node (obj): nuke write node """ - frame_range = kwarg.get("use_range_limit", None) + use_range_limit = kwarg.get("use_range_limit", None) w = nuke.createNode( "Write", @@ -420,10 +420,10 @@ def add_write_node(name, file_path, knobs, **kwarg): # finally add knob overrides set_node_knobs_from_settings(w, knobs, **kwarg) - if frame_range: + if use_range_limit: w["use_limit"].setValue(True) - w["first"].setValue(frame_range[0]) - w["last"].setValue(frame_range[1]) + w["first"].setValue(kwarg["frame_range"][0]) + w["last"].setValue(kwarg["frame_range"][1]) return w From 693efa272fefee7c09aaa93eb78ffa07b3c198f2 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Thu, 26 May 2022 16:23:50 +0100 Subject: [PATCH 526/583] Camera creates master level if layout is missing --- .../hosts/unreal/plugins/load/load_camera.py | 44 ++++++++++++------- 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/openpype/hosts/unreal/plugins/load/load_camera.py b/openpype/hosts/unreal/plugins/load/load_camera.py index b33e45b6e9..c6061bc5c1 100644 --- a/openpype/hosts/unreal/plugins/load/load_camera.py +++ b/openpype/hosts/unreal/plugins/load/load_camera.py @@ -5,6 +5,7 @@ from pathlib import Path import unreal from unreal import EditorAssetLibrary from unreal import EditorLevelLibrary +from unreal import EditorLevelUtils from openpype.pipeline import ( AVALON_CONTAINER_ID, @@ -84,10 +85,10 @@ class CameraLoader(plugin.Loader): hierarchy = context.get('asset').get('data').get('parents') root = "/Game/OpenPype" hierarchy_dir = root - hierarchy_list = [] + hierarchy_dir_list = [] for h in hierarchy: hierarchy_dir = f"{hierarchy_dir}/{h}" - hierarchy_list.append(hierarchy_dir) + hierarchy_dir_list.append(hierarchy_dir) asset = context.get('asset').get('name') suffix = "_CON" if asset: @@ -121,27 +122,40 @@ class CameraLoader(plugin.Loader): asset_dir, container_name = tools.create_unique_asset_name( f"{hierarchy_dir}/{asset}/{name}_{unique_number:02d}", suffix="") + asset_path = Path(asset_dir) + asset_path_parent = str(asset_path.parent.as_posix()) + container_name += suffix - current_level = EditorLevelLibrary.get_editor_world().get_full_name() + EditorAssetLibrary.make_directory(asset_dir) + + # Create map for the shot, and create hierarchy of map. If the maps + # already exist, we will use them. + h_dir = hierarchy_dir_list[0] + h_asset = hierarchy[0] + master_level = f"{h_dir}/{h_asset}_map.{h_asset}_map" + if not EditorAssetLibrary.does_asset_exist(master_level): + EditorLevelLibrary.new_level(f"{h_dir}/{h_asset}_map") + + level = f"{asset_path_parent}/{asset}_map.{asset}_map" + if not EditorAssetLibrary.does_asset_exist(level): + EditorLevelLibrary.new_level(f"{asset_path_parent}/{asset}_map") + + EditorLevelLibrary.load_level(master_level) + EditorLevelUtils.add_level_to_world( + EditorLevelLibrary.get_editor_world(), + level, + unreal.LevelStreamingDynamic + ) EditorLevelLibrary.save_all_dirty_levels() - - ar = unreal.AssetRegistryHelpers.get_asset_registry() - filter = unreal.ARFilter( - class_names=["World"], - package_paths=[f"{hierarchy_dir}/{asset}/"], - recursive_paths=True) - maps = ar.get_assets(filter) - - # There should be only one map in the list - EditorLevelLibrary.load_level(maps[0].get_full_name()) + EditorLevelLibrary.load_level(level) # Get all the sequences in the hierarchy. It will create them, if # they don't exist. sequences = [] frame_ranges = [] i = 0 - for h in hierarchy_list: + for h in hierarchy_dir_list: root_content = EditorAssetLibrary.list_assets( h, recursive=False, include_folder=False) @@ -256,7 +270,7 @@ class CameraLoader(plugin.Loader): "{}/{}".format(asset_dir, container_name), data) EditorLevelLibrary.save_all_dirty_levels() - EditorLevelLibrary.load_level(current_level) + EditorLevelLibrary.load_level(master_level) asset_content = EditorAssetLibrary.list_assets( asset_dir, recursive=True, include_folder=True From c5787b899257433f0873f61c9aa1408cd68c0bee Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 26 May 2022 17:23:51 +0200 Subject: [PATCH 527/583] Flame: small bugs --- openpype/hosts/flame/api/render_utils.py | 2 +- openpype/plugins/publish/collect_otio_subset_resources.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/flame/api/render_utils.py b/openpype/hosts/flame/api/render_utils.py index da22f117a7..a29d6be695 100644 --- a/openpype/hosts/flame/api/render_utils.py +++ b/openpype/hosts/flame/api/render_utils.py @@ -1,6 +1,6 @@ import os from xml.etree import ElementTree as ET -import openpype.api import Logger +from openpype.api import Logger log = Logger.get_logger(__name__) diff --git a/openpype/plugins/publish/collect_otio_subset_resources.py b/openpype/plugins/publish/collect_otio_subset_resources.py index fb4cc6ee5e..40d4f35bdc 100644 --- a/openpype/plugins/publish/collect_otio_subset_resources.py +++ b/openpype/plugins/publish/collect_otio_subset_resources.py @@ -64,7 +64,7 @@ class CollectOtioSubsetResources(pyblish.api.InstancePlugin): a_frame_end_h = media_out + handle_end # create trimmed otio time range - trimmed_media_range_h = editorial.range_from_frames( + trimmed_media_range_h = oplib.range_from_frames( a_frame_start_h, (a_frame_end_h - a_frame_start_h) + 1, media_fps ) From ae233ce80cc9ecf549d2fdbebbc50d1ca29b8b98 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 26 May 2022 17:26:00 +0200 Subject: [PATCH 528/583] hiero: PR suggestion https://github.com/pypeclub/OpenPype/pull/3224#discussion_r882588237 --- openpype/hosts/hiero/api/lib.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/openpype/hosts/hiero/api/lib.py b/openpype/hosts/hiero/api/lib.py index 5a9f38bf92..999dae5488 100644 --- a/openpype/hosts/hiero/api/lib.py +++ b/openpype/hosts/hiero/api/lib.py @@ -941,6 +941,10 @@ def is_overlapping(ti_test, ti_original, strict=False): (ti_test.timelineIn() <= ti_original.timelineIn()) and (ti_test.timelineOut() >= ti_original.timelineOut()) ) + + if strict: + return covering_exp + inside_exp = ( (ti_test.timelineIn() >= ti_original.timelineIn()) and (ti_test.timelineOut() <= ti_original.timelineOut()) @@ -954,15 +958,12 @@ def is_overlapping(ti_test, ti_original, strict=False): and (ti_test.timelineIn() <= ti_original.timelineIn()) ) - if strict: - return covering_exp - else: - return any(( - covering_exp, - inside_exp, - overlaying_right_exp, - overlaying_left_exp - )) + return any(( + covering_exp, + inside_exp, + overlaying_right_exp, + overlaying_left_exp + )) def get_sequence_pattern_and_padding(file): From 4cd4124e0128cac24938cf3d0098de04b9e5f58c Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 26 May 2022 17:40:27 +0200 Subject: [PATCH 529/583] flame: fixing frame range from editorial --- openpype/lib/editorial.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/lib/editorial.py b/openpype/lib/editorial.py index 0de266725f..9f55d1fcb1 100644 --- a/openpype/lib/editorial.py +++ b/openpype/lib/editorial.py @@ -168,7 +168,7 @@ def make_sequence_collection(path, otio_range, metadata): first, last = otio_range_to_frame_range(otio_range) collection = clique.Collection( head=head, tail=tail, padding=metadata["padding"]) - collection.indexes.update([i for i in range(first, (last + 1))]) + collection.indexes.update(list(range(first, last))) return dir_path, collection From 6d4c057a04291da0381af2a1339ec3c68319d50f Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 26 May 2022 17:40:42 +0200 Subject: [PATCH 530/583] flame: removing default preset --- .../plugins/publish/extract_subset_resources.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py index b91aec15c8..0bad3f7cfc 100644 --- a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py +++ b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py @@ -34,21 +34,6 @@ class ExtractSubsetResources(openpype.api.Extractor): "representation_add_range": False, "representation_tags": ["thumbnail"], "path_regex": ".*" - }, - "ftrackpreview": { - "active": True, - "ext": "mov", - "xml_preset_file": "Apple iPad (1920x1080).xml", - "xml_preset_dir": "", - "export_type": "Movie", - "parsed_comment_attrs": False, - "colorspace_out": "Output - Rec.709", - "representation_add_range": True, - "representation_tags": [ - "review", - "delete" - ], - "path_regex": ".*" } } From a4c32639d7bcd1aae3735f763fadd7a334968df9 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 26 May 2022 17:51:25 +0200 Subject: [PATCH 531/583] nuke: adding frame range to plugin --- openpype/hosts/nuke/plugins/create/create_write_prerender.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/openpype/hosts/nuke/plugins/create/create_write_prerender.py b/openpype/hosts/nuke/plugins/create/create_write_prerender.py index 32ee1fd86f..fec97167fb 100644 --- a/openpype/hosts/nuke/plugins/create/create_write_prerender.py +++ b/openpype/hosts/nuke/plugins/create/create_write_prerender.py @@ -27,6 +27,10 @@ class CreateWritePrerender(plugin.AbstractWriteRender): # add fpath_template write_data["fpath_template"] = self.fpath_template write_data["use_range_limit"] = self.use_range_limit + write_data["frame_range"] = ( + nuke.root()["first_frame"].value(), + nuke.root()["last_frame"].value() + ) if not self.is_legacy(): return create_write_node( From b8cade1009bf4863b12c467fa0cbdef7b8d864e4 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 26 May 2022 18:43:00 +0200 Subject: [PATCH 532/583] Fix - Harmony message length Harmony 21.1 doesn't have QDataStream anymore. This means we aren't able to write bytes into QByteArray so we had modify how content lenght is sent do the server. Content lenght is sent as string of 8 char convertible into integer (instead of 0x00000001[4 bytes] > "000000001"[8 bytes]) --- openpype/hosts/harmony/api/TB_sceneOpened.js | 21 ++++++++++++++++---- openpype/hosts/harmony/api/server.py | 12 +++++++---- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/openpype/hosts/harmony/api/TB_sceneOpened.js b/openpype/hosts/harmony/api/TB_sceneOpened.js index 610b0a73bb..e7cd555332 100644 --- a/openpype/hosts/harmony/api/TB_sceneOpened.js +++ b/openpype/hosts/harmony/api/TB_sceneOpened.js @@ -35,7 +35,11 @@ function Client() { self.pack = function(num) { var ascii=''; for (var i = 3; i >= 0; i--) { - ascii += String.fromCharCode((num >> (8 * i)) & 255); + var hex = ((num >> (8 * i)) & 255).toString(16); + if (hex.length < 2){ + ascii += "0"; + } + ascii += hex; } return ascii; }; @@ -279,12 +283,21 @@ function Client() { }; self._send = function(message) { - var codec_name = new QByteArray().append("ISO-8859-1"); + /** Harmony 21.1 doesn't have QDataStream anymore. + + This means we aren't able to write bytes into QByteArray so we had + modify how content lenght is sent do the server. + Content lenght is sent as string of 8 char convertible into integer + (instead of 0x00000001[4 bytes] > "000000001"[8 bytes]) */ + var codec_name = new QByteArray().append("UTF-8"); + var codec = QTextCodec.codecForName(codec_name); var msg = codec.fromUnicode(message); var l = msg.size(); - var coded = new QByteArray().append('AH').append(self.pack(l)).append(msg); - self.socket.write(new QByteArray(coded)); + var header = new QByteArray().append('AH').append(self.pack(l)); + var coded = msg.prepend(header); + + self.socket.write(coded); self.logDebug('Sent.'); }; diff --git a/openpype/hosts/harmony/api/server.py b/openpype/hosts/harmony/api/server.py index 88cfe54521..0de359ec61 100644 --- a/openpype/hosts/harmony/api/server.py +++ b/openpype/hosts/harmony/api/server.py @@ -88,21 +88,25 @@ class Server(threading.Thread): """ current_time = time.time() while True: - + self.log.info("wait ttt") # Receive the data in small chunks and retransmit it request = None - header = self.connection.recv(6) + header = self.connection.recv(10) if len(header) == 0: # null data received, socket is closing. self.log.info(f"[{self.timestamp()}] Connection closing.") break + if header[0:2] != b"AH": self.log.error("INVALID HEADER") - length = struct.unpack(">I", header[2:])[0] + content_length_str = header[2:].decode() + + length = int(content_length_str, 16) data = self.connection.recv(length) while (len(data) < length): # we didn't received everything in first try, lets wait for # all data. + self.log.info("loop") time.sleep(0.1) if self.connection is None: self.log.error(f"[{self.timestamp()}] " @@ -113,7 +117,7 @@ class Server(threading.Thread): break data += self.connection.recv(length - len(data)) - + self.log.debug("data:: {} {}".format(data, type(data))) self.received += data.decode("utf-8") pretty = self._pretty(self.received) self.log.debug( From bc3a8bbe2ff52903e0d9ab7f029c51407cd70ec3 Mon Sep 17 00:00:00 2001 From: OpenPype Date: Thu, 26 May 2022 18:37:41 +0000 Subject: [PATCH 533/583] [Automated] Bump version --- CHANGELOG.md | 37 +++++++++++++++++++------------------ openpype/version.py | 2 +- pyproject.toml | 2 +- 3 files changed, 21 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3dd410391a..8437540a49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [3.10.0-nightly.5](https://github.com/pypeclub/OpenPype/tree/HEAD) +## [3.10.0-nightly.6](https://github.com/pypeclub/OpenPype/tree/HEAD) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.9.8...HEAD) @@ -10,14 +10,17 @@ - General: Creator plugins from addons can be registered [\#3179](https://github.com/pypeclub/OpenPype/pull/3179) - Ftrack: Single image reviewable [\#3157](https://github.com/pypeclub/OpenPype/pull/3157) - Nuke: Expose write attributes to settings [\#3123](https://github.com/pypeclub/OpenPype/pull/3123) -- Hiero: Initial frame publish support [\#3106](https://github.com/pypeclub/OpenPype/pull/3106) **πŸš€ Enhancements** +- Maya: FBX camera export [\#3253](https://github.com/pypeclub/OpenPype/pull/3253) +- General: updating common vendor `scriptmenu` to 1.5.2 [\#3246](https://github.com/pypeclub/OpenPype/pull/3246) - Project Manager: Allow to paste Tasks into multiple assets at the same time [\#3226](https://github.com/pypeclub/OpenPype/pull/3226) - Project manager: Sped up project load [\#3216](https://github.com/pypeclub/OpenPype/pull/3216) - Loader UI: Speed issues of loader with sync server [\#3199](https://github.com/pypeclub/OpenPype/pull/3199) +- Looks: add basic support for Renderman [\#3190](https://github.com/pypeclub/OpenPype/pull/3190) - Maya: added clean\_import option to Import loader [\#3181](https://github.com/pypeclub/OpenPype/pull/3181) +- Add the scripts menu definition to nuke [\#3168](https://github.com/pypeclub/OpenPype/pull/3168) - Maya: add maya 2023 to default applications [\#3167](https://github.com/pypeclub/OpenPype/pull/3167) - Compressed bgeo publishing in SAP and Houdini loader [\#3153](https://github.com/pypeclub/OpenPype/pull/3153) - General: Add 'dataclasses' to required python modules [\#3149](https://github.com/pypeclub/OpenPype/pull/3149) @@ -28,24 +31,26 @@ - General: Simplified OP modules/addons import [\#3137](https://github.com/pypeclub/OpenPype/pull/3137) - Terminal: Tweak coloring of TrayModuleManager logging enabled states [\#3133](https://github.com/pypeclub/OpenPype/pull/3133) - General: Cleanup some Loader docstrings [\#3131](https://github.com/pypeclub/OpenPype/pull/3131) -- Nuke: render instance with subset name filtered overrides [\#3117](https://github.com/pypeclub/OpenPype/pull/3117) - Unreal: Layout and Camera update and remove functions reimplemented and improvements [\#3116](https://github.com/pypeclub/OpenPype/pull/3116) -- Settings: Remove environment groups from settings [\#3115](https://github.com/pypeclub/OpenPype/pull/3115) -- TVPaint: Match renderlayer key with other hosts [\#3110](https://github.com/pypeclub/OpenPype/pull/3110) -- Tray publisher: Simple families from settings [\#3105](https://github.com/pypeclub/OpenPype/pull/3105) **πŸ› Bug fixes** +- nuke: use framerange issue [\#3254](https://github.com/pypeclub/OpenPype/pull/3254) +- Ftrack: Chunk sizes for queries has minimal condition [\#3244](https://github.com/pypeclub/OpenPype/pull/3244) +- Maya: renderman displays needs to be filtered [\#3242](https://github.com/pypeclub/OpenPype/pull/3242) - Ftrack: Validate that the user exists on ftrack [\#3237](https://github.com/pypeclub/OpenPype/pull/3237) +- Maya: Fix support for multiple resolutions [\#3236](https://github.com/pypeclub/OpenPype/pull/3236) - TVPaint: Look for more groups than 12 [\#3228](https://github.com/pypeclub/OpenPype/pull/3228) -- Project Manager: Fix persistent editors on project change [\#3218](https://github.com/pypeclub/OpenPype/pull/3218) +- Hiero: debugging frame range and other 3.10 [\#3222](https://github.com/pypeclub/OpenPype/pull/3222) - Deadline: instance data overwrite fix [\#3214](https://github.com/pypeclub/OpenPype/pull/3214) - Ftrack: Push hierarchical attributes action works [\#3210](https://github.com/pypeclub/OpenPype/pull/3210) - Standalone Publisher: Always create new representation for thumbnail [\#3203](https://github.com/pypeclub/OpenPype/pull/3203) - Photoshop: skip collector when automatic testing [\#3202](https://github.com/pypeclub/OpenPype/pull/3202) - Nuke: render/workfile version sync doesn't work on farm [\#3185](https://github.com/pypeclub/OpenPype/pull/3185) - Ftrack: Review image only if there are no mp4 reviews [\#3183](https://github.com/pypeclub/OpenPype/pull/3183) +- Ftrack: Locations deepcopy issue [\#3177](https://github.com/pypeclub/OpenPype/pull/3177) - General: Avoid creating multiple thumbnails [\#3176](https://github.com/pypeclub/OpenPype/pull/3176) +- General: Avoid creating multiple thumbnails [\#3174](https://github.com/pypeclub/OpenPype/pull/3174) - General/Hiero: better clip duration calculation [\#3169](https://github.com/pypeclub/OpenPype/pull/3169) - General: Oiio conversion for ffmpeg checks for invalid characters [\#3166](https://github.com/pypeclub/OpenPype/pull/3166) - Fix for attaching render to subset [\#3164](https://github.com/pypeclub/OpenPype/pull/3164) @@ -55,19 +60,19 @@ - General: New Session schema [\#3141](https://github.com/pypeclub/OpenPype/pull/3141) - General: Missing version on headless mode crash properly [\#3136](https://github.com/pypeclub/OpenPype/pull/3136) - TVPaint: Composite layers in reversed order [\#3135](https://github.com/pypeclub/OpenPype/pull/3135) -- Nuke: fixing default settings for workfile builder loaders [\#3120](https://github.com/pypeclub/OpenPype/pull/3120) - Nuke: fix anatomy imageio regex default [\#3119](https://github.com/pypeclub/OpenPype/pull/3119) **πŸ”€ Refactored code** -- Avalon repo removed from Jobs workflow [\#3193](https://github.com/pypeclub/OpenPype/pull/3193) - General: Remove remaining imports from avalon [\#3130](https://github.com/pypeclub/OpenPype/pull/3130) **Merged pull requests:** +- Harmony: message length in 21.1 [\#3257](https://github.com/pypeclub/OpenPype/pull/3257) +- Harmony: 21.1 fix [\#3249](https://github.com/pypeclub/OpenPype/pull/3249) +- Webpublish: remove publish highlight when creating subset name [\#3234](https://github.com/pypeclub/OpenPype/pull/3234) - Maya: added jpg to filter for Image Plane Loader [\#3223](https://github.com/pypeclub/OpenPype/pull/3223) - Webpublisher: replace space by underscore in subset names [\#3160](https://github.com/pypeclub/OpenPype/pull/3160) -- StandalonePublisher: removed Extract Background plugins [\#3093](https://github.com/pypeclub/OpenPype/pull/3093) ## [3.9.8](https://github.com/pypeclub/OpenPype/tree/3.9.8) (2022-05-19) @@ -84,11 +89,13 @@ - Standalone Publisher: Always create new representation for thumbnail [\#3204](https://github.com/pypeclub/OpenPype/pull/3204) - Nuke: render/workfile version sync doesn't work on farm [\#3184](https://github.com/pypeclub/OpenPype/pull/3184) - Ftrack: Review image only if there are no mp4 reviews [\#3182](https://github.com/pypeclub/OpenPype/pull/3182) -- Ftrack: Locations deepcopy issue [\#3177](https://github.com/pypeclub/OpenPype/pull/3177) - Ftrack: Locations deepcopy issue [\#3175](https://github.com/pypeclub/OpenPype/pull/3175) -- General: Avoid creating multiple thumbnails [\#3174](https://github.com/pypeclub/OpenPype/pull/3174) - General: TemplateResult can be copied [\#3170](https://github.com/pypeclub/OpenPype/pull/3170) +**πŸ”€ Refactored code** + +- Avalon repo removed from Jobs workflow [\#3193](https://github.com/pypeclub/OpenPype/pull/3193) + **Merged pull requests:** - hiero: otio p3 compatibility issue - metadata on effect use update [\#3194](https://github.com/pypeclub/OpenPype/pull/3194) @@ -123,19 +130,13 @@ - Nuke: render instance with subset name filtered overrides \(3.9.x\) [\#3125](https://github.com/pypeclub/OpenPype/pull/3125) -**πŸš€ Enhancements** - -- TVPaint: Match renderlayer key with other hosts [\#3109](https://github.com/pypeclub/OpenPype/pull/3109) - **πŸ› Bug fixes** - TVPaint: Composite layers in reversed order [\#3134](https://github.com/pypeclub/OpenPype/pull/3134) -- General: Python 3 compatibility in queries [\#3111](https://github.com/pypeclub/OpenPype/pull/3111) **Merged pull requests:** - Ftrack: AssetVersion status on publish [\#3114](https://github.com/pypeclub/OpenPype/pull/3114) -- renderman support for 3.9.x [\#3107](https://github.com/pypeclub/OpenPype/pull/3107) ## [3.9.5](https://github.com/pypeclub/OpenPype/tree/3.9.5) (2022-04-25) diff --git a/openpype/version.py b/openpype/version.py index eee776fd2c..984e4ba426 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.10.0-nightly.5" +__version__ = "3.10.0-nightly.6" diff --git a/pyproject.toml b/pyproject.toml index 50cdefe1bb..1caa2a838a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "OpenPype" -version = "3.10.0-nightly.5" # OpenPype +version = "3.10.0-nightly.6" # OpenPype description = "Open VFX and Animation pipeline with support." authors = ["OpenPype Team "] license = "MIT License" From 728079233484fdc277a00e4dbffe4cc8d6fe2529 Mon Sep 17 00:00:00 2001 From: OpenPype Date: Thu, 26 May 2022 18:58:41 +0000 Subject: [PATCH 534/583] [Automated] Release --- CHANGELOG.md | 13 +++++-------- openpype/version.py | 2 +- pyproject.toml | 2 +- 3 files changed, 7 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8437540a49..15659f8aa4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,8 @@ # Changelog -## [3.10.0-nightly.6](https://github.com/pypeclub/OpenPype/tree/HEAD) +## [3.10.0](https://github.com/pypeclub/OpenPype/tree/3.10.0) (2022-05-26) -[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.9.8...HEAD) +[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.9.8...3.10.0) **πŸ†• New features** @@ -42,6 +42,7 @@ - Maya: Fix support for multiple resolutions [\#3236](https://github.com/pypeclub/OpenPype/pull/3236) - TVPaint: Look for more groups than 12 [\#3228](https://github.com/pypeclub/OpenPype/pull/3228) - Hiero: debugging frame range and other 3.10 [\#3222](https://github.com/pypeclub/OpenPype/pull/3222) +- Project Manager: Fix persistent editors on project change [\#3218](https://github.com/pypeclub/OpenPype/pull/3218) - Deadline: instance data overwrite fix [\#3214](https://github.com/pypeclub/OpenPype/pull/3214) - Ftrack: Push hierarchical attributes action works [\#3210](https://github.com/pypeclub/OpenPype/pull/3210) - Standalone Publisher: Always create new representation for thumbnail [\#3203](https://github.com/pypeclub/OpenPype/pull/3203) @@ -50,7 +51,6 @@ - Ftrack: Review image only if there are no mp4 reviews [\#3183](https://github.com/pypeclub/OpenPype/pull/3183) - Ftrack: Locations deepcopy issue [\#3177](https://github.com/pypeclub/OpenPype/pull/3177) - General: Avoid creating multiple thumbnails [\#3176](https://github.com/pypeclub/OpenPype/pull/3176) -- General: Avoid creating multiple thumbnails [\#3174](https://github.com/pypeclub/OpenPype/pull/3174) - General/Hiero: better clip duration calculation [\#3169](https://github.com/pypeclub/OpenPype/pull/3169) - General: Oiio conversion for ffmpeg checks for invalid characters [\#3166](https://github.com/pypeclub/OpenPype/pull/3166) - Fix for attaching render to subset [\#3164](https://github.com/pypeclub/OpenPype/pull/3164) @@ -64,13 +64,13 @@ **πŸ”€ Refactored code** +- Avalon repo removed from Jobs workflow [\#3193](https://github.com/pypeclub/OpenPype/pull/3193) - General: Remove remaining imports from avalon [\#3130](https://github.com/pypeclub/OpenPype/pull/3130) **Merged pull requests:** - Harmony: message length in 21.1 [\#3257](https://github.com/pypeclub/OpenPype/pull/3257) - Harmony: 21.1 fix [\#3249](https://github.com/pypeclub/OpenPype/pull/3249) -- Webpublish: remove publish highlight when creating subset name [\#3234](https://github.com/pypeclub/OpenPype/pull/3234) - Maya: added jpg to filter for Image Plane Loader [\#3223](https://github.com/pypeclub/OpenPype/pull/3223) - Webpublisher: replace space by underscore in subset names [\#3160](https://github.com/pypeclub/OpenPype/pull/3160) @@ -90,12 +90,9 @@ - Nuke: render/workfile version sync doesn't work on farm [\#3184](https://github.com/pypeclub/OpenPype/pull/3184) - Ftrack: Review image only if there are no mp4 reviews [\#3182](https://github.com/pypeclub/OpenPype/pull/3182) - Ftrack: Locations deepcopy issue [\#3175](https://github.com/pypeclub/OpenPype/pull/3175) +- General: Avoid creating multiple thumbnails [\#3174](https://github.com/pypeclub/OpenPype/pull/3174) - General: TemplateResult can be copied [\#3170](https://github.com/pypeclub/OpenPype/pull/3170) -**πŸ”€ Refactored code** - -- Avalon repo removed from Jobs workflow [\#3193](https://github.com/pypeclub/OpenPype/pull/3193) - **Merged pull requests:** - hiero: otio p3 compatibility issue - metadata on effect use update [\#3194](https://github.com/pypeclub/OpenPype/pull/3194) diff --git a/openpype/version.py b/openpype/version.py index 984e4ba426..31be1f2f02 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.10.0-nightly.6" +__version__ = "3.10.0" diff --git a/pyproject.toml b/pyproject.toml index 1caa2a838a..444af49273 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "OpenPype" -version = "3.10.0-nightly.6" # OpenPype +version = "3.10.0" # OpenPype description = "Open VFX and Animation pipeline with support." authors = ["OpenPype Team "] license = "MIT License" From c8a8831f8d7b8afb5f78257fcbeae2dc47e33c52 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 27 May 2022 10:33:46 +0200 Subject: [PATCH 535/583] :bug: fix getting attribute for multilayer pxr --- openpype/hosts/maya/plugins/publish/collect_look.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/publish/collect_look.py b/openpype/hosts/maya/plugins/publish/collect_look.py index d295492f9a..7d258b01fa 100644 --- a/openpype/hosts/maya/plugins/publish/collect_look.py +++ b/openpype/hosts/maya/plugins/publish/collect_look.py @@ -571,7 +571,7 @@ class CollectLook(pyblish.api.InstancePlugin): self.log.debug(" - got {}".format(cmds.nodeType(node))) - attribute = FILE_NODES.get(cmds.nodeType(node)) + attribute = get_attributes(FILE_NODES, cmds.nodeType(node)) source = cmds.getAttr("{}.{}".format( node, attribute From 67fdfba49f35b585ad5e391271fb6ebe5e43f2d3 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 27 May 2022 11:01:40 +0200 Subject: [PATCH 536/583] OP-2790 - added note about remote publish to documentation --- website/docs/artist_hosts_maya.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/website/docs/artist_hosts_maya.md b/website/docs/artist_hosts_maya.md index 73e89384e8..48e1093753 100644 --- a/website/docs/artist_hosts_maya.md +++ b/website/docs/artist_hosts_maya.md @@ -312,6 +312,10 @@ Example setup: ![Maya - Point Cache Example](assets/maya-pointcache_setup.png) +:::note Publish on farm +If your studio has Deadline configured, artists could choose to offload potentially long running export of pointache and publish it to the farm. +Only thing that is necessary is to toggle `Farm` property in created pointcache instance to True. + ### Loading Point Caches Loading point cache means creating reference to **abc** file with Go **OpenPype β†’ Load...**. From e1deb29da8615c050358da6254c6e9e10d9f0c13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Fri, 27 May 2022 11:06:08 +0200 Subject: [PATCH 537/583] :bug: fix multiple attributes per node --- .../maya/plugins/publish/collect_look.py | 115 +++++++++--------- 1 file changed, 59 insertions(+), 56 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/collect_look.py b/openpype/hosts/maya/plugins/publish/collect_look.py index 7d258b01fa..93c02ce0fb 100644 --- a/openpype/hosts/maya/plugins/publish/collect_look.py +++ b/openpype/hosts/maya/plugins/publish/collect_look.py @@ -28,7 +28,7 @@ SHAPE_ATTRS = set(SHAPE_ATTRS) def get_pxr_multitexture_file_attrs(node): attrs = [] for i in range(9): - if cmds.attributeQuery("filename{}".format(i), node): + if cmds.attributeQuery("filename{}".format(i), node=node, ex=True): file = cmds.getAttr("{}.filename{}".format(node, i)) if file: attrs.append("filename{}".format(i)) @@ -50,10 +50,10 @@ FILE_NODES = { } -def get_attributes(dictionary, attr): - # type: (dict, str) -> list +def get_attributes(dictionary, attr, node=None): + # type: (dict, str, str) -> list if callable(dictionary[attr]): - val = dictionary[attr]() + val = dictionary[attr](node) else: val = dictionary.get(attr, []) @@ -205,7 +205,8 @@ def get_file_node_paths(node): return [texture_pattern] try: - file_attributes = get_attributes(FILE_NODES, cmds.nodeType(node)) + file_attributes = get_attributes( + FILE_NODES, cmds.nodeType(node), node) except AttributeError: file_attributes = "fileTextureName" @@ -434,7 +435,8 @@ class CollectLook(pyblish.api.InstancePlugin): # Collect textures if any file nodes are found instance.data["resources"] = [] for n in files: - instance.data["resources"].append(self.collect_resource(n)) + for res in self.collect_resources(n): + instance.data["resources"].append(res) self.log.info("Collected resources: {}".format(instance.data["resources"])) @@ -554,7 +556,7 @@ class CollectLook(pyblish.api.InstancePlugin): return attributes - def collect_resource(self, node): + def collect_resources(self, node): """Collect the link to the file(s) used (resource) Args: node (str): name of the node @@ -571,60 +573,61 @@ class CollectLook(pyblish.api.InstancePlugin): self.log.debug(" - got {}".format(cmds.nodeType(node))) - attribute = get_attributes(FILE_NODES, cmds.nodeType(node)) - source = cmds.getAttr("{}.{}".format( - node, - attribute - )) - computed_attribute = "{}.{}".format(node, attribute) - if attribute == "fileTextureName": - computed_attribute = node + ".computedFileTextureNamePattern" + attributes = get_attributes(FILE_NODES, cmds.nodeType(node), node) + for attribute in attributes: + source = cmds.getAttr("{}.{}".format( + node, + attribute + )) + computed_attribute = "{}.{}".format(node, attribute) + if attribute == "fileTextureName": + computed_attribute = node + ".computedFileTextureNamePattern" - self.log.info(" - file source: {}".format(source)) - color_space_attr = "{}.colorSpace".format(node) - try: - color_space = cmds.getAttr(color_space_attr) - except ValueError: - # node doesn't have colorspace attribute - color_space = "Raw" - # Compare with the computed file path, e.g. the one with the - # pattern in it, to generate some logging information about this - # difference - # computed_attribute = "{}.computedFileTextureNamePattern".format(node) - computed_source = cmds.getAttr(computed_attribute) - if source != computed_source: - self.log.debug("Detected computed file pattern difference " - "from original pattern: {0} " - "({1} -> {2})".format(node, - source, - computed_source)) + self.log.info(" - file source: {}".format(source)) + color_space_attr = "{}.colorSpace".format(node) + try: + color_space = cmds.getAttr(color_space_attr) + except ValueError: + # node doesn't have colorspace attribute + color_space = "Raw" + # Compare with the computed file path, e.g. the one with the + # pattern in it, to generate some logging information about this + # difference + # computed_attribute = "{}.computedFileTextureNamePattern".format(node) + computed_source = cmds.getAttr(computed_attribute) + if source != computed_source: + self.log.debug("Detected computed file pattern difference " + "from original pattern: {0} " + "({1} -> {2})".format(node, + source, + computed_source)) - # We replace backslashes with forward slashes because V-Ray - # can't handle the UDIM files with the backslashes in the - # paths as the computed patterns - source = source.replace("\\", "/") + # We replace backslashes with forward slashes because V-Ray + # can't handle the UDIM files with the backslashes in the + # paths as the computed patterns + source = source.replace("\\", "/") - files = get_file_node_files(node) - if len(files) == 0: - self.log.error("No valid files found from node `%s`" % node) + files = get_file_node_files(node) + if len(files) == 0: + self.log.error("No valid files found from node `%s`" % node) - self.log.info("collection of resource done:") - self.log.info(" - node: {}".format(node)) - self.log.info(" - attribute: {}".format(attribute)) - self.log.info(" - source: {}".format(source)) - self.log.info(" - file: {}".format(files)) - self.log.info(" - color space: {}".format(color_space)) + self.log.info("collection of resource done:") + self.log.info(" - node: {}".format(node)) + self.log.info(" - attribute: {}".format(attribute)) + self.log.info(" - source: {}".format(source)) + self.log.info(" - file: {}".format(files)) + self.log.info(" - color space: {}".format(color_space)) - # Define the resource - return { - "node": node, - # here we are passing not only attribute, but with node again - # this should be simplified and changed extractor. - "attribute": "{}.{}".format(node, attribute), - "source": source, # required for resources - "files": files, - "color_space": color_space - } # required for resources + # Define the resource + yield { + "node": node, + # here we are passing not only attribute, but with node again + # this should be simplified and changed extractor. + "attribute": "{}.{}".format(node, attribute), + "source": source, # required for resources + "files": files, + "color_space": color_space + } # required for resources class CollectModelRenderSets(CollectLook): From a24896962062e4db576ccb33ce2dbafec77ac789 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 27 May 2022 16:14:17 +0200 Subject: [PATCH 538/583] Nuke: bake reformat was failing on string type --- openpype/hosts/nuke/api/plugin.py | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/openpype/hosts/nuke/api/plugin.py b/openpype/hosts/nuke/api/plugin.py index 2bad6f2c78..b8b56ef2b8 100644 --- a/openpype/hosts/nuke/api/plugin.py +++ b/openpype/hosts/nuke/api/plugin.py @@ -18,7 +18,8 @@ from .lib import ( maintained_selection, set_avalon_knob_data, add_publish_knob, - get_nuke_imageio_settings + get_nuke_imageio_settings, + set_node_knobs_from_settings ) @@ -497,16 +498,7 @@ class ExporterReviewMov(ExporterReview): add_tags.append("reformated") rf_node = nuke.createNode("Reformat") - for kn_conf in reformat_node_config: - _type = kn_conf["type"] - k_name = str(kn_conf["name"]) - k_value = kn_conf["value"] - - # to remove unicode as nuke doesn't like it - if _type == "string": - k_value = str(kn_conf["value"]) - - rf_node[k_name].setValue(k_value) + set_node_knobs_from_settings(rf_node, reformat_node_config) # connect rf_node.setInput(0, self.previous_node) From c3c9cca5c2d1c3b29d5b4beaddbe2c855a32b3b8 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 27 May 2022 17:00:04 +0200 Subject: [PATCH 539/583] :recycle: :dog: fix hound --- openpype/hosts/maya/plugins/publish/collect_look.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/collect_look.py b/openpype/hosts/maya/plugins/publish/collect_look.py index 93c02ce0fb..323bede761 100644 --- a/openpype/hosts/maya/plugins/publish/collect_look.py +++ b/openpype/hosts/maya/plugins/publish/collect_look.py @@ -590,10 +590,9 @@ class CollectLook(pyblish.api.InstancePlugin): except ValueError: # node doesn't have colorspace attribute color_space = "Raw" - # Compare with the computed file path, e.g. the one with the - # pattern in it, to generate some logging information about this - # difference - # computed_attribute = "{}.computedFileTextureNamePattern".format(node) + # Compare with the computed file path, e.g. the one with + # the pattern in it, to generate some logging information + # about this difference computed_source = cmds.getAttr(computed_attribute) if source != computed_source: self.log.debug("Detected computed file pattern difference " From f27176bed1ac255128fdbe3d4f9c1d6100942508 Mon Sep 17 00:00:00 2001 From: OpenPype Date: Sat, 28 May 2022 03:44:32 +0000 Subject: [PATCH 540/583] [Automated] Bump version --- CHANGELOG.md | 23 +++++++++++++++++------ openpype/version.py | 2 +- pyproject.toml | 2 +- 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 15659f8aa4..4a5e1f1067 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,24 @@ # Changelog +## [3.10.1-nightly.1](https://github.com/pypeclub/OpenPype/tree/HEAD) + +[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.10.0...HEAD) + +**πŸš€ Enhancements** + +- TVPaint: Init file for TVPaint worker also handle guideline images [\#3250](https://github.com/pypeclub/OpenPype/pull/3250) +- Support for Unreal 5 [\#3122](https://github.com/pypeclub/OpenPype/pull/3122) + +**πŸ› Bug fixes** + +- Unreal: Fix Camera Loading if Layout is missing [\#3255](https://github.com/pypeclub/OpenPype/pull/3255) +- Unreal: Fixed Animation loading in UE5 [\#3240](https://github.com/pypeclub/OpenPype/pull/3240) +- Unreal: Fixed Render creation in UE5 [\#3239](https://github.com/pypeclub/OpenPype/pull/3239) +- Unreal: Fixed Camera loading in UE5 [\#3238](https://github.com/pypeclub/OpenPype/pull/3238) + ## [3.10.0](https://github.com/pypeclub/OpenPype/tree/3.10.0) (2022-05-26) -[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.9.8...3.10.0) +[Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.10.0-nightly.6...3.10.0) **πŸ†• New features** @@ -31,7 +47,6 @@ - General: Simplified OP modules/addons import [\#3137](https://github.com/pypeclub/OpenPype/pull/3137) - Terminal: Tweak coloring of TrayModuleManager logging enabled states [\#3133](https://github.com/pypeclub/OpenPype/pull/3133) - General: Cleanup some Loader docstrings [\#3131](https://github.com/pypeclub/OpenPype/pull/3131) -- Unreal: Layout and Camera update and remove functions reimplemented and improvements [\#3116](https://github.com/pypeclub/OpenPype/pull/3116) **πŸ› Bug fixes** @@ -131,10 +146,6 @@ - TVPaint: Composite layers in reversed order [\#3134](https://github.com/pypeclub/OpenPype/pull/3134) -**Merged pull requests:** - -- Ftrack: AssetVersion status on publish [\#3114](https://github.com/pypeclub/OpenPype/pull/3114) - ## [3.9.5](https://github.com/pypeclub/OpenPype/tree/3.9.5) (2022-04-25) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.10.0-nightly.2...3.9.5) diff --git a/openpype/version.py b/openpype/version.py index 31be1f2f02..12f25cdcea 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.10.0" +__version__ = "3.10.1-nightly.1" diff --git a/pyproject.toml b/pyproject.toml index 444af49273..47d678b5e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "OpenPype" -version = "3.10.0" # OpenPype +version = "3.10.1-nightly.1" # OpenPype description = "Open VFX and Animation pipeline with support." authors = ["OpenPype Team "] license = "MIT License" From d404fbf8a285e0e25f5af3d8695c6df547f0ceab Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 30 May 2022 11:51:19 +0200 Subject: [PATCH 541/583] OP-3277 - added functionality to replace root value with environment variable. Useful for remote workflows where Site Sync is being used. When Load reference is used, real root value (c:/project) is replaced with ${OPENPYPE_ROOT_WORK}. --- openpype/hosts/maya/plugins/load/load_reference.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/load/load_reference.py b/openpype/hosts/maya/plugins/load/load_reference.py index d65b5a2c1e..7fa7362ecc 100644 --- a/openpype/hosts/maya/plugins/load/load_reference.py +++ b/openpype/hosts/maya/plugins/load/load_reference.py @@ -8,6 +8,7 @@ from openpype.pipeline import ( legacy_create, ) import openpype.hosts.maya.api.plugin +from openpype.api import Anatomy from openpype.hosts.maya.api.lib import maintained_selection @@ -51,7 +52,9 @@ class ReferenceLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): with maintained_selection(): cmds.loadPlugin("AbcImport.mll", quiet=True) - nodes = cmds.file(self.fname, + anatomy = Anatomy(context["project"]["code"]) + file_url = anatomy.replace_root_with_env_key(self.fname, '${{{}}}') + nodes = cmds.file(file_url, namespace=namespace, sharedReferenceFile=False, reference=True, From 277024de81843a3198c47402e7b251f937ab0817 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 30 May 2022 13:23:24 +0200 Subject: [PATCH 542/583] OP-3277 - extracted logic to ReferenceLoader All inheriting plugins implementing shared method. Flag 'use_env_var_as_root' set to True for testing temporarily, proper location in Setting should be decided. --- openpype/hosts/maya/api/plugin.py | 25 +++++++++++++++++++ .../maya/plugins/load/_load_animation.py | 5 ++-- openpype/hosts/maya/plugins/load/load_ass.py | 12 ++++++--- openpype/hosts/maya/plugins/load/load_look.py | 4 ++- .../hosts/maya/plugins/load/load_reference.py | 5 ++-- .../hosts/maya/plugins/load/load_yeti_rig.py | 4 ++- 6 files changed, 45 insertions(+), 10 deletions(-) diff --git a/openpype/hosts/maya/api/plugin.py b/openpype/hosts/maya/api/plugin.py index 3721868823..93b0793d9c 100644 --- a/openpype/hosts/maya/api/plugin.py +++ b/openpype/hosts/maya/api/plugin.py @@ -10,6 +10,7 @@ from openpype.pipeline import ( get_representation_path, AVALON_CONTAINER_ID, ) +from openpype.api import Anatomy from .pipeline import containerise from . import lib @@ -132,6 +133,7 @@ class ReferenceLoader(Loader): " imported representation ?" ) ] + use_env_var_as_root = True def load( self, @@ -191,6 +193,25 @@ class ReferenceLoader(Loader): """To be implemented by subclass""" raise NotImplementedError("Must be implemented by subclass") + def prepare_root_value(self, file_url, project_name): + """Replace root value with env var placeholder. + + Use ${OPENPYPE_ROOT_WORK} (or any other root) instead of proper root + value when storing referenced url into a workfile. + Useful for remote workflows with SiteSync. + + Args: + file_url (str) + project_name (dict) + Returns: + (str) + """ + if self.use_env_var_as_root: + anatomy = Anatomy(project_name) + file_url = anatomy.replace_root_with_env_key(file_url, '${{{}}}') + + return file_url + def update(self, container, representation): from maya import cmds from openpype.hosts.maya.api.lib import get_container_members @@ -230,6 +251,10 @@ class ReferenceLoader(Loader): self.log.debug("No alembic nodes found in {}".format(members)) try: + path = self.prepare_root_value(path, + representation["context"] + ["project"] + ["code"]) content = cmds.file(path, loadReference=reference_node, type=file_type, diff --git a/openpype/hosts/maya/plugins/load/_load_animation.py b/openpype/hosts/maya/plugins/load/_load_animation.py index 9c37e498ef..0010efb829 100644 --- a/openpype/hosts/maya/plugins/load/_load_animation.py +++ b/openpype/hosts/maya/plugins/load/_load_animation.py @@ -35,8 +35,9 @@ class AbcLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): # hero_001 (abc) # asset_counter{optional} - - nodes = cmds.file(self.fname, + file_url = self.prepare_root_value(self.fname, + context["project"]["code"]) + nodes = cmds.file(file_url, namespace=namespace, sharedReferenceFile=False, groupReference=True, diff --git a/openpype/hosts/maya/plugins/load/load_ass.py b/openpype/hosts/maya/plugins/load/load_ass.py index a284b7ec1f..1f0eb88995 100644 --- a/openpype/hosts/maya/plugins/load/load_ass.py +++ b/openpype/hosts/maya/plugins/load/load_ass.py @@ -64,9 +64,11 @@ class AssProxyLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): path = os.path.join(publish_folder, filename) proxyPath = proxyPath_base + ".ma" - self.log.info - nodes = cmds.file(proxyPath, + file_url = self.prepare_root_value(proxyPath, + context["project"]["code"]) + + nodes = cmds.file(file_url, namespace=namespace, reference=True, returnNewNodes=True, @@ -123,7 +125,11 @@ class AssProxyLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): assert os.path.exists(proxyPath), "%s does not exist." % proxyPath try: - content = cmds.file(proxyPath, + file_url = self.prepare_root_value(proxyPath, + representation["context"] + ["project"] + ["code"]) + content = cmds.file(file_url, loadReference=reference_node, type="mayaAscii", returnNewNodes=True) diff --git a/openpype/hosts/maya/plugins/load/load_look.py b/openpype/hosts/maya/plugins/load/load_look.py index 80eac8e0b5..ae3a683241 100644 --- a/openpype/hosts/maya/plugins/load/load_look.py +++ b/openpype/hosts/maya/plugins/load/load_look.py @@ -31,7 +31,9 @@ class LookLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): import maya.cmds as cmds with lib.maintained_selection(): - nodes = cmds.file(self.fname, + file_url = self.prepare_root_value(self.fname, + context["project"]["code"]) + nodes = cmds.file(file_url, namespace=namespace, reference=True, returnNewNodes=True) diff --git a/openpype/hosts/maya/plugins/load/load_reference.py b/openpype/hosts/maya/plugins/load/load_reference.py index 7fa7362ecc..e4355ed3d4 100644 --- a/openpype/hosts/maya/plugins/load/load_reference.py +++ b/openpype/hosts/maya/plugins/load/load_reference.py @@ -8,7 +8,6 @@ from openpype.pipeline import ( legacy_create, ) import openpype.hosts.maya.api.plugin -from openpype.api import Anatomy from openpype.hosts.maya.api.lib import maintained_selection @@ -52,8 +51,8 @@ class ReferenceLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): with maintained_selection(): cmds.loadPlugin("AbcImport.mll", quiet=True) - anatomy = Anatomy(context["project"]["code"]) - file_url = anatomy.replace_root_with_env_key(self.fname, '${{{}}}') + file_url = self.prepare_root_value(self.fname, + context["project"]["code"]) nodes = cmds.file(file_url, namespace=namespace, sharedReferenceFile=False, diff --git a/openpype/hosts/maya/plugins/load/load_yeti_rig.py b/openpype/hosts/maya/plugins/load/load_yeti_rig.py index b4d31b473f..241c28467a 100644 --- a/openpype/hosts/maya/plugins/load/load_yeti_rig.py +++ b/openpype/hosts/maya/plugins/load/load_yeti_rig.py @@ -53,7 +53,9 @@ class YetiRigLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): # load rig with lib.maintained_selection(): - nodes = cmds.file(self.fname, + file_url = self.prepare_root_value(self.fname, + context["project"]["code"]) + nodes = cmds.file(file_url, namespace=namespace, reference=True, returnNewNodes=True, From 254455e786c50ef20d0f953bebd57d50af077b1e Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 30 May 2022 18:05:56 +0200 Subject: [PATCH 543/583] OP-3277 - introduced Settings variable Configured via Maya/Maya-dirmap to use in all Loaders --- openpype/hosts/maya/api/plugin.py | 45 ++++++++++--------- .../defaults/project_settings/maya.json | 1 + .../projects_schema/schema_project_maya.json | 6 +++ 3 files changed, 31 insertions(+), 21 deletions(-) diff --git a/openpype/hosts/maya/api/plugin.py b/openpype/hosts/maya/api/plugin.py index 93b0793d9c..f05893a7b4 100644 --- a/openpype/hosts/maya/api/plugin.py +++ b/openpype/hosts/maya/api/plugin.py @@ -11,7 +11,7 @@ from openpype.pipeline import ( AVALON_CONTAINER_ID, ) from openpype.api import Anatomy - +from openpype.settings import get_project_settings from .pipeline import containerise from . import lib @@ -133,7 +133,6 @@ class ReferenceLoader(Loader): " imported representation ?" ) ] - use_env_var_as_root = True def load( self, @@ -193,25 +192,6 @@ class ReferenceLoader(Loader): """To be implemented by subclass""" raise NotImplementedError("Must be implemented by subclass") - def prepare_root_value(self, file_url, project_name): - """Replace root value with env var placeholder. - - Use ${OPENPYPE_ROOT_WORK} (or any other root) instead of proper root - value when storing referenced url into a workfile. - Useful for remote workflows with SiteSync. - - Args: - file_url (str) - project_name (dict) - Returns: - (str) - """ - if self.use_env_var_as_root: - anatomy = Anatomy(project_name) - file_url = anatomy.replace_root_with_env_key(file_url, '${{{}}}') - - return file_url - def update(self, container, representation): from maya import cmds from openpype.hosts.maya.api.lib import get_container_members @@ -344,6 +324,29 @@ class ReferenceLoader(Loader): except RuntimeError: pass + def prepare_root_value(self, file_url, project_name): + """Replace root value with env var placeholder. + + Use ${OPENPYPE_ROOT_WORK} (or any other root) instead of proper root + value when storing referenced url into a workfile. + Useful for remote workflows with SiteSync. + + Args: + file_url (str) + project_name (dict) + Returns: + (str) + """ + settings = get_project_settings(project_name) + use_env_var_as_root = (settings["maya"] + ["maya-dirmap"] + ["use_env_var_as_root"]) + if use_env_var_as_root: + anatomy = Anatomy(project_name) + file_url = anatomy.replace_root_with_env_key(file_url, '${{{}}}') + + return file_url + @staticmethod def _organize_containers(nodes, container): # type: (list, str) -> None diff --git a/openpype/settings/defaults/project_settings/maya.json b/openpype/settings/defaults/project_settings/maya.json index e03bdcecc3..a42f889e85 100644 --- a/openpype/settings/defaults/project_settings/maya.json +++ b/openpype/settings/defaults/project_settings/maya.json @@ -8,6 +8,7 @@ "yetiRig": "ma" }, "maya-dirmap": { + "use_env_var_as_root": true, "enabled": false, "paths": { "source-path": [], diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_maya.json b/openpype/settings/entities/schemas/projects_schema/schema_project_maya.json index 0c7943447b..f9523b1baa 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_maya.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_maya.json @@ -22,6 +22,12 @@ "label": "Maya Directory Mapping", "is_group": true, "children": [ + { + "type": "boolean", + "key": "use_env_var_as_root", + "label": "Use env var placeholder in referenced url", + "docstring": "Use ${} placeholder instead of physical value of root when storing into workfile metadata." + }, { "type": "boolean", "key": "enabled", From 99b6050cbec6c35d088e106a7ef5aebb182fbb6a Mon Sep 17 00:00:00 2001 From: "clement.hector" Date: Mon, 30 May 2022 18:47:09 +0200 Subject: [PATCH 544/583] Replace by last nuke familly icon --- openpype/resources/app_icons/hiero.png | Bin 46366 -> 87054 bytes openpype/resources/app_icons/nuke.png | Bin 49012 -> 86832 bytes openpype/resources/app_icons/nukestudio.png | Bin 46080 -> 101945 bytes openpype/resources/app_icons/nukex.png | Bin 44709 -> 99787 bytes 4 files changed, 0 insertions(+), 0 deletions(-) diff --git a/openpype/resources/app_icons/hiero.png b/openpype/resources/app_icons/hiero.png index 04bbf6265bb63f0615c2b98ab48891d03483f6b2..ba666c2fe0819e7c769bc2dc6d96c853b349fd80 100644 GIT binary patch literal 87054 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 diff --git a/openpype/resources/app_icons/nuke.png b/openpype/resources/app_icons/nuke.png index 423445409618d59ff822dc09e1dac2e4d3e241aa..e734b4984e1c5ce86e957274b455fd039c5cd150 100644 GIT binary patch literal 86832 zcmeFZ^;;ZG@GrW%EU>t{y9Wpf4vV`bxVw9h;EQ{LyORLHf?M#QL4vzWaCceY@SgKM z_x=O-$2-r{Pxs99?9^0OeX6RvdZW}-WHC@lQ2_t|hP<5AM*sly+5`b0NUznE*Yv|{ z1-2Gf76$-o;?Urxh_C0==5imE0RSIH03a|F0Qj#<;2r?r!36*um;eBR=>PzcOZKl1 z!ml^HEOq3ql$8OjuWbkb3?v2o_Y1(+A_^q?f7&uYW&pzfzW)052wMR7fBLAr*8d%H zujRjI{!fjN5B#6ruh;TH|F<^?kdN^Hw*PyK|8r37YlY$>r|Sj)pyB;jfPl4xRHZ}wT27^GrpuqqC^#5~i)Di(%d}A0D7+=|J1JTfiWhFVg5w#rM_+9W4-GwCf z*tsntNQEWR$0b+}p&^A>erMK_M~S7Cufb)Js_o-Qm?=B+Mir-xO3q(X#djF6Z_a-s z-Ke=iE-$}iDSsdqS{ZQBq(fkw#%Z!&FW|sIFO?<#JMx)Al_by&Lq)YQa* zK!BkI3-&l&wdKlF-hzTE_!o4b3n^o#fdTQiV3jQEuU;x2D|P5iwwx<8APrOX~|vki_}e9pO?v}CR{ue`Wo1L?(`%qdA*fr2)| z1A{XJ-aG&73iUykze&uWd5Z>iMY3A__Q#9^iRRyeZ|Qcm{-7BOjuw)9QN!IpC@(QO zmE>M&oJR296@D#k%sxmT<_p%_(U}$PscV_lsZQ#UKk*h%6yx_?TR0tT+;rnlk_H)7 z_Y{ATp)A={Uf@17r=V>l`Hy8?@7_uH)G+*zE611Dbvpmd8=tN1vDL2Xpxfrf&C2$l zhQ!N*&AyOkjHrI+z$U(cFjFk`CwAKUiG7M|WbPJ8$#*7<^dn*3U`9n6Vn`z+4vnJ4 zGkHc7I~7~+W?t7#&o7fQRP}54Onql9K?&x-@LV^92Rrgh98mU339pN1@;@BT_~9En zGElexo&}>4a(JeZY4+vImkn`!oX1bY+Ur%lx6Aw)*#sf)JP|A z!hi^4AXwrDt`GUUV(R&@=DYi>b9)GmI}{=eY1v!~I@B6h99Ia-0ATxG6X})hYM4bx z8H!BQaZTayOn}FlSKsm=-022}fMtS7khvg1Ehe&l(C%V0VPmc z6iiKE?7m=>; zN#6eEo<>kaWxu}zO2*)Q3`3E+Nie~78PfuV7laN1XOL4_xxO0gs_ap-b~~zx&V#el zwoYJxS)YSF9fmKt$R^VNp=}~f7hMG8RW-%X!O5w|MbBbW=nI?aOo)ES3R+7V(kMst zEe5KD(}A`L|F*{G0H_4iovydJWA< z&G5Ohoc8y9hyIx{2Bk67OS#LE3?mcDK{WaS!x&$4&e%XnY6{!rh%!NGM1g$OAB8D$ z?mAUayyoP!hL_|n$x!(_)1nz`KNkLP+VDY(Hh3QT2B_e0Dv(0Y14wOO*W&X@5m*qb z&*xeLyC`Q6rN$jwDk|knC*jLjL;l~RjCHpze#tsT3w6|b+;`yN29Nl$Bpj?un#6w<4#Beduz4g2C^U~E)vgI%eIw4`!VY;R z$IdEETw(}=i1uDR0~jAkHzpvSURr;L+=N3;7g5cO5rXS*;RrET+)LI)!tFMqW+>E; z)m#R&7^pp*7Ob?Wh1TeIWHq8e7>U4HCYR&bHJifha}!J#tYNfx?7JF=I@Z0*05m94 zmG{%?DS1)d`GgcU1YH#jM&J_ee5|hYK(M;^<~=oFTfkHg?v|tx%%y3m?ivaG(sz#o zrn;riU9ksK9bT-GV*BlV^X3i7uK?9pON%V4<3R@0*GMD`V`@n7fry$Jpp{R-1Rp_U z#GnC-#7`xm#2_MiL^U~$Uw&VzQ*{dDy7?^o9z{JA))eTh-|jL7i<#(9bA`&3nx3~y zF-(c&w|h=8H}!N9S=|pPCR%dN=c}ssJyfJ%S${SGyO&7x#2#J?JqK4gZjm}JPp6Pg_ey_up4_jh-NTHYC9M$T%{8P zLt-n?xWjqJZHyRFjXzfNmF|^_J=#}$Q}E~*;W3Io_TUP1HtDEfX8LWv>SWOnt9?B7 zzHp6W7FlVf&JP=Vw(E46JaeN7hGz!Q@bw8#%0S5d7h(P2XU`m_bH#Ibm}Xb-jU?EN z8TV_TLbpSq-w(g-KpJYMOLVf0kN0jDzQ)w87a7Ss2zptx5O6plC>Thrx8|lZCsV2~ z!f29&8HjQpmB@Z7&AViynUvf2a5QT~HLv?ca5!Q}yS#L(q#yt#$PpY0sz)5@DgkmKx2{0Eff#rA`r_ay#|gyH&&fiZOOJ$q7wC(9b`-nL3NQLIweC{tZ9RYWnJ1K1L}} zdwsX4Q|dz!EVzby@%S@7w@_LZHgVqBNXRkt+ zZ(*Y_ta?}oG3i<)f6&^Jp`$tTkvmFs_AX=HK01Igm6I)rRPG^zmwG|RCON7IVFZ2f zOlz+`ORKC|szLTx#g;idE(ksn2)(u$iovG-uF z>OB6E9z%{no&WA(NA{%P`RjW(hx;=r?ficjHMSqH z%r@D5v9Zy`Zv43p z<-ple2q@@COpFV#a842K!Gdqunw7akm?-@~(hElpIup{sfeCX2+|;c-a9BOQU@RP6 zS_zfCrAiOpz(%6Lpm_ZreYVYmi;wt1iHQ{SyGrz|D_GA+#Z5`=M;tbI&lzQlYe~gQ z-MM40s)&RM>;|Bd-}H!D7dc9jz-#kDzv%rXAnDMrkp=zDBuH|^93YwRyLPW#V(KNlymX)h=Zai51Vj`C#Yx&fiaT8Y zy@8ia8lTojOy~R`u075ge5`cRVWl=Tr!6%R{Lt<9(IIJ$`R1Z?=+ng3N2fPcrcu<` zdwl=NgR@#p?5_i@Q-{xbu%K(WIzx$h=s^V4;UkW?e&_88Wb}r6i;45-R=@@oR#0AW z57jJuyUvXM3v`RYtzH%*r4b=HPB2J>{TB->9@aY}DyhSc=6L1V+qhR* ze;apRJtl6e56!~gr*-Ifq)R_1P*-vc7&-6`-ws0Yn2Mmm6P-bZ(gRsHsD5*i(zDM0 z)*^`hk&CH|^>NMj+Eyz2kn>}2{N+^eNj;PC_t8Ify2U=*>Y^lOG)bBt(~v=2480d( zH26q4ZlC46)$tY(Ku`v9!^Z-}nbtQ$ z{j=mD!Lo7`wJy?ABI^jqPxMQdkxfAER7lH{ppD|+ ztVDb(0QLjg*<4J1)#KFU^%7tA*J$XG%wiL=j@VIrgTNDQZ~Qv zav_FMKMTA;Yq^K)#`fK7WeN|8Ck?dxaQL32aB-|*Wb-T#s+8@JNa=Vkz+5Bpb_ke`frYyF&MG5BBhnRsx%d_t&x{L6+zRt8#RE;uba|jecdT0Dd;vQ@LMnpq zasF~kT$24xNTT9z!HicN>Y#gd5<1NJkJQx;=DuPq?)i;4!7$_ei?$IrutG~3Sp}8A zJv;#^XPpSYJCRheWf|ZhR1zLm&KRGWVpqgR_R8%AZC4sq7B!9wEX(z5_76NGw_ATS zt}0$*Z^U3rRObnFz${oXLxIHPfAZQi3O`gXH+8=f>H^&3i?0E#Cu>q zkSfU)R!MWgJ2pe4sCyyIe?^)Qm)%aP8pZsx%V`l5!3-IP?Ts+_04IY|q_6A_6Kcdy;VWY?R z^@zsz^65mpkP)IJ_`Ap>sU zH0d;4tPI3TQk`Uw%5i=PQ#DKQMRC#^w{uaV(CLY)ot0>-Nin#HRu1V(?*i8p5tE)V0HHdzVx3Ft!^=9SRDRdE41cjf z3d0Xi__{5atB+qzEO`+b{c(a&gyW1Ube5`McsqV;o53lM)g3~S5}|TyRH1KkBRY(x z6pa1mq+agJGI+JUfBd2;J9$!rGA_c+CH_K1n-&~a<{lp5u?SS}C8}0pI;v!p&Q$3x z^zb`&x}=PK`*%l(&l;%EY4Yr4Mdt$a{5UE*$YlwZYr!!D!El+#66319svCYMb&QR;a_f;r_WzDR1B*$@*>VS`MzN)YxeM36X^K z@?luuWW60N^QdM%y>%i1>rlmdNz?9Re|O z6>}3rnc2Fg@7a$8d7gy*gz!KXKbRBWdH*UPNAI3?brLkIqRoVsM0)0=D&9-CSp?$i zmSUre-++0BK-Ll!Qisg>fsS^D)Uansj-Pmxq}&o^L%BZDi56_25Oa&aT36xXE%-%@ zeDjO$9rSBKv<2e~F*G0wOK$oW(+0v(kRWjue{bGD}u;;z&JD z{TsciiaW5`AJWomo%x2!^Y%I;Bi&wV)v2k6t(HPC`RhqWyUdX}&WXMIZKi@cDY^cLVn7{WBD}}IZo(SzuBs``}iq_@JswLp*4FTi26$Px~ zP5ccL^Q>8Axj=7OEjfY%y0V@9ydRf6mztnnmgn5Ke%3M6*pXkvJmbb|hS^VU@IVTe zi*)!ge%~ybjtP8os6wIZLAxEQ`Rfr3a#RKxvdo6j<86SCl3aR!`$DJt4p+|5z+CQiH!IY;0VOe6*@z+H>gm9$FW@H0;)Nu){ZItO{dJKTTRu6I zcUIJpKVoZ=FE~$;wc&Ls45>&uf7rAgQ09kzx{N(Pq3z`(X#9x8jgDcLcqLr-iW}({ z^H7@cVsO&sSa{UZb>h@}#-Gidb%{StJU;5Zpiu%3<8&SsS}n0+tkWgDK-8?gl17R;Y=HM9Y+? zzefP{2xzw)MGNYmqS8%c8=2)&#{uljKcp4LlP%fsc|r*DIO8wBGx47nZ$6!@B-WGC z{;n2(C9`Koo$2R~Cf957tQ8+LLfp9RbcHIz2c6}$uO830>%VRt(Z6C)!<*AACFnJM z*TStZ!m7ULFYO*Qc z8wS| zG=V$Rzg;B7a<2KBjH7igH7%O{v87+jf;InODM-CWFtabHNP_A3)(OqVb#u-GC*IDm z52~siTGFSmSFhR82Z^yzCY;lYBG39>>FT|*|Cf1t*`BDVNZl`i{n#!v86+l*`IVK* zBK`WT>kOb&q-<9W5{iz`DE|o0CUubDXz6lS=Kf$!D3U#bQRiiz5vi~A>M9IZA9Da? z7rBOq-i7_rH8G>U$U72r^-Ni-gZ0t+DIMh+oJ(mUcDSgbQ3qBCFb<`(jPmR(`x8g& zG(RoF*;W^l>(id8<#5oNQJ1?{mu{@SiU;m2u|&V-5#i}d1f-(Un<+A|_SD4U((ea-4oCiZX!S<1X+CR_lE}ictZ#U^A_^3&X3j8@V1#Fd!PAIOkub&BsPwYUs{#8YR8e#f^pn;KKv^aGFwd(^LT3!noky$ zw82@ZfG|D*2kt@_lcS9ZjxW!O(XCk$$8cYjBA$VYHmUDSqbhT6q;4gu^!Qw-K34Bg z_AhR7me*|O7S-8G=R1vLVD7d3;o~Z1r(NC4Sxm~+$z-@_#wmBgT%;(~0`Kd{nHA^# zndNJqn&m-o_&A6fO3H)1;zVwufwfc@8F2n>!u`&O!J_9A&^P_)-vU2cWWbg7ggb(t z;q0hkcU$@8PUo30QaC%it^1!o599x$e&ca6@ae;$Hhbdn%@RY2n}IV1>=mM8E-Uih z!)J7%=|Mnr(hcXHhv#yt>2XO;&B=ka#;Z~(_SvzqG3~i)@0~FkpR*Hnq|JY(K>!{E zxcM-!kByUZ5s+98ckgba(4q*d3xhc&Zty0BrU*SOJTwhHI@ zVNpfz;tk{_W@)dy;Y=vr$^=iOae|YXd5`csW+5QcBzn$=CSM+DJ~OZgG=g5eDps$% z@n-(#T^pXv)F0Vobyt@3G4U=)C>%+rYg^}eS5w9yyYqA^?*=5cOpFVGAeqSd^$#kz zzpq&AJE<0heD1iMF?^)8h)=(+YpePv^wBQ2Cs0Em-~P$x_scYy=G8jdlFc#i5pBAa zNth_(LtEn*Z2+cOV;*Dr=6gv97f+X?QWEMJu6R8`_cSWe(^}lXq^)e~2YD7xCUW%H zKnfqAQA-0|{9_i(+4$31p>(2=`8+l+?MKi5;OgjElqF5Y*2q7cR^K^|yfLAj;E6hV zd(_v?bZ#wj)E7Vf1vB2Tb3_S(M4WpL#rXXqMg@UlbvZN+m<7B_mG3Ir@4Qc!IGk5N zpfYQh7I*~wj*qXLjA%b8TX0sI$i#M?39Tnq40ew@PDC5#riHDsj#@Lk`=DR`dHEk5 zk#HnMrxY>ke{6~LlP~flm|m#lOPcwF<5N)~CG_4@8aih6u`I89M}+G>WuZV73JcgH z<65znrFR@P^4UMO`*rE8Z@bWcrF5@T8fX2GnQojFg^{4jTE;Dhv+g+vHKJU%1X_XT zzpIVKtCYJ;6_vz>L4!Vh+7%}uc+}jZaIsRKk@QbZ16K^U1x=z|-1Yzq0?BVeeoT>ZkB1uEZ1U6<8jr@r1FErLTnJAj>1T9MM);}< zv>iRpze%Jf?YEPk(%srqEepqV#yk;0 zH)e(nz!8B`{7yamLt8u| z#3mMyg<1O#zyr$sosW2M3l+5i8~X2fWeN#F>k(*{(XI8zJ&BI4=0%;-hG1I1PPfWy zD`N|V`>_YPq`9;pIBf61$(3x;ENecT*)w_JwqTZY!&|W5$J~E>{^CPlt!aRPrN)fX z&)oe?l-u;ocaG&p`M%GM8I;~d(V0uWy}2Tm%6|Q!##!f=KCt>I&CRAh^&qhu+I()1 z68wEsU_az60AUyI^6u$k8nujgE4UAkjncPgjbJE5;uP9$crknP6OI;*<+I-q^d@c) z>hT{y3tAyOgiqF*lIjM>b@w{2t$y_vy9=40sJQX?NV*#6M=r&ONBaT}Nr?4$e&cm& zp4Q$Z+>z0E6secW6-b$N!M(BjKEUx-1feR585ATQA@+`Le0qAioz1jQ4%slwGN=y5 zA8p{ohS&|U&{w+#wSBUVnDfx=7Oz^312R3LW;o_se(-)JM4F0D_e1im2QphNJkp|0 zCDwLxx5okYIQ8FLJtLekU==KtH(VM0NJ9p9v8CX6o-?IKaRv5s& zb&8nKZ2A_|mW+>HzE)>jCKIpxm(liRXL?2Q9_h zzwtZ&a`_p);Fwr;_xH=B;FvIm-IDN=F@|X8yie$l8)ho1Ltxm^vlzhsd4QN7n?w27 zX(uF;BXn{$(8%<&)GHGMVj+9@2Z_G69{oRW-$tRk!J^I}j*^p25RbOuvhI`d)UM8H zgg*>`+H$nn#Mup)m`k)i4(Q|*Q|)NXaSE`Yb1YL)ULMiO*3Q&>A9{K`tvJ=) zl)Hv7N#vcs&JHIu0 zVtof`$suBwm4}# zj>>yo>DTp6ISXuChj>d?VA#wmbG!uNC=f{U{+0ublFGC_*w>&m++nQ9*?z(M^tggm zY9~uUk3z`5viAiI?nTTuq=LDVT& zST;~_bZcnW3k}00Phff@Q5TTW0WS{v+4h;D{no?oub-D=XLEBpDjHfsD*2yrDQm`` zh#*Fq@qgD-G`^=&ur@dD7BrcJ&A7?DjpywFjdbH*nQrIRvhobgQ)FR&ZOk}USW zQR<-#u_b%Mt2mgy0%yks=VF_87AwYjH6yPs>%v(t? z9JChnp-VZXl)FH~pMyh0O)2#M2GPyT3YY!ugM@z36^$(I z@1ArQs?53=~kv?L-fLc3j$=+iSZr2_^{AwOI12Hej{>i+zDiVmh2?^e_%9UnNCF!nb3 zfD%lyf6;P!x9Z*V{&Ht@s+rCIovWxABvfw_VELx@i&WRz+0%#Gm2Rtmf2h65I6*>g zhqvQbFnpfW#qFG>P?eD_^vBcPhG(`DUB#DY+6G-{%%h^BqNXc!uBfUv?|ogyUfn=_ zFx(*Xy@&7@cFvO0r2ffXS4%aEr?JYhm3RI0K8J@cpzPBA)|%UfPr5v8tQLgG8B#fl+t_Lj5KkxByIQ zD_kjM;0iH%G+}!>?&GtaIcRwL3$YmnH8gmn-y_>}i}LKP_CX64D#+BZtW6Az=zfWl zzT-YzY0AnB6O%33`crvb_h$J%qDfqXMuPXhc{ zcv22JO)DhUCXmYaYP05}EZI)2ipTSe78Uq}>w^yT)=dUYOmJR#q1&1#a(A6__6P@} z{(dmk(8W1!e`FUl>&~seyxKoY)KdARLg_|WP?HmGLlbSyMX*LwA^T1O*QLPZxe*~fS@I1SRi(_l? zuOwEgF|ub%a)@(*sOExjyvSm^1ZOy9VEd42slW`1RHSO(jbLHq6~B91_io$P_~i?n z@4)%)w4#U!@j1gnstkXMOeLNC5gYGsfAl?f<|Xr#l-=f!Kv7p3;7QwikYEyyTBg|9*1V^ ze!c$g(1CC}@9JMS%$jXf$ckg*z(hZ-kcy2@`^3g&;pu~_Y@KA$FLd?{zd?5G>~ znXF>%NNtEpjc=XV$sYg^iy;}Bw{#qXaXic zlN6L4oW4=R)caRiXDkc~W_Du{jpY=l+{U3gY)Y&Qrk`UjT&}7Awkm%6{7+M}j_iV{ z8{B*UGvvB)Y)Sss047M`b|8>{`D+1c^*A&eQImZmKrA03_%2*bjBKk2ND^!HtWkED zEj`xJ9+GQ#LFrgkM%y}Cc!MS>e@%nS18N(l=0th-{{3VxcbNOwDA8n1gmU1_c=;Jw z*HX|xJkk}-*j?4ZgDpQU9=Rv^&b7|-+&bI+`47Y~$LN<1uS8q$oo^(U7=*df{%08b zgOwL)g3rQT2;k#rVtjC-%=TLy73GQ39og9_3ROqM%HtL9Ez2=Rf8oR3_b(mIrx+$$ zV>`;~Oes^UssiC(g>E392;Od3Q$g4<#zC1J=D`_6-i^P}Xx-@+J7k3A*!+ms`MD5^ zqCFcJ|6Xs1lO_Vu18d7;2L)12dhXnAjiDjIn_{D}&DFH&Q2@4p9RYlwea^Y@P(#8% z9Q4Df>UX1cUy_xZ3MshEe>7n5xXJ3VR`=1N7&X!F$b`i_ZJ@S7n)37S zL4PVkB27fs1yzVe8RC`tIkGda!e!avAFIx&l~`$97&^Id*2*(GJ7`riN^?ih4gx)i zwdbp!kcR0;2_j_k` zo_;Qi7eInQv}D)xhR*IeH2G^vvlFru^?KQUlDNdzg-m+4j<@V&nR^Ma_eo2ROeno{ z_h%^*|D>h^WT^vh-g$<`JYOFa`?g-_NW;i)BhSUL4Gk#H7)f| zguslYPyOKpy_behW+{d82zGY%r6iAN6j->TDL7mR5!1dH zkNEq0^?p@gRMo#CXNc}LI1gn1d4GJHgQ~VhPh?7nPV^(Sbdk_1V;=5upRf4LrxvM- zr#SbW7g_{{e!HZLAMq@`;{jwwhg1TezWM#V9zlmMzqU5IKY4Ng=h|~3Aw?(Chkuu9*vsKT)GG|Y%7FbQTq_Vr z)Z1U}&3%G@)$X~e)ZTx|ZsU12bX8(o(PS1S_O9Ub-GJUrdFVSh8&wehBbmDb&m5Dl zc47m(o!YO{-vUAS!I7K&V(uMWces<&{;6WYxepy){j@uIznd$?^Ld3L8;2Zhib75M zqc8w{-Er8oHcU6h8iTTqn93`I{!xsPe@;7B@#j}nW}RE^U${viy2(1P zYsJoNR=o9;xzDW9(7!n^AS5Wx!+#5US45M8CB8eSahdn!y;LE%#H0`N$F`J$|$UKc`0$rbVpA6T25pF~1+3X`{}rd#eTyp$^nm^fu@!DL$D zK!J$iR(HmO>BUFu2V`_ar{0qNMPV6blQeq?Vtm#)HGVLpds^SDQm1`B8m%fU41SP+ z3l)6H$uln*hPi@7Zs&8{dus+vH-kKMxxga5R0upV)~%jmSeq<14r?e;gD+>o-`lKD zx>lxWLv+6W8LOKxcFtA4+{OR#JX@aMK8j#fY;7%4gl%m_v+izkEb^P;&0TFYhAIiv8gM=Cp`|fB0OQ#>m^~x zqeV;3&kP*!jhgw_q|Q*t@3>m74J;GFk1xF0lp=_}$NvgIc9VhIeaGtPh?7ymTtKwl zv~gIceX)nWo<`@P!$0cZ>*9fFX@o~Dc%xY`>bw3krM%J0#Tq|w-b+HPIP3M>B;pGP z16sC*i4*l-Hglyai+U7+l$fa~>ByKwLMf9ZUxs4?F=ios(qZ#U`C+*M&mOES?8PFO z&a2MdcuswXoi)L2WG4V~dyaV2_5V8?XN6Xn>>-o~l z-RIsz04-AFyhlv=S_X-Fye}Zs0L8_m7P)x9!S#O zr_I%z)QbSYD3M`Wo~xG{{v7l&e+!Pz|xM$^o=1!L~DfRp&jg4a|AU8~2$6jPB6k)$b_C;*q zjb7+`6Un6(rnQUAPs_u^{?Dq!RFD3ybLe0sr{wqFu5`1*C#yGw`!<^+1`;6Z zozL0#=7z3Msl0+Gwa)vtA#eP>wYqlvzsY=27>|U z$SE+LcK;+B^^)ng@^7OVPF<=dID|>^@M!*VUWc;t0M!|HCKa2GCGdtQ%JMu`gEjXM z(BVz;8X!hC&vFUrGdWwOazn*0YtsRXB6x9nlYX&yUSwkVmqxWqJz0(R<0e;c`p)-qY)$M=*TjObE!4uHZ=^2 zeEv07FU$WHZ~gBFD2OtLrEZQZ}_c7h&|}2zUG$A=NaDBmQLw1QA&-O&33L2nsbE( zMXEb5mP8wi!&sv+%=SNL3fu03|NG^{w@!5Jiv26zv5!E4^2* zQXm^s^C7YQ%;nOvtDpRI*5)J_9Qv9`O?{Ab##d#;tWTk;P|Hw4v=O4HQs#qnXm9mM zbIJmYfi8UCNogwIOSab@Po>}%bW*)K;h8+i^0dJREy>IE zB@}e}klaj_pz|lrpJKXAN@4VpKSIaQL!yu>xix0`g{h{Wse(p3_VZN7)aCxD>fsMm z1$8a2&*s@ugA(8~jN`W>0&y#O{VJJ=NXg32*y-mE_pJ5Wp~Y^vK4#A^#K+7tO8h8B zmBhLZ1!2B|e~#R6A)xKi(dSL} z2MUCsP)g6mc!2Ubg^d#Zc~ph$ZndfWHhj$y^xP#j1sPvMsz?+?AHI}SkH_E0cWYed zoX5)WiLCNxBrF(J{)>XGe68@ zgN@=jXxX=ieqOUYNCkZctC75Dn3n?xr1eLYiG4XsMm2k)2T<+RNfO&c^uU;50!XYc za4+vGw>nbxip%iKlIxeeaQSG+#hr=psHNwP-can$Rsj*!lR2oH|Gw;%G zJD3;9xA1K`Zow&&VOH&5oGoqohyO~`$J(Q=z;`j+XPyo*7Vs~9c%3(UI^EkIYRJ|S z7+UJ@E_n6|W5!Cz9hULl9v_za-&S-!oMPX;P>hJHhjWepv)z8z2aL)HEKPE=^+EI- zf&EqzD<1-L(NRm>Py-rxKbhYjj^JSx6kbH-YmR^;qBJjwOaP5p1Ay=I;gix)*LFIj zT*d747-tU*dL0w8IP>LV};y*eA$gI)EDRE*DEP*^>|x-+4|es2jg()~#)1Azv(41GKa`#PxU z{FCBUevuejnM)e~Sgb&y^wcnXpCY@p=`eht<1j{MZ_Ievphc6!ZU8xpbgJcdk%db2M~)$m{L1+$TddM#xSVQ z**nwV#4ttW=w}g@65#OV3x|Kk-IhUC)ZW&2_rD_Ik6}g@dC!$DroIzYT9bxjVr{n6 z^T(c~LZ0l|t6i&ekfwaaBoYjVHF2WnE99#|gr>=D-PY2_%G=4aU5dvt<3QUre>LoR zySJC$*wkv(G2S?0%M(KXxb@-XC&iH}-x>>GAXF8eT(3?{(BLB^t$3+9$B#Fh}wK2J3u3>fDFd&B|HigQ0o-3LV}6ne%KjL#s#DeARE~c3t6#?+&{1i z-ew!C)eRty1yy5Lr_pUyhXpPW_sOZgr~M6Wn!0@~gS`=^`&F?i_&=*zE6(|D^TDZb zUxv?rX`-1j?<6-ccrc50`dr}E#I=u(-*z^Zq`Me)92>ZzurkdVh(6 zp6;Z*f%GoDPvGw*Q>Rt8Nh zuJp&G@Tm1MBF%As6HxG(i;k|YJY}3L7ceUDu*4efpAKBVp%5p?@Ts0(WJZYZ=AG7N zPOma`>(1%d*)JH5-h*#l%=m-70;jAA|i}O5Mn#%`L`drWS6=h^gYYPzh7DyaU7^? zHjyRW@2a>CpGQpISdS3e$e82&Mg$EE!9xgYU#Q8TQUrWl_??DK-gpeP8DqKH?%|s# z7|0G>!0%UYA2h~0t|MFHxD_+^a(wyw@V@zVqf(a_^O<45Y81g$>m^f|r|wb*MJl#o zFv=j;jZEU_Lg&y5#ou3oN~%#Geslt_pg_9SILARbk;FbzH*xlg18OB9rTl61?g3 zY&@>&?(3>vv@kkYc?zltdj?i7EA7ENFF?Qk6_@|8xcdG>7y5Qr@Ieie*VUn`BCoHK zWoAhX(M*~fNxOn?v<11T`|;FB$&($i?a8~H716{^ffoc9WGj>`U?ngyk#Hx0vqo&-Ao(K1F!u@e>55X(gmS)^ zvxuKBJTR!rlD%L9U(r_2yV=Z56p6z}c5v9Z+4a*_MC%7jvdGT3hA-W@Oc)6mLb_L! zo1L+uhO3F}zaHAYf0w#GNh>SvB+|bk+!fj@X8W6;m-E8oFb&KBb{Z{5(8z=jQWc92 zc}}-H2^oakT}%IA)&24@giY7ZANKWvoU1BQ*fHSYEDOoZACOwl^^HN|g|2H!IgV<- z5HG-so*2N6@BIEVp-i=&nFe|*N&48?C?ZfMilD&mes`i`XQOF@c!OtxNuAL3 zZTA>|tjmKx2eZs+ukiEBr6>h2jF3P@gm6`KqmgPD#+?yZ|KPZprbG05BXKC3aIB&y zZgne#DKst+A4+dvRRZtlf{EKMbS)Z>$rH9zq*cQ5@r<4+gTN;51%VLr<@jdP0pH53 zJL8sEQ64e)d!A1Bn}*#=-x6hF@!OY;w>Hm|l|L<{e!auwwErP=3$OM#`M>`KAQH5D zG#zo69n}3^qD91}agQtE2L7TEm{37RxgctcNtlH>%Z@zoo7E zzDNo>tnF)X-T#nd%rA-guC{`2B%!g|HoTw87+3wU(j?r1={d_+9G@0RO4)~qErYGr z_>);HI7c;chWCT%%+d* zr#NWzgt_vWRODuSw0_tKIkW+OFVA1?m1jum48!%o91HC&5C2Y~2SWNvs`1MKqb+5F zxdyGTPK7Mh@}$MiTJU!7!@)-}%YUAle1V-Ikf#_xYyFq(hfT8TKbZ>f$0qit9J2C% zXB8)HTc7rRUdGs1eAu}Hc^Ui}b)D>|5Ea~l_tiUIzf?z!3rH2AaWkHMjG6dL)@t0> z`$q78arG8laYoCwXyXvv-Q6`fH16&a+$FeM<4z#B1cx9&g9djexLa^{m!>a!pL5@M z@BM*qbgimYHP@_~)l4<|8GuR zmOtceUp9zKcsj|kLcF_)HVoRyOFXF7N~W`cQ*WpkAiR6->qAe7Rx}Ws3)uKFC2%a} z_wORd_U@^I;@yuGgp-5RiO>b#yiQ!n&hDQ{d&7rNlUk=twxU-Un(1p`mhAheJ$KX! zqtFZMq2}q?RO!f=8NA1kgBzA+IGmbMd2!o#@A~=N>?52|W)jSD%ci$q1&>uT@Gaaa zWrtE>d;?r91WdsXDuSnd#1(F+nZZalJyF-+8KkJ_di1(K1q!__6*;8MlP+Aw2c{w4 zwmEHFAE-y2kj2c=e9RhAAc?K+5*D-Qj-VY@bo?@sGF1o7*|^-@GDQP#H%YJ>7u8_< zAz4qC<>#S&`J6jb4pV0zxov0a0qnRGbWR(0)VB3S`P=#|Lv2u|^z}sdGjrzC25JO3 zcGKHr*f>W`47EMnPVse*l32~SgzCDmrWN$+>S}pqr2+@k5G4=o&xJ9i3geqlcOdV8 z22%2S7vqYR71g@iik8cRip~pnCg7`N;RFs|Y>0|bb@f?EzR8Qwf?1OA#XP@K@M24H zs#KhH9rAB0zz^6zaqFa@ioo5c3V99u#QqQKdc-uZV1yvIPA{7uzQhJSTnwL1RZP5nbXH-SV?1#9DmIF znFJO!85tNNNk}Ft=|T^jDqa8_a>|hy<{dFxM;gfB>trgy{iH9Ry5e9AYisRXG-B4R zd6n+>$*=PU&!0zeO`&CMlL3pJa-pCxBaWL0iV`vxeBoXv=K#wE=HY`Z{g%S)^bhnO zpkHdWz*CowZt8Uzy}pXyfMP<5xKpfWviI5d>q9<`cv#{}r!zCp_*7_0zOHBp*eyAU z?fUZO#qO;f+VAO`$*kFMZnR+UnJPP*6(-QRc#N2Eq0R(mk?o-Oa{|VS$(&rznJ$+m zMe`KfM8oWD%jC~z_fO@er4jb__7iL!a_sQXZ_@sX0871E7jkpDK!Wgc>&f$(1XgAz zL7{v$!NQOADTHeV%lmb+LM97yX-ga8)j~R9Nc_^nur9qt5?cDjc|)yAZ_v(^Kk{qO zfOKVG9S)L%za!?U)^%H0C}N@P%gaxmluF8EVY4REdvtuZx@TbfS4|G~)pSwu_>`9T zbJ4(~#_l$Y&z+V%qpEEK`MIwXE50})evtdd(>F;A=2u@%7pK7B)FJQwd!_nOnu}Uj zjFG+bcSa2Kwqk|cJLt?{U+$ao`g(N^sI0fD?Z$1|Z*I4lBgbouFAf0f9lxPw^0C=k zeHy#PGx5ZghjG09*`_jFa~0~|2zhITqj2vcP@u7NobwB*Z_ZMR@B>Xz5sah-olRCr zRlQ}rG>-sXO7(JdhejTyb(t2Nxe*zsxgWC(vyr;>8X8IrBBIzcVMZW#svaWjE zf}tm`b2Pn-l*!<4>>3-cLWI#QpeSK1mf=5M*_tfLN^#SoE9mMX8=o^bPmZzfHCYM8 zoJ6{99BKZfQ1)n$6*GZAHHnkcR7ny|Tgi>rcHEqguTFNSz3j+^-5atW3<{`AaO%AG zy7AUZ5q>X|8bk=ijGHY~$n=fHMJXE$$ivYp321>lOxCje$mfVNdspKCxDo|-2R-Xs zHbFvn7{Q<4m=$(`DiVzA)_px4g9#vGRZu{X|194e-Fw|NWV^TF_`Th^SQ?2U3~ z34!jR+Qq8l-gp_-N!*#MZZqH5WZ}^M#@7ocIxBc|*WGTEw4oQawW6j*1&l=3vo|$4 zx}9R-kK$++mk@k6!K4Z6IhRe@<8xoHfBOP-{@Ur%6?YM4+%saH!}OKs)q!L7%zH_owog4UjFx<)JuC5#^lG$z`M%>cK955!^BqW$5$LrA;V>Sa|@5o@oXBk4hc_X zzl+JegC2AtbJ!0>InEuCU*M>VgHdfnZ^a3tyzL z(Q+gScVFukxr$!qc(?5`PVKly`WnUv4BU@-+>))9o~GtN^bhNLI>^6m>()%pPa&zP z!2BDeGn@!^W{{FNV8%Ndo)WFu^ZEqPQ z#N)h{o%+`=euX)5ktl@Wwib!MfjHJK=JV=X+Y`Tn$Ti2tB&6qKHqz4*y=Fw`ZjUCX zPAb}(r8l^Py=`!Mg<&ate`UhBLW%=tQ56L81Vy3&`ndH7g5&w@;J_w@oK{@t zA{*lQwKwWtH34i@ldpI`ux^brcUWJ9a_DbY0BqbIqd!d8`@8aMsSU{2mYvbtS+q@;javqV zjtx<*%bI{|sZ}@frc(%w0SY~v?KpeLc4J&W&eGbDiZ6Zf#vN7RN1V-hw#%(M(6)U6 zhMj@Si=AlxwK;T4MVv0#7=%3W^Fuo@Tz1NGiafUKYb9BzPr~HnzdEh-rqA3G+j1SqEcLeHp>ygo z`e~khs@TuHd(X~|&H%S>^mo?mbyUU-cASk`o*~^PUoa^=(qK>5_FoReI@|^!d{NUo z6u=sUSv|HUaJiRMOULn3k2)eUCru%Zq8!}P4Pc($5h1CbFKyM8*nm7aX_#{{9ob`+{o zV}m#Z^>_KG<(_>@3>1@#>=j4d$owMo+q|#u&Xvw8;(;&Hs?feOFr#A|mlbqMNXO)+B zj~V*$&3U)y343vdJLbr{zqB`$w)IN}1j5TM;GzE5U>0OfCQUpb2Cqo}CgJwyn})D@ zgNyM;=4f3xeHW&y=ecP^SR!=0>(73tmi@*P@1n6YmGvtA#w`A3D2nDOs@-PBABQ3} z@5e}vU?*4j$$tJXAUCj&pbet1X=YG3cD}ao(WAvq+I+N`tKA78)<3I)DbRg7xP`LU zHOP^xRe;}|;1Ql)=_0ySjM&(AA;3nr~=YDOt!SJbYvpa@J3im<;4 zVO0t`b%Z7MpKq)o&Ml(5PR0lOg87{aUsmaM=&D3GIKv3!IWM&g91BIt479L&U+`{~ zVOf{!j&nZWr8Kz{s2ek-(3L4rF|Cfv-?pdCTiw z21Uw;94f!VICxJ+3VM23@I@GOA`2spe^XAoCL3ws1iF8!=by>fdR^H$Sq3Y3LO{cs z6uXl01cjXj2c}dGiMn~IucqVOdyBEXldjS|cENA6E6h5N6`gGLMQLoCztnEZVIaU+ z*7i#1Dlv^R{K+h`!u;fZ(odl{Cg#fkuYQQ12)PX3v{0lo!T#8vFK%sU(LB$RbC!5M z!^o!4|6=*FiO*raR9$zwDhlQLk2Bd{AToCj$S2yobyTxY!6smUR3bNC^8))eJ&sH9 z|1sr610OZchiOT}#hHUf0+=SI}ngd%>?sF<4-8%Vld_B32Rb!+Z<9coImbK(_Mao*xJoJoMw!ZIA1du`3Gvh_$uPKn{$TsUutJbp@sxTZ=;L)n zc%7nDg10X5V?*HUZB8?1_DfGBYGcbDjKf8Py|^bWhStJk2x*j=RA4v0T0=i<4oZXg z>Cwlv4xA}0!)Udvilvq|9%z~z!Lc;>9=SugFt;xwm+A4Z@t-(cHUZP z&Nj-%6NIp`-lAedK2!?)C&9;e4n9vQx$0!^nNJN5poKZLr_;pGkaUN_y$K+jqsH_#VOPo472IB@NcplQzd5Z;)!ho8^@RD)H zC30_1y8&()E2)mpeY=_}uLfl-sooxbD>ZcL`qTv;mU?+O3`r82{&pyD_r9zZBV#H3 zvWQ7NM-=>vg-Mk8_jT1#9`nyxRWx#WvP}5jG!%eCWpe=v*R_A5nrZ+byV|`>Kf1Ja zHn}A^VEc4>V-Lc;NvHIm+viF(@P`zc1THz)0?p-vu|24O9z;Y^tispmIPAgeo9b@Q z%6mV}?}2Vf(Lx#Q`CWXyQk0>f$>5R9Rqh$Q&^rr+AmRXN3W|)Zv}WiqqA|q31lEh&5q(;Untk0<8Ps z%>{ZJt5OfCgy<@BaBM(PlTW>`^qcf|f3SF-%bte<(JH@xKNU*2HZ=C-fs;Dp>=q(< z4Vr7DH-;(s_;g!QRi+05-T|a-Q#jcT^b=zR80GL2RB7q81xD|IZa=$BR>S49Xq4&1 z!(kJ#dXQyeZ7;MQaLUX55lpqQmF)WXP=I@uK2yP^)ZV3cNIwL04ZHL=bhQon1SKEUZR_Hv+|cuiVft1lkuAJzI3#<~v*Ck+Kj zUJPAjm9Y3Qm_5Q=Zi?#jh`2$hOCzo-ItA~W!EZWJ+u->tL#Ly;l=G2BAWnR)#+s1=}R%vU{b$B;Hr84c46? zzcT{K#~S&0Cl>&Y<8>}fO1GrH9XN;fWyzE4z!tZ_zhCn7s9~Q&QUQaf6j)3Ifr^en zcDHPExc24o{VRIqc9Ni!o54=Bg?`Lcu1FYYHs_fZJ7IP8#mWdb9yb~{?Z&<8ZaIP5 z_WcKR8d7;hMX%VzWgJEVU`odSb-V|Moj|%GzLz{VAa;q)%AA3aRYME2X)QpBe!wR` zhwSyf`(;m9&b51a2`WNBpgDUf-3DE5e-0Df~CH=833lP_ppWfN6ZL#j45YA^WVR5-R3` z_E{<^ZMwf-H{xPcHT{4%Sx5)v-+9~458<@XIu)-fe!}JbHnrewJFJ*GK7GvSArR}m zdlM_#0bi4P?s^Lf3{^QC{e;e~>{$P&pJO6;H|>#37JpeHK?!dGmauZt|D)!`uM$%B zV?>ZcHpbKKMsh|9H%(UbTWjo!3zc~}K1<};+1(s%OLgJcYWZ7HQTrD*^AsTUK)%Vn z#HQCx-ye@&2pC#Hh2ZG!H2bW(z@4}Q{~c+{^ucl@CB0WcI!)*oTSLvKEJh4&D2s8U zJ1iO~Zu&nJg@qv}CnvDj7*6q5MzUy8q?@N=30gv*-_Z~&VF7OT4UG#DF>Za^`F_-3 zkO9~B&VverN;rRue>eG|io)gaw~2!gZg02gd;0cKBygPVw*8MEs2k0}T%UbeJZ{NN zO43=Ec}C2mM0;Yc@c1#e_@wD5tgrpI_I~cT$TdrNA67Gy@kcL_LLK|)MROkqcO59WcF}7(5Ith}vk;E(@H?#is{rK?o4Pe; z9Qm!a6w~_f&26dgpao(>RUvt#FpOA22=Yg(cwJ6fK#TajH*J;HR&L$47#^WIx8w=D zvUfRGdhhX?Vwl`Ddisuruzp{8Y7kT0Fa&Ak;@fcRA4~)NCtWu_`lt@EUOjQuW@0*tXR(xDs$?PxP{ zV#3fk@Ca{%$Yui9r|ILap-?*UsWoOxcBG=+g=EmKoe5R?#iUqc+4$hbp$@@yN#9nC zDAAkwRnZ-cLfbSEaD?&#EKobaW4B4rPX*|8+;1=>VFa9uB#pnX>xBk7R=_+5NX+_& zFSU9PPrY@LB&%j}+yI5!wo%Illi9Tm_Qm(pt)Ul)PUFIrqd| zxzvhVZMcdhC=%IAhbwhT-{#eeo@z381R0@2-*EHTXn+e^0LopL7jP8fyc3R|Rj$X5NVoP>Q1UC8Z5cxfIUIi7w-obUrftl&#&!y4k?v zfD-zk;|J@=W5k!alJT;@PUYif21-B4s!+xSIgva8vrhm6vYFxkHPzz=sTQ@&jG`!n zq40M&6+DbM6XWl>Y9MndiJ{Q;Y@=y^jX1Fbw8s)yHF>83x@DWf?k=(3M2xYEU6F*J zfX#)$uLvR+i(7T;o+kYP!mfH_iqIZxB-5d;7OMHBnpadx|1wAmJ8?AChWa}Wws4od zbPQe!E$=)gPB|+sllesU6@hk7-$e~zOS>DUMDQK!7JK^jZHXX4#sjE9k-aJ+k@@I9 z!*o82S}TvZ6e@k#OISco!V{X;YWA_?J*%>^Qk%|{UjbTALnqrA=Ie+g-?VzZo??JK zG50o%?bpwC8c0;OiH(gKBKDWJoMs#tGh(S~c=gmbL*fnIe~TSd$0Cp&@(`~xEmd54 z+IIKRTc&{~eni4#bP_c)QfD-Blah~}bCYb93hUu#YKSCRhwaaugFw%pIgZWUHfV4Y zzYUniWXcxI|Aaiwo|ir?z2P!`-746SVni<0N5BK~&3n&SdDn)Cu(T9C6*e}`*L4Eh zpL0b@Q`x%D0$kdb3FNwA5aNhCno6`poS3MwyY~ovuokwtk|c@bty|Ry<%~mU{u~X4Xg#hub|jA}oH<*v7v1Ebaac?& z;aMBZ$MvgMcq*c3ET*XXXqGHNUo*not{#QSYO*;GO)WDtI*}IO1DSU98*HDUV~%zs zg8FDce8}{z96e11mLim!yX}^p`%-?o%1zjIF6AgdO>=m3ukMnZkxN!v|GE~<8ngi; zwFUeho?z$%vd3mMoQjpXhIw`6Ya2zMvW~rF@i_f_s5ZJ>@tydMV|d)bWMN7o3JMEB zIf^o9aphj>)I(pBsah;xLhLAUj}qx=2Gi<}M;IROaJ2EOA+Dz>cHuS+(v?L+NiKaN zrTm73(2G#^9cD6J3WY?d>`ZBbZ|>}?!Cod~L}WLOx`ea+_a|El+Zpl1S`l}Y$?7XL z!>FZ3FI%{oEiK}bcCr9?MW}_7-(@^6us+D)JU!(gKy^HP<$yZPQ#ykS(^6H?0YMcX zN+-u20ig7_ovJFzq%=12?gwxxn|L#-`VDU`n$7_Xy0H=wxq3WDGK6=aZ#dc-39hPn zMF{YCo=Pm#em&4avm-%J_+YZ9=ysynA%~gsgM zJe725?Z$zHAZ6)eij>MtYne|Zb)@l(tW6AXN|hse-<&*7h@C`{?GNEW2wn71KO71x zoMRD)%@@s`JV4jqRgPRN7tz(s95#^9LRiHkF5~mM@9Y4yH}zRIpno5t6pSpOyUU-Z|2{=Y#!4dm1Y5F?j|+v0xfnb9(d6IkN4 zyk3;Dj*G1~+LK-dkZS;brW&7S@^ma&nQC^jtU`=ss~Dk*sJ^yW82RvxOiLRmu`A+o5^kw)c5+`|!C+H5#LE9#!9U)a2ZY zz#XGX!xo;BTm44j4%mw*1Pl>d$^<66+6O^%yOy5nACO7xD5djz$D}O+4U}Mw?iz{j zCCG@Uhn*54(bfRe@3{w=7|YvJ;3?`zCZj2~@mfPPkV z*5++Y+q>jNkk(k`>+u9p+SU!k_2o zlVb_LY>iKEF}ifGe0`B4;&v5$Y5j&+m+X8fbB7P^ge_HkVQoSTn2+FZ@#MtH|9Kab zJR(o_h%?|auvvSROCZ%PWX^B5s48$iqVCZrs#ED49Pvrh89NkpyvBhk@UAC6KQtTx zZHeQjV)3`ZOnp2eVgV2~%;m)K#P>L6g@A4@&Xp>;F%tVsTx}>A2Jd$bw{}_CY_R?| zIS7?;GQX(s-+DNOMeReHgTc{5SqqI)g7@R@O856*E0CK(mipxDYDCSz-wZ?ehTPI(s|pXBp6VWA7GTr5sg&2hV)2# z?iip8FB!1j2P5Zpnq{5G)CV0+3y48fopmOpm-w+3c`bhorum*Ce8HHq9o=cNEd(DO z^mu|?bn8afebWxHxfJT5Wvm3NsoMny^v~E9fQ~PQe&! z**PEH5&Me-AE+{7Gr$A8skwQU!6`}UIl%E(zVD-i14;u#GDE3!?k_(!Y6>tJ(5OX{ zSZxQG2<<*?@ep&l!edkrfkV}`3>i@+c0pnrNRnxkG0Qg(6OBT?IctW(lPOdERd^ZT zLx%d`WgA$Anr*`_dShlQtg!Vwx@Bv-M8uk}u=c$b4GV~yLYprx9i~825oG`M1X(mC zPt9#VF&aA?EV08$dn2z0;QLhv{}%>59sJ83rVQ8s>Nzy!h&zCXa{FqVOO(tiB}^#!kfF>dLXR z>Z)}pcTd6HC%m@jxyidV2!Fb0(0xRsX|M1)OI9CPZ&$-}-mo&Q_rR_GdTY40D?0hB zyXT_?MPMUtlk?~BP|*(4Mhxx2YePW_ejk6PLLO8ksSphw80eV%WQrn=wzGmuDdXv} zh}+-u6|ya}2vc8+a@*h9dtxs?`S^V$2OscpAZity7ZRXAPsx_{=wCC)fmyT_`x(woAH;d>2**tP45Qi`i4VZlTSU6K$g4qu;zxpmujN5 zdAk+28&vx=Hs1JaO@GYt7i6Wu#=$0~JuMbBljszu?b?C-=bu;77?#tc-<6;$Vyjj- zhn$W@?I6PS8w*7IDjpw|JH?)?PnmC0uIvsf$%TvAxgKY|pFK8LZ07n(WzF;{lu7*s z?Rt*^SS_s*BN^qG29j(oR$b;M}i{pPsl-9b3FZ6V|n z*I1f2%R_@ftM=PBS*EdQ9OY&3(?zBSFQEPSC^PKmGz_!%Ou9-@o55zax+p_=t%LEJ zj138Alz}zt09%sG-RrXFH1c-sk9RKMx1*bAt^s?h)*)6A`ahO4=gc~ech3G{Y*`9| zkDpM*WMYEw_)d9<(8qfn|9Imn-7ghWJr(on?huqaZz=sU8D1=+nqFL_`+5)jM!5X> zQaAxk@PVgFqYj5T7>;1R^;PgvDP-=`P$ad|b4lN38h`7N>K~PfiZlh9y~w`vrW1;PN^nYDC(9L67^^VoXD8Fv zv3Eda%`%j^dip%M!(4yS+}ne><6tnJhM{H{9m?H3Dn|b8UkNC7{E|Ayo~VnG?Tn%0 z)b;vX+rGQs*)R?wtkz*=Q#zb{Wqn@AEF3Mow8yvPf1*X-uhGq9BCUA)f0MWIh+a9Y zv0v{sPZ@pro@hjah`Avy*aceztH3Kc8x802&O)LbKz2Q0j`O}DClo0Jzi0OgWBp*N zdhp?xv+XcO#okUP{}4vX1#{*_*{IRc?8m`n3ksJQ2|}kiVZp1N+F{pfMcto)IFu)^ zl{i{T&l2v2lXSM1bIT>@R+pigr-l8phpJ9esuiJmvkg{X;oVC=MChI=_b?kqUtS$s zQNNoFlP@1qr`rDS1iw@dCv&1K5bc}LW-u|K)}0rJ=RDRPf$|9XJ&~XfnY*_^G5?A8 z9H{!N87S4WV7| zar$CI$nM<1@JRbjrcR4PRP>X$X*@i9Q~@JFv624^Pe?&SyDl~kf0*ASl*0j!0Mz^X zw%C{HbYeXpmsc9${@r%&*01O7rtrA|>y#d&&o;Aff%W_7@WJra`PNWq*@n{>{^T;| zXxYFPsXu_h9?bxJ%Zz^#yd{_o>I5R=XJ-v!BPVXu=L8o7rpnZZ{rJ51sAWfuiJfA^ zg2c8%jd+qquJK1$J$x8se@gP4!1VuUj^bb-{H!X z_dThYcG;7^_f{jS^bmf^43O5tv83{S@98~MbQvaa9-=xA!m$&i`K6$|gQF#Ch7MEz zQ)i#GBGa>>^V8HALVai4SN%xY-(Il7)a1kw@ZXlq#cOK26C-6@GK4Z<+y`hUY%Z_edv8(T|PctQCv-~u;W)SZkr#j#CW zbc0i=sOy?@IsD5l;k!te42_sMHIVq9>E2Hq2&}|QTzWfhQ@IA9qn!2er zWN!ROFR*m9q^8v8Yi{g^P@wU?8KuXwvP|`4>~Au06@3al*MGw#xjA9@f$a1wu64!_;pq}MV0?* zHB=pW**<&{Wn(EP9!`P0T75))68xO$IYD0}UveArxD|Nn@Xf>4B^|a1QRBN&l2ItK zyd1T4@zD$%aIisaKGp$k*IR^DvehCOO7S6reMLV=3)u*qUU664?mfE`acfnKHy9_i z>oNYJJ0Y|YR@7WGQ=9aLRr1H4S==h@%7*PLN?nIp!7C_K5lH(ARYRZMXaH~$Nx_=0 zS%@mX9zazv_-ycAhQNvZgezDNYuK63zKx<* zeSti^p@;_9U(q7MP5pbsS=1q?Bho{lZcn%Zkxrqm0+gur$cP7>(l{ME6~!6I!zg-B zZ}qqeaDs%RZ{-75(W!;*%9ARUf&Z?8g03wRh^K#tVIL~SJ6$OiC!o|&H|^F+wZ8`o zJBsro&qJ5N!czg-JjX>wQ7T`FQTKNS}MS14aRz#9h3|AHD}`0hac zc~oZnEMKUC6J^mrwg6(k$;{*YdU{M!OzgCF#zI0J^YY?%ECO+qyZ_rlB1PYM{H5yk z)=>t5h4kucM1|1^2&ocuDLqe17T)k*lq>G?)0}7g06iRV!Qb=%Yi>uj{{6LnLm#)B zSJ2%KW5_eTNhh0*a;a%=szxlMw#6!@MI5U}mS+Ilci>;mfN4kR@Ek#!q^TBeGuMwO zS45%;wl@k(vHN^g-TucX`q_-wgU~aqFLDw@Geo7!!BfjzC8Vw-FrS_ohSR904mFi> z(+ixx{0U|QzxdhPS1rY>Y|yDW$b*|&<`#SrhLjL7w;c_T)7T&|$uOH~~W4XEfK{We(Ldn-P z0JXJK@aHLk+JB>Jct<%hxaTQ_*q28em>Sg#^SK0OGl+K4kh7OPc!c06UVLQ1CG#hM z_Y>?lvAGUZb~)_4<-drzZ0m0WwK!&3b~$nl86vF|ZLs*Z3e*P0d$9(H9Dgk3gm(Dh zGqD)27bHYadl625fL3|@P6OMIYgNxnq>&HPGQ}u~h!ZYO)Q{;~Hl(+n7H1I4$BCMF z$)exy;k1bF8iD|^Jqvt!1xKl?*AC)0ly5Aw_tX^>b_KY~qUABtwd(e#2Jvekit#(M zwc#Tf55%J%V{dqvdSK@@UZt(qjm09Aow;{QEr=2g+-?Q>7cZj8l}p6plYAlW&9=rv zHdEKc(dte_m(SSURJLiTpZFz1g@KZFZWJ!>BQz8;j6rToePNC7^x^PmlKu-0$UN0h z4(J8a|5OAayN~Ot#7EnUJFdhwf^1*m2;h?#Bk{zc)e8u)IJyyOf)qld`;J0ORn+fW zz4)otlRGSeNpUZOn)f1X(n-(@9Ccrseyvteihe3?+g#VtxEwvUxDtT^`R{r9UB@64 zsE;i)WF1n}(@gWD(}s?8<-w-c|D3Np6!t!KO}Y67a1u}XG%qqWsZM=8?lRiQxuu7$ z)qdXqvZy=cBYC-9*=?Px;|Jf&= z`KNAn-cxh4vaLPO{$H?GRP~98v$t662zlp?1^bJs@xy_ z4ig=&hY=sg`l#%(gYEgOzJ^o6U%Pmpl1foM&IcPREFPU4ITsu}Z*B8P)S%VS*?Kzm zmmWk&WcUDmcqOYRzr3wy6>1}8ilGL6d>xo!?2<{H*Etx z^;J;V_Cd0f`}c}3;J(M@>qEC^^oLu%71bLU7}PoKcSS_dZooRdye_95evH}$%=OZa zyk13CU73@;88Mm6Ou*q0xcr2sBPTFqlIK?=dzM>ION9vyYoW(eoJ$f*6&pQJCaMqq z%l7R7p6IS2VhP+&_HR|udT#cQ>uP;NKR&A%sv{Jck3Dj=-<>E!yq+QKYciqp7+dEF zy-AznUT&T!by6XmZDr|gUi@6wnL=B7=_QBhD*Sz+sZN2c{sgMwZ~ z>aaJ^$f!;3k)u~tBusu?hPnIRrv_4Uk=tr(mNhnh{-x{7{5)Wx$^=ERA>fjyk@BVT zWC0@VK;{G2aOC+k!g8F35U_Xof(mmN*WD*qfmnBvugc)g{kEH{T8)bNGX2UPLL>$< z5r}r*d^LUI3+@~b_f)OX)rJ{TxbSa=rn~eHq7OCwCya%Zs?RSTncu7iTPT;r%Gglm z5%Av-y!j7?_j2XoT#mEBE55q?zcuJf1}#!tRKTa7e#7*_C$Z+a3>s_Y*(Dw3pHZ;LxO2j6+9|LFB~HQefV7! zN$xwQ9Njyzb5J^T-VU;5RJc@~gYgH5Mndv?!`Vpo$!^&BuaP}JB#tlBX;{c^8snt) zW3-@cYQ8|a;BYiGK6d1t$Kjc>V3(S_Z$mBXHQ)jCt8r^>!jIRO_>#Co|nMvGPpu@J96Y>HTT z=+8I`@nb`?6WvqY4rER9yIS4fM7nIDWaiXgz$QBX9>zCTxM*y8@cv2V;uQ>_@j8HS<8@@Y7>_evB zmo{#I{^xeru$p?3xNr4W85#L!Ew^BJN4 zBBK>Z)xmnzLyLO1+3<7jpBNAo4K1A&cK*YJUK_9324?cVD<5rUWPn9aHZKip15UwI zVHolLVcNQngnG7vrmm2wcJ5;d2Eb)|WQ|dO&7S`$ilhAI&t8CvZ@qq_SJ=#1RqgEncOFjuody1FnUeQgBo+x3|ruh3-EmApCj_75O9`?wD7pF7lD_KIWD zUANIQWe?9H)40jgpeusnT$b2CB|c#OZ(2euQJ}8ZY5qT)E(VjAxppknnW3Ali~i_x zURBZCZudt-@R`%$3UKOW<;KMN0f?qO-TSd5{VTq4w0@U>cyvrINOo{{Jhm18nphaG zD|L@KNs4i$GMgWFJ5e>JHQDupdwsY=g+*PGgZ`e6MxjO$+OP>-fk^5efEfXfLwzBs z8Q%Bij&@PJ`37bEskGB@#87zeI2}`)_dCkV)WfrU+sk@ZtBzmz%gam2%ZB-#uWV0u z-ik!6a648rATxZs)!yo4lw-1Q1G~8yfHwbSY|0QC+w(i)SI@C_QUc?qx>+JbyaXVszH3YvO{gCU7iGY+Phn!>0eDMthaC=8^<|EEZ&5lRP?*vU~S z6yV5M$)v-IXUte31k&Gxrqix-igBf|*?!=uGQ}_L|G0`)zc)P9Lj^y%QdrOG<_m>c zOCs0VsM@ToK{w4oMVap2%d&LdcrI1d*7ko3*4e}?I4x!+9QXBM1jcul&Hus&Kl_i@ zZ`HLGp1=WO%P-4pl{g6?+gBt_hudeecQQ3x7$ni)+X(K}hM2N0q)#HI@_ z>b<9O0T3Xsumm1?Bj+cM?S#36TT+UCNcsZol;#lxBY2IxvNmLaTuE4*w}YQj+aDs*&0J_yxzCrfS9ua zykHx1{{;w|#_a2Ue|%XTDGDn~%q?Zs&=&k<08kUk7oZ@j78q>3M`wb3uU-}TH#-E9 z)~g+$zDpc_oaU~*QGxa^ude=>ygFQg_&2UVpy%0hLy)GP5Ef0xcjAsr)t(h1u^dVr z{h(4ZCS|mD;r=)K5!#1QbUf)Z$^8#@vex_`>_i72NdIU3e^MVWdeB=O%}rxC0#HPP zP`y+WD@+s^2h|AB{qZ>rPZHzf0zE0Exdl6aIy+LH%fF0d1%#ykgv(46f<|Mvg$iH* z;>S7nI#p?J>8b=VW42Vn1;=A`2irRkYj?R)qK~*ur97Di+BT|Ge@;9*k+Vn-y3KEK zSf!`8d~O2i)m_X<*>pWmu0Bu0`&muC2bQNrPGlNG(>W`Ab?buvj~Ons`@s|)ieL$< zkKYygUtSRDe~H#3(E;u_^(XKDa#m^uMm6Kf;e(Q*5nM$I^i)Der-(OxFY^^hZ3ord zrihtzz(zno$l3;amy%J=Vov}IAEt$2Frw2Z4{SLPUw6;GZ&1)ZNWX#tW+`i0v7^Kn1)d-rQ-O1asA=uc#j&qHDpNVNeHY-rW9cdL{&@vX;q&jPxpgam`6qZF7^_5{5W z(uR0oqn;!)p@Dh##0c+y%~Q7f|3?tx(M-8=NMT(Oyk4+hyk2;+H_{6>H#dKyBgC4u zKSt*Edt3%W2JU-Iq0>{+BBiA?ADC^SShB8mzFSp!0mTAKg1iig(*G?WYcm5Ts+9A@ z>IcM!Iw1si2*!~Qk%^uI8e)HfRsJn@UlbIocSiQgrGBIV6|kb&hRxQWgu8ldFO0n{0_>= z-37pzX<;^fXaq<=Fx3?k>T36WupU+Z+n-zSh@zLG^&s@*a?elCgne$31|2MTwWsvu z%{|iW5;*lFTdxCb#S>;W0VfjEAE}~W{M^`Rlk`(EDXyqMT0$TAvH-DTz&KF;RoIdn z)&xOmNM)8qbF_h6q{&LLKQksXWc3)6sb9%HR=ly+CnB{byM0uubheKIJoufOV1^nA z<$>UzuXb%aP};$myrj(JsIV;m@@LtVVN+WauM=_6W-pElS7s)q3Kug4&gVx=`CC)= zAg>4i@&3V!8|W(20GIT) z43%oBwB95BqV-iho>8PHShfmn7=}ENM?jtaYDh1yW5@xgl(({LJZ4Ag^_3$TUM;+S z?^w%_Ta{E+^*aJe6`0)=ojx8y71QC&h*ksWhuQU5+xo!{-VW)mub=U}I=mYm9zOn| znOH)2zztbh1-X!h`~m2X(h6SJLr2lEe8`F{gX!Z zcuMvYbTJcI;42WR_XD{4Bxu8Q9B9AklkbAiCXej=+y`7yI4~JL$d=6yK;0T&J{yBW zN>W;ZBkRG0(Dk@54gH1_=mi1+n;IK2)@%rQ1_l|4C3ed#5=Gmyc&mIv@W|Y-J|who zwgU0hw`{O%=?#_UNihQH`IWpIsfL|H_Ih*+wS>K-d zUlv@{{LhzmJYLsk3N}Q}VE?0Q6@$)R-&1n(XX32FG3vmq~pY#Sv$~qPZ6+-Tgmo{e@eVQMbkmKZ`|o zcQ=T1cL@k0AV{Zlch>@>OQahlrMn~+jnW~~-HmjvgZtg@ckSyt=P!7!`OFyi824|E z`Ga~VSf@ip(D<;(9qcx2UyF{%%SV;ifq%%8QrWD6a_|nWO%;I6w{3T z3e&~C&oZRnYY~o7Lg2;ububPhvYS56uFpyLG9(dJE z(O2dxvIY*+e&PU|wrroym=aEY3yJ%Do~!*{^0l#)x!0@j;0fJZ{~ZY}?(@W-TVxpW zXJNTPK`N2|(!}B(A850PVy$6Q+Of8w+8|??81JAY}a(e~V3w+ku!P*RW z3&m3RgNYS8wMZ`_U$f&gTGT#;qX+?>pCP+(q_hTkP18fdQEehN^Dji1Sk}yimA8^- z%r#Sy@O=X8Tr@1)mBUR_o*{ooCKjYdgW^V5`YkP9mPjUG$yZ&QxT)=MBvh7h0Kk>D#Ncauam z0`gS6q3?xT)E04|yKqThHDYjQTWh?g__JUPXi~mD!TZTC?Sx~jZ8;*kV&|;yl>`Rx zTGHTy(D^zQMMvM75dsJQYl}~=1ZuQI+k>jFU)^$k7e9L1O>e-e4;&$WzIS4BzZUj< zS2y?^w~n1JbCWF>PT@-X*Q!p7crjLVv#H1B{gZ2j8s0-}8SB;mgplAQIxqs9g!|fB zWiRtd(#x>5;ThnPVdd2rQ+K(6k--ez z5`C@o9jEQ^G~RTZ@CJOg_4OmHQXAI^PF+*Ex~X;w8#p*j-nnktd)tJJw};Xpe+5}M z@B51WCO&EpNu5$M+^`pEA?Q>+2Y}+8a}iTMVG_XvrIeLpJ@(_(TULxhV|zj^sbu(G z*DFx`M#0Y-@3b@lf?tOjVi2~!;r~;9B(jWQ*Z3a?7u7L;rPDTs{i#`N zuRnIIiLZ^^<~@Itxjk94$co4C>5hniSM+PxEwlEsb4l(j1vY;i=Jhp_$bOal4-;no zY;o6l(F~E2xVZ?yAr$*gHo|9Z(Z8itaope9rU;RfjiYRZ^l@YzVpWKLXZycHJC^85 zF`O5zpuptyzjyQ7o!aZKL9h86TG`;e9L)=ZY}O4xMv=bi@_?1be$<=BMKn6 zf;6^Gli8}ks{4{YJK+91Hr=`V1>ot!?5mSd0qx;aeEjPs9UcS@mUu*m_j?EZPZ6_Z z1C_KYK_B}Kr$jW|tg@On7vglIKvl`E(J<)=L39t2ZEeDFmXK>{M~8v03)7f~fZsuJ zi?_O{x4?TnJ+jdZzUZCNO!L5Bs#7Zpo)+-cJ5{z}Hw7 zhHL0Q#5%Ae@#rdmA(HhtNRjy1EwS0~I9J5$-u+>Q!~kKY>Dgp)b&p@8l-8U5hn%@k zgBA?wzq)>H?Rfu(;WzdVn0zQbpwsX;8AG^HXK4!DwA#YbJHij*qcwO zl;w5W$W~%uGgh$-)*NnEE$!&4S|tq{i`fF{C?wJJG}BOQTw)r2C;U`L6J?4{iS3EeU7{2 z#zaWz&?9(y63hK9M%Gl<=9M<0}5EL z*<(im9Ymgr0C#e$g9hz^1|y*Hz6RN15()!Oo3lNz=WjEo}R* zB}-F(ZzzQQMQsSP_GkMp<~gdD+%6KA2l&)DI>_>KL9^hV3Y`>sw8|To z(!;L_lX$els$3lqK{*Qg7|Z>Yo(%R{PdFJ<@Cdo!oC=MlrA(VbKScC!fpL}zzzH_{f&&*|VUm?J^#!u}OOl6Tc9xiCP=w>&{Fd|W-u+9Ll03n^7 zL;Cs%We>ySX~o|4%0p{wc^a#gC3H({eb*-1x4Sa0*8BBZ6M#jB#&3bUeUEU+>{oBM zCy+W_AK|(bQj|(;;2~DB9Z9k^9(9%e zc-D^^`GKwGU0h=OgK$M#^yq8-%`3VxF)6ZDfw?@#bpR70yj>%w8xtK&d|M24p$_x8 zA5T8CDs)9FLu7Q+MpVtf?7?A=rOq_)VGS1_+@5}QXp>B$v}+#6lA2<4UM~GotjNhJ zV&#*!3I81Zb-NN~Qft(7Zo+jYJg3CnSzfwe=+VqJlWE?c zaz<_A?vMR`y7^R#1fwXY7^lg5#7|$_^e=S(83r?!|H345(1!WM_%4;3w~qa8jx+hW zZP#{uqcxAk$d}IdA}WhW;Z}ncI97vGlD4*F%RV=*Zr$d&g`*v=EOnO!f-s6OvzDG8 z)P+i1!{VOivW-kun|cHx;=4-tvyAcFhP1*GO7Moy*0#~pt#dd~PEpZ{Co|p_kGFrh zq2+o$R=>+E^V`kWu_^paQs+x>%=fW`cWKtx{;mV`|$j9+@MOywp z)BvaNi*?zm_eW$E|47B~{GEXPf_0Z=(kqN{V%ZWkwoqs7EJ{~xkx|{2uKigFh1ihl z+d6|!>`Do3WJc?RnAGu$d!vsfdP3O5HHe45HL8qHHSuKOaA1;1GHUcjaV>W{PK=Bb$ z{TRFvPlXo6VQ$eYJTWYmh>E}JKnf^>EQ#*4R6Pva*K(sf@Se% z>PZzQ^SA9#$`Z}I7E4DCh+rl0IV6!cEk{OI!Ke241X12o}uN7n022We$bfX64t?kv|IZtqkS2P5e2!mE3cJe zEez;!MqoR7t=FV@gM(JsnE%6|_0@$fv&&jZq^{FhbgBWIPY%4Af?`6t}iS zHv1bEdWz1UIu@63d`QyT-9lb!6ckH2eD`dgO4PY1XM6S4)5e2@F;DI25VT;@8+juM z0-g2!AMk=^AN#iKV`|gd_9CwF>7ygVQC>>Le~eUE6)(L)GD1H-_h!<3O|E|4ReCEd zEVnmF^g!M^S|4~)vqT*l-|VC*_FCkxcKLa($44$+z|rigtMt(!hD}JH9y-n~85INm z8q)0M@V{)()>~38vN!P#l$U6-G=+(qB(Msf)Ooc6CZdMSP+<0YG?OR#e!zCWXSli` zH76jLR!Lle_ey#1tTl1BXAzRa#NXZN6&lhR7kh%f%Xbx4Ox&>_apVAWCO49sLYW63 z7>oCNt-HB@8uWDiORecb;}O}L(ARQ)`?a=UGJZwv$4hX%$idrlqPhwm(?1@Qp}~F4 zWe3}|9+>cHc#~#9T1?JhhG_FEnd5qj5a~(+>#_Yw1#xO0pW=rdSL4V$lDiBCPH-%E z?jiT!jR#Xs5x4m{#-PkL$7|*PabS38|Gyj+K|i4+ zPXq&BePCm|E0d_b%`1%Jl8I!hJ8pZwbFL`10FD^nm8dw%;|!!vhyf9~J2=o>;>$M~ z0ZcT1b6wyUl+_ zO75z;>H_ELydE0-;!?7PsHW|h<-?{Sy|5CP4SXR9IbTh5%<5j%$X&kO5nK9iC{s%9 z|6kNDrw|8CZLWV(7RHz(hrak;908Q)IKc{>jTjwW2_$|`@igRdu&F4q_iwz+S9wBm zw-3mny{LWjZymrZTp}@~GVDp^@-UVKn=-y67)lWC0-_I>3PUlNuc1?|Z!;wLe39|V zz<-;dA7VlChGyFrnbr+xNUhr`pi&bx4-(08qM_CynS5-1_Mko$JjukE+AYZR7?n5_ z|HJv8mo=Ug<5Okg9r_zqalh-DHjEE=aj&fM$YFIs(p$kpW6x^irQgLBqf<6A_eGFa zDj^-P`9#>4n+}4g8Oy+^qyN@5#gbJfoMP#*@2tTuejD*r#4K~qU~gV^JU1dJul|n> zFk~MkGG&8Acu#hac)78|`_xtKM|RU~7lQ4CG8y9jvcoZajydj5f` zqse|QC3ETRPgKA!X)~C_o!6bTDXfO~3({py#q(|l?9N(4+{r~`>%f)7{E`c#^#KzZ zb0r6r;U5K@hd&CD+$f=scqx8@177O2DIel6<1M-7{_xJ|G^SOkWwJq~CH)OBmg`-Z zp7tIjBo`?QhqBO2@M_2%@)6l8#3D=8XOZD z_?MMirMGOo{!);t>o9>i>MM`COzbY) z!+JkM|NrJKek2f<|7Dy7f|%3QgT_>-5dAAh+6wXZx)(^%C^(R64?Z?2?<**`@Tesg z>OJ40F9-gqb~b$FK%e!1S^9_>gl+uW34hd1aPM%gpc*24`suv^es;Y!`s<3yeCmk5 zxX_^?h5ccm2F=&%_hAyrfo!PUlf`cd4sc&)0wEToq07z}cLny!*k2%lKz7USJqTgL z&1Y0#(rU{q>hRC%q*77MlcO2WuTe40eQ52V{Pbl!Q~p~gV1B-fkOJceqfWSD-Z;mc z0A>N6zP5%#07acPi6hm4>v_!m|CX;$H}3dD760Ru6tl-_OIi}iBxJ}6yeh$OtUL}B#% z533;sKR=LohJQ{mP52+!f9B-4&d1<#`{R`Tp1Ajr4>`W$xQp?HA$YI(Ta!dZ8q{Yxz?Z z4(H!=H<0I5Ii76wW}!kCK}rZ3Mh8b)`~5^lBkB1x;WbxNaflu^T9kO3ql}GLif*$r zduo&g_fg^|aFDjm+B^K%Q`?uE{6}x5YRT}@^B^B8?Ng#)VWH#DjFan;=__-NGR2b_ zAD_1TJYwe36m4LwGLR&N-@lBcGr$n9woWf2GUvGbpC`)_tV%z`KWw3*a*j}!u5e-i ze^Jl;!km6benB`ss)&y6wp*PjAY$g!xBW zWakn9FUo57R2{CxU(@<)@E0@qptzDT7Ls|&*mBWT`ANrv-Me=Xw}m#D{+y`asSghX zubiufy{a7lU?S+oI2Wa;H^`kG^wM+kt{dmocgS7g1@^#0+$v5a87MQY_45X^*Frc7 z{N9V{Bz()X#ZP;<8Ehk`J3Cq()=a2yx1w@C>bu772<2%4H2Ko*a1|{6S6C7JzlD{Y zl?Xwu}RQ_QWf{S?@?Zm>-z)|2bVL`qC)-xNt*Z$^}PWNBlj=u}+ z2P={4pw@u;9U@OKv-^eD%xuB0AEW3{Zb|iyx>E`x?XauR$RHHk*2z*A^8S&-{WRtH zV|XU|dUFe_xNuRf)0cGK^3S{@(z;@ID$EO{iFSOXJWUrb8ho_hfSSPC=tbCm{y{;d zg%%W$0{UP^{^i9>bKU&E-jk2pKj0{+l;EF7+P|A7;uGPuQ$5TqL z0K9@Z)b$}q1!1Yd7GkJVkBLhYFVFl2zaDlUs?$u=gNbe|0-(P+Mq-a!la7i6vCn^& z%$P~UIJ8-~hgl#4bFgs-ChU=|67bn|7tt)%ghlu?%q#ON(N&j%T(|HrW*%6Bu}5C3gbOhL=HMZ=4>x-Wo|y*5mDt? z2v6D}gZ65b5Lr@`XoM{DNA?hUC{fZUl&cZy0$RTSfhgj7Fs8uXRxWp9E@Odpa^iFfBV6=}BjA8mQQiO1IP*xW&sG{9SbSbY1B~C5h09Or5by0mwPSHJ_iMY4g<&PJ8eJVW&NQH`o!Ly4^k|F6P zqK^%}aunU|`~RsbqW`0+UPzH}UxAWZxv&PhIAmsey|U=kz5>BNk7q@4AnH&u9;J^R zDH%jvo96)M*SN{oUC*!V{A!QFr6jFINcF&2gQc6!^W(|ea6!2Yk}s3a;zMsHEzm(Y zbia=q39%?-l!o0nF+H7bjwy9)(~o)puZ$pS_sG1{70DJd6K$<~7bO6QQ4QIjjA%qP;8aSt&*WHX{^NhIt-Vh652Ck<@Ek0W+$zCnfhYR(k7BA( zsg=s@Mez1m3_{*50g;2zwHZ3{DziiC=QjPqg&~UsXn-%6UbR@>a4+xS)burwiuI>= z%!jNAF4F<7@m0mEVFR3_Qo_eyF^1eU>TSh;;B+Jm_#k5@hg(a`S!%cd4P}jv(}rGwC@aXWT8hr$APv@=<^@e#C}zPn2~VL1(&^H zx^0gKFnQ0Fdx@D`>+qK+kKf?3rl2`{!$%c7{d(I+-nR^)H-WKK?ke1k$=N#eTHm7&=_dc+d zAVdPs7H-yoq)^5GNwchLy~J&D|6!u;+ECb~OD>BTnJh$*m6cw>{;6i>^kAk(RW1<9 z5`+o;&4gZ?2S3-XG9oO3a-#nFHkX{fWmKeVFbz|OSF|j|3h@B=~e>AD}S;2@6m7_vH@sF`ebi+G(2ji~JKvxK&qk>?Q_n!X*?IY7&Hn zeNd3J7W&zckMtN$$%=jls zEhXDVB*U%Ec-5hd0Zv0~Z3pGEX^g#JbpD5d!vpJwxa`Z*X3;7H{!N8n-&+cNerC~+ z=IF}U+oKHeG9dHOKIo&lL#ypM+cz38LZUEBTGX-G;}|GdhxY&Kgg3Too^>~#(^N&U zXB(nKWHs)Oa+O71fcSji+K94w;)8OcKSm;O(iTn79Pd$1JA5G!T$)$5jZwQu5*&p# z@80L3guF$^&%#l7WV<4}00q*$^xHafd(j-seXj@2X&lcde!0!>tM4NPE=l(a`X@S? znaV(aIv_#qHcy36^&k?@S0=*eyllVaIGs&?XD_Ma_% z_F*}D#U~(>q}2Y#2S=M8?pQ3SE`-TwDM}F#fhf=s$t_|k{7e_DM{J_$N1rvHmQVLZ z;;{stRO-|od&*k?N!QcRXR|TsJux?P$#B62{>25iP6i>h`IErl_?<>1aoDP38oxbj zRUY}|Sndgl0uF4}akDo@x|9&}jK$F-(Y^VRfUJ*uujp1zl;_OF8LmeTH&_)79_XyP zjCd-Zcs_P_o7?cw6)Z`y>0=PyTRXa!k1bAiV`muzTs0Mao4+)B5%1MWx=wtf&u*#+ zeQtA!Z-Qks0-|WH1aKHhWf!5108FVXP_;c(e%JO%p;c+D4P19-oL#X1#cyeOstE;*i9ILFD(N(*6V;Ag znN$YPlF;`4-UP&5p`zIj5p|g=OE$w7(ZF8N z9B~SHL7Drrf|K*+)kz`QYWq0_9wN%5ZDJ*UbwV6>v12Tx$!jhXtFEW|VT-Pd#qqzI zEm2ba2mpi3FmsgIAlDwxS*h4cq>)4Uo7v{VK!o~s_slS>MTZA_VNtsE?uGcM{Bi1O z)9z0vORX=&G73$hXZ0LfC5P$WbvPKDR$HknQ2O+IakFT(->%!{cBj#HJ7ev(_6@O8 z=xnlh-nqMXPu1p{?X9-x7PG0?_0nd>-oG1EGZ}^(^>ccvH4L|AgF{A=3Q{TdlozKVGioYv6FglGIjaj>oFPYd6HM67B0={0(rRBskgW* z^X^ft&PIX~fwP^$(i%1v|D1wFfFIjbra5LSUXkz%eXYkAGyV`GH4wyIivWRv$D=|m z`ZHV1#$^{ix{V??ur-_X5H$0u=6N?gl>$EhnP|TT8%p3b5JU2EK_1S=-kyP9kgZsn z(7AV)ED`Ldd^MaIwtuhJJi+y1yR*YyZ12$%v#?J8d0h;Hz zu$#>*(F>z^GG&h2U*(sy z9s;4NYe4yfcjXI+q~?2Hbj?xt#l4p1tjR!(cd9=8>*xjNP614rH{ru{SnnaH_Z6?R zdlkMIxR2n_!}VUOQ%eINUYu&>o#pgRINw6#G&9SVCCMgXqP48KZF5oX^5PQ>(S`}o z=lSALh-s0Z0C!BROyQAE2KXvtE}OoJ74Ta&!&GQ=$+#J}BK3?4ShW6xKz}?!&*Y@q zsPX&pYMfG7pg)ZAez?HkGU38<()~0`$$n1xD@)z>+uW3Hduwh&EU?ccQDkWag2Jra zUi4{(JP}MUw+Vk>c5WaW|Bd?h14&9JO80Q;k%89VHIC0}=WGI=@8lnstbT5AQB=|O{~c=Ibqe5SC%ZqFRchdKkh%BR zN)g~#Z_oC9IAQm(-_#U7i$J)Yop@%hSzPC#@yWw+L5LgxC#4_~92EWvt}Q_oJXRQ; z5lWE#q_~c39PztCa~%w^;{d)I>$8vBRn-1)P4Xz{o&!}+x|*YueVr@%89*y`Zr1gQ zAiH-tZ|T7X=dvsMzixT9@8rb38Ld>_u^)_c6TcRF`{ znT^#ry+7CkN0I@cqv^J6<`(_b79}sxg;R|9HPEq}GxyF>EP!?SN+DdcibcJ6YcYr+ z_U%?TQD6I|4+n<3WB&a<(w0TAAYoHgcB!MghbnWqC}UWG?;~kSc+Mhq^UC;D6q9iK z3=5;^BYud5j?VO(>nYf6suG_x9J2%TMJ>Fz`;@>3VqELZa(uO2>2Y`G`=U4r~=ReQtqP9da z_~hr>lBK_9>p;fg9RRfc40|PwoD*v(c_&!N|9mW)1g-dSGo9O(#jCZnx>|+jo9X&m zD~62Q^{!EIwP7oj|8toP3wIrb5}YVFk}%5TWy!?DRb`gPl)fWIznWsP+AXb^|L2$jVRnE!bEI_(C;m$r2n15Gx>Qxy&l=$Xv!4GP{Ehuow zXGH%eU7HBD|5F^zpjeF*DbQxg-iIt?JPw-texCR-v%UCd%E(Kj?F=avw5m;Qm&c9Q z)l>2T6Uty^#{z|3IJ@lr0JD=AbmcH3bjs_<4F5L`xZg6~p8Fy$l3?lT%IA7Nh+6R> zS|ev*aJkm^=CTDW>0HN`Hdv!IvB|r52)3hhng_P~3X!}IUdIL;0F|IaF9Fn-w5DyNB>?X_K`Fw2I zMXil+_76@As@r+r;2_^-j88vDs+sy_6=MN);~i|`aS4=4eWJstG+UC5u99wZ`j*~g z%a3;#jbk0fj8Xzm_l9j}3#-#9x1spyzLZhHJLUFLcw*6mQ^~~2j06PyXjENpgxOxI zc*hILVq}txh95VLza}Wkt5TxFw_{d-d`{>qx=lIf%%YX$u}5|+o!43?kjE&z_Jk}$ zEGYN9Cw8fB>_0&{-V33>P6oH*8EaWmVcfX)?qDql%mQae5W?L+Or-yeu)y6EL;GM9-fhV)Ol<`=ld6;X%3#?&-GFQLr@dIG zd?GOL6VC6!!8~Cq=n+2MB7HbAdJn&Sx28SYxD^FassJJ#bc;MQC=cF(4pp=5d%a`U ztX-poMhsG*xL3?!C^j*ZkY2g?gB`5aS4F_y;uz}yg2?v0mL`ux@%AWKRya{ex8>D& zMH;{<-ek^YJ`k$~)Z-hRpg@d0;Hkd-u#EBW*v2H{c8+5(%TWu(b}{C5kwk{`YwGI> zMcXfOUwp9w#ouL$;NlCNUCc#=i5H}i*2Q4HjWTMLTCG?fHkR+DdH6jkATfcd2Vsv% z@3$EGz@jXOI{;dk=7CE`Ym&mm~`#s>6(Pp)EX z{-^pJ(xvD~w?90u%NtCY&}W_BhXX|p;#s%GZH~-6ir()XHS^w=k-yAFL?4+2>$z<9 zuV@N%HnAcuQx+{8)NF;(Vu+uSsO=qc(v2z{50vxQpdaZok+@ z5+WAJ1xPp(LYnYZqmGqR6xXSA>$Tsd-z}?94kmx6RpkOv>+*CYZ+uC*i0LUEl?R4% z04r+-;b+OlTj4m^F~u*EoxR|>pRtUezkq$gNG+SYs}n>bYMdJ4RNgA$RFMQlCT#})`3a0)^0Upb6 zx)V`)JcF`Gy0qOWaTX+L>-l{q@K&C*S>Lhns^3II5-j?5u0;$7E)P?FeA^EXY9cp2 z{%yNxVpVkv4^8X0XVZ;$93Pf&N=5PeVP){)EqjJRYc@cD2jtxwW#vAi$;I~mO73SB zi+E`P{w+`D^TqbnH5?#7`W(ty|7`>v%tV9K@?L;c$32r%vJd&31gN3LPp;+@2~y2vmX4HBaS5--h5!=8BY`XKT1dB>?C zC?zt8$yjJDTWEcucSIKbUL5oBrD4E;hA{dr`0=*4lDC}UTef2!RxY|jXN$RyE$&}} zqbS)x#vRUYzS2^k6LWJwmmZs>);ymx-tow@K8(RZPsRck>cAPcwRnJ$?)O~B?4gtd zzARxsKaxqYH>lOzAx`|Wyy|mpUX%73Y^SZ4 zBIT8*fGU~;);s08-+N!oR#M+}Piy>gS@wZVH@H(tVjaTC8H~5V`&yEOPu-Iw+Q*tDTDhEI|msb0|L$w}b}1{(#xHfpXtZ@rsv*I#kA%_JW;0 z!-I4o3rpSp5C1{bmtA2?%k?AX<;PkUw=o>Pg%f~GM?794Ar2q$i6`#mY1viYOP0Ow z2xrE5%ho-*pm^g?$xSS%;Y{<#aL5Qid^-k^-6dr?&dDo2=g!#V|2HmEa149~roMYa z_bX{WHX(`}Q3D4A`QRUJfdRa+53fm@I))LgJYJ$k)K9LD*ik;tyYRfk3P)bdGs&;e z--{9tPZIkBWR5*-2+bH{fg+PL>eTQ_oWE0S3gCW{HR~Wl+nD@M%Y53dmxT41Dm0cc zL4=A6#msKA>BE!f^&o1|mKM9m(8H16G)Gn&N}tRmYu-jXQgy!tW52gJy8FmtLbpyiBF zb3p+()b^ge#I;vQ+Q~wWTz+3zR`ghS zc|T`Bz~dwR6SfBk%~IAH-X&^TaxX0z0Lu-2a64g9vYreIH*KoM%e%eo${#)3e6CRN zJov>ZCLP(1Uh_z^&xUj>U4F6xI@s&`+Zf=Y!&8}u!yKZczgmD2YQca0^Fy~x0_maA z1_{HY8To!~hzEKqYZ6L@le#l>=(=jjCnl287LZ03I+NQ0&_LRGg=7x*cu!k9x#pn| zgwrs-MQ&se|FN6|rV#gg)Sp})hNYabu1j~d{A-uJo0sJ(%K1uh<&*qc%9r_4EK9t? z&9CMDwBLMiyLTVzWZ#?{sPL$kmM?5Z86q%u_VCC%& zPk^dMw?SiW#w#OM90KXNf<*=TEW+PCpi4QN9G-4QCG@Wg z_uySeJXet|?p^1IQbrk3${3`1BJLsU1231tu-1}3ztamqqsF=?DLv|_l38dZ+3Nh^ zOHKuzM#OUpLE!@!i$7O8YGa-y&vWSyTvbxRA_HzR7i%Zx6j0GXu}DPL(H^m5V6@?g zu^S|(T(6x3=ljtT(!I^y*{OzN=9p6xD&rdH%@?Zp(yZsXJ!S*%$w{~F#nsTaXhaG~ z6BVUDsNg;@iB6KF2fL>^x#ZoF$c#AZU*q#;(r7RtX_vpa4RqI!Ou=-9;YM_i^hFw6 zYZp9NY=&lme6<~Y$effE8ByJxe1WLct}1wd*K!V;9(hGjUS?lSS?17hWYfM#ka zBE;hz+S5uYIkq%ZtMftcewgZ6GKvvi|IG~Kr(A%LB(I?V;8u{Sxg19!{2LKrd2x=s zR;m1Wqc4RL{`sNL#|H&_kfLa6crz<>+jA+B{&Vq14V#!1-|hsrc!>#jyG#5gtjw2K3V?7K2S_sn%q!lN|b{WYZY}r8g%AQ^C#sJaZ-=vrx zg*9}I=IqU1*@h~dqx7%41X%Zid7ruG+6H?dB=9Pu&mN~0AYug>h639(AyLZ5UPRNu zhKbYI5s)FKg5|f%{px3UZf`^$y(4t(OPmiz4>-kq)h4uh8+GZa9*4J}qsX(EG^~~M z`K;m_^5%4Qp?%f1-EGsou?NFKOCxQW5E+>y=w$oS&T$+5;FcX+OFU($@#F$=NqpC- zy<7iw+FgOG)`b?77S|Eh!%}=;VfA5Po*4ZH++Sm(Fh8eA%vr0jC!NTV3n9`!wXu34 zs!Xn{8Lu#AV5|p`#ZBB;w&V@~PcV&Y;w#-^)iqO|lBf)Fw!$Q{5Zq`Qif(v(@zD|> zeEB{ZZ%<3&mbc?H+p(y% zzQpuQ%i78de<3KTnB_dG=Nre^_|5KmikeAkJvP^GQjR<4R{t98Uv0t!UJfKu01eo zu)h?;`yad#P&eJg7rq-2undJ@2uLBnyesw1_0t!=PjN+{CO!pK0f(DLCkR?M0kHh8&8G#LgPbHz1`TDPGXc0_XdySfv}om%8|~pt?{|9dm2y&eqS-{cr16i>LxK zU8I2&@GOklwCG<+#E)5E(Y|;|c?tMToERAJT%-LVO5iLS!|UOUO;u8fAbTDeoCKk90s&#I#T_`t zGlJKuI|vMKcjmr*pNTX8Jg{wmqZZuzQ1Y5x$#+cfOP9H_OgZNkBQdKg2Vr_=N8@8* z#|1ln_GG25=oD>Z`!O73-@YGuGlB2Z6AXx3noT;MrO=n&A_J;&pT0|~{~qvK<>#%q zCm~Bsth{%wjbDJ-Xx;`<=UKyfE#_)5w;GezZVef_l5F^H0gGSJd*|GooFW!3ufjb+ zTS!e)T~vcH8{#nwq)$4%lsqhvhSHTOg;_E(JXznZgmJsEMeoQVSu+|*84(+wKc5+h zT`K|kh|oyu{J& zm#0|C5NkHlwz()0ha=7&r`hRv&cIGkhoy3FVl^4l4z9Zf3qp6TFEQdic&;*!TAJ!2 z`ne)36djdtRKuM)k*Kip2DTZ6G0w9sJjs+0aGE)3H<8RinK@X};nF=fEK%!xDVznt z)S~c1aeDbiLqQwlL#QBBgNyysiLR3;5f+|!T63DF-hL ze-K`qcV7$bg$_*eBnBf8a!U3nTUr^{tA6(w;Z1?32k zA1_hhsQ1;!q`5&Z5c2`43iPWwhRmD)_5#2-)8W6r@9X`pBEvf5a5{8EWz5ApCBEIH z&3{EGyGBlxL+uZWOn1odw?5=-fwwXnu>rqajZy_0?-<-tx=7}LGqAtJO@>!JaQNIO z5EPCGXW(}#rdbYVU_+WJ!lkEY{FJMG-ocitB3o#^#+3m24olh?u1WHVG7xMV#33jm z9a;Y~lZHBe{Wlk9R`-^Wb}0lfW(7E$toT8UfrDBW#kWGVByiFgkRXb6rJ0!{ zxI((ibB+}S+Z_LlB%zXosppj}>^!YNi=WY&zs8|ZJ{&E0N1f?Jv-^5cEx zbS;bsI9azi8f+8 z2F|nQB|w8@>O0<=3<;+su8|gc;kd5H-=OeB!zPp2)K8a0ip_~?uBAA zZ9&M9zVbQy1RP`DU#4~{J!LOa!)Hm)1R#9B+XjS$b87T|fVJRNep(NKzPOEl=28n# znw2fBWNG&8&X$beQX}WItuqlUtIKBzl;tq4fV)d;$^FEk4>v>E#?n!uAmZaO`%}ypX@7)2;gsfdU0p!-vIAZxC7J6W!i5Lr&tAa!bK)MDXqGR~*@MS&5|iT#ZNU0*fiA%}GBT zcRCUf{7PCx-Fin15{`v=puh0lESO5eJLbgfAP&%HL%1&vgCE@h0sJg(*RvksUY7o) z+-L328J}<=A0J7$mqNUI8)Oqr)7a>sa`Q+bEW*rAPWcd9V zQ0}@HdAe^}-MFk5d}EAX%1dE}4=0;VK-ObV< zASvA;2udRjOShzobV-Q<5|X>`{{HW${j}H4o-=3So|$_LOG-dybWf{)Cuo!U(}o~n zRlv;p^&XAET(GyvXR7wtOP&=W%%~-EyuS(+`4T{3{e+6b6Uj-e+?=4ta^CKCmxc=b zZrscg+Ex$*3|h&8`%}43Mc&Sbp8aKJi8H%Wv*VT)@h zjwuKMpJPy4;%*hOjQ+p`;KkLr4^bAKXM$2R;Bm3b-UFtZ!P6dWWEt?&d|owmhy4Ym zJVN~dYNn{eO}KgF>8&IAs(+aqweqsik?=`U{ad|aF!EVltbh66)Zh+^1j(1Q>AV&V z&HeTNU2X6tuudx=kye@mwz*%m$<3;$j%EW}orN z>|W2M?Th=J?L``Yswl*xVj!bSL2p_v_H(V;>G#K}#n3lJ0poDAw5?ObTj^qEU0-i+ z5ene%N|h>(YFS2-wUwy7XjWxBDPTuys4v5tvXKw1ETDQic_#206J~1?pLBzk{YYk7 zEbkaxLVZLrzIWai$59KhQ`*C~^exgVOF4p!OJOJRmFHX=VayllTJ|Dko_F}I;N5n? z)3?wk&dqR|y%lYiY${MORiqhh0UXX6p1%ISB&JwO0+y`yYW}SSE@-{NrLH?79jyC) z^v;jTYvp2%%LQMGQxY-0X|Ju%ysVBqCDmp0X_&V^0~X>PCS;cokO z*2w4ui&@aO_vo20Aj?V?vm6!U6C6~Dx_|EX7YOhIQF`ojxMueIFj-~Pd3K1Q zpNn@buF*5vudZLJ(+*+wtUK`0a5@b3OLl&qkatj4bWeHV`x`;IA;XK84`GLI#7wo* zwVgBa^?f~ImP{Z#miD3VM~aJl&|^; z^+^T~a5A2T;2}+(3wAZA73+aO0OyN+h47zHv`Edy+{DQ5GDb|_0S9pzfgF9jP?VMOOyn*PW!SqUT-E;BR&v(X> zBBV2oq|Wv^UWBS&rsSYdD0gbBetBjdlZaNwOli=A2k(kh)hizKmfNvfubOUv5xF$S zV?r7fdK4yE2}1e4iYET#R@8(Xx4}S3!ke4$Xnhlclbi$xQv2v!Y%6ENM>W0$>w}rQM5$6?$sIJ&?~%CO z0&1~Lh(`@if4^|1E`b{v<79>p=8O5XC{?+wVdBmPM+yRCGZVlP-_Fm$Q>?ih{UIqj zy-|||rizcb2qt%8_bjS}p@Tg?84RE4>9o6%J0uE0vV}qnsYC&@8oM_noo1TFiz<0~ z@)O0=?bgJSBy`mIt#=%bAU>&*cUS5geSLj;Kz4ERDETD!h0nqYE)fu>1zLLgZ|F$4 z>*=@5;ZZwr@L=k*y3b47slb6@5befYr3!_X>GsHzMp+w-d!Rhd0;SI!Um@5G$7^5Uh{cm&x_W)1aAO~APL z6fXp(szFqYj3gD?#U>1MM3Q>gI3k}!oe@3P+w$|jd)o`*_(>u-5NS=`iU=L>+O0k< z%Y#cEuNm<@v82fG%+KHFzVj7neK-|79LYqgvz_p*b6gV@gauGvFOjz^p-- z%1zgYW>{C*-XdnSmzi^6(KtBPLaI< zE38-o7JadKS`J(%$UdCVJooo`zoB|XD5)1hXT^-DlSWT|4^!}4zvf(F@&}?2uN%d2 z|F*Ptgtpvxx}~!Up6o@o&6D?bRerHvE;;D`U68L$*L91ET|P*`B0bzcB5DCHKjKej zxd23y0IOH4fj_#KuBrM%a9P%Lp&dYIM z$k|Mt6M<_;v^0p$pC&Mtu8NGf{w6mX!5_MvbzXP-hxO&~rUd7PWd=`f0@Jr=qO3find9JqMgTsS;5c<(aD<)`9v)r^CyWV}mi!j9 zz1I1-=AUOr&pP_|s%B8luU5Z~bo)^GSO0t+D)0Op*7Cl;Z*`t_i3Y(!8++X2Ey3Y- z$_Um#vCo$?1tgd(o~W}AI>`zV26cl5u-m?BD4vJtA$9TEN5#SfW1SYYaS(8a1lPG<&hG%J4} zF|5Q@i-c_;+iS+RP&?p>y1S-9XYzHz9_kf&icElzKbmIQ zp@JljGT_mMK+n_YL9FpNx;?8bv;g*ai7Dgu7ogm4Ige1)mcWjtP_@jK2Ai z>SOU$Qts0F`ebDC^d%5QgurL@;DM==LWP=y_E*Eg)X+B@7%W(@msmc7u916;tA9yog$=i5-lJDA5eW-YZ!bYql zwh03ww{N5pcZV&ofp;{Eqm^0jh{#D@KXII@Qey?!L+ws5KM#dNGOoB`>L94h>kwAi z^95R?R#Dz8RZ!wS?~(@Axf*#=@{n4SoT&?^sP}XgSGez0A1+Lf{K-@3l@gAe zBP8o87J2!msDl|vFq!CF)ew?+&n6@x!gI^DRyL@wu)#qaZ-v%1JH9XqcE1ZWHp zcicCEp!+zv?SAqMA&u(+?wB7dmL8~D?x$Cs4~IpGmBQxowL7*Kyv104F_!Zs3Mpr9 z;GR?tkOBk*^@8MRJ;BJdvUx9^cDk;rVn6GJ_D7D`1+K&JXjaGYWo$p}m+s-BqSkJ& z$&bq^KzY@OaW$X*WBs9es&8z4fFnREj|JwAfMbI{cAXs`AKV^?M$Jo?2kBAM)O;Bt zJyUHi1S!P#2#LP};fowNo(-Ltj_|Z4bp4e_r|@=c+X>s7^+5+u?C<0b!N`heNhAFj zH&RpKf9%0Zm&PRWGk$^{!W2&}sL)A>HV-^&fDqRSssL1P%i9AR*p3F#`a79dTH~z2 zgc%zkfmdub6v64~w%l*nU@+WrI^tZixENzq{JQN%o5lW2*ORRJ7G6@b%s0;b?NEYN4`mT$PhXxxD z;mZiv12)=sK2n=ocm~hOZS}2X3t`wuc8!`K(*aA?WuU&OZW4CBb|9^ft-k)Q*rAL^ z=yvrX86q?#jsP5b-AHru`bw;Q+`cY(qp z|L>jOef$saO&RhZq_TRECdFz)7fiSUvvpiUhwC4y8d-(IJ~E-OFTopUCS-*pU#3?D zBICC?0z-(j2_aT4pHt{dWw7WNkd1OAMdd>3H%k;hsC&-;f`vb0U0y(~9$=>A;{JDu z9N>dpk(8A*tg3rZYxc+XKV37FKEiNgUz8<8BgCufcyjGN@)DR0t$7>f5}Zv+m;A*ZK&C5yBZ6 zwzQd2oN!M9OD1e8ljUzIb^ZyRnaGS*0}w6Pb*|zGM5ha{coeUSWyx>kb+L9+_#Zjx zyf3-7EHzf((rAOww*gb*Z#H|p3+0U*#8V?(R&Cina7{R-98#iPUK#!;&L)*9t;OTr z!qu;KB400tzz8p^Ko5VC*wLTZd8S5=?^&ikBk>_Kk9=_T-?lx*XgwPv<~FZRdbK{( zR8W&CYd_<~;#!O(yiRxj^Ai6?R2oDf33__|%XsjiOp+ZfU=RKyA)NO%mm?byXKf^l zY?kBf#eF71$T3#w4A3xctnb zVeSuHZbThDAPn4brorJ%DIK^}f({D{tux6J-xbBio#ZB|1(?J#7>b{}up?MFi{C7+ zXP{O88f9m_ERN+Lf6v+nU9=)+GO)uvv1y{q1CnY1$OgF=HB<+O1gH?Dr&Mb3WH`1} zqypN>FZrJG0*U6rTfk^u$o~9L4tdutfq#Ond6E@y-e;V_0;A%B`WL)x>D<3%d=_yT zL>(Ak0R*w&Q}s8Ddw{moC-3Ix!y{PX-J7CmIfw{40nkJH`TPd#4H~r-aI?re{s}?g z&EF4=bc6`i8@3-z4aY4i@g-ST8^Tk~Y%ydkfg;v~h}8VNmmlKqJ}G)165=aR6w(y)vA~=`$>y`G7C#%r2vpxS*DL*( zF52cgyP3N~Rft@GcUnF9@#Om-wsFq=985>zlL_OKM`q)bkAd|M?@gKDnM?H(cp3U) z?DZNaf$u&;$IJd*L_f|ri zIAkb6I2-|I0^yz*ZzWB+7+EGPa&;RhEs{2fBaXlc9cu@{Qjc>Bl zgWYuzy1%%h@FL>~EYUKqHPBU%k@edh@_ufU&n}H7K7Z*ROev(MQ)cW6@&A#}~ z_572uGtrSkoTT3+RIhI5>tZy^O?6k-BdY)nsdt2DM0|q{KomU!pXNCS z;iZ~B@27xJb$%wLFODR_FpXaC_m!ze^r037vT_@D&%y*^4p zoIUNObNH5u(}mneCHjhAikP9fPMTO&Hh*E8@pFHZ59hB-SeK+Hc@;ZarsJ~;*8O+O$QeH5$) zu1*gr#U#ZNMH4 z(laNk1cSoi_$*dWgy-g3zAnrUoPO)ZD-BcnVi*eycIH*mzo+4L%gqD4xCwWOon1(xS!sm(+Aii+{bx zr+2NGL&oe4KT+NA{r{t?fC;1!5W=55q&4N9r`#nH1MmDEdZ1x5cgd}a|GUmo>fwQ+ z8RA;l^Pr~*=7`tudo@iv^X{w6PHnqF8F4!_fDm3e1VTfZ#vEd zzzb+CB@2hkkZ!2-(p$(*eWHh52XUGfifL0FQ^#?aj<6{HBi`38X%F$>GFWx72S1T? zq`Kc-(Ux%WxzX(WN*%i>sRaxt(tk>m0|9lQ;z2Q>Tl-Z8aw88LuLf-&`AKS=tTd3f zgb>lE-@WWI@QghOyrCu5=Qy3{Xb~s?aPd8wSeS;TJO%FlDwE;jLJep^oZfYmpFw*3 zpNrArqqT@Jq2YG=@uDaGkA~Q*5+qc0=T3C{kyT$h$yY=47|8xsR{`SYL^MB zC*myF7CtuItARGyqT))EvdTHYnsKQ2OS~K}Yqzblf<8!bQ04B6jJQb34vibj3|+N* z6GdvkwPNI7KlbS(v%KG=h}cs1^@5>PiXaneh6*M>@`mzz{6m zzgDJ#<{?;kM$?~jzfIQ2#6-9^=@a|8taDYb;)FmS!4i(^8o9@b++viOnTxiUm?p+< zwTM><9_FR;;Zec>u@Gv9G^hAG-524qNm^0?1SfnEqRb-h9c9ZSY>m6$DJmcg^pp-L z5AYt6V{Y3<1rWyO&_EUKQ(Ur9SPXdc@pWmIO|bL(pB@(y@Eqh0bZa9KU6K1sjK2(5 z!rm$fRy%!k(@zX9gM@$B0Kr%x<3Z-$rmLSU>-_#)equxQl6?W%IzAhBKBi3IWD}Tq zCyv!W40jU6h*2J<%4+cqWZ2%-gBiHu9VXRaVuGpbK>b<1jH;^%WMZ-s|CJHxiy~8- zYW=+}8z-$O65$n?@4Wd#qnVuBKBMmN_%*nI4XKz}?9oRwqq)9%P@>wx3m<@qQoV{0iR;GD`&qWA&iik7BTn00f{)^mrPcQ3ja&c^?!4)!Yd#Q&HUtI+5?p>tg zDWhi}sm&fVu{$|B(~}uIwUEfnpfL0ZpoV^g=19+JYTReZ`9~Lynu`t`qP2@mlGdkjQ^|cGe?@SmmomJ7@Iv4rZ#GE3gr|WX zK_Er9NmLA0$;XEpKtc9LO-*yRK_K80kljewh1K+|e3Y0GQMX zVIeUX4`-@?SwWh~d_hs12wqX67t?(v`Qwy*GR*rK;K|2Q@8lfcSaVn@=j5P5Z=p1) z4kd>Q{m_#a3xts)vXxK1bv?3|BE4ySOMGxY|5lZ8+!2@1+eSrH;8G%QDB zC=!{ML3T-qzjBG@-Tidgs$E@54p$uzd~BxoZYnaoFm*+!zv4zYoosLE4dd<#+0!49 zSIk*TrPZ4U=ilhbtAs|t8gSe3nimCnm{R|9&7j|cpLs1$P-BgG`daHh3!;clw1==~%{X#^L}F7qwL)jpw%SYACO?spKW zE&&u93VM!(6a<1f%s(>FncR;wK5)}2)i29ber2RdxwWc6|rocHrOO&s=yPXmh3@9QBa@JA>Dr~xUciQ2ZksG-QOC$3zn z;mhOH1<|O#9dxcsUcqAB!Sht(X-k_d*&W)L+>U^Fg?2W%ez{y%QG1Zs*{NoRyC}#S zwc&70LI~H2J@?7h9b#Wb2m&j#krOvBj}gO2v7JC_1Q%vgF}?lLn}oSn!%;o;$bMpY zLfX}1m^wX_uCVi6ZRa8IJ44#-COMshE8iOn29}{iaorzSRS6xhMVIHft=k8 zj5E}Nbn;5^&Hd3GPHUWqp1c^rnfKofydF`wILcyR}0R@ zu+rTFn3fIUPNRnRdA@ZVIZ_+`=$D`uxRY+lMZX*cLTN09-*2QhLFe_^bNO3O6p?++4h*1wva+F;ScwX$yIbMx@f zlEq}epU1qs=pPf>AX=rhD*J07w<9USOSBtVD@|^RX5+`I~ z?H3Zy(Aqf+>;Xy-B8Ep08q9a$Lh9~0ezVVGDd)GJ8S6h&qy5U>&W4Qtif)@7cy+_D z4YMXotVr8A>HWm!a|}%d{~#nNT8s6Vi+v#7=;Z_A zrI~=~+P`hkn-<7Mvfv`l0gh@{Rpcd4@ycXRZ6iJ4707}Ig@QkX1F?XuqT$=Sl|cM0AD(i?~56Z>N-PZ!3t;URykArfX7_SVbta~WuX z!93GvS?etlTExi;8oX_XEM3@55ksxN`S<_@)81*3tIi&M@zh7O?#3}^=cEH7v_Z{Y z58+ZG9wJ8mi!_j>xuHnb^o@U?G*SBDX^0c>76<7BH1NY6aaD-Jk#JU|GMg}K!MLU% zw{7%p`U>&gj-+CF6$bvB{r*|W9=wqmaxwPuyyi?zFUZtW6YQ^_U0A3ZJ6TZi8yq^Y zR{r4p#T3Q)#DnbpTRW_;pI-Z*o3C2fyK<0-_aFyAwcy%5dE(otxf_DSmWKLoCD?KI zu#9T)F79uvVIkk-BK=7@g!JW1-r|U%KZMIMt_;qkLb@?Vl?PHeN_$tj`t{QfJf@qa z&*Rzv8>qF&T*0rvT;um|7o#G(G=LJVi?|9qenozxhW7I9M~5{Pxjoh{J!aR$j_P(; z7FoE!te%9N1|g||Kh4WO1B>#-j^EHNNI&Gq^kfJ3+W{02$5XY}@iuHyHol;j4(C44 z7D0rAdmM)pxXO{htrl$qEmOI_wnoMvAwrAloW~t1OAKJnB{t6r*=uQTqS0Dtq2Q7N zWg{oYV8Pe7x@8|Dtex||eGfJk^%=OBZltBRN^TsD>&bmfZGFE!%ia>)+IM1Ti3Rcd zrbdcWOSJQ6v29`1_Cp_+gDY?fs46@FMBxG+2{GfZLvPMwY@bf&XGa#pPgpNGd~xiO zF)g5aFj6%P=+9ym_0vcdyDRGEF}@L@!e6nljTi4>D8yr2mC1;yk01gfe~6&(&a+W% zAqkpWQ24&oC*ia{;R@X^p%%&iPfd@4%VKiS^%s#oNZr zCmxW8M560AKakI&vq4rGa3lpE`_>g^rzp~fESS|KnQV!obtjbIugn4-64X(Gp_iH@ zLUxBzs#_jO%(BR3C{(q6a4K%TyNK=?qAb~`G`&vO%kj>+E zd##y`^JWRv6jcyE9hvpEe14DeQV0b3SujM<&$>HZJQ=L~_JR~0X<_0Uu!=Uht9z2F z&yvoI@-r1P?!*>B^>*NB3;-s-o`wDTD-sxTdjWo zcIy58KZJK*-Xct|3Pd3?sLzzrg?m!nP}IO!3TV>3)A_}a-L*Q?`cjZQ#PzLE)8TTn zyXLlE?mO3Fc{4+mc~u9D5pZ#N>@VeNxJC9oD=S@rV0Y@$kb4mQ>Cw~A9QYRc&7@t* ze#t=>2me07!^cw7VnCzo|Gp9NnSS@wgk-VDV)3bH1A{ih0Tg<-YT{GyJqB!XB{woB z01FR;j> zrPyscX=g-2jTh{4+TU6Umq)y64V&1mN*Q7bs(IejU(}1^i6SDRF!5#@IY>Otg53tl z{-JK7@p0Im(=vb`xQ_L7IH6Y}3;82t3nRqgxEyEP(=FUl9o-mc2$=bCCM)(L-X7y@ z!^ZV-QsTR{1GDlNXxMyQ)b7v)2;)bnWJ3A3sP8`xe2rSEQ4fBkpH3AR5h6Z$?d=&n zQ;QA9k!lN4Vz|T-c+Da-gus!eN~9`O7&%&QRVvnl3<6dDMoa=$xQNAur=QE{)4c@x z#jS`EKW)Xug%+b8L5q^d^4fe%=;n1Q2`jsJNzzBO;kLGSex%+C>+)S}MZ5F@;AOd4K|M`Wo7?+g=Ekosgf0De6B@Y88FhKEncSvTUa!6_EV^0vue$ddDmBTk zT4z`6Mf`XOrBtXe-~^CTsPj9kNPBY$ML-`AtM@eHo@3F#1upeI2Em z^SWfYrFy#IN%u>^PrwUacXBI3Fa>mRS|ZcO;mC5+?7X)z#B%=%rhoEWhIXC`=XUM~-F zmhX9_VAkI8BKY;yR4O{qNp`QtkM(9~3;l<$wYz8FNo(Xl99e>B^7W~NPP2AY;`o~BA+2fF0pGLlp3d3=8fFkY4c>QCih z#Sd6w0}o%`hvAp9#r2@GcGDbt$0S8cv&)cyWLl0G_NR=Z4hDLYdtHWS3OIkHSN-uCPm0C6I&=QGz|x&J^Vv5Zfi{I!x|J1kv9OGA#_vA zF}8W?rGD$*J$^%p^t|3ZgY8^eLZrJt^eAa+dErA@87p62Yr_@#YG7CITfJhi7LTZK z9qQ8sTT9)5cx5VK6)oi-$#Oy~V9lb*UT1FTlW}sDdnft7U{}-I*Pa>tl-CV3#g3MV zUSA33J~JJ8Y>tge4V{(=?K{2`WU2vWlfwRofq+!%kR`;V&lb7mv<`hqoFEqA)@Jz9qiJ0ftEUs3{}j+@e^L?n!Rzu@Q~9 z2k#)-X9g@;L{j{?|euFp z@S}QhBvmp6ykt+I!AJo#JZjK}HnHV9)pp<(4)5RGd}r zB}j~*Dpz_XEdFW}Dw~o)2>e@haSKTLD4W*B63Z~G*UV^m0ITPUl2;a6*)0_k((xXf zUG$mBli<>mdHm#EAjU&5!EhPFMWS$~$08mgeD5Z<(0X2qR~O3p>C0tz*CE*z3FAfU zgl$x){0CK0CGG|BV!}Wm8z;D5r$8cYcXDmw^<8CvTGDz1z&xy+7%xk>BwcW^{>kf} zbGUTt(tl0}pf=44`T5!RWAqV;?cmQ->`_F5y8K&9YIv$Hl#cqu&RJRTeSRx-v?eKK zWj~dG>v}Gi2@^~ZEOY7*d6irXKjB*=fopVirMUoP&aGvzV7VI|e8ElIVv2i=OoJGi zWLwVF`Aja0Lgv7l(<+moYuUhcoG`<0*vTkMl5P2_tY6aQx^)487ksiy^vObLnIHkW z3-#9JZ`YI!Zwuo?12O9x_J7{IQ=pLLbv}FcBJIguwHf%wViYI+|I!URRp2b5Y+^cs ziP(i$pi~zhEwy!PoHJZI?r&Ol=+bgW5UQ^3<2j@}PyA4kGUDmb zb!?VZo>ekm+>kZ~LBbr0vDwfW7(@@VvPnrTR2_D@-m~ut6N+jjlo0 z4~B!&#o0w6Dbly&Prnpi*4c>ygJLmV@r%s$2G44X{K}Rf%h00G6bDv(i~C={J=41C zESoV^<~v-fO>|MtST+2qsU&X7nkk5&v<~c#c%qOh_LZsMmX53w^^S6H{=*0(SS##e zBx>BhiShFGcXm4u7nsVDJddg%3{3L{PaC8IX(Uo{2CDmTuhXPmMStKUmk@yl~Pv*gsUl5m25s*W3GT~$^PF(45% z{DFXRHUC4q_f;bJT7g)mk1sp|go`L^!=%VmV6vD{nx8*CCESNLk~y98&n$ph0gz2a zQGM$}4Hak6^~J;T7U_E}z$Pa-KhZDD;9!ZPWXZr0@1iAQk&MR4jeWhe3XcGGF`2!;^X}N zYEBqP%(rv%CrSk^?~EcQKRsYXc+iv|=qDOxg6ZCT;v!idVa38UPt<__S9igt1W}@C z=QpZrGH(>XOs5e{Mh0;`H_wY4C?clS!nogz4O$9ry>Nswf&Bk18y9{57|JEv?Uv4T zaiO=@_~?}?jk*7DY6`l?L_MTdKoxU#E}7&~Bl{_OzyZdl$$yw;^e=>{Nj2@Yng9 ze7lx5*=D z2A`|#$wsc5dQU1BP3Uu^XXid*c#93faLSm>Ju!8EHUwLG}GVo?BB=F)w6eB_SlG;qDW^S?1+&{1Dva zn)b~GQ3|$4NS(Fb5!GM1{>|_(UniC0DAsbIVVg%2hO3+a;JD_Q?Cmk31S=3VNluaz{}yVHLeV-ll!`b=%^5+bDfa~5|U(&mXgXp-ThXswYzz^X=U~2%^cUsdpD> zIYKMsN+E5&IC&;Iflne_+gZBUp5f=$umr!0I#jJ91-0u zg3v(kJ|2E0@6b(+*uO=l4$5oK=zbvEB#^frV?BDCl#Xfhxn2Ab1941SCtW50VFtiOCF>U~@TnRi$L$;X=E|{20a2zTx_g_ve$}eI03!ekX~l+- z+;zt|y*L6TY<&$gHqNZO;k0s{CPLYJHcojTw2#=NR(UW!HAeds{j#~@412S|U!7;B z``En0Xy8?pqa)eM6G?1@U{lM-Fyk}`q~v-xco97LR0n&rlqPV_YP-x5Ny$W@%F{*u zE(IKw@M)A2HbVkR$J>&~v8kFc8m_y^mC${FX-hGbk62!}L@m?RkA1x5=i?*xm!Bc3 znNr}SlZ(B~X?eEsH7r@KBaHaY@cWU)^|a_W5=aYYPF*D%Bt`t!u+I~%W%-{-_z{^& zqDzFZCK8GilCZ<~+JcK^nU_M||D2N~&=X6;hyZ7PYdp8pqJ3h{drGn!p_S|hT2~# zdEMlFxMFCV;+_C@CcDnn)ck(k#=#;2Kn8#>o=6P@a%xv{hZP{JNhx=u!RKC;K4Lff zbR=uS?>RoQ0i^5=m$!=70)q-jts18v4Tr{eBLb--Lj8uK*;J6w((8dG*TAE0WK7tK zH|71=u6!yRr>_USbYlje)Sd)OWsIcVv))1}6I2+U1=*e;M-KhaVOUW2FOofe3$TZg z)BB8<0F`T2=P$vrC6{*t5QpTG7#w@)L}0-zK#e)BCrw9EGfvfab?l``EepouVwmC7 zCUbaR#+Uu)FBrfKwzb~X)lt%X!&Yu-MD4I{9eZ$K-J5haHGn7NutCFndE)DWHJ)?x zlpOl{`kHjQfv=JvCQQxnN1WzIJkg)qd8tlvUmqV9ktS+~q`#g(i)qwxNFFKVWz7-7 zFBj*t+B%lyiwTKI2#0IE8TuitR#EZ~OK{_RXhaJg#x>^-J~#2~Oz?vC|NNme07TWd z%$Q&}Q2&dfRE8g#0>o1=l|uvqWZEHrs{pz8Q`x{0rVYlF7gc3iJxAAb=0|1^I2qUE z_mfH2b-x>@(e%Vf;E`=pr8nNJf7=iJn>GDW^A-9e!ydSj)s+2|-1dnwj~8q71B=oo zui1H^?I8TTWj${B&wGNmTqW1@viaOvphaOg+kb{86gS(SoN5irXlkKpf`1t}r@!*B`)N5~3@mr+zhrzx@8y|sci-%Nc`pM=k;5}U)Mkc_eg^#8WBw{xi$b{((`vA9K}5On zdqtCkP9a!Y{BkPaBBh>g_wsS6HF8=vpd&t!_Q~H;ZxWZpg6+3AqVI`<77xA z2lL*vnyh-CG1h#jFtp9CtWL+n6kxSyST&9={qC%r4pNjZImg~}W2&Z~YT{pHhA$2A zX>9SQJVyJ8NEwD+OjA|LK#&FQ&2IOfIy<{ZtOjG#r6~;%IsRG}+OA?2r;Ja|&z2uZO=AEVc7$hsJSfFk$K|_mC<+rpC3s&PBmNW-p@(&IHb#C= z^ZvT8c$;+d5tUPftbDfmCsg9A4|+USn2QSdlUNhOoY5neDNw!HZWqew{3W7+&RNBmYbzVC1#eLI$(JG3C z9p!u_ZhJ`8u`y}OeDYK9H`@VmuikQ$v3ja!Z3ken08wdNoM50YsGjHsl z6#Y6zuTvg(-rv)P#>R;k$F9puK^c^mW?5wK&huce2-8HWRQ3FR)v8r|`5XwbxiaLF z0c&0+Cp-rD<%FzYSEE16xa60k?mtRLnQkyq!%As2Vt}~bu|##m(u3YhzckUHD)Cs` z``(LyNB1Dtus$-i+XU5fH>JT|;kQLvXM4vu1M*4T56$k&FE4O!&Z7BB!(8QmpFdc;wG!44PxH(K#amw_*# zHa9mn%G9bl-omPnziUN0K1!%-F<8u5UD#@K`{EzEcch5H;bgt-Yu$y_+ zfhEt~LU9(p4)L0*>A>g875XVtiG8RWxieQmatFjLN(nE;+9S%v zwKYCxP`|3K9cGQa8X_K@O*+6;Kpp6_bvh}x%0i5wc~>sskk!kOmscCT*4x1?@X78q zy0K9xD&TEC6a&V^Oz2kpO=Z1PzEkQ#$JD7inT|~OnhOZRbM?O;qBiy|Ywwh^!0A;f zqqDlD!?&+BQTea)e$x~6f@tHGF;xNkc89MU$Kh194OYzZ@<|Lm&KY#Hhzh{B(_8=~ zXvM?u0GA85O>j1ga3cJ5wta3IdtgJ|Q3eJPl85?lUd;URIX|64@`ah1YG`N-g2JvO zYo?y4@n@@W(uY+QeG2X4vMFQyKqGy2I7rvXZ2K&0;d-0$d!62Wal+ku>gR+Whn17L zB&ee!=j$(4svEJ|QSWv(;2wGq0RuiND8pwrka>_zI~_S9=YJJlPzX!_lZ@s}DqICz zFjk&^)UAoN=YCJf+D+7r9&r1Lg@xF@Yg)D}H%52;(0v)eyz2Q|(oW=6snU39X#9%w zIwT7rX~GG?@H4nSi#+?<#AtSHIP;`pe@bBtprY)uX=yR$_g#L>2Vn!nG)Bh3I3)P0 zfsY0&0wgb6k=?_)quAjridJvlV$U9xD~yAcjlLT=bcD(uE6KsY59cPywJgQl8$(kj{EIvSD)Xgz?$3d z;Wz1r-*MO>NPc#|rD(LTuV^oGCsyv+s9peX?ID|(9f93Xl8V1J<@HQS45lKvLM8l8 zj*b+h;A?iw$Uz)wcxY5n1?Y*tHJ&IuarE$CTm1dKw0ZMM!*8Sc(uUDzPX}&LfB$+) z)++V>s~+DwZpJA^Z<7@0RG!+)`zGl;?7aSA71h1%68tdNA3)&~Y||Lx6JK|>7zbYc zjeTYud#bK!Pu)xi^b(CV-ad2H3L?P45u%u88K^&)*@B|rkv$608)psTrw&`!UO(8S zyp2Jt{!UnubiSmt#Sd9ABU*99APzET3{QgKI(SF$BSUOY>rmYwGt+Af74I5h0^036 zmS3_%0@~3GwRjkYX(?O{9Ei5l+%B-Z?G1Xh*Exe$x+spEmM?Qqh7PPw)iSPK&0N@Q z&1eVfMrbQT{n!2vc6?(HjoSTkBbt$#9q$HtNR ziQY)J;sgw@7!aBFqi0jlA?VC$EhXYFXWwIYzFp!ZT#b857PcF?WJR^7DGsF$wKb*I z?Htv0532!Dx&HzoZt%U&kk#(*MO_O{eYf)8q}R7Ffr`BS0k`D?SLIuROd%nm(r>p- zU;h`vazYz#jvw5gmAdIKggJ+i{|vBx(q1d{{Z+v2fuRYUThm|w_B;HNrVYb?jayrm#hfz zYB4P%2lKjini8x3fqQ36WDR7(%74Ws3tjg6?Xus0p!Tr8|M20%qtg3xtpDj#M$=8# zoQvY|4S2)>K2=FO^cDTH;a^LetKo-j_B(wl8l*q8BWqB0^S6IH_n(f=ChQgnV-dsz zFjO9mj|m*3Lil@o?fJMvbCL?d_dgNwUIB}aFQrosET-1h7NH90#aEmN1j0vvN0&Ab zpv(-|TX91lW3TXAGn8}s&WlVWYZ@Ie`rAgeErEmmu=IwF{<5;NA{PB~5UAX&bXxq2 z3#q8Aq<_F4sC1>q@9?Le=%0HDYcbkf@A~xxzh1L>D7^6@!2m2?Om1t-_PeC+_C{Vz z$k74wr?Ao<@7VX_4*t>=!XF|RvY9bR9=~cdjjzb0+PVhe6%ZUUaaSPVg8=j9nPAw< zi*x&P0m25r2dnt}@INmavCm3pkDIQ1VmmF{5?npbI)B#vN2>Sd{x}>Cy87bdY4)61 zegghL*KgTP(sqR9*U=k;*FxZL)XNKS(CbENPi! z9C;!P0%1TB(wImg`g{yOxnK}Dx+0yPx@HU++;*ymYoJI10f9ic2uPHXo<=2wPywh# zzdYeH1K<-JDBLndzTgP>C;G}WvNHyzLALn+;QKb^vfm>UevbAR4e{@>BHG^}v*^-` zPQ$K`T6-J^%hv-e_V-q#?rq@Y`&x|Kd4E zTiw?NxOh;(9*_jg^GqiFUUhUtUt0`E2|RlL0=o0Ok<`)Nh9E(mQN_O2mMeY>1cm|v z3OVxWtIPld7CZao6+gm2KtY+0ffMsv21I3i;PqO1?xWy4{ITZ-i~jo?a0V`4x#NJ# z-}tI>y6fhJWKL&~EzQDqGRlAB`JPv2HtSI2}Kyh-zx;#9jeffW&Ws zfFA^S-cL_AQ)y8F;%%$lc5y=%`}%oETnZC`@QA)r80R*^GXD90HPGVMb}N_t9?fPm zRaRDF|Axq;H?rQJr9SuFvXClAW1}CZX7$emzil;Q{eOVEH5)|vZB2L=O;`|OtKSXZ zZ?)VX-V=i-*N|cW__by4anZNVj`~MrRRFvJ46F(WsRoGth07Be!esv8+A5k*X{Oz^ zby11MqHlqCQXr6;2=GQMX+Apvf)ud}uY&{547sT*#+Miw(N|YyM2!D`eAP-fKED&Z zm}W(;%E&~&qM`zf8w~WcD|b~sf2Uk3>;130x-ob#1Ah%q@oPhJ6t!EI|JKpoyp%V3FqG_1hN79WUMXJhQn3ek z=Gw7jz#i32&8R+ z-L~?5or+AUVumCGP`2mdE^Axk&)l|ds{x@U4Wmwlys-%LOdMQ`n6<^93xntIeeqDPjrp;$Ia*d zH8#7qezV?oho(D2=I4-P0Ql|Z?|J4|&aRe6h?&XkQC?MmPVe1I^tGA=4ia?jNoBO? zxKi3xT}w{rxS}zX(W#Z97=b|2BLEqlZyQ0H;(rf2TI+r=K*z;f1{(_Zj$ReuJAV9sKFA|M zhxY4BM$z12Q+Xu8g2>F6?;Grg*SLQFUsE;LZz z{EweNx#jC@=~|F$q`%xemd2N1>)^%~NCAfQYY2*$xH)tYknPQU#s&x-6Msj?5LN-i zhv+L`nTPn>=(PK`(8_JW-Qja{b9>$WB3;Pv)afz$JD!d{<`6%`A9Eu6`)~a#cKO?_ ztKMI>`fqS;_-a$nT}r=)>T$?Zp6or(S>kH1xsMDW`nhAE3OEVh0)L(XQWQjATM`=u zju~#IH-9yYszw%5Gj+wqVE9;@%_yv z+EN4J|G;Ki5rX)$u^(Ifv!Q0B#GmW4BG}LA$Ihiimz{;xJ=8jilt*rir@tKMUus?o zW{(X)^+^yoP*=BPU$Mh+<50Bv*P=M&82|<(UHZS@J6dZ$Ga&S+p_EktGkjG6IuLy= z@Gh5urj4-Bb3dCzx!IX83bY6&Kns%iJ!BDJN!h631qi9F_V)&1aAH+DB;o+XO7!{D zQ6{cHpHW{O^2)!)3Y|ZQ|JL9ue-88$t*<|~#0nR`{bx?5+kbv33_W4s=R^FF0apG} z{g))weu{Jbh`)>iIDjp+&fgt-$ErcU)1Tbm5dQ=N0Due=pR~CfcUX#xI0Pj1@oPFh%o~G@$k*mjT$YHO5lf8EW4J18Yz5xc!4mx&f zBi(z|LDbo8CAT}UudN<6iZTQOsek}W0Ln{;Aw;V>q8x~U%jzWzsnCmX+Ss2Uhx3$g zp2&&5@_Z!3KO-Z9nD}$FzkaKBrNAIZw)ih8%B4r{SwthpjzOG%ZLlA>WLav+z4;Zq zXLj|}k38`Xh8>;m=H)vbi=KRKGmJImgwAm{n>Qn(FgY@{=5#w94l>o4Zhjyb=UhF(ATZ?uD{Ix z`SKT({)?jr0U-g+!^xT>S8`gE~2k=hl&3Wi12sn zeVb_AE~r$3B!&31;a{}G-v%}3&n`QO&O7G>yg%(?3K&7k<7SKhhHrEy{_OI%tJb;Z z{U2Sw2-3|VdrLjKhAar$j^|8U_=Bx==PyaJN>n(V43)zxFO)}x%(jlhxx7fglPB2XrS8TL(`Zjews`MYU`xVB^w=A-2Cjy;CmQH zQT||fL@)pW-eS(=v!Av$?S7fXM~2Fyv0;$LH88+)v0iq$4OC=XLQmZ=ji&E0fh?Bb z9D1zviRT3Z$%p{2Hq+9xK@?O|6x#zhpdL#~PXLARM~i0khJ4}F$k6ewet-Y0na=sm z7OHF27~p9NQC$6^+1XDi%8r%)nJ3JnpI?6=g#2n_PHzq)b`5O!nxq}?crK{zJ(NPC zJaSP8FCeYj?zrQO2UbJ0Ad)`>RHMt^?V)|mUcdBaM{~_jhU`2tj5(JmYj{E?AD6>L z!<}E#EB72mRii4X$BJ;AJimw}5Ex1b$cA9*4VR z%FBTlfDf_97#VOQK!qUlF`A@|^I&sGjCH6UXE8X3RKDxwYP#{c9n@pj5MoUia&mHH zv46DI{#+dE{O2Dqoqn_UI?6(M{(SwV&aJTce+kR_QL_F0>+F$|Atx|LjaNjYK3snR+Qb;lNxR2S7(ru~KyKql%&i&lu#c zGS&lSiLnkpkF)Q4d$)@&|NVBl|8MZG55{w#S^4?-F-7>}p7F|m=CpD2_ygBdQAv^i z-Tf@PNm~C5q5`$xUFzrV$Mu`c=yQj=ZtZs0!o@GHK?NcOLqIS9!O-#E%ia5uzd76M z@251x2N-cI8A|40Vv2S}u1o|WMkCja!Qtv^ph>1r==XP>NkxT)WP?MiNCJVOfB@69 z{wa)c)Bm9;uK1y-5tkBbtjAbKkjL55pN;&^d~gfB{Ap9jmJb^?jD`;%j?lb*boC*h z_GI7?ztg9Tp(h{u8Hj%|0{v*_DunoDAAk3T=fEk{>redIk*ZsAFWu(4{`kc!SBKf` zA)QSy0AarV^bMarXm8y9t^u217}zy1w+iA&%?BQ^tWXm{zz01{K-(^wmH#b0cmKtR zq*X|EZ3L|#kBeIZfmB0)rT!V|W-2Mn2cxZstU>K~yg4{dJyX7Tq#zVE?CL^|bsg$Z zity7WzQx4<`*q!P^c@@Nlb_%=86p)I7e~FTpHj3;{3ln@vyc9qDo2m>PyFG+>E8G} zcH`OZRi!FV$9`m?kXwSke~s(z!){srX6z*njS>U{5axR}9Q&lr-n!~aXLI#RLqR1O z$6i2`mWN466U(}{yigNCZ^qCkFqm<*Y@peNOX!&gub{Hh62#PwRss>aI*~&lFwh7X z5xy-QOn|Ss4aQW!@fK~16>7@IXj7=Mu0tJR6u}YiSpWSuSo)uE_eS{m!(cK<;+_0> z)n8g#8YEZfTTJ|?d5QlBCjQ#FiC6wI@qYnJ^v(Jce=vkb>}0sH#`)LFUNJo!y2T=g zf`DKE6ePkp=FPml(b}^4Ja<>qZbR`j&!$1Ln-Wg|oI*_mo?KD=XC|O&HO(5fg#K{< z6;xg(n1HBo6c42Y0=zvo6DXeq;xFl90}uu^^{Lq3k5FS>hdLrCg7y6_2utq!^KM%B zyKU6a9(;k2mX;>#`wO8~j3&U3f`~%=pTo*OB=LvUzka*>^?}3gjoWG*Km7eW`*ll~ zE)CvsrQzZzBcK^!0+mn*W-Q<*SUIHoW{yS5F_9iuRVam1cWLf%KKbA_QiJ9 z{YbM4zNJ4ra0OKiFT)Ok;(L!0QSn&PB7hhGrZmdULnJ`8)xTl{7?ld3k6=XK?c^f* zNMGAUXmh!0ik9HLbkF$!3g%BR$-*;=f=$Jb<(i>lf zHhNdw{Mgq&wv|7u{r9Y*=Yy{NJD~H2*uQlZ>n7BoMQ|P%9F}^tbx0lGZg5<-@VCo1 zMNp{7>P0{>08xGl*?XM*l)Yu=Bb0$ngT^9AQ0{nrn1E384Y$|J1We2QfnNCCjWl)2 zI4}WmU_he8Mm#1Eh!p{Y(L|=S3=n)C5J<9>7o=Sd7`3UR1A6Xi{wqX7b2;pvp$4H+ zJ|Ck@F&4r8{cI=k{68D$SP=geTiG}=cw%vXEZg}irE_@SgXT=5=O4R`Do2e7pz~+q z4}(9M_`{YXq7)h&-XfygD!G?$bKY>mJu5$psCbdN7XiTl^g9R{hz|nhOoCpL`7y&rk zg1~8g#6cJWN&rF=jo@&E8icWykJ@!8g8N`Xl)I)5CoOt%2Q7MPJGFKN_eN5T{mRSB z!ABXQUimXZO#BxdxEDPESHF_-vH;fp(GdUe!C})7H^AJq+j-}LTb94Vn1~b|0)hdE z4mbI!u?t?YHmz8)$XdVgJ44ANGLF9lqZ9GV15Q$^%0kUIs;mJ|SrSkKCcyR~z4XYn zbO83*mcs@Id_dd~2qZrOJT$U0(qv-Q*IEz=7go87L_od~KGEnaM|~E~AXMs5KNrEd z@;D!3Ol;-PBL8n8@@E&n=RR!kg~Lzz9L|?l{p{x-i^!j!a~W7 z+3*ZF3)%3M`b<>bC&QU<{8bX{8cPBiexj20cj(FcFQwy;JV+j>u?+TL)rorofp{a} zf;sz?v89v=Ccr242H#Q$htJ(Ge}pp#mGb!*MT+rpe>KWh{i|l;X5y+VFkJL*S9{F{is>wCz3!Q)e!I{SYCo5tJj!-N0UN5 z7vngI@$o=C;^8ZAY*THeTVC8n?|j`FC~NO^9`hXBr>Lk%cBS;ivNu~;CvLH0js{K!Z!Jt#6>SCA)X#9zCoEEzG#!IMhSROt5 z@;kDjP(Oca^>gA}AdqATFwrS1DS)Uq4cas>;buzc=n7B0*4v*r6Me2HcDmNvTI~rN z`f>Sh{I7*>{^Ks%*@#_1LnPkBr>v~Zvr5OxJr?5cgd`>n;`U!&cP=fw_!Que{dIdI zGkP0>c}GHIKZyYzt z`mAgB+AEyaE{Adaxt=`(TnV(!XC|O^JsCGWOTV~m0p0bBOJ#p*r!!Er!f<*e@(2VH zivSa?+?*^B6D666y`yP7u-@f+~P$(fj}Y6S zABeJ+PzS5f&pqDGcg%=~E`az&u0EOF{gf#Qwk7Y)JO=*S;4& zg@<)XVc@`&;IUq8Z&yh?81|%CWv|j0?{JC1dO+)C3g0f zSM%PIWa6(7`931yzaAYZwhl%3Z(IMXvV}iZ{kt0Ny{rB$wb=SNM9R#}q>&>>$~ym8 zT>O-3*v)V6spIJ7CvKzJb7p|}YbBUW{IO$T-T!&~{GCwC=(eS=@=iUiVd9V7{aXLC z(s|J_x3BtEX`?vmhk#%J^uyApr0hv&J>hCxdyAA&K&C0zz^|!1u}pvgdt+h)4%ezD zXy(XPdiCkWG!GDn>iK@N^Rlz=U-3yyRfvZJA|M!mfY8K#!#wGN-#S{W z@1eX(FacK+WtL$AOQ0y82dA@#Tx(yTk;Y~8%%iu`nI|7c4(Rt{i2)uvGNJ^5Kwt!z z7^S7>$ixQ!kdgl|Rl&fue~b;~J|Ck@IusF2%_c1S*{}bnjTSoP-pzC|Hu2fn5Zcg> zWBK!{pN;)k{2wdvXSYk2%SqQ5XbXp zMnI3mBL9hFD(Hy^ucsr9JlGT2PfMrSo~UyxN$Z~>>evt=@z)j>sMVgAVuH;F6aUS% z&a3vje#J|Ha*ONwAt082`e7NcB*UA`SD$g4^P#^R(uU9YxrweI*M{dXxgnNCw3AuD z+JfA`V0Jft4{pANj+u7_O&q(JZols-`hMAZ%1AfsHn9Y0fw(0Q2#Nr&YO^!VG@=|v zfJzL2pihLjsSm9TQAz0AvQIx&x8JVmqK96urT3SF4(P+BvWTB`{Y>~xVASr+@|R@dzxUP4|JZj& zoW&D?C|T)vHfeB{{j0Sk-CO(CXHG~0jAhdgG@6Sb30UDVYSM|L4+}YFIIIxoH6eWP z=&5wnv8PaXS0}AlvqgTKcNh~%ATW>!u<`%N#~er_VBiMjG@8QSB*fAU-w;Qf3&}Kl z7L=aH2C^floAvxG?%z^vqq|?Nrk_8*lUDEO39D7?*Uzhd_K47H)z2|OxJ}nxaw6S* z>lIW0#-d+Lf3IXxs{R*A+x~`kfcEy=m`UA*NQl1ja{8W^iN9SC|2{+#cP1Es#9>X$ z1Wd1a^S_gs2`HZdCIEwvx65S9=7cfa<#jT2)zAfJo=?-JjHA0BdX`qL z+a^>3ae`eG8yW##?e?=v0h27jqop6CoFoKa=@kpm4A`)yz1>N#f6+vbzEg*7YeW0< z^Ybk3XCprr`71_#dJumOt!#%?|H1oDr+aU`gsLWt^SbycM4XRkI9B;`SYLMW>)4Fn zF#hvNN`9Yfyf20KrsCa-AzHp|^SgJMOGe*o zXjlR!;1v*L1Pe-}O@M;C8~(icQ{bw2F|}9==+P(sM6bX1AMhi_{u8nCj5rDe;(!1X ztNh$_diTY9Xw=w=SfR3YJfGT~pf9d3(ZBxZP6 z+|SniZ0x58(O0TEbLoM*uc3YRngmzDzfyf;n=B0&@-IsOFt7-)Za`L* z4Qw;CwadW@vEsLMyXd_ynq{}XCF>EeD9pq`e0asr3;-v+h(D8UtJO~9tIFt(8!x28 z4xKC8Q}p@w%hk!NevTC)?R*cLm;Kj+v8?Y8m+*&A^u0AAysr@v{#+;%kxIfe!|$jCDXMQ`)c#nr$H@m;{^f+zZ+uLf<4GI`C{rZl+ufTdXt`i^?mB> zwn7>pc*b}kFUrNIhn0RVi~!$!?hYD1Wh(UkUfUSBp}Cjcjto#&$zA~igO=>h2iqy(b=aRjVT01gp8p& zF_D)L(zgjB|Lt$eZo&Qa{ptt!Ja$sS#J{P{we5?Q_KQz@VAU7e8pQ9!BM|#rO?;aN zZH@ImJbqen)tnbhj+#Q(nr9%xYxHpC1JPq%ab*;dY3x}<74zuFA6C%)kG@RH;Vzht zNlds44#Z0t{B^Pj9Hfb!d-w)A_^|ojl`Ezxc&)fs+~?VOE|JEvfSd&oMz+?)s((8S z`u_A;GyNHV;cV8UyfG&HY~aU)Uor4g?&)!KA$XD#g#M5N_M%_ja2`#XI+?@#^;apd zVV|Req#f^)RR1{`0k~HNv^zqdbx2%_5h0K3n_cVP`ObFs)xTf8Qiozi5jr5C&l}dE zA)*Km0$DmoCUp|k)D6~4SMFhU`W}vr&(oBXq_vNe)W)Lf#Eb`7zn_NHGFLeg0e#{C6Y$ zcXa@yBvpTi z^B$QQ=<7-x$N-9zJtM$CZP?}f_?s2dg_k_J3?faDk_mx0y!mA6Uh1~R^4)Jn8Y=dB zJu^RNuC(eIa<{D+Kqde@kvF|KkJDnP+$i8i>AB_Zh;{z8XYi9er11qdpLQjyJS%XOFDkfBlOVTD2`a zS3ad?Htu5`e_mdmEb8a$eFRy_72A>NyUlK=@{$5tbm0kf{#nP8Ia68ntEY6P!mt78 z+(ZOjztpxCzgc?1dDQu0Yftafsv=o>;zAv(b~s*n;lGaSo_-TCkVQ&51XQmq>Do5r z8&v(DYl;i2j(9G+xb!q>!z+m6kIhixxccb_c<)L=xx>-`HV~Xdrg5+hDB6=&{IrsO z^TeO&+aK3r!ZXQ+O-gQY6bM9v029m%vx(k%;ZB;g=QNKvn?=|N1MFUTKfXalEf7uV zeb4mj_MzWzLBzfoN*AI?@ zj#)5^etz{?GOL5s~|(c5w5u5tN^ir$zQ+W=tgMVmN=k@*`Z>je@NmTU-! zSvuL?NPzZOHoSgqM&Zauj7^_sxVFCS8CEhQ5a5AGE(az0>Vj0C3qQl*iy;j#jyZ!2 zrPFEq=B@P1OK;IXKK@4DXPjdPh$Ik*1_5>l%*#%vH($7$#!a3KLI)L;tOme}cFW5k zK;eH|>Xp9RF3F*J-~Yai{_$;_?7JUMe13HwSijHy`}~)Yks;rT2hrz(nR@cheYx2g zbk4~O=<=hG(3stVV-KVxMC{bU3ap$~r15PhElygyha zz_tMu3&>b?JW+Z+eevZIdi>cpXxXaGSZ$ePiGaUH#WjIIFa&H^-5xk=BE9hYI}pM) z4}kz732<~m4e&rncR<+yja~Qf$3%Z4-1k2DsRP^ag6JbsUuREbQfXf8vzVWWeojtK z-1PfuJQQ*N-2L{To32D)u9;p^mtOmbcGWb>MuH-CKv-`{WKto( ztKXB4oK1J%av2RPuYgM)2>On<;m7`t&v!2qA*T9}fV26zy~j=KAkJqi{*Qlbr|+U5 z`rHg&?Xy82|8bmtR!_uhwXbxWt@|DD>7P8dg05P4G97o+d@_Uikfk6>Ci%$y#R}in zB(M0T`Y(VF>?34DKc9@@{|I;3drM^FNyHQagXMbf)$iKcF zgS(w||6Wvip`nGq270e-L(mjWWdOu)2Bm2~yOljwjsv#DShh~AnuezS9rWeuPU!c$!`A1kD`z7<*5@GI0Mry!7y0b^}fe0qSwM--J3AZqoe|COM%_PU2Ccxzd7TPw)?hk z-yYt!hha2HWJwGHQNH@bG)~kK7=bqyQ~F5@Kl-IP!+4ir*Lx;c?blu=0IxKWM897X zSsDPfK>9E;j6f*T5epDeFrQY!Ti}^j-lhM1v5a_ylP*FE_d`xx2#f%W!wvYZCyy(q zF=MJI!@33TZCfD`H&JbigBswozp2JbHO(9%xx^jXBuD}PS$Ci+SpcBnhD zGSlhABj?bS7o7y_{_#+EU<+hK`147MxSyH+>i0=%S>d_O*CRo*gwd0BlA6g0DWRX%8ffzrBRPFZQYIig20`kV~ z`;ytZen0nyzd~2o>1SdH_f^M0^!>rg(g5DsuxN&d5h$Kaw$2Xv__J?dAMhcqSi40Q zvGShfA_)Y-MSux4$G)$ttD~;2@MFxdc%N7LO!V2Pj|2B4lISb_vm;_3kI313Po%3Z zK8X&VJBtkI2ztcc4n7IFShUa9{qQhcq1h6@U(fi>y5XU z*ss4s===Sklv8QITnmT*6K-CqcXoCnY;QaK zc4R{E;ukOPFD)6D7PY2i7?&=H5sA#+ZyA3Z}!@khI_t1t22x9 zltDn}&m?8~J!l)+x#``jveS*fHa32g@7n#Hcf#=ZniD|uwJ5WCf(->Ti{UA7I2nh} z#~uSE)ZWxY?|=LS{pHPn)B4T31S6ouP5cgufU<&jU;`YM3MfRKuk(tZ|CBsjka9n0 zp*r5=k;a6d_4#{F8bz0$e;gfq#6gq+DMUa6Kd8{8_6NU9T${h&XutlHdsZR9fJlP{fjE3Jg9Vz@?ML_OzwDcqH|*JT%d(l$*7p$z z3}QvuNN50vzCRch`+)3`*jwNTGM3FD$}XgirbhbbM_vH(_MX) zVdr}WS0~19BKHvp2RJqoWM&|59Ml3wk)dQJqyu^M|LvU#a2wZo$G-)Lo8V1R6iFS_ zeV>*sJCc>yQ^#(TbefT!Nt-lD+oY43CUs87wHwVOlVe<`ZJIcC;)y2m)XL>}(!}v0 z*>WOVwoXc-Zi*7cQ@lX}-~nKF`~M!eEW}z8WdbAses2cc1-Sd(+u!cn?|a|--uHst zY!TQ;&;I6BI&=QAiiXBM0@r%MkEbX(MhHw60T%o`#-D?shjlBf=@Sp!Lw9Yxjht-i zAF%{nRTt&i53_!#0b~5T$?Qg$?HGc5U29t3B)qlRnFJ=`QOZm(*;4ENGr#y-uiN;xy=zao-gX$5g%`~YX9-O5)$_0bx;|Ro0t^k@ zHL3^<1_HF_j|XVi^RLpOnsdtg+W~W~1QeR&`y<7Ti+}+$Jqv!H$3?fUTSOn*_I}!Y z+gi$j@4-8o`c;n~ui$}Z#O!~a%#QsKvz7zzm{;?duj8uFyEuqf@gpqZ3zL7%YqinO zf9%^Q_PvX2Zl@2t|2?!|A)Z;7^i3!Pko*Z)@Tz5{n}@wZm#|_LY0Sa{L#QT?Z*|Y6wih<2E%HDy7B=bk*#*#pknq-yJ$| z8?_$5N~;)yPq!ff>t?Yc!0%AsYy=Y8jG=)IPzn$l6+gB%Hq$G6-=^QZu#e8vHNpE^ zryQ&yZ-?WcBq5M00#=-Ux646mmRHlZzt}=sH{U|VGx1zvlKeFT_!z@v_Mca#e(I^k zv6dAMuizjq3jJ{k&NV zOCNF%U3d`73%m7xE3inK60n>FJ!M#S_%?htk>;<&Sf!nYG5`DRLnr9@KkTD74xOaV z?tV=BimyN@PJ?NBKQpXaR&SqU49uEQNL%k%O@DRYowR<@m($yNkb+;4&XFh3#tBE+MQ2s)!HGr2erb}H!FrnSA zAEXy{zop^{C{Jvj2QIz>WA40s9S;GvD)A@R=c46{=hBDn*+g3qv9EFtluz!qvq%3} zGr#ia$Nz>p$?Q6W#d_W%3OC_+n6j*Ce&Z=Tg~#w%|1g$FZfG}uwf{oop-+9M_F@W) zldRL2fG7ZId~+tFYKMRMRQ}S{`Co8{j())wIGC%4d*d&v$te8YmSM#aup^9X-Xd58 zZYL^SNt(A9y({=#_4m=CV`r2i@aDl-Z=No#9>VuxOpF%pr~ zJBu{T8qi8M!cU+I9@sARzmB#x3=y29-@__!?BoS%?+U0$gKQPxKtqy*fD-yJfAIoC zl`{+J*7Zy2zPoOvjq6rWUNMwP_CraCFc>F>SEn_9KLVvWa0-I|Ej7+hI#$JHp`S+9 zG@jQ#Fkl$XUG&VpGXr0jv3_~(nSdw&@*Ip3*t>U!dqYX&)9&EOM{R3fb*dru=)I2|e?IYYWPf@SNS`Nldf$h%y88CRKcAmx ztNVr{So;zC;6+W3pqHISy_xI7VyggV53rM!XC`R{D~XCRd%!=JG#4VVai>2rL>DeL z(V?TK=%u}f=sdy>w{`?nfFV`{ID(s`>>|KJ_3XsQvtD@Ies*~=En6~=?tR}z+5n+n zQCSW_3FkZ(`tRt_hi8H_8EPl97qR(S@P{rUQ}p(^ZLngHM#*yW3-;@wiyg)fp7~Sw z@yB$Q;fG7ZIa1+wC3V}n9f7m@-x7`(~TS0@pczI^n<6>C_5br?q&4D7Y z7S4lE2zU(wr#r3;M2D#Haw{D@agKKX=?I;MWuOs011u=wmI1p&9!%E<^ak}}lfEVN zIh6FAic(s$Vm{ryWdp5UwV2Av%Wfd_SzE+2&nAAJ!q)|%kFEO7zo%Jm-@?I8ei67M zH23H)9=jBKk{(12+efIshW>24m zK0Z-I<_jWVPxnCx{W_w-cKnX##~SxfQk|2Tjb-j{?~c`-Yc;=l=NE0eh>l1M|71eW zvb;nA$g&%ns>^A4?UBXq?mb`1bw)qtblY4;lufCrDlL`yqS8D>z{%l1oxnX+?XY#>Z$>znB8xq8~W{{)@KK3fNdh9ja7#0gI6Ujq*ykLB|?Jq!N4 zT(|1wFI!Sgn>Q|_rHdEP!fH&}V`~qmYmVu&&`08)K8NS!dHcK+FOTc96)6o({P7CM z|FVk<$L|mH8~yd|`V)I=^?(2JlP3_|Ly{1RHI!#S2 zZJ1)%1@G#XP*pep01@U%L_t&`Sc5UE0DcF&m8piQc_LWYtKK~XKX3DLJybQTgyzku zpsly9pat{i(Xxe=5q(ch`o=VJ)8B- zz-g?+*NFN0e7|sV1_*t=EPiR*FwP%?alf_Kc=L3V{;hi-KK2LxpClo0GX(fqk|YG) z6@mTVTV6D8hWnF#xA~yYZ?E7UmyS>7CtQPaAmnR76LA&Atpjf4PM&H8esgsHcvOPBq=Itph?v$rvaFtQg?KO2Kqr+L)a! z*;$VTJ%4zgh5f9u0xHV)Q&q)G+O&QVRaI8d!g&=`KC={-87OGn!)H0qX8m^*_)1LT z7);mWUOq3m%K|^tK;}RTtONL+53#HP8S>C_?0&9zbGLr>(iQ!?TYePUMHepMVk8NH znb91g=;&_~_9K_!Tw5-5|d zr5vzgkS&3VEFlj`JtF9V2K#KnE-T5$GP-{9czv{T$!zrQ=TPa48B{&HoXXMTx4RM7 zfe++26@pW#dHFbZH1Yo!xY}Z$r{zU@l+&K_*biQSz{gmAv>yr>YhXAgO}+O=ujw6j zl;B6*J9}d-jorq7?ASB3N`ATt0^E8e34x>#IPm!DMT=+WJm_|6f9>*U zeu95MRw)7&C2M*iOC?^|g%>GeB>>8RTEZCL3F{S*%^jCZ+*{>C zwRTfmM-TP%z-rLY4u``w>IwAH(7+(|^o6k>M!5MH@`yK6OF2;A@EiYZ{l>!$lbI5G z?JSPD_s+@c$y@slIEs|OX{0dUM{c)=iVE{-^^!`OTQ!sX`9(AvQ}brcC?HQR{4IDM ze*B_T&z@OX`KrvBw=&c7wekmLvPoa{@L|>u!doA4_st>rzeV81&%9NBmVyw^C6oSI zp8bJ-y`!)Ofum2ZU0s=P`zODL9&meY zxml|SSj-X?0v0$tWZ*&sLJmaapRcy;L%_oZWP|w{f=dZ?Rti`-uoMHxGc!6&pqJ!A2t^}NH5(xe2gJ}|l=_2VB`n#Jpwh_3s-mSt zSl@57y;|_%Xs(+&iB0r&^7}maxX9~qlH29NQp^r=I9xQRvXth`DxmTi#pKV+C$G=1 z1pJI*Klxb5=fICb4aai@@quHX67;MXjVAoA6dD|;wtOu99~*#R4`9r`3qg1ql{sJS z55RGcd;VNQCGhz?E+li4CHQ;$^seS^+Ih5o_=!(EcCtQmuSja1js&=6NfH7nA#f6k zKp86nUhV#@wg`-+r-lqzpp6J5Hc>03pa{bSPz(^1NDU!43gVUm2O<>mN^v+|HAq$p zc!#%~hw5GPmcPP`4kaOisfYuEL(~@>r2g@ zsV~q+p<$DT5Fkd$RAer3JMFcta1)HP<4N<7fUO6$bqS54wp~;UXY(l z1%8)O1iW51Ii2hviIbH8=A8K&@Zn5(IEZ`mYFLObg44+V6Svmy`XEg97#d--J$m=% zV5x zk`G04OfCUd1ZF@H$g>my_7TVmXTh7`l&BOap@(zrSjLzY0|zVvyhVgUr64#95u*S~ z0fH8h9dQY45Uxu4$IDZV)@r%D<<9P?R38zrmYj{B5*NhQ{&*y%mA`e)=y%>>fsT(7 z?)()>3IC?b963#lT(XUFz$*p-UI#7zhnpvwZynfN$ef{H6Xk_Pj@VeQi&#k4QcW!z0!h`tNS@o>4J3W4f=z(sg<3SlK zVE?>4Ao~YF`z0t9zG-vb3!%W3=`e?VQ@|~CnLnAk!%Q9XzuCarATlvmxg zj`Zxs;Edps_A;0pt-^Ac=Zo$zo!%fxSDX?9u50|3{{AblAdvpGD0Z&A{%XNN!|Vwk z>K&8=>665gql<;t(av>bziOjl#=`L7AiYs#h8O}-v$*7}oEl()&3^n&-5Up;Y_gm3 zE~LJNguU7SsmDriQ|(3Y9^BKJYSoLwthH%5CJqz1mRW>x4;MN?SIrjONScMGKMbP2 ztTo#BU8*|MYitZX7)XCn6TdBO4YD!9Df``e2EK+U(8Y;!nEG1G`9ksa*2ahH&5bI# zFfU|1@HL^RGwzyft3tZ)3a!!||JgPqj8?gYVh`q&^s|Hx*`~K&YL(OPt`*BQ#VRlr zhZS-yIiOG>m2qdlf5`+vp-2WTRcz4}D3|^@MLmR?#v_`2suFlUDy8F{()cMd-v+|l zGM>2y;7L~#3B8P$+G3NmsqwR8go&tu1<{tx!kw7a2wS~T`y~lru8GKH!{+*LCY)@I zspQ@)_Wzfkls=DX6owucV9W&UX9Ze+>dn>ywp3DC` z`Nv*bD|&4`a#dVmbEGKpC%Kuy7_$MZXC=?ebL`n#4t)0^f)|aoQ4%A?kz_4%HEo{a zj+TYba)Q!TPzs#0E5tds1p|5EVsvw2;Kl0}lh|Rd#CDy1vw4#7H~saG_D2g)fTY!FK%TV( zAK||DFHptS2i_I-c7F55XMcK6i#F8F>oLtwiJW!pl8rj!B0lSiVtyfrE&UTJTx12? zAeOy}iyim%?`>nRXS6Rj>uedv`q3!csB9Td{1ws}$vVT%9_d*sz%#hB9rgDZ_{xIs z@2{N*F`N*616OiNSq>WQQed33vT%YBi>iAKgKdm2e@KzdORIVRahR&kj^1HRHHqyr zDn9<#iLw+sG7SS-EQPC-NtM=qDio`%{O<_P4~BPk>{#cr-)RT&rb@Hl;IGnw+9M zN=Ff(RH4qd-RJ18l{H;)t6&a+4vRy1K3|5b$Jkku1f^SDlc?g&F_4MWZDu47!qd|V z42CZ{!#kSgw7pgiyOf9}N9<|tYExxS@qz)ASSMTXy^!p0W}Sy0!)diOwWDMIH1C5& z_TQGm!Yad}s4A!`F1RFgCy4Wmg$J>dj8I74XjH;)gCJgwq06DmJ`ZkU4sBXpF-P0C zdu4AtEl>k$W}7rj-pKo8x}*a9Q=wL?){M4(kge4ln@B759|-m1yAW3MX)$T%FBh(h z^1!2}za1I~LuYtn+IKi5rx5YN*+lI5K7t7mh!fza=L)=+EU68|dYt5^W|oEV_`S+8 z=GK8H+g#;fO(>M|fhvJQQv8TvPR}{&3dc z&(r0(We*_kNV4Zn5pNy-hxxODn`RbHzDWSv;zy2H@K9zXBtGLn_XpLt-BU7M|6GOC{-CCI3|~=Ux%qE?Y)2eUs~~rLRc)N zjYa8w`cF^NGX%JL1_^6@?!>dM+h7r+oMg$o(ZnGB6|>Ci$76lb^F=-ea(go6(GK-znuZ9 ziDOuc(3blAUwg&w6XA^HZ(%JOW1D$LdalObLZ36^OfExyd&^scK)5rIadO27X?3le z`?F%9Oeyz`;q0|>f7LQ_xxwW1*}h>!VzGDR(=;Y)#j}$!uVUf#;Ik+|k=GA!{)}bV z(ZiaumlkrL(3ix9LhEAR-s|fKmcKcd3y<-TYvi|lTT8|cYeBV!29!qyM?E#7mi3A+ z6h+NBU${Ul53Rf(j}@{Kz$rxBd^K+-jg-u&0Jd)|ZtbD37Q1X9LQbAVPW+g9;_($S zavip+{hdyHG-5!2j6HU&_P++~qKA)D=FGT`!;596Zk!#;?=~ON{UwyS8)4A%z`yTx z9{U<9ikd=C2oe_*a0qnP5%oFadnxQoY$+M0IXWIdH~R6{-RNm#nHi)azd#8X*ZBdI zS`ukr&BbZ^AY2k8;!lYZ-@^3|2t@Q6iwR&ppT(E7^n24+bgp8|ye}mPp1g*D<-{$f z^jtna_Yx8yGI!6e$cglGg$hXTO_X|#Ym=(e^<58s5`WRV$=f&KuBghNsl=;H|FZ~m z#d35CF+Bd(@V3#(ofG|?nOethPCwCMU{qnCW0DfUIINHmz{~{nf8~G7ftMa+*V7=1 V+x*Ep#xxg4tS#)!Yt6hK{RcYQ(zyTt literal 49012 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 NBt&F|s|EG_{|{S*X`BE6 From 45e0e39ea1e262044e1764f546943f4b436dc926 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 31 May 2022 11:39:03 +0200 Subject: [PATCH 545/583] :sparkles: add support for skeletalMesh and staticMesh to loaders --- openpype/hosts/unreal/plugins/load/load_rig.py | 2 +- openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/unreal/plugins/load/load_rig.py b/openpype/hosts/unreal/plugins/load/load_rig.py index c27bd23aaf..227c5c9292 100644 --- a/openpype/hosts/unreal/plugins/load/load_rig.py +++ b/openpype/hosts/unreal/plugins/load/load_rig.py @@ -14,7 +14,7 @@ import unreal # noqa class SkeletalMeshFBXLoader(plugin.Loader): """Load Unreal SkeletalMesh from FBX.""" - families = ["rig"] + families = ["rig", "skeletalMesh"] label = "Import FBX Skeletal Mesh" representations = ["fbx"] icon = "cube" diff --git a/openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py b/openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py index 282d249947..351c686095 100644 --- a/openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py +++ b/openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py @@ -14,7 +14,7 @@ import unreal # noqa class StaticMeshFBXLoader(plugin.Loader): """Load Unreal StaticMesh from FBX.""" - families = ["model", "unrealStaticMesh"] + families = ["model", "staticMesh"] label = "Import FBX Static Mesh" representations = ["fbx"] icon = "cube" From 9c1b1f11e97dfb7deff3c3b76a6ed9058771451b Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 31 May 2022 13:52:29 +0200 Subject: [PATCH 546/583] updated windows oiio tool to v 2.3.10 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 47d678b5e8..6b98178aa8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -126,7 +126,7 @@ url = "https://distribute.openpype.io/thirdparty/ffmpeg-4.4-macos.tgz" hash = "95f43568338c275f80dc0cab1e1836a2e2270f856f0e7b204440d881dd74fbdb" [openpype.thirdparty.oiio.windows] -url = "https://distribute.openpype.io/thirdparty/oiio_tools-2.2.0-windows.zip" +url = "https://distribute.openpype.io/thirdparty/oiio_tools-2.3.10-windows.zip" hash = "fd2e00278e01e85dcee7b4a6969d1a16f13016ec16700fb0366dbb1b1f3c37ad" [openpype.thirdparty.oiio.linux] From 5464fd40850ae1d25c1b467149249bd5edaaae9e Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 31 May 2022 13:58:11 +0200 Subject: [PATCH 547/583] OP-2787 - fix extractors could be run on a farm --- openpype/hosts/maya/plugins/publish/extract_animation.py | 3 +++ openpype/hosts/maya/plugins/publish/extract_pointcache.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/openpype/hosts/maya/plugins/publish/extract_animation.py b/openpype/hosts/maya/plugins/publish/extract_animation.py index 1ccc8f5cfe..8f2bc26d08 100644 --- a/openpype/hosts/maya/plugins/publish/extract_animation.py +++ b/openpype/hosts/maya/plugins/publish/extract_animation.py @@ -16,11 +16,14 @@ class ExtractAnimation(openpype.api.Extractor): Positions and normals, uvs, creases are preserved, but nothing more, for plain and predictable point caches. + Plugin can run locally or remotely (on a farm - if instance is marked with + "farm" it will be skipped in local processing, but processed on farm) """ label = "Extract Animation" hosts = ["maya"] families = ["animation"] + targets = ["local", "remote"] def process(self, instance): if instance.data.get("farm"): diff --git a/openpype/hosts/maya/plugins/publish/extract_pointcache.py b/openpype/hosts/maya/plugins/publish/extract_pointcache.py index ff3d97ded1..5606ea9459 100644 --- a/openpype/hosts/maya/plugins/publish/extract_pointcache.py +++ b/openpype/hosts/maya/plugins/publish/extract_pointcache.py @@ -16,6 +16,8 @@ class ExtractAlembic(openpype.api.Extractor): Positions and normals, uvs, creases are preserved, but nothing more, for plain and predictable point caches. + Plugin can run locally or remotely (on a farm - if instance is marked with + "farm" it will be skipped in local processing, but processed on farm) """ label = "Extract Pointcache (Alembic)" @@ -23,6 +25,7 @@ class ExtractAlembic(openpype.api.Extractor): families = ["pointcache", "model", "vrayproxy"] + targets = ["local", "remote"] def process(self, instance): if instance.data.get("farm"): From 376ddc41329206173f2d8f4e9d568e0aef4cebc3 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 31 May 2022 14:01:23 +0200 Subject: [PATCH 548/583] OP-2787 - added raising error for Deadline --- openpype/lib/remote_publish.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/openpype/lib/remote_publish.py b/openpype/lib/remote_publish.py index da2497e1a5..d7884d0200 100644 --- a/openpype/lib/remote_publish.py +++ b/openpype/lib/remote_publish.py @@ -60,7 +60,7 @@ def start_webpublish_log(dbcon, batch_id, user): }).inserted_id -def publish(log, close_plugin_name=None): +def publish(log, close_plugin_name=None, raise_error=False): """Loops through all plugins, logs to console. Used for tests. Args: @@ -79,10 +79,15 @@ def publish(log, close_plugin_name=None): result["plugin"].label, record.msg)) if result["error"]: - log.error(error_format.format(**result)) + error_message = error_format.format(**result) + log.error(error_message) if close_plugin: # close host app explicitly after error context = pyblish.api.Context() close_plugin().process(context) + if raise_error: + # Fatal Error is because of Deadline + error_message = "Fatal Error: " + error_format.format(**result) + raise RuntimeError(error_message) def publish_and_log(dbcon, _id, log, close_plugin_name=None, batch_id=None): From 8de1cbf7320f792e0d6cf8e2709f918a9d8c4ddd Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 31 May 2022 14:15:05 +0200 Subject: [PATCH 549/583] OP-2787 - fixed resolution order --- openpype/hosts/maya/api/pipeline.py | 16 ++++++++-------- openpype/scripts/remote_publish.py | 3 ++- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/openpype/hosts/maya/api/pipeline.py b/openpype/hosts/maya/api/pipeline.py index 6fc93e864f..0261694be2 100644 --- a/openpype/hosts/maya/api/pipeline.py +++ b/openpype/hosts/maya/api/pipeline.py @@ -66,20 +66,20 @@ def install(): log.info("Installing callbacks ... ") register_event_callback("init", on_init) - # Callbacks below are not required for headless mode, the `init` however - # is important to load referenced Alembics correctly at rendertime. + if os.environ.get("HEADLESS_PUBLISH"): + # Maya launched on farm, lib.IS_HEADLESS might be triggered locally too + # target "farm" == rendering on farm, expects OPENPYPE_PUBLISH_DATA + # target "remote" == remote execution + print("Registering pyblish target: remote") + pyblish.api.register_target("remote") + return + if lib.IS_HEADLESS: log.info(("Running in headless mode, skipping Maya " "save/open/new callback installation..")) return - if os.environ.get("HEADLESS_PUBLISH"): - # Maya launched on farm, lib.IS_HEADLESS might be triggered locally too - print("Registering pyblish target: remote") - pyblish.api.register_target("remote") - return - print("Registering pyblish target: local") pyblish.api.register_target("local") diff --git a/openpype/scripts/remote_publish.py b/openpype/scripts/remote_publish.py index b54c8d931b..8e5c91d663 100644 --- a/openpype/scripts/remote_publish.py +++ b/openpype/scripts/remote_publish.py @@ -1,6 +1,7 @@ try: from openpype.api import Logger import openpype.lib.remote_publish + import pyblish.api except ImportError as exc: # Ensure Deadline fails by output an error that contains "Fatal Error:" raise ImportError("Fatal Error: %s" % exc) @@ -8,4 +9,4 @@ except ImportError as exc: if __name__ == "__main__": # Perform remote publish with thorough error checking log = Logger.get_logger(__name__) - openpype.lib.remote_publish.publish(log) + openpype.lib.remote_publish.publish(log, raise_error=True) From 2dd79b32e7ebbc3caaf863484806569bf62ab1f3 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 31 May 2022 14:15:25 +0200 Subject: [PATCH 550/583] OP-2787 - removed unnecessary family --- .../deadline/plugins/publish/collect_publishable_instances.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/modules/deadline/plugins/publish/collect_publishable_instances.py b/openpype/modules/deadline/plugins/publish/collect_publishable_instances.py index 741a2a5af8..b00381b6cf 100644 --- a/openpype/modules/deadline/plugins/publish/collect_publishable_instances.py +++ b/openpype/modules/deadline/plugins/publish/collect_publishable_instances.py @@ -34,7 +34,6 @@ class CollectDeadlinePublishableInstances(pyblish.api.InstancePlugin): self.log.debug("Publish {}".format(subset_name)) instance.data["publish"] = True instance.data["farm"] = False - instance.data["families"].remove("deadline") else: self.log.debug("Skipping {}".format(subset_name)) instance.data["publish"] = False From d37815467a059cfa2ad2dbde6ce4025ec00def2d Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 31 May 2022 14:20:50 +0200 Subject: [PATCH 551/583] OP-2787 - added extracted path to explicit cleanup --- openpype/hosts/maya/plugins/publish/extract_animation.py | 2 ++ openpype/hosts/maya/plugins/publish/extract_pointcache.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/openpype/hosts/maya/plugins/publish/extract_animation.py b/openpype/hosts/maya/plugins/publish/extract_animation.py index 8f2bc26d08..abe5ed3bf5 100644 --- a/openpype/hosts/maya/plugins/publish/extract_animation.py +++ b/openpype/hosts/maya/plugins/publish/extract_animation.py @@ -95,4 +95,6 @@ class ExtractAnimation(openpype.api.Extractor): } instance.data["representations"].append(representation) + instance.context.data["cleanupFullPaths"].append(path) + self.log.info("Extracted {} to {}".format(instance, dirname)) diff --git a/openpype/hosts/maya/plugins/publish/extract_pointcache.py b/openpype/hosts/maya/plugins/publish/extract_pointcache.py index 5606ea9459..c4c8610ebb 100644 --- a/openpype/hosts/maya/plugins/publish/extract_pointcache.py +++ b/openpype/hosts/maya/plugins/publish/extract_pointcache.py @@ -98,4 +98,6 @@ class ExtractAlembic(openpype.api.Extractor): } instance.data["representations"].append(representation) + instance.context.data["cleanupFullPaths"].append(path) + self.log.info("Extracted {} to {}".format(instance, dirname)) From 0d7d43316b94685f78fb0a7e2e39153a98996b36 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 31 May 2022 14:44:37 +0200 Subject: [PATCH 552/583] OP-2787 - changed class to api.Integrator This plugin should run only locally, not no a farm. --- .../plugins/publish/submit_maya_remote_publish_deadline.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py b/openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py index 196adc5906..c31052be07 100644 --- a/openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py @@ -5,11 +5,12 @@ from maya import cmds from openpype.pipeline import legacy_io, PublishXmlValidationError from openpype.settings import get_project_settings +import openpype.api import pyblish.api -class MayaSubmitRemotePublishDeadline(pyblish.api.InstancePlugin): +class MayaSubmitRemotePublishDeadline(openpype.api.Integrator): """Submit Maya scene to perform a local publish in Deadline. Publishing in Deadline can be helpful for scenes that publish very slow. From 55a69074c6b550b4e30d99b658b9ec664d30214b Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 31 May 2022 14:46:05 +0200 Subject: [PATCH 553/583] OP-2787 - Hound --- openpype/scripts/remote_publish.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/scripts/remote_publish.py b/openpype/scripts/remote_publish.py index 8e5c91d663..d322f369d1 100644 --- a/openpype/scripts/remote_publish.py +++ b/openpype/scripts/remote_publish.py @@ -1,7 +1,6 @@ try: from openpype.api import Logger import openpype.lib.remote_publish - import pyblish.api except ImportError as exc: # Ensure Deadline fails by output an error that contains "Fatal Error:" raise ImportError("Fatal Error: %s" % exc) From 26332ef0c8e7eccc1022b95ef883c6850a907681 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 31 May 2022 15:00:15 +0200 Subject: [PATCH 554/583] fix file hash --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 6b98178aa8..d398257f6b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -127,7 +127,7 @@ hash = "95f43568338c275f80dc0cab1e1836a2e2270f856f0e7b204440d881dd74fbdb" [openpype.thirdparty.oiio.windows] url = "https://distribute.openpype.io/thirdparty/oiio_tools-2.3.10-windows.zip" -hash = "fd2e00278e01e85dcee7b4a6969d1a16f13016ec16700fb0366dbb1b1f3c37ad" +hash = "b9950f5d2fa3720b52b8be55bacf5f56d33f9e029d38ee86534995f3d8d253d2" [openpype.thirdparty.oiio.linux] url = "https://distribute.openpype.io/thirdparty/oiio_tools-2.2.12-linux.tgz" From b073853a0b1d056a0976b5a3f983184bfb195d2e Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 31 May 2022 18:58:13 +0200 Subject: [PATCH 555/583] Update openpype/settings/entities/schemas/projects_schema/schema_project_maya.json Co-authored-by: Milan Kolar --- .../entities/schemas/projects_schema/schema_project_maya.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_maya.json b/openpype/settings/entities/schemas/projects_schema/schema_project_maya.json index f9523b1baa..f7d92c385e 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_maya.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_maya.json @@ -25,7 +25,7 @@ { "type": "boolean", "key": "use_env_var_as_root", - "label": "Use env var placeholder in referenced url", + "label": "Use env var placeholder in referenced paths", "docstring": "Use ${} placeholder instead of physical value of root when storing into workfile metadata." }, { From 6530fbd918f795077f0a715827e101c97b4bc8ea Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 31 May 2022 18:58:28 +0200 Subject: [PATCH 556/583] Update openpype/settings/entities/schemas/projects_schema/schema_project_maya.json Co-authored-by: Milan Kolar --- .../entities/schemas/projects_schema/schema_project_maya.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_maya.json b/openpype/settings/entities/schemas/projects_schema/schema_project_maya.json index f7d92c385e..40e98b0333 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_maya.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_maya.json @@ -26,7 +26,7 @@ "type": "boolean", "key": "use_env_var_as_root", "label": "Use env var placeholder in referenced paths", - "docstring": "Use ${} placeholder instead of physical value of root when storing into workfile metadata." + "docstring": "Use ${} placeholder instead of absolute value of a root in referenced filepaths." }, { "type": "boolean", From 5be6f27eb19c76e78ec197358930f2d3de417c4d Mon Sep 17 00:00:00 2001 From: OpenPype Date: Wed, 1 Jun 2022 04:12:29 +0000 Subject: [PATCH 557/583] [Automated] Bump version --- CHANGELOG.md | 29 +++++++++++++---------------- openpype/version.py | 2 +- pyproject.toml | 2 +- 3 files changed, 15 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a5e1f1067..6613985ccf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,20 +1,28 @@ # Changelog -## [3.10.1-nightly.1](https://github.com/pypeclub/OpenPype/tree/HEAD) +## [3.10.1-nightly.2](https://github.com/pypeclub/OpenPype/tree/HEAD) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.10.0...HEAD) **πŸš€ Enhancements** +- General: Updated windows oiio tool [\#3268](https://github.com/pypeclub/OpenPype/pull/3268) - TVPaint: Init file for TVPaint worker also handle guideline images [\#3250](https://github.com/pypeclub/OpenPype/pull/3250) -- Support for Unreal 5 [\#3122](https://github.com/pypeclub/OpenPype/pull/3122) +- Nuke: Change default icon path in settings [\#3247](https://github.com/pypeclub/OpenPype/pull/3247) **πŸ› Bug fixes** +- Nuke: bake reformat was failing on string type [\#3261](https://github.com/pypeclub/OpenPype/pull/3261) +- Maya: hotfix Pxr multitexture in looks [\#3260](https://github.com/pypeclub/OpenPype/pull/3260) - Unreal: Fix Camera Loading if Layout is missing [\#3255](https://github.com/pypeclub/OpenPype/pull/3255) - Unreal: Fixed Animation loading in UE5 [\#3240](https://github.com/pypeclub/OpenPype/pull/3240) - Unreal: Fixed Render creation in UE5 [\#3239](https://github.com/pypeclub/OpenPype/pull/3239) - Unreal: Fixed Camera loading in UE5 [\#3238](https://github.com/pypeclub/OpenPype/pull/3238) +- Flame: debugging [\#3224](https://github.com/pypeclub/OpenPype/pull/3224) + +**Merged pull requests:** + +- Nuke: add pointcache and animation to loader [\#3186](https://github.com/pypeclub/OpenPype/pull/3186) ## [3.10.0](https://github.com/pypeclub/OpenPype/tree/3.10.0) (2022-05-26) @@ -25,7 +33,6 @@ - General: OpenPype modules publish plugins are registered in host [\#3180](https://github.com/pypeclub/OpenPype/pull/3180) - General: Creator plugins from addons can be registered [\#3179](https://github.com/pypeclub/OpenPype/pull/3179) - Ftrack: Single image reviewable [\#3157](https://github.com/pypeclub/OpenPype/pull/3157) -- Nuke: Expose write attributes to settings [\#3123](https://github.com/pypeclub/OpenPype/pull/3123) **πŸš€ Enhancements** @@ -45,8 +52,6 @@ - Houdini: Add loader for alembic through Alembic Archive node [\#3140](https://github.com/pypeclub/OpenPype/pull/3140) - Publisher: UI Modifications and fixes [\#3139](https://github.com/pypeclub/OpenPype/pull/3139) - General: Simplified OP modules/addons import [\#3137](https://github.com/pypeclub/OpenPype/pull/3137) -- Terminal: Tweak coloring of TrayModuleManager logging enabled states [\#3133](https://github.com/pypeclub/OpenPype/pull/3133) -- General: Cleanup some Loader docstrings [\#3131](https://github.com/pypeclub/OpenPype/pull/3131) **πŸ› Bug fixes** @@ -70,17 +75,18 @@ - General: Oiio conversion for ffmpeg checks for invalid characters [\#3166](https://github.com/pypeclub/OpenPype/pull/3166) - Fix for attaching render to subset [\#3164](https://github.com/pypeclub/OpenPype/pull/3164) - Harmony: fixed missing task name in render instance [\#3163](https://github.com/pypeclub/OpenPype/pull/3163) +- add silent audio to slate [\#3162](https://github.com/pypeclub/OpenPype/pull/3162) - Ftrack: Action delete old versions formatting works [\#3152](https://github.com/pypeclub/OpenPype/pull/3152) +- nuke: adding extract thumbnail settings [\#3148](https://github.com/pypeclub/OpenPype/pull/3148) - Deadline: fix the output directory [\#3144](https://github.com/pypeclub/OpenPype/pull/3144) - General: New Session schema [\#3141](https://github.com/pypeclub/OpenPype/pull/3141) - General: Missing version on headless mode crash properly [\#3136](https://github.com/pypeclub/OpenPype/pull/3136) - TVPaint: Composite layers in reversed order [\#3135](https://github.com/pypeclub/OpenPype/pull/3135) -- Nuke: fix anatomy imageio regex default [\#3119](https://github.com/pypeclub/OpenPype/pull/3119) +- TVPaint: Composite layers in reversed order [\#3134](https://github.com/pypeclub/OpenPype/pull/3134) **πŸ”€ Refactored code** - Avalon repo removed from Jobs workflow [\#3193](https://github.com/pypeclub/OpenPype/pull/3193) -- General: Remove remaining imports from avalon [\#3130](https://github.com/pypeclub/OpenPype/pull/3130) **Merged pull requests:** @@ -128,7 +134,6 @@ **πŸ› Bug fixes** - Ftrack: Action delete old versions formatting works [\#3154](https://github.com/pypeclub/OpenPype/pull/3154) -- nuke: adding extract thumbnail settings [\#3148](https://github.com/pypeclub/OpenPype/pull/3148) **Merged pull requests:** @@ -138,14 +143,6 @@ [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.9.5...3.9.6) -**πŸ†• New features** - -- Nuke: render instance with subset name filtered overrides \(3.9.x\) [\#3125](https://github.com/pypeclub/OpenPype/pull/3125) - -**πŸ› Bug fixes** - -- TVPaint: Composite layers in reversed order [\#3134](https://github.com/pypeclub/OpenPype/pull/3134) - ## [3.9.5](https://github.com/pypeclub/OpenPype/tree/3.9.5) (2022-04-25) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.10.0-nightly.2...3.9.5) diff --git a/openpype/version.py b/openpype/version.py index 12f25cdcea..1d8ef28225 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.10.1-nightly.1" +__version__ = "3.10.1-nightly.2" diff --git a/pyproject.toml b/pyproject.toml index d398257f6b..27b32cf53b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "OpenPype" -version = "3.10.1-nightly.1" # OpenPype +version = "3.10.1-nightly.2" # OpenPype description = "Open VFX and Animation pipeline with support." authors = ["OpenPype Team "] license = "MIT License" From a10bbbcc79b5a369d51bb4716164611edcb4dbfa Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 1 Jun 2022 12:33:09 +0200 Subject: [PATCH 558/583] OP-3068 - better handling of legacy review subsets in Maya Multiple reviews from a Maya workfile are blocked by legacy subset names without variant. These names could be used in later process so we cannot replace them. Unique subset name validator was added, check for existing subset in DB too. --- .../maya/plugins/publish/collect_review.py | 17 +++++---- .../validate_review_subset_uniqueness.xml | 28 +++++++++++++++ .../validate_review_subset_uniqueness.py | 36 +++++++++++++++++++ 3 files changed, 72 insertions(+), 9 deletions(-) create mode 100644 openpype/hosts/maya/plugins/publish/help/validate_review_subset_uniqueness.xml create mode 100644 openpype/hosts/maya/plugins/publish/validate_review_subset_uniqueness.py diff --git a/openpype/hosts/maya/plugins/publish/collect_review.py b/openpype/hosts/maya/plugins/publish/collect_review.py index 1af92c3bfc..e9e0d74c03 100644 --- a/openpype/hosts/maya/plugins/publish/collect_review.py +++ b/openpype/hosts/maya/plugins/publish/collect_review.py @@ -77,15 +77,14 @@ class CollectReview(pyblish.api.InstancePlugin): instance.data['remove'] = True self.log.debug('isntance data {}'.format(instance.data)) else: - if self.legacy: - instance.data['subset'] = task + 'Review' - else: - subset = "{}{}{}".format( - task, - instance.data["subset"][0].upper(), - instance.data["subset"][1:] - ) - instance.data['subset'] = subset + legacy_subset_name = task + 'Review' + asset_doc_id = instance.context.data['assetEntity']["_id"] + subsets = legacy_io.find({"type": "subset", + "name": legacy_subset_name, + "parent": asset_doc_id}).distinct("_id") + if len(list(subsets)) > 0: + self.log.debug("Existing subsets found, keep legacy name.") + instance.data['subset'] = legacy_subset_name instance.data['review_camera'] = camera instance.data['frameStartFtrack'] = \ diff --git a/openpype/hosts/maya/plugins/publish/help/validate_review_subset_uniqueness.xml b/openpype/hosts/maya/plugins/publish/help/validate_review_subset_uniqueness.xml new file mode 100644 index 0000000000..fd1bf4cbaa --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/help/validate_review_subset_uniqueness.xml @@ -0,0 +1,28 @@ + + + + Review subsets not unique + + ## Non unique subset name found + + Non unique subset names: '{non_unique}' + + ### __Detailed Info__ (optional) + + This might happen if you already published for this asset + review subset with legacy name {task}Review. + This legacy name limits possibility of publishing of multiple + reviews from a single workfile. Proper review subset name should + now + contain variant also (as 'Main', 'Default' etc.). That would + result in completely new subset though, so this situation must + be handled manually. + + ### How to repair? + + Legacy subsets must be removed from Openpype DB, please ask admin + to do that. Please provide them asset and subset names. + + + + \ No newline at end of file diff --git a/openpype/hosts/maya/plugins/publish/validate_review_subset_uniqueness.py b/openpype/hosts/maya/plugins/publish/validate_review_subset_uniqueness.py new file mode 100644 index 0000000000..d70096ee45 --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/validate_review_subset_uniqueness.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +import collections +import pyblish.api +import openpype.api +from openpype.pipeline import PublishXmlValidationError + + +class ValidateReviewSubsetUniqueness(pyblish.api.ContextPlugin): + """Validates that nodes has common root.""" + + order = openpype.api.ValidateContentsOrder + hosts = ["maya"] + families = ["review"] + label = "Validate Review Subset Unique" + + def process(self, context): + subset_names = [] + + for instance in context: + self.log.info("instance:: {}".format(instance.data)) + if instance.data.get('publish'): + subset_names.append(instance.data.get('subset')) + + non_unique = \ + [item + for item, count in collections.Counter(subset_names).items() + if count > 1] + msg = ("Instance subset names {} are not unique. ".format(non_unique) + + "Ask admin to remove subset from DB for multiple reviews.") + formatting_data = { + "non_unique": ",".join(non_unique) + } + + if non_unique: + raise PublishXmlValidationError(self, msg, + formatting_data=formatting_data) From 6a5fa89348bb822e1f2c8748fa867f19e2f70a8d Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 1 Jun 2022 14:27:55 +0200 Subject: [PATCH 559/583] Fix - change default of use_env_var_as_root True was there only for testing, false is more sane default. --- openpype/settings/defaults/project_settings/maya.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/settings/defaults/project_settings/maya.json b/openpype/settings/defaults/project_settings/maya.json index a42f889e85..efd22e13c8 100644 --- a/openpype/settings/defaults/project_settings/maya.json +++ b/openpype/settings/defaults/project_settings/maya.json @@ -8,7 +8,7 @@ "yetiRig": "ma" }, "maya-dirmap": { - "use_env_var_as_root": true, + "use_env_var_as_root": false, "enabled": false, "paths": { "source-path": [], From 8b43c5e733117d528a164f45a6112b6d76a53e42 Mon Sep 17 00:00:00 2001 From: Thomas Fricard Date: Wed, 4 May 2022 12:16:19 +0200 Subject: [PATCH 560/583] add a tab in nuke project settings for gizmos --- openpype/hosts/nuke/startup/menu.py | 1 - .../defaults/project_settings/nuke.json | 22 ++++++++++ .../projects_schema/schema_project_nuke.json | 4 ++ .../schemas/schema_nuke_scriptsgizmo.json | 42 +++++++++++++++++++ 4 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_scriptsgizmo.json diff --git a/openpype/hosts/nuke/startup/menu.py b/openpype/hosts/nuke/startup/menu.py index 49edb22a89..eea2d940f8 100644 --- a/openpype/hosts/nuke/startup/menu.py +++ b/openpype/hosts/nuke/startup/menu.py @@ -31,7 +31,6 @@ nuke.addFilenameFilter(dirmap_file_name_filter) log.info('Automatic syncing of write file knob to script version') - def add_scripts_menu(): try: from scriptsmenu import launchfornuke diff --git a/openpype/settings/defaults/project_settings/nuke.json b/openpype/settings/defaults/project_settings/nuke.json index dc8ffcebff..a10b88464c 100644 --- a/openpype/settings/defaults/project_settings/nuke.json +++ b/openpype/settings/defaults/project_settings/nuke.json @@ -290,5 +290,27 @@ } ] }, + "gizmo": [ + { + "toolbar_menu_name": "FixStudio", + "toolbar_icon_path": "{QUAD_PLUGIN_PATH}/nuke/icons/fixstudio.png", + "gizmo_path": ["{QUAD_PLUGIN_PATH}/nuke/gizmos"], + "gizmo_definition": [ + { + "type": "menu", + "title": "3D", + "items": [ + { + "type": "action", + "command": "nuke.createNode('Camera_Smoother')", + "sourcetype": "python", + "title": "Camera_Smoother" + } + + ] + } + ] + } + ], "filters": {} } \ No newline at end of file diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_nuke.json b/openpype/settings/entities/schemas/projects_schema/schema_project_nuke.json index 1ae4efd8ea..03d67a57ba 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_nuke.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_nuke.json @@ -83,6 +83,10 @@ "type": "schema", "name": "schema_scriptsmenu" }, + { + "type": "schema", + "name": "schema_nuke_scriptsgizmo" + }, { "type": "dict", "collapsible": true, diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_scriptsgizmo.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_scriptsgizmo.json new file mode 100644 index 0000000000..c1e67842ce --- /dev/null +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_scriptsgizmo.json @@ -0,0 +1,42 @@ +{ + "type": "list", + "key": "gizmo", + "label": "Gizmo Menu", + "is_group": true, + "use_label_wrap": true, + "object_type": { + "type": "dict", + "children": [ + { + "type": "text", + "key": "toolbar_menu_name", + "label": "Toolbar Menu Name" + }, + { + "type": "path", + "key": "toolbar_icon_path", + "label": "Toolbar Icon Path", + "multipath": false + }, + { + "type": "splitter" + }, + { + "type": "label", + "label": "Absolute path to gizmo folders." + }, + { + "type": "path", + "key": "gizmo_path", + "label": "Gizmo Path", + "multipath": true + }, + { + "type": "raw-json", + "key": "gizmo_definition", + "label": "Gizmo definition", + "is_list": true + } + ] + } +} \ No newline at end of file From 25518a1c42ff9958f9bb9763b0792dd28e8cc691 Mon Sep 17 00:00:00 2001 From: Thomas Fricard Date: Wed, 4 May 2022 17:01:32 +0200 Subject: [PATCH 561/583] generate toolbar menu from openpype project settings --- openpype/hosts/nuke/api/lib.py | 48 +++++++++++++++ openpype/hosts/nuke/startup/menu.py | 59 ++++++++++++++++++- .../defaults/project_settings/nuke.json | 9 ++- 3 files changed, 110 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index f40425eefc..a1ac50ae1a 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -30,6 +30,8 @@ from openpype.pipeline import ( legacy_io, ) +from . import gizmo_menu + from .workio import ( save_file, open_file @@ -2498,6 +2500,52 @@ def recreate_instance(origin_node, avalon_data=None): return new_node +def find_scripts_gizmo(title, parent): + """ + Check if the menu exists with the given title in the parent + + Args: + title (str): the title name of the scripts menu + + parent (QtWidgets.QMenuBar): the menubar to check + + Returns: + QtWidgets.QMenu or None + + """ + + menu = None + search = [i for i in parent.items() if + isinstance(i, gizmo_menu.GizmoMenu) + and i.title() == title] + + if search: + assert len(search) < 2, ("Multiple instances of menu '{}' " + "in toolbar".format(title)) + menu = search[0] + + return menu + + +def gizmo_creation(title="Gizmos", parent=None, objectName=None, icon=None): + try: + toolbar = find_scripts_gizmo(title, parent) + if not toolbar: + log.info("Attempting to build toolbar...") + object_name = objectName or title.lower() + toolbar = gizmo_menu.GizmoMenu( + title=title, + parent=parent, + objectName=object_name, + icon=icon + ) + except Exception as e: + log.error(e) + return + + return toolbar + + class NukeDirmap(HostDirmap): def __init__(self, host_name, project_settings, sync_module, file_name): """ diff --git a/openpype/hosts/nuke/startup/menu.py b/openpype/hosts/nuke/startup/menu.py index eea2d940f8..0f587fc62a 100644 --- a/openpype/hosts/nuke/startup/menu.py +++ b/openpype/hosts/nuke/startup/menu.py @@ -2,7 +2,7 @@ import nuke import os from openpype.api import Logger -from openpype.pipeline import install_host +from openpype.settings import get_project_settings from openpype.hosts.nuke import api from openpype.hosts.nuke.api.lib import ( on_script_load, @@ -31,6 +31,7 @@ nuke.addFilenameFilter(dirmap_file_name_filter) log.info('Automatic syncing of write file knob to script version') + def add_scripts_menu(): try: from scriptsmenu import launchfornuke @@ -58,3 +59,59 @@ def add_scripts_menu(): add_scripts_menu() + + +def add_scripts_gizmo(): + try: + from openpype.hosts.nuke.api import lib + except ImportError: + log.warning( + "Skipping studio.gizmo install, because " + "'scriptsgizmo' module seems unavailable." + ) + return + + for gizmo in project_settings["nuke"]["gizmo"]: + config = gizmo["gizmo_definition"] + toolbar_name = gizmo["toolbar_menu_name"] + gizmo_path = gizmo["gizmo_path"] + icon = gizmo['toolbar_icon_path'] + + if not any(gizmo_path): + log.warning("Skipping studio gizmo, no gizmo path found.") + return + + if not config: + log.warning("Skipping studio gizmo, no definition found.") + return + + try: + icon = icon.format(**os.environ) + except KeyError as e: + log.warning(f"This environment variable doesn't exist: {e}") + + for gizmo in gizmo_path: + try: + gizmo = gizmo.format(**os.environ) + gizmo_path.append(gizmo) + gizmo_path.pop(0) + except KeyError as e: + log.warning(f"This environment variable doesn't exist: {e}") + + nuke_toolbar = nuke.menu("Nodes") + toolbar = nuke_toolbar.addMenu(toolbar_name, icon=icon) + + # run the launcher for Nuke toolbar + studio_menu = lib.gizmo_creation( + title=toolbar_name, + parent=toolbar, + objectName=toolbar_name.lower().replace(" ", "_"), + icon=icon + ) + + # apply configuration + studio_menu.add_gizmo_path(gizmo_path) + studio_menu.build_from_configuration(toolbar, config) + + +add_scripts_gizmo() diff --git a/openpype/settings/defaults/project_settings/nuke.json b/openpype/settings/defaults/project_settings/nuke.json index a10b88464c..48bbbf0dcc 100644 --- a/openpype/settings/defaults/project_settings/nuke.json +++ b/openpype/settings/defaults/project_settings/nuke.json @@ -293,8 +293,8 @@ "gizmo": [ { "toolbar_menu_name": "FixStudio", - "toolbar_icon_path": "{QUAD_PLUGIN_PATH}/nuke/icons/fixstudio.png", - "gizmo_path": ["{QUAD_PLUGIN_PATH}/nuke/gizmos"], + "toolbar_icon_path": "openpype/modules/quad/nuke/icons/fixstudio.png", + "gizmo_path": ["openpype/modules/quad/nuke/gizmos/3D"], "gizmo_definition": [ { "type": "menu", @@ -302,11 +302,10 @@ "items": [ { "type": "action", - "command": "nuke.createNode('Camera_Smoother')", "sourcetype": "python", - "title": "Camera_Smoother" + "title": "Camera Smoother", + "command": "nuke.createNodes('Camera_Smoother)" } - ] } ] From af259d215e08d54cca7dd01754a03f8b0863aefe Mon Sep 17 00:00:00 2001 From: Thomas Fricard Date: Mon, 9 May 2022 15:12:16 +0200 Subject: [PATCH 562/583] refactor default gizmo --- openpype/settings/defaults/project_settings/nuke.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/settings/defaults/project_settings/nuke.json b/openpype/settings/defaults/project_settings/nuke.json index 48bbbf0dcc..d9b443c958 100644 --- a/openpype/settings/defaults/project_settings/nuke.json +++ b/openpype/settings/defaults/project_settings/nuke.json @@ -293,8 +293,8 @@ "gizmo": [ { "toolbar_menu_name": "FixStudio", - "toolbar_icon_path": "openpype/modules/quad/nuke/icons/fixstudio.png", - "gizmo_path": ["openpype/modules/quad/nuke/gizmos/3D"], + "toolbar_icon_path": "path/to/nuke/icon.png", + "gizmo_path": ["path/to/nuke/gizmo"], "gizmo_definition": [ { "type": "menu", @@ -304,7 +304,7 @@ "type": "action", "sourcetype": "python", "title": "Camera Smoother", - "command": "nuke.createNodes('Camera_Smoother)" + "command": "nuke.createNode('Camera_Smoother')" } ] } From 5bc741d993b67a3e9ebfd74b5cc711caa56e06fb Mon Sep 17 00:00:00 2001 From: Thomas Fricard Date: Mon, 9 May 2022 15:13:50 +0200 Subject: [PATCH 563/583] add gizmo_menu module --- openpype/hosts/nuke/api/gizmo_menu.py | 77 +++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 openpype/hosts/nuke/api/gizmo_menu.py diff --git a/openpype/hosts/nuke/api/gizmo_menu.py b/openpype/hosts/nuke/api/gizmo_menu.py new file mode 100644 index 0000000000..56532ed1dc --- /dev/null +++ b/openpype/hosts/nuke/api/gizmo_menu.py @@ -0,0 +1,77 @@ +import os +import logging +import nuke + +log = logging.getLogger(__name__) + + +class GizmoMenu(): + def __init__(self, *args, **kwargs): + self._script_actions = [] + + def build_from_configuration(self, parent, configuration): + for item in configuration: + assert isinstance(item, dict), "Configuration is wrong!" + + # skip items which have no `type` key + item_type = item.get('type', None) + if not item_type: + log.warning("Missing 'type' from configuration item") + continue + + if item_type == "action": + # filter out `type` from the item dict + config = {key: value for key, value in + item.items() if key != "type"} + + command = config['command'] + + if command.find('{pipe_path}') > -1: + command = command.format( + pipe_path=os.environ['QUAD_PLUGIN_PATH'] + ) + + icon = config.get('icon', None) + if icon: + try: + icon = icon.format(**os.environ) + except KeyError as e: + log.warning(f"This environment variable doesn't exist: {e}'") + + hotkey = config.get('hotkey', None) + + parent.addCommand( + config['title'], + command=command, + icon=icon, + shortcut=hotkey + ) + + # add separator + # Special behavior for separators + if item_type == "separator": + parent.addSeparator() + + # add submenu + # items should hold a collection of submenu items (dict) + elif item_type == "menu": + assert "items" in item, "Menu is missing 'items' key" + + icon = item.get('icon', None) + if icon: + try: + icon = icon.format(**os.environ) + except KeyError as e: + log.warning(f"This environment variable doesn't exist: {e}'") + menu = parent.addMenu(item['title'], icon=icon) + self.build_from_configuration(menu, item["items"]) + + def add_gizmo_path(self, gizmo_paths): + for gizmo_path in gizmo_paths: + if os.path.isdir(gizmo_path): + for folder in os.listdir(gizmo_path): + if os.path.isdir(os.path.join(gizmo_path, folder)): + nuke.pluginAddPath(os.path.join(gizmo_path, folder)) + nuke.pluginAddPath(gizmo_path) + else: + log.warning(f"This path doesn't exist: {gizmo_path}") From 94356faa9c16f359dca2e94a726e79f16253e1d3 Mon Sep 17 00:00:00 2001 From: Thomas Fricard Date: Wed, 11 May 2022 11:46:57 +0200 Subject: [PATCH 564/583] fix install_host import --- openpype/hosts/nuke/startup/menu.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/nuke/startup/menu.py b/openpype/hosts/nuke/startup/menu.py index 0f587fc62a..88c727aaa6 100644 --- a/openpype/hosts/nuke/startup/menu.py +++ b/openpype/hosts/nuke/startup/menu.py @@ -2,7 +2,7 @@ import nuke import os from openpype.api import Logger -from openpype.settings import get_project_settings +from openpype.pipeline import install_host from openpype.hosts.nuke import api from openpype.hosts.nuke.api.lib import ( on_script_load, From 0696d505c2cf9eb53c10afe2c2ffa6b73f3cbe7b Mon Sep 17 00:00:00 2001 From: Thomas Fricard Date: Thu, 12 May 2022 11:12:08 +0200 Subject: [PATCH 565/583] set the default gizmo to a sticky note --- .../settings/defaults/project_settings/nuke.json | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/openpype/settings/defaults/project_settings/nuke.json b/openpype/settings/defaults/project_settings/nuke.json index d9b443c958..06679ac314 100644 --- a/openpype/settings/defaults/project_settings/nuke.json +++ b/openpype/settings/defaults/project_settings/nuke.json @@ -292,21 +292,15 @@ }, "gizmo": [ { - "toolbar_menu_name": "FixStudio", + "toolbar_menu_name": "OpenPype Gizmo", "toolbar_icon_path": "path/to/nuke/icon.png", "gizmo_path": ["path/to/nuke/gizmo"], "gizmo_definition": [ { - "type": "menu", - "title": "3D", - "items": [ - { - "type": "action", - "sourcetype": "python", - "title": "Camera Smoother", - "command": "nuke.createNode('Camera_Smoother')" - } - ] + "type": "action", + "sourcetype": "python", + "title": "Gizmo Note", + "command": "nuke.nodes.StickyNote(label='You can create your own toolbar menu in the Nuke GizmoMenu of OpenPype')" } ] } From bd7243f8aa1dd64d3bad4ef822eb24e4ee70e4d4 Mon Sep 17 00:00:00 2001 From: Thomas Fricard Date: Thu, 12 May 2022 11:20:55 +0200 Subject: [PATCH 566/583] add the docs for the gizmo menu --- website/docs/admin_hosts_nuke.md | 4 ++++ website/docs/assets/nuke-admin_gizmomenu.png | Bin 0 -> 34803 bytes 2 files changed, 4 insertions(+) create mode 100644 website/docs/assets/nuke-admin_gizmomenu.png diff --git a/website/docs/admin_hosts_nuke.md b/website/docs/admin_hosts_nuke.md index 46f596a2dc..bab63223ce 100644 --- a/website/docs/admin_hosts_nuke.md +++ b/website/docs/admin_hosts_nuke.md @@ -12,3 +12,7 @@ You can add your custom tools menu into Nuke by extending definitions in **Nuke This is still work in progress. Menu definition will be handled more friendly with widgets and not raw json. ::: + +## Gizmo Menu +You can add your custom toolbar menu into Nuke by setting your gizmo path and extending definitions in **Nuke -> Gizmo Menu**. +![Custom menu definition](assets/nuke-admin_gizmomenu.png) diff --git a/website/docs/assets/nuke-admin_gizmomenu.png b/website/docs/assets/nuke-admin_gizmomenu.png new file mode 100644 index 0000000000000000000000000000000000000000..81e63b20418896173bf5f6417ce2515780e2bcdf GIT binary patch literal 34803 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 KXW7E9J^v4^Goq*f literal 0 HcmV?d00001 From 08e04911b3c100ca1dba8d1107a33cf689c98e79 Mon Sep 17 00:00:00 2001 From: "clement.hector" Date: Thu, 12 May 2022 15:55:11 +0200 Subject: [PATCH 567/583] fix string format for python2 --- openpype/hosts/nuke/api/gizmo_menu.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/nuke/api/gizmo_menu.py b/openpype/hosts/nuke/api/gizmo_menu.py index 56532ed1dc..c1132792d0 100644 --- a/openpype/hosts/nuke/api/gizmo_menu.py +++ b/openpype/hosts/nuke/api/gizmo_menu.py @@ -36,7 +36,8 @@ class GizmoMenu(): try: icon = icon.format(**os.environ) except KeyError as e: - log.warning(f"This environment variable doesn't exist: {e}'") + log.warning("This environment variable doesn't exist: " + "{}".format(e)) hotkey = config.get('hotkey', None) @@ -62,7 +63,8 @@ class GizmoMenu(): try: icon = icon.format(**os.environ) except KeyError as e: - log.warning(f"This environment variable doesn't exist: {e}'") + log.warning("This environment variable doesn't exist: " + "{}".format(e)) menu = parent.addMenu(item['title'], icon=icon) self.build_from_configuration(menu, item["items"]) @@ -74,4 +76,4 @@ class GizmoMenu(): nuke.pluginAddPath(os.path.join(gizmo_path, folder)) nuke.pluginAddPath(gizmo_path) else: - log.warning(f"This path doesn't exist: {gizmo_path}") + log.warning("This path doesn't exist: {}".format(gizmo_path)) From eb590602c329b90d8b1e40aeda4ae04287e01a06 Mon Sep 17 00:00:00 2001 From: "clement.hector" Date: Wed, 1 Jun 2022 17:31:19 +0200 Subject: [PATCH 568/583] make it works on nuke 12 --- openpype/hosts/nuke/api/gizmo_menu.py | 2 +- openpype/hosts/nuke/startup/menu.py | 11 +++++++++-- openpype/settings/defaults/project_settings/nuke.json | 2 +- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/nuke/api/gizmo_menu.py b/openpype/hosts/nuke/api/gizmo_menu.py index c1132792d0..a541fd3ab1 100644 --- a/openpype/hosts/nuke/api/gizmo_menu.py +++ b/openpype/hosts/nuke/api/gizmo_menu.py @@ -24,7 +24,7 @@ class GizmoMenu(): config = {key: value for key, value in item.items() if key != "type"} - command = config['command'] + command = str(config['command']) if command.find('{pipe_path}') > -1: command = command.format( diff --git a/openpype/hosts/nuke/startup/menu.py b/openpype/hosts/nuke/startup/menu.py index 88c727aaa6..6c076fc87b 100644 --- a/openpype/hosts/nuke/startup/menu.py +++ b/openpype/hosts/nuke/startup/menu.py @@ -71,6 +71,9 @@ def add_scripts_gizmo(): ) return + # load configuration of custom menu + project_settings = get_project_settings(os.getenv("AVALON_PROJECT")) + for gizmo in project_settings["nuke"]["gizmo"]: config = gizmo["gizmo_definition"] toolbar_name = gizmo["toolbar_menu_name"] @@ -88,7 +91,9 @@ def add_scripts_gizmo(): try: icon = icon.format(**os.environ) except KeyError as e: - log.warning(f"This environment variable doesn't exist: {e}") + log.warning( + "This environment variable doesn't exist: {}".format(e) + ) for gizmo in gizmo_path: try: @@ -96,7 +101,9 @@ def add_scripts_gizmo(): gizmo_path.append(gizmo) gizmo_path.pop(0) except KeyError as e: - log.warning(f"This environment variable doesn't exist: {e}") + log.warning( + "This environment variable doesn't exist: {}".format(e) + ) nuke_toolbar = nuke.menu("Nodes") toolbar = nuke_toolbar.addMenu(toolbar_name, icon=icon) diff --git a/openpype/settings/defaults/project_settings/nuke.json b/openpype/settings/defaults/project_settings/nuke.json index 06679ac314..6c6454de36 100644 --- a/openpype/settings/defaults/project_settings/nuke.json +++ b/openpype/settings/defaults/project_settings/nuke.json @@ -306,4 +306,4 @@ } ], "filters": {} -} \ No newline at end of file +} From 8f9c08549292a58b7beccf3ae8a9477b2aac1020 Mon Sep 17 00:00:00 2001 From: "clement.hector" Date: Wed, 1 Jun 2022 20:40:28 +0200 Subject: [PATCH 569/583] beter loop check --- openpype/hosts/nuke/startup/menu.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/nuke/startup/menu.py b/openpype/hosts/nuke/startup/menu.py index 6c076fc87b..715bab8ea5 100644 --- a/openpype/hosts/nuke/startup/menu.py +++ b/openpype/hosts/nuke/startup/menu.py @@ -95,15 +95,21 @@ def add_scripts_gizmo(): "This environment variable doesn't exist: {}".format(e) ) + existing_gizmo_path = [] for gizmo in gizmo_path: try: gizmo = gizmo.format(**os.environ) - gizmo_path.append(gizmo) - gizmo_path.pop(0) except KeyError as e: log.warning( "This environment variable doesn't exist: {}".format(e) ) + continue + if not os.path.exists(gizmo): + log.warning( + "The source of gizmo `{}` does not exists".format(gizmo) + ) + continue + existing_gizmo_path.append(gizmo) nuke_toolbar = nuke.menu("Nodes") toolbar = nuke_toolbar.addMenu(toolbar_name, icon=icon) @@ -117,7 +123,7 @@ def add_scripts_gizmo(): ) # apply configuration - studio_menu.add_gizmo_path(gizmo_path) + studio_menu.add_gizmo_path(existing_gizmo_path) studio_menu.build_from_configuration(toolbar, config) From 0fcfdf7fa8250d400a7da772be12972b59c667fd Mon Sep 17 00:00:00 2001 From: "clement.hector" Date: Wed, 1 Jun 2022 20:44:10 +0200 Subject: [PATCH 570/583] remove studio operation --- openpype/hosts/nuke/api/gizmo_menu.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/openpype/hosts/nuke/api/gizmo_menu.py b/openpype/hosts/nuke/api/gizmo_menu.py index a541fd3ab1..dd04f4a42e 100644 --- a/openpype/hosts/nuke/api/gizmo_menu.py +++ b/openpype/hosts/nuke/api/gizmo_menu.py @@ -26,11 +26,6 @@ class GizmoMenu(): command = str(config['command']) - if command.find('{pipe_path}') > -1: - command = command.format( - pipe_path=os.environ['QUAD_PLUGIN_PATH'] - ) - icon = config.get('icon', None) if icon: try: From 7afa319b25ce94cb3cb64b1c050ad23eeb1cd873 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 3 Jun 2022 12:54:27 +0200 Subject: [PATCH 571/583] add subdir 'bin' when oiio path is prepared --- openpype/lib/vendor_bin_utils.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openpype/lib/vendor_bin_utils.py b/openpype/lib/vendor_bin_utils.py index 23e28ea304..e5ab2872a0 100644 --- a/openpype/lib/vendor_bin_utils.py +++ b/openpype/lib/vendor_bin_utils.py @@ -116,7 +116,10 @@ def get_oiio_tools_path(tool="oiiotool"): tool (string): Tool name (oiiotool, maketx, ...). Default is "oiiotool". """ + oiio_dir = get_vendor_bin_path("oiio") + if platform.system().lower() == "linux": + oiio_dir = os.path.join(oiio_dir, "bin") return find_executable(os.path.join(oiio_dir, tool)) From 0034d7495cebc18f0cee2466d1109d69cf52a234 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 3 Jun 2022 12:14:23 +0200 Subject: [PATCH 572/583] Hiero: add support for task tags and collecting tags in general --- openpype/hosts/hiero/api/__init__.py | 2 ++ openpype/hosts/hiero/api/lib.py | 25 +++++++++++++++++++ .../collect_tag_tasks.py | 6 ++--- .../plugins/publish/precollect_instances.py | 5 +++- 4 files changed, 34 insertions(+), 4 deletions(-) rename openpype/hosts/hiero/plugins/{publish_old_workflow => publish}/collect_tag_tasks.py (91%) diff --git a/openpype/hosts/hiero/api/__init__.py b/openpype/hosts/hiero/api/__init__.py index fc2d017f04..781f846bbe 100644 --- a/openpype/hosts/hiero/api/__init__.py +++ b/openpype/hosts/hiero/api/__init__.py @@ -29,6 +29,7 @@ from .lib import ( get_current_sequence, get_timeline_selection, get_current_track, + get_track_item_tags, get_track_item_pype_tag, set_track_item_pype_tag, get_track_item_pype_data, @@ -83,6 +84,7 @@ __all__ = [ "get_current_sequence", "get_timeline_selection", "get_current_track", + "get_track_item_tags", "get_track_item_pype_tag", "set_track_item_pype_tag", "get_track_item_pype_data", diff --git a/openpype/hosts/hiero/api/lib.py b/openpype/hosts/hiero/api/lib.py index 761a36bd0f..06dfd2f2ee 100644 --- a/openpype/hosts/hiero/api/lib.py +++ b/openpype/hosts/hiero/api/lib.py @@ -274,6 +274,31 @@ def _validate_all_atrributes( ]) +def get_track_item_tags(track_item): + """ + Get track item tags excluded openpype tag + + Attributes: + trackItem (hiero.core.TrackItem): hiero object + + Returns: + hiero.core.Tag: hierarchy, orig clip attributes + """ + returning_tag_data = [] + # get all tags from track item + _tags = track_item.tags() + if not _tags: + return [] + + # collect all tags which are not openpype tag + returning_tag_data.extend( + tag for tag in _tags + if tag.name() != self.pype_tag_name + ) + + return returning_tag_data + + def get_track_item_pype_tag(track_item): """ Get pype track item tag created by creator or loader plugin. diff --git a/openpype/hosts/hiero/plugins/publish_old_workflow/collect_tag_tasks.py b/openpype/hosts/hiero/plugins/publish/collect_tag_tasks.py similarity index 91% rename from openpype/hosts/hiero/plugins/publish_old_workflow/collect_tag_tasks.py rename to openpype/hosts/hiero/plugins/publish/collect_tag_tasks.py index 70891d5b45..27968060e1 100644 --- a/openpype/hosts/hiero/plugins/publish_old_workflow/collect_tag_tasks.py +++ b/openpype/hosts/hiero/plugins/publish/collect_tag_tasks.py @@ -4,16 +4,16 @@ from pyblish import api class CollectClipTagTasks(api.InstancePlugin): """Collect Tags from selected track items.""" - order = api.CollectorOrder + order = api.CollectorOrder - 0.077 label = "Collect Tag Tasks" hosts = ["hiero"] - families = ['clip'] + families = ["shot"] def process(self, instance): # gets tags tags = instance.data["tags"] - tasks = dict() + tasks = {} for tag in tags: t_metadata = dict(tag.metadata()) t_family = t_metadata.get("tag.family", "") diff --git a/openpype/hosts/hiero/plugins/publish/precollect_instances.py b/openpype/hosts/hiero/plugins/publish/precollect_instances.py index e54d050f0d..b891a37d9d 100644 --- a/openpype/hosts/hiero/plugins/publish/precollect_instances.py +++ b/openpype/hosts/hiero/plugins/publish/precollect_instances.py @@ -106,7 +106,10 @@ class PrecollectInstances(pyblish.api.ContextPlugin): # clip's effect "clipEffectItems": subtracks, - "clipAnnotations": annotations + "clipAnnotations": annotations, + + # add all additional tags + "tags": phiero.get_track_item_tags(track_item) }) # otio clip data From 3464a7c5d7bceb1174237aeecc25e9d12c178638 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 3 Jun 2022 13:48:41 +0200 Subject: [PATCH 573/583] global: hierarchy publishing only to active instances filter --- .../publish/integrate_hierarchy_ftrack.py | 47 +++++++++++-- openpype/plugins/publish/collect_hierarchy.py | 4 -- .../publish/extract_hierarchy_avalon.py | 70 +++++++++---------- 3 files changed, 76 insertions(+), 45 deletions(-) diff --git a/openpype/modules/ftrack/plugins/publish/integrate_hierarchy_ftrack.py b/openpype/modules/ftrack/plugins/publish/integrate_hierarchy_ftrack.py index cf90c11b65..73398941eb 100644 --- a/openpype/modules/ftrack/plugins/publish/integrate_hierarchy_ftrack.py +++ b/openpype/modules/ftrack/plugins/publish/integrate_hierarchy_ftrack.py @@ -2,7 +2,7 @@ import sys import collections import six import pyblish.api - +from copy import deepcopy from openpype.pipeline import legacy_io # Copy of constant `openpype_modules.ftrack.lib.avalon_sync.CUST_ATTR_AUTO_SYNC` @@ -72,7 +72,8 @@ class IntegrateHierarchyToFtrack(pyblish.api.ContextPlugin): if "hierarchyContext" not in self.context.data: return - hierarchy_context = self.context.data["hierarchyContext"] + hierarchy_context = self._get_active_assets(context) + self.log.debug("__ hierarchy_context: {}".format(hierarchy_context)) self.session = self.context.data["ftrackSession"] project_name = self.context.data["projectEntity"]["name"] @@ -86,15 +87,13 @@ class IntegrateHierarchyToFtrack(pyblish.api.ContextPlugin): self.ft_project = None - input_data = hierarchy_context - # disable termporarily ftrack project's autosyncing if auto_sync_state: self.auto_sync_off(project) try: # import ftrack hierarchy - self.import_to_ftrack(input_data) + self.import_to_ftrack(hierarchy_context) except Exception: raise finally: @@ -355,3 +354,41 @@ class IntegrateHierarchyToFtrack(pyblish.api.ContextPlugin): self.session.rollback() self.session._configure_locations() six.reraise(tp, value, tb) + + def _get_active_assets(self, context): + """ Returns only asset dictionary. + Usually the last part of deep dictionary which + is not having any children + """ + def get_pure_hierarchy_data(input_dict): + input_dict_copy = deepcopy(input_dict) + for key in input_dict.keys(): + self.log.debug("__ key: {}".format(key)) + # check if child key is available + if input_dict[key].get("childs"): + # loop deeper + input_dict_copy[ + key]["childs"] = get_pure_hierarchy_data( + input_dict[key]["childs"]) + elif key not in active_assets: + input_dict_copy.pop(key, None) + return input_dict_copy + + hierarchy_context = context.data["hierarchyContext"] + + active_assets = [] + # filter only the active publishing insatnces + for instance in context: + if instance.data.get("publish") is False: + continue + + if not instance.data.get("asset"): + continue + + active_assets.append(instance.data["asset"]) + + # remove duplicity in list + active_assets = list(set(active_assets)) + self.log.debug("__ active_assets: {}".format(active_assets)) + + return get_pure_hierarchy_data(hierarchy_context) diff --git a/openpype/plugins/publish/collect_hierarchy.py b/openpype/plugins/publish/collect_hierarchy.py index 8398a2815a..91d5162d62 100644 --- a/openpype/plugins/publish/collect_hierarchy.py +++ b/openpype/plugins/publish/collect_hierarchy.py @@ -33,10 +33,6 @@ class CollectHierarchy(pyblish.api.ContextPlugin): family = instance.data["family"] families = instance.data["families"] - # filter out all unepropriate instances - if not instance.data["publish"]: - continue - # exclude other families then self.families with intersection if not set(self.families).intersection(set(families + [family])): continue diff --git a/openpype/plugins/publish/extract_hierarchy_avalon.py b/openpype/plugins/publish/extract_hierarchy_avalon.py index 2f528d4469..1f7ce839ed 100644 --- a/openpype/plugins/publish/extract_hierarchy_avalon.py +++ b/openpype/plugins/publish/extract_hierarchy_avalon.py @@ -1,7 +1,5 @@ from copy import deepcopy - import pyblish.api - from openpype.pipeline import legacy_io @@ -17,33 +15,16 @@ class ExtractHierarchyToAvalon(pyblish.api.ContextPlugin): if "hierarchyContext" not in context.data: self.log.info("skipping IntegrateHierarchyToAvalon") return - hierarchy_context = deepcopy(context.data["hierarchyContext"]) if not legacy_io.Session: legacy_io.install() - active_assets = [] - # filter only the active publishing insatnces - for instance in context: - if instance.data.get("publish") is False: - continue - - if not instance.data.get("asset"): - continue - - active_assets.append(instance.data["asset"]) - - # remove duplicity in list - self.active_assets = list(set(active_assets)) - self.log.debug("__ self.active_assets: {}".format(self.active_assets)) - - hierarchy_context = self._get_assets(hierarchy_context) - + hierarchy_context = self._get_active_assets(context) self.log.debug("__ hierarchy_context: {}".format(hierarchy_context)) - input_data = context.data["hierarchyContext"] = hierarchy_context self.project = None - self.import_to_avalon(input_data) + self.import_to_avalon(hierarchy_context) + def import_to_avalon(self, input_data, parent=None): for name in input_data: @@ -183,23 +164,40 @@ class ExtractHierarchyToAvalon(pyblish.api.ContextPlugin): return legacy_io.find_one({"_id": entity_id}) - def _get_assets(self, input_dict): + def _get_active_assets(self, context): """ Returns only asset dictionary. Usually the last part of deep dictionary which is not having any children """ - input_dict_copy = deepcopy(input_dict) - - for key in input_dict.keys(): - self.log.debug("__ key: {}".format(key)) - # check if child key is available - if input_dict[key].get("childs"): - # loop deeper - input_dict_copy[key]["childs"] = self._get_assets( - input_dict[key]["childs"]) - else: - # filter out unwanted assets - if key not in self.active_assets: + def get_pure_hierarchy_data(input_dict): + input_dict_copy = deepcopy(input_dict) + for key in input_dict.keys(): + self.log.debug("__ key: {}".format(key)) + # check if child key is available + if input_dict[key].get("childs"): + # loop deeper + input_dict_copy[ + key]["childs"] = get_pure_hierarchy_data( + input_dict[key]["childs"]) + elif key not in active_assets: input_dict_copy.pop(key, None) + return input_dict_copy - return input_dict_copy + hierarchy_context = context.data["hierarchyContext"] + + active_assets = [] + # filter only the active publishing insatnces + for instance in context: + if instance.data.get("publish") is False: + continue + + if not instance.data.get("asset"): + continue + + active_assets.append(instance.data["asset"]) + + # remove duplicity in list + active_assets = list(set(active_assets)) + self.log.debug("__ active_assets: {}".format(active_assets)) + + return get_pure_hierarchy_data(hierarchy_context) From a9fdcd80aaee0db6dddc0e777e9dd021459f04c2 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 3 Jun 2022 14:44:11 +0200 Subject: [PATCH 574/583] OP-3231 - return only active projects in webpublisher ProjectsEndpoing --- .../webserver_service/webpublish_routes.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/openpype/hosts/webpublisher/webserver_service/webpublish_routes.py b/openpype/hosts/webpublisher/webserver_service/webpublish_routes.py index e82ba7f2b8..70324fc39c 100644 --- a/openpype/hosts/webpublisher/webserver_service/webpublish_routes.py +++ b/openpype/hosts/webpublisher/webserver_service/webpublish_routes.py @@ -71,16 +71,12 @@ class ProjectsEndpoint(_RestApiEndpoint): """Returns list of dict with project info (id, name).""" async def get(self) -> Response: output = [] - for project_name in self.dbcon.database.collection_names(): - project_doc = self.dbcon.database[project_name].find_one({ - "type": "project" - }) - if project_doc: - ret_val = { - "id": project_doc["_id"], - "name": project_doc["name"] - } - output.append(ret_val) + for project_doc in self.dbcon.projects(): + ret_val = { + "id": project_doc["_id"], + "name": project_doc["name"] + } + output.append(ret_val) return Response( status=200, body=self.resource.encode(output), From 7500097cc47b18f8d9422547d13500e89427dd0f Mon Sep 17 00:00:00 2001 From: murphy Date: Fri, 3 Jun 2022 14:54:53 +0200 Subject: [PATCH 575/583] added Royal Render and Multiverse updated Nuke Studio icon, Flame moved to integrations --- website/src/pages/index.js | 22 ++++++++++++++++------ website/static/img/app_flame.png | Bin 74845 -> 39096 bytes website/static/img/app_hiero.png | Bin 40175 -> 33079 bytes website/static/img/app_multiverse.png | Bin 0 -> 4814 bytes website/static/img/app_nuke.png | Bin 25887 -> 32869 bytes website/static/img/app_nukestudio.png | Bin 0 -> 37527 bytes website/static/img/app_royalrender.png | Bin 0 -> 11650 bytes 7 files changed, 16 insertions(+), 6 deletions(-) create mode 100644 website/static/img/app_multiverse.png create mode 100644 website/static/img/app_nukestudio.png create mode 100644 website/static/img/app_royalrender.png diff --git a/website/src/pages/index.js b/website/src/pages/index.js index d9bbc3eaa0..f57fd1002a 100644 --- a/website/src/pages/index.js +++ b/website/src/pages/index.js @@ -299,6 +299,11 @@ function Home() { Maya + + + + Flame + @@ -306,7 +311,7 @@ function Home() { - + Nuke Studio @@ -374,12 +379,17 @@ function Home() { Deadline - + Muster + + + Royal Render + + Slack @@ -390,10 +400,10 @@ function Home() {

In development by us or OpenPype community.

- - - - Flame + + + + Multiverse diff --git a/website/static/img/app_flame.png b/website/static/img/app_flame.png index ba9b69e45fa73d3f9298e77ec4a167a38f7ecf7b..188153e573f64ae8f66d9a27092cb47b7049fc23 100644 GIT binary patch literal 39096 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 literal 40175 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 diff --git a/website/static/img/app_multiverse.png b/website/static/img/app_multiverse.png new file mode 100644 index 0000000000000000000000000000000000000000..c0d80e4f1bf6cec0d95451fe51172cd8e4a36333 GIT binary patch literal 4814 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 literal 0 HcmV?d00001 diff --git a/website/static/img/app_nuke.png b/website/static/img/app_nuke.png index 4b3797af7aea30fb37b43c7d94d91a249da013bf..1465da8ce823d72303087250d3fa74453c13aa83 100644 GIT binary patch literal 32869 zcmb??V|!&y*KKTcY;|ngws!2KW7~Ge>G+O~9e1aL9ox>1ZQJO)dCqS*AF9?>^{H0X zm~+$`bF3IO6+X2vm7FDF6fnB-8&I0^HY(Z(yS5*8|cWAS(g6GRhSEH9&Nc z({qP_K*suCgM`S=#fN|hL6Vme*YwFf&x6;+*1?M^8FD&j_9}~;^T#=Fv|q@=*I3ZR2-3e`(_gns# z_cq0Ul<$5?nIuQ^pGAuN|KHBDAwE7Hw@gpRfReTjIj3MTb5xPb@-C%j{0!R; z;M8aD;5r2FpguJ1p#G}TO#Z-SkW4&*OCwvz#vG+ZiCsS2085XtZy!GL$v=u)ekG

FeTI>>8KOkPC^ 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 literal 25887 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ⅆgv-9FWR6p~| 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 diff --git a/website/static/img/app_nukestudio.png b/website/static/img/app_nukestudio.png new file mode 100644 index 0000000000000000000000000000000000000000..dfc2ad5a9745a5010589f7292a8c16f1097294f6 GIT binary patch literal 37527 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 literal 0 HcmV?d00001 diff --git a/website/static/img/app_royalrender.png b/website/static/img/app_royalrender.png new file mode 100644 index 0000000000000000000000000000000000000000..0e49519227003bcc031b8267315266c7da9e3f9d GIT binary patch literal 11650 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 Date: Fri, 3 Jun 2022 17:37:45 +0200 Subject: [PATCH 576/583] global: fixing color metrix scale argument --- openpype/plugins/publish/extract_review_slate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/plugins/publish/extract_review_slate.py b/openpype/plugins/publish/extract_review_slate.py index a2cbc1b704..01a7b0f592 100644 --- a/openpype/plugins/publish/extract_review_slate.py +++ b/openpype/plugins/publish/extract_review_slate.py @@ -189,7 +189,6 @@ class ExtractReviewSlate(openpype.api.Extractor): # make sure colors are correct output_args.extend([ - "-vf", "scale=out_color_matrix=bt709", "-color_primaries", "bt709", "-color_trc", "bt709", "-colorspace", "bt709", @@ -230,6 +229,7 @@ class ExtractReviewSlate(openpype.api.Extractor): scaling_arg = ( "scale={0}x{1}:flags=lanczos" + ":out_color_matrix=bt709" ",pad={2}:{3}:{4}:{5}:black" ",setsar=1" ",fps={6}" From 248d3bd1a36630adc42d9ca090f73c6557498d1b Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 3 Jun 2022 17:38:29 +0200 Subject: [PATCH 577/583] Global: removing duplicate timecode argument - this will be applied at the end in concating stage --- openpype/plugins/publish/extract_review_slate.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/plugins/publish/extract_review_slate.py b/openpype/plugins/publish/extract_review_slate.py index 01a7b0f592..cff71f67ac 100644 --- a/openpype/plugins/publish/extract_review_slate.py +++ b/openpype/plugins/publish/extract_review_slate.py @@ -173,7 +173,6 @@ class ExtractReviewSlate(openpype.api.Extractor): self.log.debug("Slate Timecode: `{}`".format( offset_timecode )) - input_args.extend(["-timecode", str(offset_timecode)]) if use_legacy_code: format_args = [] From d1ff95129f447b52c46aa158c24b2c5d9ecac325 Mon Sep 17 00:00:00 2001 From: OpenPype Date: Sat, 4 Jun 2022 03:43:28 +0000 Subject: [PATCH 578/583] [Automated] Bump version --- CHANGELOG.md | 18 ++++++++---------- openpype/version.py | 2 +- pyproject.toml | 2 +- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6613985ccf..50cb1d423e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,17 +1,19 @@ # Changelog -## [3.10.1-nightly.2](https://github.com/pypeclub/OpenPype/tree/HEAD) +## [3.10.1-nightly.3](https://github.com/pypeclub/OpenPype/tree/HEAD) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.10.0...HEAD) **πŸš€ Enhancements** - General: Updated windows oiio tool [\#3268](https://github.com/pypeclub/OpenPype/pull/3268) +- Maya: reference loaders could store placeholder in referenced url [\#3264](https://github.com/pypeclub/OpenPype/pull/3264) - TVPaint: Init file for TVPaint worker also handle guideline images [\#3250](https://github.com/pypeclub/OpenPype/pull/3250) - Nuke: Change default icon path in settings [\#3247](https://github.com/pypeclub/OpenPype/pull/3247) **πŸ› Bug fixes** +- Webpublisher: return only active projects in ProjectsEndpoint [\#3281](https://github.com/pypeclub/OpenPype/pull/3281) - Nuke: bake reformat was failing on string type [\#3261](https://github.com/pypeclub/OpenPype/pull/3261) - Maya: hotfix Pxr multitexture in looks [\#3260](https://github.com/pypeclub/OpenPype/pull/3260) - Unreal: Fix Camera Loading if Layout is missing [\#3255](https://github.com/pypeclub/OpenPype/pull/3255) @@ -19,9 +21,12 @@ - Unreal: Fixed Render creation in UE5 [\#3239](https://github.com/pypeclub/OpenPype/pull/3239) - Unreal: Fixed Camera loading in UE5 [\#3238](https://github.com/pypeclub/OpenPype/pull/3238) - Flame: debugging [\#3224](https://github.com/pypeclub/OpenPype/pull/3224) +- Ftrack: Push hierarchical attributes action works [\#3210](https://github.com/pypeclub/OpenPype/pull/3210) +- add silent audio to slate [\#3162](https://github.com/pypeclub/OpenPype/pull/3162) **Merged pull requests:** +- Maya: better handling of legacy review subsets names [\#3269](https://github.com/pypeclub/OpenPype/pull/3269) - Nuke: add pointcache and animation to loader [\#3186](https://github.com/pypeclub/OpenPype/pull/3186) ## [3.10.0](https://github.com/pypeclub/OpenPype/tree/3.10.0) (2022-05-26) @@ -49,9 +54,6 @@ - General: Add 'dataclasses' to required python modules [\#3149](https://github.com/pypeclub/OpenPype/pull/3149) - Hooks: Tweak logging grammar [\#3147](https://github.com/pypeclub/OpenPype/pull/3147) - Nuke: settings for reformat node in CreateWriteRender node [\#3143](https://github.com/pypeclub/OpenPype/pull/3143) -- Houdini: Add loader for alembic through Alembic Archive node [\#3140](https://github.com/pypeclub/OpenPype/pull/3140) -- Publisher: UI Modifications and fixes [\#3139](https://github.com/pypeclub/OpenPype/pull/3139) -- General: Simplified OP modules/addons import [\#3137](https://github.com/pypeclub/OpenPype/pull/3137) **πŸ› Bug fixes** @@ -64,7 +66,6 @@ - Hiero: debugging frame range and other 3.10 [\#3222](https://github.com/pypeclub/OpenPype/pull/3222) - Project Manager: Fix persistent editors on project change [\#3218](https://github.com/pypeclub/OpenPype/pull/3218) - Deadline: instance data overwrite fix [\#3214](https://github.com/pypeclub/OpenPype/pull/3214) -- Ftrack: Push hierarchical attributes action works [\#3210](https://github.com/pypeclub/OpenPype/pull/3210) - Standalone Publisher: Always create new representation for thumbnail [\#3203](https://github.com/pypeclub/OpenPype/pull/3203) - Photoshop: skip collector when automatic testing [\#3202](https://github.com/pypeclub/OpenPype/pull/3202) - Nuke: render/workfile version sync doesn't work on farm [\#3185](https://github.com/pypeclub/OpenPype/pull/3185) @@ -75,14 +76,9 @@ - General: Oiio conversion for ffmpeg checks for invalid characters [\#3166](https://github.com/pypeclub/OpenPype/pull/3166) - Fix for attaching render to subset [\#3164](https://github.com/pypeclub/OpenPype/pull/3164) - Harmony: fixed missing task name in render instance [\#3163](https://github.com/pypeclub/OpenPype/pull/3163) -- add silent audio to slate [\#3162](https://github.com/pypeclub/OpenPype/pull/3162) - Ftrack: Action delete old versions formatting works [\#3152](https://github.com/pypeclub/OpenPype/pull/3152) -- nuke: adding extract thumbnail settings [\#3148](https://github.com/pypeclub/OpenPype/pull/3148) - Deadline: fix the output directory [\#3144](https://github.com/pypeclub/OpenPype/pull/3144) - General: New Session schema [\#3141](https://github.com/pypeclub/OpenPype/pull/3141) -- General: Missing version on headless mode crash properly [\#3136](https://github.com/pypeclub/OpenPype/pull/3136) -- TVPaint: Composite layers in reversed order [\#3135](https://github.com/pypeclub/OpenPype/pull/3135) -- TVPaint: Composite layers in reversed order [\#3134](https://github.com/pypeclub/OpenPype/pull/3134) **πŸ”€ Refactored code** @@ -93,6 +89,7 @@ - Harmony: message length in 21.1 [\#3257](https://github.com/pypeclub/OpenPype/pull/3257) - Harmony: 21.1 fix [\#3249](https://github.com/pypeclub/OpenPype/pull/3249) - Maya: added jpg to filter for Image Plane Loader [\#3223](https://github.com/pypeclub/OpenPype/pull/3223) +- Maya: added jpg to filter for Image Plane Loader [\#3221](https://github.com/pypeclub/OpenPype/pull/3221) - Webpublisher: replace space by underscore in subset names [\#3160](https://github.com/pypeclub/OpenPype/pull/3160) ## [3.9.8](https://github.com/pypeclub/OpenPype/tree/3.9.8) (2022-05-19) @@ -134,6 +131,7 @@ **πŸ› Bug fixes** - Ftrack: Action delete old versions formatting works [\#3154](https://github.com/pypeclub/OpenPype/pull/3154) +- nuke: adding extract thumbnail settings [\#3148](https://github.com/pypeclub/OpenPype/pull/3148) **Merged pull requests:** diff --git a/openpype/version.py b/openpype/version.py index 1d8ef28225..e5dfc2bb8f 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.10.1-nightly.2" +__version__ = "3.10.1-nightly.3" diff --git a/pyproject.toml b/pyproject.toml index 27b32cf53b..7a620aec8f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "OpenPype" -version = "3.10.1-nightly.2" # OpenPype +version = "3.10.1-nightly.3" # OpenPype description = "Open VFX and Animation pipeline with support." authors = ["OpenPype Team "] license = "MIT License" From 7b16c6837b1c02defd73fcba984909d085cce61e Mon Sep 17 00:00:00 2001 From: "clement.hector" Date: Mon, 6 Jun 2022 17:08:37 +0200 Subject: [PATCH 579/583] refacto code to have simpler menu --- openpype/hosts/nuke/api/gizmo_menu.py | 90 ++++++++------ openpype/hosts/nuke/api/lib.py | 109 ++++++++++------ openpype/hosts/nuke/startup/menu.py | 70 +---------- .../defaults/project_settings/nuke.json | 8 +- .../schemas/schema_nuke_scriptsgizmo.json | 117 +++++++++++++++--- 5 files changed, 222 insertions(+), 172 deletions(-) diff --git a/openpype/hosts/nuke/api/gizmo_menu.py b/openpype/hosts/nuke/api/gizmo_menu.py index dd04f4a42e..7f8121372c 100644 --- a/openpype/hosts/nuke/api/gizmo_menu.py +++ b/openpype/hosts/nuke/api/gizmo_menu.py @@ -1,67 +1,75 @@ import os -import logging +import re import nuke -log = logging.getLogger(__name__) +from openpype.api import Logger + +log = Logger.get_logger(__name__) class GizmoMenu(): - def __init__(self, *args, **kwargs): + def __init__(self, title, icon=None): + + self.toolbar = self._create_toolbar_menu( + title, + icon=icon + ) + self._script_actions = [] - def build_from_configuration(self, parent, configuration): + def _create_toolbar_menu(self, name, icon=None): + nuke_node_menu = nuke.menu("Nodes") + return nuke_node_menu.addMenu( + name, + icon=icon + ) + + def _make_menu_path(self, path, icon=None): + parent = self.toolbar + for folder in re.split(r"/|\\",path): + if not folder: + continue + existing_menu = parent.findItem(folder) + if existing_menu: + parent = existing_menu + else: + parent = parent.addMenu(folder, icon=icon) + + return parent + + def build_from_configuration(self, configuration): for item in configuration: assert isinstance(item, dict), "Configuration is wrong!" - # skip items which have no `type` key - item_type = item.get('type', None) - if not item_type: - log.warning("Missing 'type' from configuration item") - continue + # Construct parent path else parent is toolbar + parent = self.toolbar + gizmo_toolbar_path = item.get("gizmo_toolbar_path") + if gizmo_toolbar_path: + parent = self._make_menu_path(gizmo_toolbar_path) - if item_type == "action": - # filter out `type` from the item dict - config = {key: value for key, value in - item.items() if key != "type"} - - command = str(config['command']) - - icon = config.get('icon', None) - if icon: - try: - icon = icon.format(**os.environ) - except KeyError as e: - log.warning("This environment variable doesn't exist: " - "{}".format(e)) - - hotkey = config.get('hotkey', None) + item_type = item.get("sourcetype") + if item_type == ("python" or "file"): parent.addCommand( - config['title'], - command=command, - icon=icon, - shortcut=hotkey + item['title'], + command=str(item["command"]), + icon=item.get("icon"), + shortcut=item.get('hotkey') ) # add separator # Special behavior for separators - if item_type == "separator": + elif item_type == "separator": parent.addSeparator() # add submenu # items should hold a collection of submenu items (dict) elif item_type == "menu": - assert "items" in item, "Menu is missing 'items' key" - - icon = item.get('icon', None) - if icon: - try: - icon = icon.format(**os.environ) - except KeyError as e: - log.warning("This environment variable doesn't exist: " - "{}".format(e)) - menu = parent.addMenu(item['title'], icon=icon) - self.build_from_configuration(menu, item["items"]) + # assert "items" in item, "Menu is missing 'items' key" + parent.addMenu( + item['title'], + icon=item.get('icon') + ) def add_gizmo_path(self, gizmo_paths): for gizmo_path in gizmo_paths: diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index a1ac50ae1a..335e7190a0 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -2500,50 +2500,77 @@ def recreate_instance(origin_node, avalon_data=None): return new_node -def find_scripts_gizmo(title, parent): - """ - Check if the menu exists with the given title in the parent - - Args: - title (str): the title name of the scripts menu - - parent (QtWidgets.QMenuBar): the menubar to check - - Returns: - QtWidgets.QMenu or None - - """ - - menu = None - search = [i for i in parent.items() if - isinstance(i, gizmo_menu.GizmoMenu) - and i.title() == title] - - if search: - assert len(search) < 2, ("Multiple instances of menu '{}' " - "in toolbar".format(title)) - menu = search[0] - - return menu - - -def gizmo_creation(title="Gizmos", parent=None, objectName=None, icon=None): +def add_scripts_gizmo(): try: - toolbar = find_scripts_gizmo(title, parent) - if not toolbar: - log.info("Attempting to build toolbar...") - object_name = objectName or title.lower() - toolbar = gizmo_menu.GizmoMenu( - title=title, - parent=parent, - objectName=object_name, - icon=icon - ) - except Exception as e: - log.error(e) + from openpype.hosts.nuke.api import lib + except ImportError: + log.warning( + "Skipping studio.gizmo install, because " + "'scriptsgizmo' module seems unavailable." + ) return - return toolbar + # load configuration of custom menu + project_settings = get_project_settings(os.getenv("AVALON_PROJECT")) + platform_name = platform.system().lower() + + for gizmo_settings in project_settings["nuke"]["gizmo"]: + gizmo_list_definition = gizmo_settings["gizmo_definition"] + print(1, gizmo_list_definition) + toolbar_name = gizmo_settings["toolbar_menu_name"] + # gizmo_toolbar_path = gizmo_settings["gizmo_toolbar_path"] + gizmo_source_dir = gizmo_settings.get( + "gizmo_source_dir", {}).get(platform_name) + toolbar_icon_path = gizmo_settings.get( + "toolbar_icon_path", {}).get(platform_name) + + if not gizmo_source_dir: + log.debug("Skipping studio gizmo `{}`, no gizmo path found.".format( + toolbar_name + )) + return + + if not gizmo_list_definition: + log.debug("Skipping studio gizmo `{}`, no definition found.".format( + toolbar_name + )) + return + + if toolbar_icon_path: + try: + toolbar_icon_path = toolbar_icon_path.format(**os.environ) + except KeyError as e: + log.error( + "This environment variable doesn't exist: {}".format(e) + ) + + existing_gizmo_path = [] + for source_dir in gizmo_source_dir: + try: + resolve_source_dir = source_dir.format(**os.environ) + except KeyError as e: + log.error( + "This environment variable doesn't exist: {}".format(e) + ) + continue + if not os.path.exists(resolve_source_dir): + log.warning( + "The source of gizmo `{}` does not exists".format( + resolve_source_dir + ) + ) + continue + existing_gizmo_path.append(resolve_source_dir) + + # run the launcher for Nuke toolbar + toolbar_menu = gizmo_menu.GizmoMenu( + title=toolbar_name, + icon=toolbar_icon_path + ) + + # apply configuration + toolbar_menu.add_gizmo_path(existing_gizmo_path) + toolbar_menu.build_from_configuration(gizmo_list_definition) class NukeDirmap(HostDirmap): diff --git a/openpype/hosts/nuke/startup/menu.py b/openpype/hosts/nuke/startup/menu.py index 715bab8ea5..1461d41385 100644 --- a/openpype/hosts/nuke/startup/menu.py +++ b/openpype/hosts/nuke/startup/menu.py @@ -8,7 +8,8 @@ from openpype.hosts.nuke.api.lib import ( on_script_load, check_inventory_versions, WorkfileSettings, - dirmap_file_name_filter + dirmap_file_name_filter, + add_scripts_gizmo ) from openpype.settings import get_project_settings @@ -60,71 +61,4 @@ def add_scripts_menu(): add_scripts_menu() - -def add_scripts_gizmo(): - try: - from openpype.hosts.nuke.api import lib - except ImportError: - log.warning( - "Skipping studio.gizmo install, because " - "'scriptsgizmo' module seems unavailable." - ) - return - - # load configuration of custom menu - project_settings = get_project_settings(os.getenv("AVALON_PROJECT")) - - for gizmo in project_settings["nuke"]["gizmo"]: - config = gizmo["gizmo_definition"] - toolbar_name = gizmo["toolbar_menu_name"] - gizmo_path = gizmo["gizmo_path"] - icon = gizmo['toolbar_icon_path'] - - if not any(gizmo_path): - log.warning("Skipping studio gizmo, no gizmo path found.") - return - - if not config: - log.warning("Skipping studio gizmo, no definition found.") - return - - try: - icon = icon.format(**os.environ) - except KeyError as e: - log.warning( - "This environment variable doesn't exist: {}".format(e) - ) - - existing_gizmo_path = [] - for gizmo in gizmo_path: - try: - gizmo = gizmo.format(**os.environ) - except KeyError as e: - log.warning( - "This environment variable doesn't exist: {}".format(e) - ) - continue - if not os.path.exists(gizmo): - log.warning( - "The source of gizmo `{}` does not exists".format(gizmo) - ) - continue - existing_gizmo_path.append(gizmo) - - nuke_toolbar = nuke.menu("Nodes") - toolbar = nuke_toolbar.addMenu(toolbar_name, icon=icon) - - # run the launcher for Nuke toolbar - studio_menu = lib.gizmo_creation( - title=toolbar_name, - parent=toolbar, - objectName=toolbar_name.lower().replace(" ", "_"), - icon=icon - ) - - # apply configuration - studio_menu.add_gizmo_path(existing_gizmo_path) - studio_menu.build_from_configuration(toolbar, config) - - add_scripts_gizmo() diff --git a/openpype/settings/defaults/project_settings/nuke.json b/openpype/settings/defaults/project_settings/nuke.json index 6c6454de36..63978ad1be 100644 --- a/openpype/settings/defaults/project_settings/nuke.json +++ b/openpype/settings/defaults/project_settings/nuke.json @@ -293,8 +293,12 @@ "gizmo": [ { "toolbar_menu_name": "OpenPype Gizmo", - "toolbar_icon_path": "path/to/nuke/icon.png", - "gizmo_path": ["path/to/nuke/gizmo"], + "gizmo_path": { + "windows": [], + "darwin": [], + "linux": [] + }, + "toolbar_icon_path": {}, "gizmo_definition": [ { "type": "action", diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_scriptsgizmo.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_scriptsgizmo.json index c1e67842ce..80fda56175 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_scriptsgizmo.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_scriptsgizmo.json @@ -14,28 +14,105 @@ }, { "type": "path", - "key": "toolbar_icon_path", - "label": "Toolbar Icon Path", - "multipath": false + "key": "gizmo_source_dir", + "label": "Gizmo directory path", + "multipath": true, + "multiplatform": true }, { - "type": "splitter" - }, - { - "type": "label", - "label": "Absolute path to gizmo folders." - }, - { - "type": "path", - "key": "gizmo_path", - "label": "Gizmo Path", - "multipath": true - }, - { - "type": "raw-json", - "key": "gizmo_definition", - "label": "Gizmo definition", - "is_list": true + "type": "collapsible-wrap", + "label": "Options", + "collapsible": true, + "collapsed": true, + "children": [ + { + "type": "path", + "key": "toolbar_icon_path", + "label": "Toolbar Icon Path", + "multipath": false, + "multiplatform": true + }, + { + "type": "splitter" + }, + { + "type": "list", + "key": "gizmo_definition", + "label": "Gizmo definitions", + "use_label_wrap": true, + "object_type": { + "type": "dict-conditional", + "enum_key": "sourcetype", + "enum_label": "Type of usage", + "enum_children": [ + { + "key": "python", + "label": "Python", + "children": [ + { + "type": "text", + "key": "title", + "label": "Title" + }, + { + "type": "text", + "key": "gizmo_toolbar_path", + "label": "Toolbar path" + }, + { + "type": "text", + "key": "command", + "label": "Python command" + }, + { + "type": "text", + "key": "shortcut", + "label": "Hotkey" + } + ] + }, + { + "key": "file", + "label": "File", + "children": [ + { + "type": "text", + "key": "title", + "label": "Title" + }, + { + "type": "text", + "key": "gizmo_toolbar_path", + "label": "Toolbar path" + }, + { + "type": "text", + "key": "file_name", + "label": "Gizmo file name" + }, + { + "type": "text", + "key": "shortcut", + "label": "Hotkey" + } + + ] + }, + { + "key": "separator", + "label": "Separator", + "children": [ + { + "type": "text", + "key": "gizmo_toolbar_path", + "label": "Toolbar path" + } + ] + } + ] + } + } + ] } ] } From b77cb4ba1a367e5974342e670241d19137fb2a3e Mon Sep 17 00:00:00 2001 From: "clement.hector" Date: Mon, 6 Jun 2022 19:17:34 +0200 Subject: [PATCH 580/583] Add global menu from settings --- openpype/hosts/nuke/api/gizmo_menu.py | 52 +++---- openpype/hosts/nuke/api/lib.py | 1 - .../defaults/project_settings/nuke.json | 13 +- .../schemas/schema_nuke_scriptsgizmo.json | 133 +++++++++--------- 4 files changed, 106 insertions(+), 93 deletions(-) diff --git a/openpype/hosts/nuke/api/gizmo_menu.py b/openpype/hosts/nuke/api/gizmo_menu.py index 7f8121372c..42b5812360 100644 --- a/openpype/hosts/nuke/api/gizmo_menu.py +++ b/openpype/hosts/nuke/api/gizmo_menu.py @@ -38,38 +38,42 @@ class GizmoMenu(): return parent def build_from_configuration(self, configuration): - for item in configuration: - assert isinstance(item, dict), "Configuration is wrong!" - + for menu in configuration: # Construct parent path else parent is toolbar parent = self.toolbar - gizmo_toolbar_path = item.get("gizmo_toolbar_path") + gizmo_toolbar_path = menu.get("gizmo_toolbar_path") if gizmo_toolbar_path: parent = self._make_menu_path(gizmo_toolbar_path) - item_type = item.get("sourcetype") + for item in menu["sub_gizmo_list"]: + assert isinstance(item, dict), "Configuration is wrong!" - if item_type == ("python" or "file"): - parent.addCommand( - item['title'], - command=str(item["command"]), - icon=item.get("icon"), - shortcut=item.get('hotkey') - ) + if not item.get("title"): + continue - # add separator - # Special behavior for separators - elif item_type == "separator": - parent.addSeparator() + item_type = item.get("sourcetype") - # add submenu - # items should hold a collection of submenu items (dict) - elif item_type == "menu": - # assert "items" in item, "Menu is missing 'items' key" - parent.addMenu( - item['title'], - icon=item.get('icon') - ) + if item_type == ("python" or "file"): + parent.addCommand( + item["title"], + command=str(item["command"]), + icon=item.get("icon"), + shortcut=item.get("hotkey") + ) + + # add separator + # Special behavior for separators + elif item_type == "separator": + parent.addSeparator() + + # add submenu + # items should hold a collection of submenu items (dict) + elif item_type == "menu": + # assert "items" in item, "Menu is missing 'items' key" + parent.addMenu( + item['title'], + icon=item.get('icon') + ) def add_gizmo_path(self, gizmo_paths): for gizmo_path in gizmo_paths: diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 335e7190a0..0d766c8459 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -2516,7 +2516,6 @@ def add_scripts_gizmo(): for gizmo_settings in project_settings["nuke"]["gizmo"]: gizmo_list_definition = gizmo_settings["gizmo_definition"] - print(1, gizmo_list_definition) toolbar_name = gizmo_settings["toolbar_menu_name"] # gizmo_toolbar_path = gizmo_settings["gizmo_toolbar_path"] gizmo_source_dir = gizmo_settings.get( diff --git a/openpype/settings/defaults/project_settings/nuke.json b/openpype/settings/defaults/project_settings/nuke.json index 63978ad1be..c609a0927a 100644 --- a/openpype/settings/defaults/project_settings/nuke.json +++ b/openpype/settings/defaults/project_settings/nuke.json @@ -301,10 +301,15 @@ "toolbar_icon_path": {}, "gizmo_definition": [ { - "type": "action", - "sourcetype": "python", - "title": "Gizmo Note", - "command": "nuke.nodes.StickyNote(label='You can create your own toolbar menu in the Nuke GizmoMenu of OpenPype')" + "gizmo_toolbar_path": "/path/to/menu", + "sub_gizmo_list": [ + { + "sourcetype": "python", + "title": "Gizmo Note", + "command": "nuke.nodes.StickyNote(label='You can create your own toolbar menu in the Nuke GizmoMenu of OpenPype')", + "shortcut": "" + } + ] } ] } diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_scriptsgizmo.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_scriptsgizmo.json index 80fda56175..abe14970c5 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_scriptsgizmo.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_scriptsgizmo.json @@ -41,73 +41,78 @@ "label": "Gizmo definitions", "use_label_wrap": true, "object_type": { - "type": "dict-conditional", - "enum_key": "sourcetype", - "enum_label": "Type of usage", - "enum_children": [ + "type": "dict", + "children": [ { - "key": "python", - "label": "Python", - "children": [ - { - "type": "text", - "key": "title", - "label": "Title" - }, - { - "type": "text", - "key": "gizmo_toolbar_path", - "label": "Toolbar path" - }, - { - "type": "text", - "key": "command", - "label": "Python command" - }, - { - "type": "text", - "key": "shortcut", - "label": "Hotkey" - } - ] + "type": "text", + "key": "gizmo_toolbar_path", + "label": "Gizmo Menu Path" }, { - "key": "file", - "label": "File", - "children": [ - { - "type": "text", - "key": "title", - "label": "Title" - }, - { - "type": "text", - "key": "gizmo_toolbar_path", - "label": "Toolbar path" - }, - { - "type": "text", - "key": "file_name", - "label": "Gizmo file name" - }, - { - "type": "text", - "key": "shortcut", - "label": "Hotkey" - } - - ] - }, - { - "key": "separator", - "label": "Separator", - "children": [ - { - "type": "text", - "key": "gizmo_toolbar_path", - "label": "Toolbar path" - } - ] + "type": "list", + "key": "sub_gizmo_list", + "label": "Sub Gizmo List", + "use_label_wrap": true, + "object_type": { + "type": "dict-conditional", + "enum_key": "sourcetype", + "enum_label": "Type of usage", + "enum_children": [ + { + "key": "python", + "label": "Python", + "children": [ + { + "type": "text", + "key": "title", + "label": "Title" + }, + { + "type": "text", + "key": "command", + "label": "Python command" + }, + { + "type": "text", + "key": "shortcut", + "label": "Hotkey" + } + ] + }, + { + "key": "file", + "label": "File", + "children": [ + { + "type": "text", + "key": "title", + "label": "Title" + }, + { + "type": "text", + "key": "file_name", + "label": "Gizmo file name" + }, + { + "type": "text", + "key": "shortcut", + "label": "Hotkey" + } + ] + }, + { + "key": "separator", + "label": "Separator", + "children": [ + { + "type": "text", + "key": "gizmo_toolbar_path", + "label": "Toolbar path" + } + ] + } + ] + } } ] } From 25e9ee617e798e14cffb6e2090e1a4dbdb88214c Mon Sep 17 00:00:00 2001 From: "clement.hector" Date: Mon, 6 Jun 2022 21:00:24 +0200 Subject: [PATCH 581/583] linter correction --- openpype/hosts/nuke/api/gizmo_menu.py | 2 +- openpype/hosts/nuke/api/lib.py | 20 ++++++-------------- 2 files changed, 7 insertions(+), 15 deletions(-) diff --git a/openpype/hosts/nuke/api/gizmo_menu.py b/openpype/hosts/nuke/api/gizmo_menu.py index 42b5812360..0f1a3e03fc 100644 --- a/openpype/hosts/nuke/api/gizmo_menu.py +++ b/openpype/hosts/nuke/api/gizmo_menu.py @@ -26,7 +26,7 @@ class GizmoMenu(): def _make_menu_path(self, path, icon=None): parent = self.toolbar - for folder in re.split(r"/|\\",path): + for folder in re.split(r"/|\\", path): if not folder: continue existing_menu = parent.findItem(folder) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 0d766c8459..2c5989309b 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -2501,14 +2501,6 @@ def recreate_instance(origin_node, avalon_data=None): def add_scripts_gizmo(): - try: - from openpype.hosts.nuke.api import lib - except ImportError: - log.warning( - "Skipping studio.gizmo install, because " - "'scriptsgizmo' module seems unavailable." - ) - return # load configuration of custom menu project_settings = get_project_settings(os.getenv("AVALON_PROJECT")) @@ -2524,15 +2516,15 @@ def add_scripts_gizmo(): "toolbar_icon_path", {}).get(platform_name) if not gizmo_source_dir: - log.debug("Skipping studio gizmo `{}`, no gizmo path found.".format( - toolbar_name - )) + log.debug("Skipping studio gizmo `{}`, " + "no gizmo path found.".format(toolbar_name) + ) return if not gizmo_list_definition: - log.debug("Skipping studio gizmo `{}`, no definition found.".format( - toolbar_name - )) + log.debug("Skipping studio gizmo `{}`, " + "no definition found.".format(toolbar_name) + ) return if toolbar_icon_path: From e2d6d903e99120048d92cb7fbabe42b111df373c Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Tue, 7 Jun 2022 11:23:45 +0000 Subject: [PATCH 582/583] docs: update README.md [skip ci] --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b6966adbc4..b8c04f8b49 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -[![All Contributors](https://img.shields.io/badge/all_contributors-26-orange.svg?style=flat-square)](#contributors-) +[![All Contributors](https://img.shields.io/badge/all_contributors-27-orange.svg?style=flat-square)](#contributors-) OpenPype ==== @@ -328,6 +328,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
Malthaldar

πŸ’»
Sven Neve

πŸ’»
zafrs

πŸ’» +
FΓ©lix David

πŸ’» πŸ“– From 884fce81ce10c6f31e8738b1e7c6f178787ba9df Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Tue, 7 Jun 2022 11:23:46 +0000 Subject: [PATCH 583/583] docs: update .all-contributorsrc [skip ci] --- .all-contributorsrc | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/.all-contributorsrc b/.all-contributorsrc index a3b85cae68..b30f3b2499 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -309,7 +309,18 @@ "contributions": [ "code" ] + }, + { + "login": "Tilix4", + "name": "FΓ©lix David", + "avatar_url": "https://avatars.githubusercontent.com/u/22875539?v=4", + "profile": "http://felixdavid.com/", + "contributions": [ + "code", + "doc" + ] } ], - "contributorsPerLine": 7 -} \ No newline at end of file + "contributorsPerLine": 7, + "skipCi": true +}

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 z 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

n)d3@oZpYFsmbJyGIDGW; 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 R3jP~ZQC3Z+PRcy&{{SdSemwvH diff --git a/openpype/resources/app_icons/nukestudio.png b/openpype/resources/app_icons/nukestudio.png index 99c95f59ff858d67fb92263f594f4b71bda87a66..601d4a591d7fd21a0c9c6a1850e569ca05e523ef 100644 GIT binary patch literal 101945 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 literal 46080 zcmXV1WmuEn-yhv24U(duNT)OdM7l*l28gtDkM2@JP>>o(2?(Q<9No3iNH?QLkAC+1 zU(btsFYfE!`JVIb_?$RhZB;U2CSm{pK&JLaSswtvYX9#c#K*i5d)nX$0E7b6lobvA z=JsJhzd`5T+X=SdZ}U5Z9GQwEu=E|6mH~r0c0|zUFV3WYQ-2myk8nA>c8n0yS4|m@ zO?#_0_1gdWqtZ%4&Oc8dIb5xS#x)I6%6_iS_WB40<3OxuR%VlX`JSi6Zan%yPeJ2` zdUbOMp9?rRoo}-{+Wsfdb|-V=KRP!)UJV~Qt%1NoAj_tE&C^XtPU-!sF7p5XVenRl z%bg`6uNIA!;qrmzXSc0d9|ZVYB(6K|llXGXZ`a2E75Mg99CiInXMLE@hMjEWs&C!p z$bqhBEjAiAUY%cU{^^E34|cvF`ezlx;xns9W;9i3`iJ?u#h-1)2@oZa(yi&& z8Jae6kTtQM|G{xUEJ*UCgEXT>bz63x*784ELVIrW-t+U_xE`f57i)3zc#rrOXgAYMvaq+d+oN2%;GOxglYiD@CrOFQLl4XKKR378 zKL509d-3-c7ErKRa+LTTzK}BOE62hIS)l6G4*9U@{&1i4bvq8ClY7~Cv+3Qxx9Sb^ z1A@4rCZOhr*7Oke*hPeF1qV%@ha*z4g0z&G%KAF|EG(0txYZ9uC8i}BL2y}2S z&$#9P9vre^JQL4q2BBZ~U7O*8l132B!W77JTb6fJ1 zjx64179&S%+}wCweYp0C=S#%P$tJhGHLZpk{EcoW97?};#1sl^;aaO(ifZk2N>u{l z0IdgEbegnaxNgU67+MA45_rvJ@K=xipwfWBfwy7X+dZKGv z%rU}A0XH^#fg-f#yl5A3mz6&8c)7;9Z%gdekuNb<^Y|kbje(9{q{22i)GsdZ`x-|v zhXHqty>BQ+Gpn0`S-YFC2Go*f9gt;SaK(xr_5SFvJ0`Uyoqw>Fyje&IDo%4%AFcB* znY~GmeU0VBr2N-wbwT|j1h5FRjXuqAfs#oXaQ!dQQVq}i?U}z0uhAu=g1ss@7KgNwBT(BV6l|#Wxw!6UWEwDIKDR8~C4s<;EqcCEGnDjuq2E)=~9I$EVp&yv> zEILa3xhe`KbIkSjM~s9Lg7;5>1k^7kQ|YWlNiUO_lm(9Ph{o(#^(apjBw*yC4kCEf zVwP@<2lm4$8`sRAsh^XI0FfY#aDS#*BujsmQ2db*oozLqzm@Rg=OnQ3zr!kbaDOQ< zjvuRK%?$p5Ee1Y*P-(|TWq&yRZ66>8_Ae*%GWulY6in^)sYg03Su)?fo)F@Mu(Ze6 z?PtF3=ebK`%_2H*XNH5Vvk$1a*r-gI^Aj0lg_Hn1&v=N-;+K`1>YFD^R$Gj{Gn~2k zQEv{ax)NPlWC!WiSbR-0aYsg05xxp z_>T-2p)~oGrgS+yt4wfk{!}TuctI59O$M}+di`C1Yd6%G=RE4OkEtJYljy(CS6802 z#caHvPidSOfi2|zlBB9bSM-4;89VYM%n-~>oKyFeiN*{tKcq=xapPY;o&R{S(XQIj zv~`KPpb1p5(vF`bX_1Y{RfPKHT9I}mBMUh3A~U(`umhy`1yN6_>Bj}P1yT7Mg@GN% z7r(XPc{2ghV9yUU^Ch5so;PAT_Uv_CiBklvg+zaFO=? zKXBJ`Uu@j;T3-iV-}coroe}R65oi)0x`LjUu**39_iv-EV-7|Fu+2&Vk=T`N$iUE`M0@{K znm;M{M(OC6&FzeR)@G$BAMBZcP{dkN~z!$?5zYQ7fZWGDqYwkBKS&cWD)Sh_MZv{ka{ zB1fLq-2Y1FwS3UA&c}VW}gJ;IrEeTt%djU7Y)y8{z6nEJhumL;CrA_n*Ztx|+@y0qHjd8Fdb zbFr5dyKTI`?d40ivD|p?KI-8=x?W2J!^53Z5ox_Ti*Hfn ztMXOshyB_6TW_KVWAQm73SLO_YR>B=LN!iI1)(@xe(0l~qu$R_^(s>WM@d!*^IO$A zE7j$WmT$8{Qfm_gTK#@qwIzKS6o?MC$Tk?|+s^RRLZ^8`{NwvzRaZ1X=p#bNRY)tV zY?~|fWp;%OhQ|)5vBJzk*yu9v;AzTwLH4RNL_5vZVg6U?z+zECId)P{x=M!bGfqmN z|9W&5i7>nOPEq>nbaNk*v$ngVhnU&6hf8_hi&5YEmf7Z|^UC4MhPC+YyO;+QTlCyF zW6+PApv!8%{fE;Ba7l(7HyrM?D%MCz_G4%x+w<^>bTIUtiDfM!WXLVp0!^5zth z{{*>D@W`s?kd!Sd+So>!pCsK;&qYf5DrhF1 zf|CQrI)7q#fR9euezKtEs+1QqvmKEnC0ATA!2)67eex0f_|PlAFFa1z{Ww>-D!dMY zGBeIIfc9Um(YUFwn!d^SG7%j%e*Vn=!N&48weV)`y2wWDh6pW#Bf||k4XCTZ%{tFs zB!b;}bZhm9PAva3i;oxbT=oIwEqpsc<}3U|aflJHoWT8c;Gb6$QV1QN7qg8D3E9ul zpC|@_eg(dz+JA>MK_w_idQYs_vP?v?S`KwNXLEB&U0wt>B*1g-SPJsUULeBxa%_`#UfHy0|xXL&iA@? zo`-gyHfy>P#aF0PhFY~A9pZX19iGADI&)ldp>|RGrl}>lxg;>%D;YMwN?*IGJ-Yba z=hJwc@@)7@4ugVZl*7e3pERY2b7p<+D`0y^*T*RqAg84~79Y_twtN~XJ;8JeRLapQ zOfIKAaVEWN@)~(i>S5ps=EyU~myt+oWa`QYB7~f{E-QJ_{-XaDYu_OLOM2M**N;a~ zUDfa%!e;{%hs)^h@vs9niT{|f>lgIGBR`o1?Y}ZK$n*iKljb0+@7uJLJH5^Etz`WT zA+q#EYScHpX)*^U)HRth?ab#B^2WD3o!fJ(Oqeyz>dyc17IyetyN3Dhl7VJl`3fxM zRk&bu=f%u6IH79Yw0e+#Z6HKKV$O^-IzAzloWN5iOD`L*BaGf% z^(1qMHEaRGrW18yObu^-uVGgE;}YW^;WN7$p+l3aR(aIv^iU;=7*K(=dMT$wnu6Iq z#&jtx29_gFE&`FOvOH&`ezinO5{9N5j2>DJW3n@*)9p9ifA7H&qk^DD9Xgj_70-yj zyZ@|IGxyA-n*AVY;sCk&)C6!4vE0F$uvJPzBE<*Q(QxI4_)2zX!|a!Vt$saZ&PC;?C2d8;QeUr|oF7PfzQtr#7!YK#vl>?(t?$5ZsP0<>=1|cDUJnA5NmD z!zJ3V=5cf>*!U=9fUNG5%!_gfG zf%Z!nu%4}SkLoYJ6+4n_7HGOv?RLtmaU&6OaI56mX3ty5QcKcirQ~>g4U(zWN5~Dh z*=4RsKTJ z(=0^^pDWUQhRM&y2gpn)bR;&^;&2}DgQZFY4kVWeP}Ny3k9I!_ebDou&Bm9l<+uE0 zz%OcybeWud@))pMm~Tw?NDEJx?$ICX;*+3%d2`tL3273Nz;rd@_qvnS??iK|3DEB% zMeDcu^=)m}0r^|R=9DL{l@688dS9?R*HdFa`3kR3!X?^jT70`CNW*=G60*k&I*}*K#)T)7$=tFsR85+?E5L;3j-{DfXf(6$U^rikZIDQ%#t8NGD_~_1 zk$jnMu`&Cw`qSLxok!D#^NFRQ%bm{j+b2OPEars%+Ha6Q7e(y(RJiZOS0)5Ye*6;+ zO(u+@03Ko;;*E5i6NU2@J(6hq;E$oqzR`F?{M;QS-DKaNDwpff@42w}zAlE-TiG`*+ zmOK_1vA=q&PJn+?L4tnj4%s@fe=))UniP_{XFpzU(8Cug<^8DPhe?0c23G-*4SS*2 zhZ*x3F6)fz4Kn`1k>_-OWuN$WnAgKb-dajO%RVx@{TW*Ma+$u%_2mm4frobMsJrJCxaQ*0YmEw`~qajEF=x(+8%^{;cp0Z}$Q}&0xamaDN@-3UM3gmV+8N8$^^)RyEQsE7N-#Lyt_R@ekoal z5gEu90XMpeoiM{TtMR5y6;^B&_yL67Z;m*!d zw?nH=u_R*rCCx2eOvd`TUSDK?QCz!9mq9+z{3W<+Se@r>`nIp%Hbmv0?O53I39ru6 zs)A?V?bS$WV1M!W8J4%VkBh%jK3_E;OWHjbC3mHyY&w|ov#6A42=wZ-8naeY<&NLd zi!~i zED2^NwUgf_sN$;I>7q!|$x@X0%TbMP9WZH$OFLp~0?F`&Sp_EDp^oK~?)Np?xUah1 zBXmzA7F3Nv$0R5B9!r)jj5+q(8HOC!)1!^dWZ^!z2H@9r^e$gO!D~ff!UQXQlNW6d z1s^{I7@+yyio*A-nJ!ewcCXH&U25Ao(yUgo907-I%hwiMhANdY<`2G!@Pr1e_2tMK z%2z09w!0|{I`$K$Gk4O*wKngm126u`u7iU=->P#M+lZztn6*>#JhF;g-HC8}Ka(Pt zPKWSZWNUozeR?3|?wTx2gkj2$NS6GSOHA{smZ|MgQOUxv_qW`H^I;>u@!|T)neFdl zb$DzRSPT;K36!DKVG+cPleZ5gy+_!}K8QnXTh`T2ej$EWpS(PlAi+} zZfk91W&c!|@S*v{Maca1Ql>KIUy3yD!Gb~vGt?INoQ;E)B8_c$*oonHNr#o<`(V~B*a-bPfr{4x}M{Q^rT zxemL`2owX3&`3&T43D&&5d0+Wu;hq$U{Bme0=?_f=eS)Ejv?|kx)pVMMkm||Pa^kk0?<>-|-KR!KeLDqbLa0uMs$YyQcPM8_s@U~(6d#^N zb(!m)`r)(lG!cV*rBCC&Lik*RO-c79LqxFxU7+`}Mh3kv6aW8%(&fl1%o zm}D*}j%(ds)vqJ6lR&=$Xg0cQa((d;h}{5Nc7{Y$2ayOyO*oL7=I!ZtT1K-iCGh%~ zkh+d{ozALLz!R7>o$PVsTt3o~pQCGTS+~0h*hrWW(v45{d`+dfn=%Ml4F7>|pyp3r zNK^M|THOm6sglQ9tOclg__-WHpSbdX_g`aiESjNu3IFjDHZQy@En_O;x#R@#;N5Y4 z)u4rTC>mphm-=ZJtG%npV~JQv#lGY~S?3$0cRE{Vsct_&lN_cX%kKVs`3c{}-EhRi zAfp=ASWnVZHqmuXG4hCXK|5kOz6bmH6D@*s&NHm=Eh25ZD;!4)VOAYhc?(dc@IEvL+vog_NUh#iPSsku$(C^@+z zY!2w&bKkSs%;ew}*9J)C>pj+Tm(M$_};qe0vUF=+}F>>uq25sW#E zo?3Uz=)G2Lj(~vWCNCBPM|!>)7)b9DOq#FoeOvlLGq@%S9JFyY z+gQB);jt%~xlZ!yZv2kt_<7&uH7P^YcDm^XB%EXqjOBv7zccSQmGHpBxcg5BuB@D` zp7h3YF6b|GY4C?`v31_i?tYhE9xXD&;z3Y9a-bhT4xfd3;M; z@N{CMUU9_CPN33VfufrfAl6ZG@1Yc+%rXoUwE}F#_pFoAZK}LC+a>$3(caAZ$7`xN z_^s44n=hwfD}^#0Puk8HdB0nH$%W80Hls9TbF15r1}@`fqln0jG#<}I%6QYqrzGV8 zU{#|jmpV(SS*K^*Z*B9ug{Di@|MgdQE7|7HwH&HPtkBSOOf*PwhwA|n3!YWJeHgC5 z#%@vM6nt-?Y}7>4k6ba&CGaj=uv0b;T`4f@JtA_9i&S*`)}GWmt%5QDBoYNjUSvp` z8|{bxHpR%XMGlwv(cuQH&2;5F1)A??zK3o)yLP-#c&a+cY15f;kDcuFdBli3^ySHW zog|kXdcac&Ni0{v;PHyqWH^DTr=tZLHCpvsvF~sXlxgD!BGy9v?;<1PvS@bVuy}P1(o`k*05y3 z(yE8q5YHoh<_B|5ii-vlznqK@VJ(NxD@7yY@FX~_%Pz^9akR$*Z!7C{QNBj3M|QJQ4b0g7D+zV%f6&^IKR zZl{yfj_X`=`x}6|ih(AK=13V3%JU%(7=n3Z%j3=Kg7g=YOA=px!1`I~ z`Wv*o_EEFsBWj5({O%Y~*+3)PFJf<$LY0bw_^<08rS2O@R_bR}`9ih%{A>b)U&c3xM`BK&QUZzrAuR`r$7d){be)+dp z_#O0P_-k{*w^5)tZl1*tKBIS^scbS=(!Kl}byl_`LYcoFbE(!xLeDjduDRepw%qi3 zM)`lWXZi&#u>U$N{PT5m=hQQ(T`suIMDgZxD5g;SBsol-{Spud+)!r+;C0b^|Qb=6-9s$)i{ z@LFanu@u>vS0`){8f}{3UjN1+a1=iG3dot6pBsD+ooAnY0&uJ=A_H)f!i1{{nawNf z`SkFe7MjIUh#8403i%Z^0AKicqaY+3$8M}(|@uM z@$o}!zo;V-=O4CW;V^pG{yf*8;IrB@SbiOI)UPpYfd%i?nZjX3rQEr0(kh3tEWj z;Yt~s7|okTC0_J5fd=mt)gMX*==`c7se|UI-r#=E$^M^=5xG$Er~a;#*`xr1=+&Pe z9^M)1$#En?I&RUChl9ykYE+FWTQE2z@9ND))c@5A$n4wg;xUu*%%`l|EY( z*M_h9RXEHbFj#2jxRB>Tw{ao=!!N#b;igEowxp8&S(?%#-hoRSEw5v@63vc(4w92U zUbs>|P9l)>U^slK2*=4&5Uijtd|Bc|s9r6jMc~!IpY^#C);>F5^tgMA@B%M17Sw-J zitaiFsEB84hmkkBmCph-SYFdM@!sx6%Km8;;PyE_4U(#wrf>fJ0U5_vxTT1&CDL|d zy3M#TO~eC}qu=9DR!SC=5PI;PHjImpgsqI{kL0we5?vMlWO#xoR1r8s8y>;2^1Slc zO3|O_K-Mt?w`E>_iYWb=)Acpmc${;25BKyx?grHws*9hBAAwtonCX~*N#|NM)UEP9 zj+Wk{l1~LdD2Pr_qRvOVO%p$&6q27Y)G*53whHF1R9%*=u~GMEN*(!na8O75!Xkj} z5O*N7t$rPhV8}QKcYjZiDmjyR&~$j>v}9pS{j!(|L&D}o(-REeB}gxSaI^N$U=?3} z@cKA09QlgZWIygWw^6?NQ*i}Z-P6m#D;Jy&siUK@kBj-{ncnd~BG*p8abJ@91xKMs zxS=z>(*-hZ#joTouf{CyHkJ1~IoGCzciR@PTgm4d&L8=ZU=1_=8v5`N$GdC0sKz98 zv{+Pv)R#_iTM^nHnkPPS$Vtz)r@2q`NNDg|;FoaAiOU`T@Sy;Sz%F*lUTpylyQHNY zEorx|+*r`UD0lvfuMC#u)Kae2ifX=7@!898$d+hhm2|{1X*fKB9UtS3_T89TNEGy` z@>e8O5X_;Lf$$f&%?vf=nOJd;d-hbHeLm%wr2{{`QkW}Z%;|j&c2|O!5~ymwWU*y5 z)PLBxQJylg$+a*??4R2zALGxW{~T$$x$b2nOpwCf*O6*<&-l(`^c37zpbL2Jn$7E>#`%|z9XIG znBYPaAe#fZxo^oDZtfrcNm==GBscnOkr&SPDEROOc%{xWX-k-xKf4IO=|K&h1aqv` zC%3yE=Q{m#TD~&qn9nkbh72Fg(<$uNH05veDlL|tn2U>mv|hD;VyNU*FDGtij9wDd z5fsH;tklcYPBBF)&D6)N5FWk#>H3yyc|@_gdE;&YQ4&osaKl1IA-~%v4hnKF>Ye6P zt7N=*Ox)&W32k8|+G9EcGG#rLQDpHT#;wq8g%M;hN0t{P4+1315zN?aqztO!-sFno zFVwWf?;p3`McGpRW?Xy9_9mr;iZ^Y$FEWNcF;I8+ju;5}%51j4G5rF(&rDqU4QPK(S6dgBdpj(|7S2&XIi7hK zZ;{NezxJ&yTm0#AoA_;`WCrj&59l^8;(D;)mN3R^IDm5=j7i>#4Bnt~-fWztNq^Q4 z#O`~G;$^lZxfK>liZkiu_&j!Dndq>856^(mjtfb07Cc+{OR6c z1o!ObRqPD^E>4AmPX=N;$itxVDa9O76om-aZ6fy)8~r*@kKLl$;Wy9VKQi(WARbr5 z!RqYPn=BK8yq9&`#)lKX=>Ee%Rmb&aCc{c#6%9dXArcyN_+C}zgt zed2fL2B{kpE^+6+KuwLZr8(;0|D3u7kF&%`X05Fdz;1 zsh;HXC^jikyBg4y0qqEc*H?19NoQp#`mQo6$~_xqQeOAhUp}Neh55R$HR|vYmeYMG z8{W7v=sxwysxM};HQfXVv#`p6fNP1{qAKuVLQ1afboGq5%-FiH&ntlJkFlqgz6ej} zos=&afk^cNrm}@aE2tjt4*V4rPowu4U34=4z~mJW2a<7b{v|i0&YBoa3v*ljU&-Tm zxBEUL3gxNP_zLYThSVYsOdK0wqe}BR5Nw~q$@>oc=n(%5uy3*hG+yC7!Cg!g%6YD| zER#w|XpO>;fut_e7$GqejlNQ$8Y8@w1d=1#sgk`rT4LqSSkSxuj6DhUp@`B!QS-5PvfL6^`5(+@>Bh zkds8>>9f1$BTUQSeGlSU&x`%#L-P;Xf&L*5LZSstFXN0eL;)Lt8+WtmopyZem6v<-KNFM#{+8(ZZxT2dRz{Ifj!i*na zL5(^Wt}`cDX^o`}Z<{fthA%B3l5MqhnhM zUXN@i!%pItqTNA?Hlk#n=$~%K$c06F1rX6+Pj=d+ z1#@6h48P2t*@Qs}V2nE%*jlKP(RAHgY}Sh|dbJg`xH#}(7n`!kDwQ=&_V)}k!5Oh^ z2SfN|NpvK8QkSyN`JM?$N24y(58M>^bCRpm@Z z`ykb`pZ=7NcmK6XdA56A+#-i_zlaJW&so5&Jkd^l?C;1Gj-QS5#E^H7p}T!o zA=XsPn#tA`c5sbKn<)@r@m ze2=CdJ-YOrvJqsT9S5wL9mvnA&RSOYs$4ok!-HLAcM5^cT0WB{iK~ZX#61P1SvAbz z&=A&S&|x7kas;bTPL3G!=zacc&*l7tIiu;SJ%Y@%jVem|L<3@SZk@_?sX78#KGcj7 zSibT?Ah`Y81VT=>Ur*nZB8%cL)3ZAFfeS|_OoItxuC6Cn?dxVDgg+5x2b9Bre!&GR zz`h;`s)1)`kFo*CxJCSqDI+CgB)0LuwRtJZHn3ZPqX?JZmJ;8o$nlh@+|{+8J{-Qx zY=~?1g^G{}C?G{H?#t?=CQ1*DlpwJnhTQ-r3^m?hx6duM}@K{DK&8?d?;e6|4tqx=?}%qlhECcrEkb| z!s)qDLa))qIrUSXzaxYWF#1M(zQd7t&m?+O^2Ab|7J_~3+!1rmbOyFeT1-=L>tOE> zQb?#+VBpHZo23aQxG^rL2O`F&zq7S+__LJqJoDm7JJ;;iobxofiDD(CfBXGZQF&#I zuiTjO35!L1q<}N*S;%zO#A=)464VhXQeN57H}2JODXo!xyC?rs)&w(U%G%u) zh1fPPpW_6aPI^35>OUXHu(PA(iGmV$FMu$Jrd}nX{6^RKRcDL^0qbu?s0D(#s^`}5 zY6@R*Nq{qcLDX-tUpOofKVh;t8pMe9@bS{9^Mi|x^_G{HI7deV*W2Jxz==Pfb?q5( z&2rXfNz#zBx0&;AztLw9S!C~wVap$|NRmD_qF*ya0~e=%xd?cl1it`}i31f^?DDe6 z5oAJ>`KA_w=hc~=`7Oo{H`_j8Y5|K|06&Dn41>?pXl_5WhOvqo?Xo z6+O>rGU>KrL~|Ib9i_V2gT8?LJ(4IW-0O*c(1aBXU>nDtC%BAeaH_kcxeh-2(MJ%cfzU{hWNAd37`@9NzDc?Nxb z_^Q;^R@Pc+T%4xN;0f1ALDmiW^z(%U8|au{eWesk6Vwn61UXn9mtz`+z zZQPfnll!u0vO4EvFHID%er~6z&VIRFVgkV%&K`Zja7;@Q7pLUgODem}lY}5Q_U1G-iuSi-36>&`Qa9v~ZT-poY<7yD;KbYzl=XdN;ZX~LDbEgowPaoUby z5*fMsf54-o#+zCe&-tjwPg$?%9j(hW{Tco!jkmB#G>SG}tdtC_mPSH|Or;{0LSHp1 zDxf7UZ;KZG(Pci+1ATjUHrUd2pA>>ZBRivZc@OfYvKw4WsKLZI^7nNDnA zUNo?1c=ON1z?^DRNi=)$^!8Ws(Hz)!yKU_pM#{v(xGBpe3#rwdME(LG#E73X>|={k zde%Y|r1c(=Zx*p+7!XjPiK{>N@}3k-9R`Ceb_1QmT&;?ukwO*#7PQ()!jN0ZazhKjkWST*U zhZ5)7xyg9OvrHxUCAJV*RE-re74fE2GPzJL`!g;;k@ve`ME$86ei#=AF*^629L7nsCoy~Sfj1!(zfEs>Yx1x zGGlVwxbAi`gzJ&LDXoq(=&EhO405R{D$De)yVg;URaS;Nj>c@G#_?f-{}rmYrVG0E zJe$yqpl@(CJB1;0U`8Dz{}nNp8=6wi7;278DJr zSA^$DbbqICeM5(3*&n?(Z}H~mGeF8&SoC8t;Ograw?~b=cW)+kX@30>8XbDb&6MBj z$78FgAq<#4yM1era^29eomoyyrpFXU|E5kc(}tRO)0Atw*;i{8p1Ov1ILD6Jy2l2G zNbTC+WNJi%LyW9fOw*r6!GcJFOk+K_^0x!RT?lBU%3`sUi;Hvvh-sd8St&7Q#SUf^ z1N+EH-;Jl>t+yIjk9m--svAwTtybGCZT2;%c0)OlQTPxx;vxk(L!Pv%2A>CaJfT0p zd?PvTbLBN)avIkpwiw;~rP2war4VhN2F%dkwkUWVMyk5ve zImPc3Z2#%Ozv0jCS%nOz0p@n{iP)Bl@K2_mVQY5|GxXdqsf9ycFC~c^oVAj01TrGe zcXUeWr?%;EAc_Jn(V3bWP)TDbslv-xYymneIxn`ug3tH4_%M*tQD-jggf4Oi+F~}? zy_**Pg^6%9v+{tQ1W$-hP97vOQ|%S=qxik$8D=7Dvjo{w!Aj9xV(Uzwy(w7U<~f3@ z$B-TP$0#VYynXPd){gfb%>jqt%bs3fE5L2{oJEne4A3y>XDxj#WoAN0_m(qN?LJGj zL@7B}Mfo8NF1N~QF>xmoy%G?j&(7R`Bi$JEaZGy2V#-gVtw@8cBL9U%Gx zhjzqLniBj3d>57LN(xb-JPl*!AJJEGR?0UKc*mE(o}I}446z+gpRt8qVy%yRdXHZW zTUSXDq*zttdj@#CdFSv?iPkvR?=PW6Jf>tvF4K+P{O1)i6^>RuE5Oz1dx>E`b$P-W zqVej;q8#~k7vejK?q z)D_VE?9_N&Zr9b)R%SyH=tpSraaRa+%59Y0Xl;`( zfx@T}{&EM?QXa;bW^h9TD(MAKsPxK5+-o&M{7!t)RuwAp`Q(vlYk897NW5o_?auI) zKc0uZ!JPunPRIZC+~9Y{W+zWT2p<6rk6hm!QV70f^e*g7mv23-e{=+o@p%zgdT_^h z_BJVeBCozIPhw={6i;Z5qgcD{cMUy{<3k+>IB!n@)?R;6*_L2${QyAUcFm}*-dsJx z+stT@_YpN3Vy#KUSLvjWKErN%vGSL)BubK-W~=TW7$5em*oNv%r&8*1U|Qk_cKTTQ zSp{0tiyvP}3I*>+%q3cC+oFz4fh#lcKWBh2wl3wEsk^E3`x+Pg%FD~S%Tx;2v`agT zYh^rp+5Jid!TTZs{l$!*yjg+j+69>G;L|fGkd~ zVcuUmBhQd8Ep7F=Lv6O1O}$VPI(-ai-BnBxPCQ|Zch=2nn*KvOf&Gv2X|i}Y!wB9M zO}-?}`(+M}H~el#(XN^;vYC_jFt?2e{p%HM)Sn{)UUuV6?sb`H|B&q4-X)0fg&_43 zL=k$45ZNqNbbW+4B_nJCS!R(7VQR9GuOwapk%#8g8*}~X%AXpF=!+=iG;1!b!dm37 zXT}=y433flH(&Z;Y@65k-6n#W0FCKG6tuL91C-4LWP#rH{2=xjb#?*W%#VFDf={XL zDBBtYJ8lXnYt+bcww<7*MLxHwfmOfqZB8+*z-t$(w$HdLu~Dt+6MIcd%T)_(gPak$WM9q2&cQnhW;=C|%-e@hMb|$HuI(fX zAuzwNrmern*jMwvWtl*EpSx}*2gSx#n)C>FAGoI(Ur#~Sv>ebA^ldaz^1S@$u@7rl zkM19D-5IG(@47^0oP}PN4C>JNM}Qu>vhiWP2%JwFT7o%raKljg!!~U?+18VXPq(D6 zb2m}=5lf_)9vO*@SI`!ouzqDJ{V%PWr=(@3B+f~1xA5rNwPjneD3XtQX58ys7x%1< zBlJV6y)KZ8QA=q8!46gEiF3ST5@&F&px{wXbb8yv>3AqGrc^y`Bjt+$g)Gjpn?UlR zV17&cr&&PhGval|j6HB@S0x?+_|W*$cOx^~;Ocau|{g-uEb7<2M$Vlh)h4)tG29&%kTU)YVXw$i(>Ld&$ zRKO~2M}@|j*y4d-3Ap)k#*Y0>b@m8TMiNHEqflQ;zKW&OFkYq+)L5}X9-8F9#oRuFHByF zGwN7Hm-iGM4GxXGxU2{W`ZTfT5cJIR;eDPYHH|5b;KluG@8D8X^e90)`A5sSfZe+& zKN!YL^^d-Vi^xIzg4h~X$EOj@wtmrmL@(2X>OKOt#O+Bgrmha3FA!`R}vn7os^FafoK38~fOg#}N_UaU&7D(K9$5&unv*9bm&{fJdomlPNQ zYRlhdUMnFuYygT&IX89LhhR)x7_6~vwd{+aMCLS;r!?DDj+tr9;_SN$bMguD-z2*yyoTc=O@rwvfF|ax4 z?=Dgew{Afrj}U%z%~8<{hJtcRQKJl@tCqT`cg%A4KY8M z6iS>1z}M|?4o+nisjD(kr;8T*JVRFKb7MR!W`cPC4qK0v`qv9W|J?bIgG}UqNXOk) z6#frcyAR(>h50@k$3(gC|KFfe`G?iP%ZlOYtP_vo@j-j6Pwo9G4FS?N9xk;Jqe zD$%lT=u^zaq<3@Z%&hVhbdPI5?`T91OsjPqliBPIe3EUE2O3FOh{&7~<;}*`V_Bq> zruWPC-Iank;8(m7=CmvrU|;H-fI@>^B^@Cd_>LzI(GQ&Jz_GE?<@E51PI+(nB6`3y z=M~3@J_C5gv#rSyq1~Bh9wVPlSFe6x@eg6<^83XaJzid{Mc`4=IDypE<0VVPo`KzW ztCiY+;4WwPp*Kf5L(u>u9@IDg=mp42AAm9KJn5UD&2F}5>%AA1Y{VUGpBYWfZo~ptE|5B3Z;we6l5G(V3eG3(s8qn8Gko4?yDO;Nd1a~R)-~HOvyFRWF`lfZNl$EM7oi`&t-zYAk7gi?#=Pv%}-iePcK3A7kLYje`pYA#*T!_yD>9wl!Cd%OAC zHiqL*$bJ745O0t?g}Eyx1sxgY^7d(#YAQ}zvY^8K^C>(#?~~!b3~l1@a$3x^eK2o3 zATri!QcbU=F1rv`2=tt$5GDfGYFqdDGlRLu5He!v*Nv${jcztCu)_a?F8^!D{;lR=rh~|%#|1a`E#%x*%)8~sF+{`7xRp01^EM)! z{6f2cKWnDp(7V3rP0G1Dm6h%9@_D}s^IBBybvhv=T?xy3lY-UE9EFcy{?8~B7WVi% zA(#|#sUhE32zRdTo&TfWpl1KYZ)&@oe2K4V-_2u=h8KhB0a-URSpS`iRQ&T+njYVK zGOt3WJ;s&LfIKBl{xK}TGFw+lE8qFv_1%$9j*^jgOotjR;QC5lT?{jKn|7PalTbtY zOUN~n=PVp7Nh{bet!x8zhn~E5wdBGj1Lr8YG62L#|Bs}r4v6CGqO-fiO6}5}DkUW; zy`)H|NP|d9C=C)zBZz=BNJ=Xm(kvi|w1gntAV`BWe8cbigTH{8H}B2dci*|^oNG9N zKbGsYO<2OJu&*8*(zr0XB^$M5lq{!oJ;WO@j9;_85-xb7FI*p=-Z-c(dUl`ZEQlcQ&vji2F%Q{=benJaQf7)Zrl{F^p=W^hF4LmE8C4v;uG)8Hsn zX1!AqOC|5pp`^*xY83qLq(j8KIFx&tl|)uEaMK06ICx`#=qw3##+5*^VWqYFVr9sd zadybZ6EOHRL40)iqHP|xW(aRsi~i#?s*~&EELK3`je`QUeLfy(aOMTgC0#aNSsGc_ zRz%*nE$LL>wIiI;M-tw;O2!fxVQ&<*zshyYdrtrB^3_MogaQ0#e$P&;)fw)Rwl{mR zAW)9}EmJSSWeIb;YlJdx$~6G|FstDX*fV+gM>x~xOCng@_ihPZ z?4__ScFTzw{*2$+zjU}H%$ex{*xUY%*}W`H)Z7^Lq&;QDYMFUC<=Wy<*K4ny{tqY7 z1&+;>b@Eo@?F1t@ZxMp&Ymlge03R7)8nzX0G_OQWam`!j*}ZiX)fn#tdoRQ=u5n;2 zU2(%Z7f@!Fav7a=#gA9_$x>WyEz~qTSEyp=e~6~3G$085N9WTJHhJ;u?NYs3v0>bI z4bcz>4{bo(jG=@=un#6o9dqwW`s$J~G>Z8k;QQCLMm#e2fZ~yh>Q@W~hWZJKPT$yc zO?SB-JVMYMJ;gtA0cMi_y7)2o*A%1rrmU>+m$^b^-_R`4(OkbP7iey)%B)s{^e9HJ z-5;SdX~EPyP=&*V2VtA1p8~yomW8kEMGW+VA6FfJ& z=`c6rsjqexm-X4U+dWUMT1=DI=2oUBjQRC4%u8zedcAM6W9ZxBzy&fnULC*giSb($ z3x_=6*xsoG>fUlCeKE4Tx(TTsP><3+;{whYsviEbDG-dYDSn_+4Aw3v)9v%5JeoJttZgZ z>bF*~bZ^`-#uEZ`4Lm(%8_)8ccn6lx{+s7V#HNfBy1&2t5Ozl^s3B)0nBG6_kyr@L za6S3e3yTESW{L%1#{u)#vMlU>*l+Z|Ua06%8f8U&Ux(!qrN+x>I8R=;e=r8d1^2T=goR#rfpPGAM8Lhycfp6 zBwMv(yBc(w2l&skW{m6F<@(Umez+Ok;mrYxoPCskdzD}TGIwXI)&`8)5B)W9l79cf+`EN0fgtGUwjUN-T~*V2=9#my=lc(Od>#g>(pW#A|Wz7^A7{G&)&Nw4ve zvGh<0CT2~F$6D@f9%6+gbdTA|oDsxu29i1Ag2UDiUQmm#u^T*=ITs@k1KjMoJ+hiw0(@9xZOS;}FZ*7M95 zPuiP`d+jp2(>O_*Q*3ra*CEMk6Ejbr!0$q-@D}U>pXzGP0Y3Jf=K*1E#o^vTn}i=; zzKGMLGVB>qEtl(%^$JC6cINtFX9avZY&^bB%jdHd77JJS&=q&`?s?_i)5T-rr7s`w zjkr&+U*;=P$ck~uU)ct-9YVy5aPzR#@s=JyDpUcTe869~e86)0o~n*VQ2M)HwjgVL zDwKipBZ7kc*Fz6kKVe{=Z5fBAFxrRkldvQ@b}O+pSthf{q<3tfDyX>VCg3&b44q_G=jZL*$4m17fq~GMx|A8h!>&XY)F+N-NhwseFzpnf2 z=Wby%EFL->PFUYH^0YMlUVq*9?m}*Q(;Xt!wxc5~nzp6(nP^482_Hpcxtn(Jv*$fu zKE1iE!U`ouB=z&x3O$c;6IQ)}^XVn~k#ykX;=17RCkUQAV;C81nNa!bdFQUmT2fb( z6(d;Gr=*fIq*YTLbvw04FTAbOZ~P7pU?L_a3u+a4#Ibia;;dV`QvNK@h8e3w@@};Y z+(q8q9{Rw|da?T6kpZ?ZO5^U+<*+W0B0b0hV|qU!mmYkK-Exb>F9LbkQz|hxj;!dP z#ha>|j-q;g2>`3xRaUA2oT=*5`V>}38m&wvEAk$%x=k9&F+IpTu+k=yvdH_3-3e6a zvApvz7b1c)0klvU%NCl7+fOv+6?)yd@Le&ZYwB0YUiwD}v#KGIF^_I(FKpvhIZi`_ zvJ3P$PoRa8vrJHP8sT6_r|;F7CfzLOR?#OIn8|%@RO!NB@yYLEIJV749YJYDDRHI4joP zD4Z2-BqIyL3x&7D$WVecpvY7V-~f%#lq7(Q6l0sx&BImQ+NpU+?iE6fWu@F(_!JOi z-XQEbJ1I^6NKtw-CCI&l52WsWfbf|zRsVhsQN;g)nb0D&cZl8;Mx>B-)ITo-WT_PI zCf3RK)uLPv0$f~Q;(}MFV?|fS2K^QcjHQ1tmo3XD=K1JPVw2~97t=GXz4qv#4t3@e z!9was^AY#>?-vQM#bQ@&Ctw3gvsrhJSn*L}9V=4Hb+S~m1S9OvbyaF0*zCXyEN%R~ zx4@chE(5o=e)K0`uK&veXJnCssi#$yt3q}lTM>}Pbb6`D9wmPE*^V&upwaGKh7p|( zbAPC_>9kJ`a=p0d@&2Q&cr|#u+g02}&-BVzYnJj3EFpUpkcVu-&9Tt$Q1%>X2>Kl} zCrzmZWy)`^5P2<*Uh|>kc*Ry3=E=H3E8jz%88y71fi*#BY-XGlK*p>(UA+Eg4Npg~ zdPLjFP*}9Vg|hABW#5H7t6Tzy%zdm#=omRBL>o;+9Vl1~^%AI#T@+=Ch>T7$36ZOI z`lrTB)!3q+hR7=lpwY|uLJVWp+=i0^=#O`?E3$Uy!k&uz64MqM9 zR3pXmc3cUo;$b#~Gm@b9MIQqHI$@wOhXh~-l!70rk~!Wxk0D73BZ*>I6&5OhaRHe&N;y_zuJJaE5WsM@isQ z1^ET{kqY2@Z%>*0+aAr-utN}Jr~XMFIn$DhyxIt8G&FT(JO~Pr&X-K`z}3Pp0>+;p z{nNUEN7Q|3&zqDr>EF|Kz-F4R%YFhYZ#8y`h)}pbqCn;h&tf9@3f+!Wh7UjdA2P_P zUBX*NHa9+mY40wbFq8ii+$H`8WscVxtTKO1AMF0l(z6FX&a`}>^~=vkb}E{U7hyM7 zxbDm?v7F}8U@UZ)l(W}ivFSg`AvG*I)#J9P*!QeFSQmLj$HQAk!RnJ@;T+oj;W?qmuFg zh2gokpgW97V-o38#?9*#7x)ppA%&TU(e{0ks$th)4ZNVcti^pN#65rC%Dgs@hikl_ zxoOf)4i}3jE~W7H2xw~fY)Mez_VWR_V1MGV?Z4ilPhqQk!WgMMOug7 z?~u6bv5$itw7KX8X(t~3EbzpHwE`w9?;_aewc{Viyo%=)7V-qblB3;`N)_9AbaP1% z%7#xLaFVfnsJ*d6*Fmx|T60Vt-!nek=WEna5iL zAkrXSp7)L6V;i=NBaK0vw){nDv`E#!$I$KzXVkz&6Gws*-@eW6Aa;L3+xC`;*^pXT zvaqd{iTM<;2i6Lqhoh7?A>%+=D&Y@YPOL!12XGI97H#D71h;QZZoef8qmnV(170RF zW*B{;DV|nu>-_2PmwzuIjHdIS#7W9|QGL}P2g*|*6#>AQ6JJ9E8WOp<50C<4+rzoL zLjnE(+GfWV0VW%{k9Q1S0)hgcpPjaV;7>4;&V5t^@X627^4NIzMZN+cSXNLO;TW3D z@`^x5&o(>zW@Q~Vak;I$@tTEYIRq$wJj}L?b=S4+SwR zBQ!L+4FitDlTXfo8WwQFTYANgB)h-TXNS)os*lJK!3#S`oPZ(&4gdZm?sZXf5w8(i z_8if);oBRCsawrh1!{!s`GNjgIea+k7;wEpB@gnP z61EKm@`$ZSXV?#}?Dd)^Tf`bu_wW$7xP$q^SR_C# z73D9uf9*jI3_FHoKw{Y(ps28QF&v=n@<)~cxMo8AcZg7$))S=C=F%weSq-id2p@&x5!} z`^8+}t%vckFd1E?!vyGfupH11{RbPk#o&50La2aj;SL5I4D&)C;N|k>XqwUIu?3^% z7Sz)EX^Rz2Nxdq4n(T144PfjJ4dV?2 zs(sXQi=#>Gya;3x;&EC~{}!e?0_$-wcRU_P{$|obuwj)DR;B}*Ez%splj5Ehb~kI~ zdN6afjWcFVSazzxBlw8eJE+A78MJ*Ed+9T_PuaFAQ@!87eH;u!E$+P&dOS1_Jo#VQ z&=3}#2;kTsk!nyK8aM{I5>EZVkRjeY!eKR+J<+?=wSQNnWfqvci+BZ~x!GjvIfqxz zYMc5XN{_=jcV8kf@zblhoX~8)VQcFF3fzoj9IRRue@eAnee6>@z~BG->MuOUFvvy; z>M^u}@YERaUkFnnL|a?a9}x`{^t!fnJv}$dAV_g`~(1)$#*|4zvASs$Z$V z^oSX$YPCcfzHoVzS|{fkwdnY%6;A?_1JEVGxPVN^aKFQ46t>Rl?#02n%Pc>E$MN>@ ztg9$9fxY`@{T*sxT0PX}dKpDD%p1ZB10yLlIE3zb30ios?pck^^FrjuEUl<{+KkV} zfVR)`mwGWk&sZ!g;D*bjPtnc}ybKz;eQi=(bKHi&ZR_VlX68rI7X{uZlX3_9fdp8E zt*SmZxvkt@1Hi=@%m==s?xXyKaDEj{YaeL4uv+!HezKj4;5NLo(aKfj4Hkxxo|+It zve6*?6rdpQpuL@thfF;v6R|&ixj8WRtpuh1a_#BV709d)%II?e6YevJPdsE{zZ3^0 zQ2p8`Xul(8pjw0e+}>9@%h)bc5-;e`x=(yUKD^CMPq@9i@#*n1Y(tn244|9B8;}71 zr#PHxp!!zLKoC^*>J%xsaew`u5C9_P7eP?j!TeJFW!wdfARK!^WPTRzn??knJHIoh z;(`x-jFiXhgQhQPtV*_re+hzcTAr%}3^F_dU12{eBSVVVhL>dXRRU zTYRCg?O>-LzlP6N14se?mQZR4_Fuv;3%ox95btrawjlmjgC_*Qa9V#_YBUI-&*gaf zuPxuf8l62-m8rOYJCBdaBwW>BsYr=H`>+shqL_%SnANW~<8hmYdY=HuwNc&F?o40FVP~ zbaEbQvgPq*1!$ALIimxH7>pq1@pR_)eNjfYy9({qQJ1s@IKf*_<~1o&j32ofGCjiC zIE&Pr`lLmv#I8HR9cAi{Ky5zL;(c76F$mks87P%R5x4)HzRV#9GEM5oahXJe3`H@} zC<^EI>VED{0NN|$sjiI>)Sz6WBtJYK-!P9 z`A>mrRI}lC;=>1;ayeds!Ysva0PQwTLo$grGBp2co|?1KF@TK*1rwmO+%NtA>Da;# z`Uil-WIw<}`Ck0R2>3DnVuUXZ05U%=NS~)A@D)L^Zlb&uYbj&QfGUs+#Sy)N0 zhnBT=M)~I#$vXInP@9mOsQ!T03*H2KfQ_4ndjj)A)w&S*yjx4BLLAsaY z@aELWj{E-lqmxe}p0j)ExM+kKWy|F^6;S{r14TpXK^>cQjc(IfS`;pzWKnE_0`DQR zqacqv;tl&5s2;n47c>fOVMe|f#due570Ll6P+bU>S@MTMr)9`aYE;tN#I@zEu^t_; z)5?O2`rA)m3v9^p+C&-Qge~1h)bm8wMgM!dAJUm1Uu0!o8RqDZJrFciVys`rqZYS9 z{_TeMfe=_Jt`hqj@0)J6+|X$rQ;7Lx)<)uJp$qVCc0dc@3eChtefTZ~ly)|z8c4sy zvZ{QNZez1&I57OcF6HW$Nwapx!;O3HWOZm2qVzuPEh{&qzS-H&?N+d(brc#<3&osD z?vfd7=q9|hTMue)@E49QRYy5MzqYv4GKhE;-4YKFe0sY4i_lv+a zkcb#Of^6ajI6_fEtFTx@n%kfv-Gah;_?tyuYVRjKVIPGx0@Mr0(}P;oGW>VK9ZvWd zHUjvyWx#6SPkb zi&J62+yZ5emdM&)`q^(OPWD6Hrn3|4Nl$3!A9Q1VCN3yY z%`x>B*51~5GMH$b9hTz)7+bE@s@|18Kci3Xn*tT70kc2zB>QfPudWztd4kwtB!{sJ zYN$+Uo(Gf&0YZq=+kB{u3o>E?k$QpUgZ zelAltt@eN;X%k0NbL?FW6($!~05%Tb0qYaKWiznL>&tcA$VJ5bGdPBGp-(m&SC}wC z7C?Q*2E*Y^ZjIA+?UVSw$r-sTssxr5oufJni~E(oBrhl@dJD38C^IUCQO|m9j%tN9 zq3y>77AFAkAD`I;Xa!lAq6ofHY~6_QKrpdwJ$HqaQh+It*jV;Etrwi~Mu3#Zr@!yN+>Y@40R6Y$%;z6QnaV9} zJX`8$?*MLN&wiNHppu)_ddgO%t4_XmhOBWfuRrTRLlcVR&<~YV-jwn322vT|5yR1c z(|iF#KcVmw151-;_ z_<_UUgg5-y6H5!NeCJ!)^xRV-%xO43#h*)o2Y{*yvpvK)Adr5BD+e|!no2U(@8I+8 z&djG&BQ3D2cI@ znK%qf)_>vQxOx~63Z+Uj|GEvts|BVsy%q$n3MT*Dn}Yy5&gS952)Kpr9ec`}MK+mxdfXXG{CeQZ$m#tzZ$FDO6!2m&`LGyD{_uvg&;|BUaHfZp5R*WN6TQ;H03 zFaF^;dc^p}sLb%!uL=)Io9drN@1cV72u%+3eD%(TCbq39+|`AdEkx_SEdWs5vG~GP zl=_Zyyh}5JZ5sOS!EI3!C(0rBesE86=U<~?i)Xe*9k=T}x?^Ft-#y4fM?f}Q14l-ojQUHvg zz4ShsgS;aTc1JE$=IW;sYsf=^trM0m{nXJlvJMS8^x+Ta!aYB13#fyE}-5_})$vsGR$0P-SC2?6ck zWy){q$nJiJMzMBP5;V<}J;{#lUPD96>!9d$t3{iE&KgYy4cmlGTQ5E zBes4_jTO#sjxXnZE18=Ui1_pl0z~g`{QfRI{S>|&G*s(~Tc=di@!xtdYoqd$kD+>i zdM-hbsBhDUIxE{dn`jNW43l6DnY`HL2mAqRoJ-rjNBRMx=U3A=>8g|2r|FlJct*1Q zA<%H@w(q-#pleu6=kk8Ngw)Wolyg>lQS>iwUeQy#XuHH6-b1PX^QO8dEklw8y_i&mxtueHp_xe?Q;qB9I( zqwxGD=QR{^Ccl~G#8l)>`exhh`JbD7?EGu4tG18MSCZ=|LdHGI2d85r`@RyqLqpx6 z6P*_xMwQ2qh)qJuwVUhfnBLB;!|`|Q?IVM=2sk{BzRY@u zDv7`8yJImEbQ%N>pgs9_>+w%~gyRPqdJ!IHZ}9l&A6ESAkt^^*jY>zrp;?ON>1bCz z=jo*8VWV8a!&6zZg}}$Y>o!t6=06Yl7gD2xX#JA1n#@+N`Q35H?`CN50Z4JW=VZa3 z<0zLuUlS~Qj<%7ODmFiC7f=3MS$wSdP^u1vj&#y(asmc zsq9)`t|iA`B#byA#DUnuv9True2`vR4MN;ZA7?ZXJ$9!Fxzmb9ZL+;D!@iesyldZy zCs7*67ueO6V8N_3kiEwU<5;@wygF!Ho(OMDJ_$`OUmSUj;ro2^p!cR0$a;V-HU`YN zLJaE1GYI0M6(c3<>wtS%N+KQ)B18nXq=6r7(S+#32l10v1=OF$GMax0JNEWUNHQ(T z@=^}E-DLQ9ECnw9gorm&o7V^+KG)s#=QOyagK7HNnk*?m6|z4<<0jP0Mve)ZpgI3; zR?cvQ2@P*1aQvI%%cBnh9(ZHI%i|>$8U$dU;tXMXanV1E%Li9IJZ`5Wodl@b_&%p< z3-CAMSP&{~t2z{Y-gyC=eQ~wt>JzetjPH5FfFnt0v$V)O!$fvxk8ZDqer@V)L#Cx~ ztHL~SKqi`&>Y&y8<|FPP?)v=|E;;h2iK{n;;nJg*W~I>6IB!%TQ!6)MCHO-wJr^6%e6iHIf=8z7X4fQIPpU{x3of%jpF3TL%mY4 z@}T6NNoH?Eh?>Fr=qrx(;KPnHt$o#WZIW5t{S;1$gRL7ul3~`HueaG5%^h>)L$tb# zFB}mokuAA@m*_>{r%H~nC8#0sVVb}O|WDY zVPH~>kBa83>Aw*wyLm7GYi;;|yTW3-g6;oOZfAq@ZzpOBcbT7X~O@x;(%^DYw<@ILeSks}vG|q?mbMMEG1ska6<$Jf4`}MfP;?RGe zP)eTw?tmWI&G%^bXJ?()(Hb?qH>-zeupGZEk&Tf2E=bVZwfmrfi@@Ic7XsMi!q5T5 z_)7%g4 zeTdXta@frpp;!KP4;`|~;57Y>zs_Lwzq{NAIPQ04`|1LRn50g?we0xQAM$1P#?=<| ziaBxY4VBT7-Y+;QD<^2;zl-RJesw5BX(S5fI|vBe3yWcg&zMD~L|sK zO%J!9fAB)4*o`T7$zz6it&}8VxZ08_@@Y@)YnW8B>zDqcZae5A#Adaaz~>M94!Tte%bDoVCAL2=oaq1Kys? z+TrYkN>>j);%;hFx~LXjMZg%c&4}97HT#&MK&#3Nl8*yjB5} zf!o+Dj_?g(u8V1HunD6?++k#<=zZ(t>Ve?c3T9Nvd^p1@Z`(P2+DcAM0BBlvJpA~v91ETg~P z=H(`*B)NZxgq19ed5GmSZmV-^bVA&DnD+CwaRX6aHGxj(j4{{Z5R$=WN-Sy#0)+X-e2()y5Ky#9zA5z8E3N(p`Cm}B?&nsfObnZLU! zWhM)!28Znl?%?LDXRMQXrt>8Po`Wafdl1TxsU==bws+sVeg~GI^&8t9>hF7<3Q*$s z?GINQh=Rv}caKXJu^We7)yA`(oHae({UQgmHkX3P-Y<@cP$cnNR6dnI|G348Sn+-- zv`tJ@ny_yqQ%x=tCS+Bt$vg4vclHEA<>ll1K|MBOC%3Fq-9F|?YtQj;&=f}0xUa>JgZfle$CMQ&-;&W&U*U-_=^@4B(=9y;}6ft{O!J$ z*d3qer8Hqc>otXc=D9NjB+sKE|ADsdB-bFP`_?w_ZO5i+E4LwR<4loCH}Sboa4hZ@ zBdnL!))Y<@Oz|DAuG5MD@>Gw*vj|^$ncgbl4q4(ixj)7n$Hhz z{I;rgZEiH$lCR$T2b@Gsxo|R5DLE>i9(J@2Fm7^PHjv=Ve&wLVk#v1LGV%qohtrJF zCFoQlOH>xI3w#0IjtE`zwHMe>4<1^*dD=}=`Sz+u&6&|~NAv4oj1X}8cCrgM%ScsC zB#yz`kNPi}eKA!CVaJl>gi=Hb9R~}uZj}Sb1Z?~FKYjrY~@q_uL-IW>e_ zKCE1@&eRxM-E7uE*ftj?$6$OoXo0`U&Ft$k(T92yr{WCZd;&3j%ZCyYW;k7B5d!#~ z8B^C^?jD0oEv;aCt>*6O6Jy41d@kE)YYQ;b{Q=<z?jf zpIQ;jT_FpFgegz3Uo-6W0>q}Y6|N9{t6o17SKK@7RNkhUjLuqtDP)mDrl$fN{`0T; z6qd#036-5SH3(^-=uPa#=Tzy$;4TRRN=~{Y%Y#+u$Jy|2*ni@4M3OAOxkCRUB+#m3-h4@CDaIt1_f0zDWr8QPxUr&qXvzi|%aC(;!CEjZ3e^!iBra z*n9gGXwyWCRzE&dHa^{<*pomKSMPP*aOuu}*I%$?zlyN8%)ot+b0{vJP>zva;-neXOyoU?0Rt*n>56P(nt6TK*e z5b!T#Pkyv1x=*l=SB?BrR;z<8`%U4={>4l*W%NZv=DeSR8`%71KFI2vw@p7PiZ^d+Ps{4WzS+M(trX!2;-xqNj zha;`+C;PmT*I)Tfr~&N&IjewdzM^+eBHJ2W?8!S*Qd6S~5$sWavi$y>kN~Y8cjq*W z9#E)$^|Sc-y!_LEqN8bTC%(c?gZFEO%kGC=GUFfdq=26|zIYO$8A0mIYlfr$VtB|* zM&C=}DI8PzF&#)!hP=h(W$nce+3e1Q;)}2EwAgAtz7_gcKiRi4Wzwb6?cxiOQ6sRl z78V8UClU#i{v0{UYTmktd{6sRq1&uxXMg1qIHhCL?GBeNoxUqlkTY1Y0OQ+SgfN;k z84pKqRdYdEn0SJiZ`S=jn-|1bkzo+W>$g=CRU-6z8o~1{4V&6W%*`kqy(Mgxu%Gk= zkrocG`2}bZK;Vek>>C>fWq|J=010tY?~~2Cyeb+l4DvuZZjPVj82@#77dJMLFZTv= z!LG2yt`Sokc_MsgK@U5(LsL?YGQ_uz-AI%%Rb;Zt&Rz3(y{sXejpW*`Gy6ZM#?N=| z363Dq{29L=>V+IIZ}6ykCe)!)Q^9gfGliXGfh$0czL?K?Y4GgnuKs<{jVZdSn8-Ao zGIeA4b)5LZ=qombRkaq$W=@fs-zT<-TX6^8hpBdRn)B1QZL~lws<5lmifhWNQ__9> zu3bvCbEUEDc|RV~PTvdsTAx4=X;S-s&(4O56kOM6Pl|=Pw(C*4ggva(Sy9#Vn8#Z8 zlk+ik&W)geg8@PZEY7}X)TOhoAVcFuEqu;<|>oP1G!$BAT<>*LApD+=v znscUtBby=$^KvPrcMEmsQ^+TXFKd{E8p^bU-sxfVPnt`f>tH|pDDZ{A=iK#Xw-KEi z6#E@Fq{KM?;W_Eajn4GYD|3>IXJ~A$TiWyL)1>hP zJx6!YF)vlnjO;wGt#B|BpRny}BC5X3sVBY`*SRRN;(K@|#mQkNWb)Rn>4Q=J=m4nW zw5cY1&5{eU22)c%|1=s}jo$q(z3!RiZQu4$oFVwTHv0kHj%yfJjt9<2qk85^B56R1 z>H>z8E?gufGO_69V*k?pr4LNTF1p}(itha|N-DgdH2k9$lu=PXSaWV^bHjrH-|#Ig zc%kOw{P#2X%^uALN_{H&Vdq3&KO=MC_wW+gI85|~=v1l6_SskEC*eMp%^HK$+1kT* zR($Ir%@eIzUnf&zj}u%w3V`ML%6)@~9a)*PO@aUV$AuOuIqAveGarVOJqtTwyIW!# zLK0xTMEL#_POQYj<PT!asXF$<~7RuwBOS&+9+TqS`R>8EJd`v}nr%looOt#LX zw#{|Mw{&6+NglD~7hOVE@qr5acp{m&48!9T=$(ho5mlY9eD~y@Nlp$(C#L#1q{o%A|B# zSKr*T@(COSMQewMfvcqzNKZnIF7&t{Wa_`x7~sMew;%$jB<_; zWnND?zGleA;z#g%J$&l(k7oOqXtVJSkAc_Go93NEUK*D?uiBSkzEH7=QCAGxF6qW% zZ9az7i`(gkjMJZufrpcI1$ErDrCk&^I{=lp1iHr+m^hs|p#Jc6*>&uM5*n07+s_nn z*r~Xd&aHAEFMAzfo@5{Vny8Z=ul-a(lZ5%&!FF>X`S@#n7i_lf=P<1mzL7q|%?2KU*-g{D_j1ow+k>p&B)yROr!#5lcfvEp zKNiWSdj);MtosBuk@CL!0(<>fJu2YrZSqR4diq)z7HEq#j{l|cLQp{9r7!_y3?bri zXCM!7R+6Ei*p5KRP(1d$wGkx%T8vwssh$4msV#9O5oPm|FnB+7+hW8_tkP8C;hmRG zYMn_|n3s^&P|)#Gk*)6S#1ol|+l~T!@Wzq}IxK^m&!`HVBR6<8(etEKkQ*4_nCtlS zz;xk9Xg~ft>C4!?2ihS|2c*FbTu%g>sXK09{#3k(g^_uGAQN#IXWv$OlR+hzRRnJr-I)^L8Abg#gQ(okaZ#lV)#L- zz2kFsxb>!HN6u6~CfrJ;xTs&LN0CpYH=Y^sXcH4tQL%DvTs{=Y z_%d^47B21qP6R*^%xBmv>T_6`e%qyz;m==EnxK`KJKz_^2OJ&Lv8EGFMpZ@RNlyL1 zE~ZyjVF`lqaa+5$pl*Y%NY@?@08t!I3c;iJMj48|+kf(D!SC5lxpkJ(FYL|B5>=|}^xPCI=bWwCNHWOl>q; zq;pNB6I0_6N^`$v56)_4OvtdyET{6nLeHW1jlb7D(HJvp_*0TCDpN*Xe0D^ejtl;R zNfDS#u8Oc>^a&^HuHIT+itzMl2ZeN5Sv&vw@jtppP@4| z1LTkROIlL@Jt)m(m|z_XIn?w~o*dskN#zX zqmNsS9@Ry}|PqJh)K5ok(`rQwvUrO)YuXP}XG8s`1SMFCgKK!YsLiMBTI3Bn72faRe0 zCp#-wvK<;KBF}29l!Ns*vn_^vnct}FdAH%dGsY*nH7jL)XW-+eazy*pn8KKBdC?^yYr>1P?ABM}MW%EY$hi)HE0o^sl0e8#ukDhf!IeQq`t3sH!k46qHt|G` zQPU=@C<(1J>2!wscA^k_2iWVUH`RIr+sK#hT$T+9qS1+_k?g<9ymrF*hFR#7+51*^ zOQYdsUe(-Y6=GD1-}(5c%EMcJ)p)_Ng(@w56)JFlxZA_j98Yp0>7gWW8J< z|95{s7adT|LDG8i{fe>YLheX*;}G=z6s!7blTj3_{soE7@@#Cf-4HOX&+m4&aJAGx z#6oH*8K%fG&h4SEo1ZoF$Tl>y3AKJ7I;GFfGbhtsbh&t=u{dbgstBsL?{-Jv>=-6*;{&O5(w zvvYDfYqZ7_cT&oS6_U?jLAXKu&y5>wt>f*$L}!2S{+G`6c#9{is4jgPk_O!rzxPN| z_Tndf_X#w|pPaV>dOB7_n8zjS8S?G&c8zFxFOlzu@5WOH!RE5m5eVe&af5Ll zUjNhhI!Do6jl+QcX1PCq@XSi!PqoVtF$ga`$DdDe+*@wQdJ3)(I_kvha%0vs&#DGx-x@$rHo97i^1ek#__-0;iG|3U^( z#9e_S*2d)jvy2xK0(Owk@x&UqXaJI{a79kmi=?_PJ{M17siR9GwQI{DIxJ>Ogix7< zaU9=pXXDDpq= z_pnaNk~89Jl>`K61EvLkWSlj$xha0E>A=gr%v*C#*~@pD8xm@ccjyS-y(%WDCc4y? zxkD&Eg&stNpL^UkH_~CqBTw)6681G2mNk|0qAq(&_m%0i;xAp~U6xORs+sA`U}o7u z(wP^}Qk5Ruh-${5!B#M!*^PDmQwj@fVFsY2S7Yb3L;)EZ8Urj*x&(LyY{n7b``KUA zDW0ixWGOVXeHR`(ynYWFPi*@5msm2LbhDZ^?H27z`giYcyn6M{!%^DzawvXK@STiU za}M#5OOk(HUK<)^h91pcS6bbp(<}jwp+OOVh^rs z0|vBpt%`^RW?WlPn&RJVp%Y9XNKs0BonQyIYZBo0iyhdZYH4FFMjV?H+Y&^W8L!$z zv$!WDs2UZ)Bo}b#Y8KJ|%-z-IX?NoZr8Hk;=)jfRMP1?E11LU1o1~zj@JySKr?auH z5t>E9G(F8Ia;5hUDJF&YSf~^Kb7DA>BZyaz9wJ$x_YO=@V2XHcIl&qM)@pyj`o-EF z&6zooV5pkgI0f*+-OGQh-e?mOh?c}PNNWVX7)KhS*WJ#Bh-0J*XOE1Hf{p( z2*S+5U`@WHM@%xn^o-P31()()yWCuAWx(B9{J0R)iOr-~O1YF=w>V_-viiM-6_dq&Ie$)TKYVemmyz8E zcgbpTh}qq9<@)-pJzB0s&vB{TTWF%`rC2G9Z{ItDL?qJ z`TE{v>gTJ`)+~hac;nwd)40xrEW^VIH?3c5vn*)u<5Y5hWCWWDs8IY$?#Q*hwsAS} zxuJKM*0n8(cqsDE5wx9TlACcdAvHv@81{}?{SVIiEak#p>y(4=jP>s&>`kFh>RL?j z5$Xbc3(dd<&_XM=D&lbHg~$`OoMA~<5_4e5~wV$vk0NpgnYIeJ-_!g=pgqG>4q-9p#PFw5VAQ#SjT4 z=rb))|6`E#3d9&Tb~ZOJVfPkr)X>z30QbmBtcV9c77Yhc{Xvqpc{?$Pu|H!U>*uW3V49gpRx%kThXVsL&YT}df$=vJ2E?;u)8tuUju^Nzu~VxEX(meEUEOO z1nv=>&pTi7K4`E^D|opfp{XT7%Hs2rwnhngmohC(MT{V{s_Exs(=G01AwigTBDxs0 z=3?vjiv-Q-Po1Rl5`6YGU<>$Pdtdz!)${$mEDJ2%-Q6W6-O}ADpmcY`0!v6MAl-s= zH;a^XOLs`4uyjZ~yg&cM^UL@C19NBYId{&?c}3mFitw}cC^n|r-?@myVjN_tlI}0H zw&ql`R82iyO70;n$(1o|q*>WcMQ|9B1ud-SPDCc=`adtie@H$5As$5nuUtluN+&!z z!4YgA*5t(^-T&e)lGKxa5BvF${uddoasJ;-a*;Fc)HXG7qa!WB|NBJn4PgTTRt$l_*`useD8#T2lE*^r5;`_F*r8H03UWFDa-68?%GA;bLM zjD0v*$vtU)*%nB^4&sIFl~U!u@4&qfuVd}enSk=x8*?xKMi9;CTIunBZatJHXemK_ zwox0hciE4be57e*m~Ct-yPk{|mL=Wp5dm^N`up|Jt5Sk}x|6rDkn+$znoN)!QXA*d(C5f%5y*dpCQDVveGU0RV{`OpCzvkgb4Rv@_YP78 zTdEmj3GVSAIcQ=$D;l#Tzc~K|UGur8Yt+Wu@JMzz@+~{18~XMP6A#dSE;SN&qEySY zsEHv*g#^#7@kSLGE>*?xy~3QzkzQEBqI%%^cUzvv3>0Fid5jP!KxtL*_W2d}OOm5P z?2}8mKnNnu{yB*^pEB9rpjnn_^i-Dy=%)_K-hbJBb}oEoaMD|N;7GkIX(FJ@1=0pf zHRl?>GA4qBScPbqV;;U7VS*(gS}~ta)Kb%>)cXqoT4CRV6^FXGY#K=jA%*<~fd^cs zORe{^#MkFI;2(ZFJ{z#M9@QjdL(xSaWNDxD)OY{oWYsd=gYEl=E@=de#+Q|V(9V=c z>iY*vwc1X*BPM)KI1qs*uFglxUf6gzw*~;#`bbA3lLPePhrLzD&O6X^^~#wNuOb-u z%aU3qy%>cWahfR$zHE9b$N$t*Emkl11c{UN4o2Z2*qNdfFBChxyU^{`~e6&hax6{NY);`9ZuAO|00? z37@p^twx1!J!~_`aX1hyL0a)3RsxyokMC%{A&!oBt>-u2-ui|H=66kPcHJ%?t{cH5xfqZ=iY}M&o##+&tFs%1Ab`XF`|J(Q zudYZp+u z%`A{i-v?3j`O}4M1&tqFgx9uu)M|UcYOy$k#@ls6@~hEtSnt!uc5h}%%lUVP_#5jO zk1`DcjB^-LpB^IJ;>IyTaT(CRhez8{FRehBdm3$5W7E$0SCOWjPF?}wh@=}s?0HZ@ z_No)-v{SuX+DcNTduoK|haVavgdRi8;>{N=JJFci6Chr~sRk zy@{bdU{jx4trs-SW47)Q7S!^OJbWXhofM?u{7oN0_7qWBTP^Cn4T2gM3OTxEs)6~Q zcFeY*eVGikBzd$9`2$th{dPjp9xe4{AzI1Du!CQg0RO@ zYpSG!Bs{z>faKwMVQ1qbmiSEQkLm81h`U4~iBKNP21QK3 zH|E27YhkR=^R4RW+NUf3K)`aC0129&MDjZGJ<9*)QI5a?s7oZWle7BqZ{StY{F`5= zvj%a2W+{Z(4$%W&gCj;iD~ihJ?2{#8c;}g#<0{?|#3c3HgRu^k5yM%An{PjRp97(AdBd9(qufMQO!oK){ws~$6d4B%vXCM` zRhRQt(mP(E0q4--a`MBN7j(NFY{hR|<-+YYJw{qYl3K{HLp)P4JW+i@wWWy*%QBeL z1WI%Lr~+AOHHzKCq(Fz{pwNqDK<}#-6fj73n!R2nkQ4)D@mqJa+h0Ck<3jO+YLD2q zc_U`#cvEjZ5i(SO4IMka!TdByAS(h1+AeAK)k7%d(ts2pKQ{<{n(zL4QlF=7hrD28EX6_1%hywGtORIm zXn!4?zR1AAzdQPB#7%;lA3Z=)?6pb@L4hs%98r=aP8-2aq}b%y-%u}_Oe#==c&|KK zItlxD9W-`vygUJ=#St7D_=uVw*TRkf{{x(&v{%-G{c$mrm+i9!qD95Hf(x(RB0^Vt zuK`lu&pCHwoG`zWQZ9^MV} zv}Gif_+(X`#=5mNePoCLL2vOO7ox@L?3_Yyh)g|Hjy0vAT)@q81(C+|?Nx;WA+~6}ra@5Y!Ke@JBl@XBr9wSWAJy#@+9(!kT1{l%(L@ z#fkBtlp)YWajh7mA z@G~2Np)Bv?uRw^f)_A3cnDu>+ozE5r)yB8M)3E5s^2z^3*op^GG%F?`SS;@Qpbf`( zdi{VTi&zPcVQe6WBlqLsv|x5sAk(C3Zeap@ zj<1^J%x&o{_OYJfKUg}10nC6chPXK}fU5}EO&jdsg?mtTS&~MsV`Iq9QVujl!CuGd zD~^M1X#VvCBG-ssPeJ8~QG2zq>q_et*?%MK%kj?9S@Z6^0Z*cL(Sn(<(Qp`;hbO0K z5BC98fUTc#I^0Vv03RdBV7t4sAwFFH_Znl0O@=62?KBMwNuCRJ05HGECab5nnK`{( zQKN1Su?iK!zK4KCIO0g4;-!=QhKhjdBk^EcH9IK!7=YA6T9A%tF~HGN^Qwf39EL)d zX(dDslgV-f_&LNcE25Av<%{;zNgpN{lb5HYFoQv>=8J46fD^PZE>B7eZwa^Cwmrml zi(N!=(0g9yLmaP5b)H#3ozOysNG}H>Ag*1yMu!y4n6=z-T-_2vlY@)F`94P3>#Sp? zQ0UVK3pFV`@M+MVxc>1>q&kmxti{&`{BgM=680~lYq2`j77g~`jYdM6yYlV;jqfHH z?d|jH=yOEnuLbLk;~zETo{#68CrBNt01mF>DonO)mTWhzD-7J#%y0sPi6h~Y4> zdY0%J_(%7{rnDDMQo9kO>TOv_jLZ0q9J9jB(~h6^Ct8~q!d>jhd-moM1{-M3%7=u^zUdQ)e=5+t79xMJi5c&c z0%cRb9cxuBm+;@RZ9R|s7>a7!~fh6mSUu_L_NDlI2wo1 zSfbG=F7?nJwu}-n^*M&I3HImV1+>evEvw>bv8qGgGXjXLUwR_LAKZC9%9&_>IeniV zUZ<4Xul_)Y#J*uao9oAqn}^U%WLDNreE3Thn*Uu#Rh{%ibx(MHbp;psa-?T2!ju7Gco>D4d9_ z+L4oe&b6U_=-=+G2X@w<=<$O^PwSaTKQcMY-^pCq0mPdWPCw3;DyT~_ntkm5vdJRJ zm~VwX|CzV+is#O9snkdARewsKS~rlu@l`&=BWad0n)k`b9`V!d$k~97Gaf{B<#lf-NyM0%qX2yT;9b{v|Euw72_Zvp8k2tzJ2fQ%aB1%hW};gNa?Xh_wCiT#U@W&ogVvC#uU0(tZ| zL(v)Hsk*?p=W|S8-9jby;5-1mC(+L7iEyh7d9RcGdD@~7EVbXgpa}}KcRitPb8|Ao zirC_&f~Ar5wuu#Sf-kn5wi$BMF|gyX zHGF%Jsli?x`>8awMrtg}7B74mQ!%c1qM2717$#(V(50!nJ@WGCBLT`nubx8bdG)3Z z{=L*eiQIc>E9ruxE4>%mbb?yZ*hja5-rg=?j$Sqp8|kZFVxEQ*C7(VJq6Hv;Tft}W zy+8YhglXO)*5Z(?1k>`X>EK+1Ut+L+eceCpoY@#ArXFTZB-|LPD%z2gc}_pf=!ncrZ5!?U5T8$1a zb+canEidjTvf*eIfUNci3-Ep85W>X>R*aeqnF?9Q|CHk3y#=j*H9~#(^I=mQuW#1@ zL1@3lN;)W|1ZQ;WQhjHJc1Yo~q6rtF4Z7d<9%#ICXuN-d6Kgt!tC?_KNYypmT@yy&`7hbAoullpYMx8yj!*h|C|ud1&WPSY!O z_iK5J>Fe5#6Z!lVV3%fu6%bp`%8<+p;Ds0C@}cwQ4?xnTr!0uGCC*+p94$q}2JoWa!r3Zcbj|~FUMekR<@vV@y4@FfC5zl%9e^P; zc&l9}CYaI4k#-A?8apJn$m?I#P|1#49Q4E4unosW=glGpMWb84J*NnB*J1xv2e5P1hwp5ze z@yKY#%xE5Hu2#@(yujIS6steL-A!flTavE;@wdo_`PM zIV-}%PS1k41@o6$k0^xOnq`-VRCF805!p4$XbyZOVs}}_k7pjgynuT&Hr^mDI2#!k zMeYv>2y!L%7f`SHnYi>;vuophyM>DW9w7q0?;ZQ#j03OO_#oKmWrHvm7myS!Q?P6c z+`0cmIg6?kqo0Q0mgfPEl#DnaUVNFKHnRIG(!;?AnwL$56-*I%nIM1Y|9Z*qz-Ef| z6-MsRPk4ZE0tXDM*g712l5`0Jr*AbP6} zXIgt|w_(@w*ymx1uc{{axo*Y_7EKH%m@Ri^y_2l_A zOB-q<0_EpxhNO;6Y9 z>PjLg16ss_Ar3b;4sW~z@W9Ax43<0{_KgW(jCkaPj}96^X(2?zq|_^7rL4D=h%*?t zK4?r{tr|nr&HJUS&b5nRCsUCrg0^>{xl;8rcY@FSMTir`o%1i1hGYdSfY}wx_2-Y3 z-#zXy+^tjSa6z7Xy01Wss;(uVM-c;2_1I;QgEootXRTbWPAQVb^1p29h~IJ|T?B-srgT+SD)}$!^`E!yyGf5h^P>D; zv04FLg+`{CLc?)#>~zG#ZQi&LhmRB>MklJ*y3NL15e8lq&K29`{axf<&Ug?M9fkr= zynFy!QqJV~$>G7r((noT^ZBAm5d2M(glo}KL#bt^2H>_qe{Dheefdq2|GZyTFsEK7 zj!|ujQ^y(8>8g+p_535&3_mXTMTp6@TwWxXnJBCj?R~s89(KTp7~J_H61TI56ove& zKKXm8e?WRe);p;_CL&k0>+sn=&ARQU12Oh;{QvjlP4*~n+2}LtSw-M z7v3`n4q?=Eqx>B{6l4|NH=YfMaLN!fcJ zyu;eOp9v0sisFc%so;S18q)lZt6weZFKEG0(flPv!as?$z#8?Ln7pI!r~kN!P(2HN zrh0PEb^E_=i*9U2hJh`=LWgAREtbL_f+MYB7qwCVovAB+B7y9ui^nDC_)KOr+}53A zUaU*%-V~Z04U?QqpX8P`b0`(kZL4{%>e7dgH%E5|JvymxFA!a^!KBTZ9KP`z^>sFOs*!(?%6``MGeCEf}ix7y=;Hr<&YdY3%VHzM(_V+ z2-$I?QAkp)VJ+Bw!QJ281SV@97-0BU5JYN+3vUxwx*o8G^`z`=MggDF$GWUSVQ~3hMDt`?8+h64`3CgDlY=K6MBkwkU#ElGj)s^()Ev$CS?X%#`+7e{zY2 z5;>snjm9N(eaY-yBwr5XgYcYZR4)rU%o*g33vExrU`rke9rw4d%`9r#DUmG2eD&`= zzmN0~+_Y)G=vF|)nJ00Z`n~h8Lp(4@LQZzlqtN!b^Ft3EWgB*o!Zj&B#ap#Ai>- zbuw3x&y+U+ho8gYv}JWHlt+3(ET5XVCtaNVA;Q=WlMjtr<}~CIS#ot!i<#*0f%aLd z`R9s8_(%1H)2Kse3C&J6`OP1G-vR~Wl?XDPvtLl)y8IH%SHQKn!RlA=yYZZh^!CrOFMDhCP z-q4lP%rFx?q@TS^Xc8ORvJimrTF@DhclkW0j|9NorB!H5%W`pD#r8zSux=$so-k_v zZPSFENc!zWc@(CLXC^-?*yWhj051`n7Dp#~o(0nv;kUt0+i|xWHq8o9x;1()$9eSe zLG*b{Dd}Vnnm%uDpJ-eV;~z@|YJD~?_NxHdP}ifBu9NE3xiZ)$|3mB<++W6?2MS5qe@?w^H2TaV)E?BviH&tt-I;E?>ReW;1R~k#;F6D8H$p zR<-@ZB?%rVM1<-frxj9F~7zzLx`5hKIWUN z+pW(gSlkj1M~h=phjHzzVsu}o@4GFzHI=2%+QMk4OxE+2IJ*wRl+eO-!@E|$*G`Ql ziGaJnK^G5T|K~~9E^E3wwJ*2$k4tbvNj>jhtY5|AF(@^bFPs(ue&@d?7o0b)OU7sz zxbHS!e6Td)h)XVJalIXd5spuHLZWG=eBZkq0CzeJ{1A!Yz zd*oUM^j!4mOv7HlfX{fQTm2`M4ld%3BN1B)|6P76`rxNyo${D( z(%jP*CV^e=Xk(bc1j9l=)D>*HzCs3i($2-`^# z_8&~(V56TDP?ttPvZ4$)K?^XEk%x@Lpe=?dy)`Gt@*W3-=Rh)%iZ&=P6ZwfQc#mmf z5Lhpi8{5bLL%rH*j|h6U@Ac)#y5d271P3DD4Gf&R%U)&me4kS9ea`+~^Aejb&Or~h zFDy+y%uHE_T440;QXtS$(XOwJ9Kok6m*!uCO2->iIN~MfP^Ja8QC9E>54XV27Nh}# z?S_~j01A=0WXJx0z^Gn6kai$$(;FTtjMxt;QA7EWXUk(D>NkydLVf{$1ya~xzJXvo zZ(H8ok%J7TgYR{=jE{4bO;~s9xgkE>frbysho|nKH)hzMkP9&)uWr`}q!cfx%lTrt zeQseqr-U5|>~S>TT$(NY!uh4K;yyIySjc z($zBY{aTFw5ZOA*J6^ z!}|p_Me6wc(Fxxz;H1bulq<+6VQp)>@#R7768WsN{>OHgzeQWGkSTL1mX>~ldtNvCqr}prMk5tK=#=%2iNfgNeUAMA zYo~|6XUvzI2y)lPGsd+Fdy@01e4?Nn+YGV%;3%zIoT=mqR~GL!9*l0yLkaZ9cl`IQ zS&~uf9dPRbkBFDXmrwmeFQ=%w8M!ye^f)U9D>BwJAA>MxwPnn9(TqTFj1w+pACuG9 zCO+c{PO+w)ikY4+hlZ!0)Ee|H6Edx2_c%SpL1fx8hebAWPztPgO>|z$_;VoXV*{?c zPQPoo_YiFRiJawCG_caYk2L>%@v-_1h_sdpb=}qp{oA%5>brq}pf&xVkN5F=8Ie zRjB$A)bcEEr*Ll}X~#4XL}7ABxu8dx?@PNS5#k+eTw>JmqETA0q`K7iHhucwlO$6F zy~!eSM}6Btb<7fR+@MZ__l6LDcV92%OmVf7fOZ=fI3Qa2BTv?f_=omlrq0zQc9&*4 zEaemAq04OQUo|f1ucJqQbzD5Gcl}lny-y@-cJNx|0u42Zi5qNA%dLVNwfn(unIftZ zRs4Et*W7bzgs`z-^dI>#{<4*ZTBMwoPRJj60~FUfdyThIniz65gwsItj4ea!$IvkK zMPu`9i<(U#`zHCG-4OR$HrYJwfc-48aqO-gO;=?qv9s2N_TKg1>C}&K$qb>ptpt0G znT0|jXWSDvzd0YTo@5;Riya=RjL5oCzALi+J;y+zO;P^<-YX~GiT(aP7%HsiCr9+0 z#|QfjW(-%&$jfjka`O5&Qsqe%D>(X@&t{~5N|m@3oBF$oTe$s#2mXBvj2v>Io%#~= zUd=J3qsvqzcsAdE!*#vC$hNgsiox@KMk6FnbEY?h4gHkkYH$}#Him%Y4KKZaq2D%% zNUSPVrT}*lh`*gDF)%uJgaE*O`lmhO-7mAoTR7W&FKbkfCQ_S0#KzTot z!U@{LdI%845%dluHLW6>-=&U`h6IJm+q%UyweEFzB1rxpf`B4?jT;i<+zPH#&#EQkw*dez0a%B9Q(tw!QTc4FORFgq$C zhdmXwA8C>EvZy@&wxgtW{OtKqNO5ri$&DxDy$Hg7Q3G`)V5#e$mC*Mg+o$df1iJD~ zfzR}(mK!28XdNUL4dJYd(Tj^$rn62xe@lAzb8GIdJ~)LW@A6=e?Ma^%VMbG2eZ>yY zv(!GuNaGs^@wdbBg;8Yd#?R_E2kY@$=K`L65WM zup1HS+7bZ0F2qe>`@hv-m9!YK2>!?eqhc!cMSG(>&b0ipQ0CiNnOx$@vuxW^a)r^>fTn$&`HdEn9G7;!_B%_wH}4>VMF=L1uXgL969UP7o|cDrh1!Xi!Fmw zdc@*>s%)mG?Aauzi;8fqm1LOh!dPR8$j1oka!PJr_=i$>Ss481#0^gFMn?wqc)Q$m zHbKl&ao)GDqT>(};uE$c7M3ryRwz?_B|j3ZM?3V+4`T;yk-bb2iR8q{Y9^AE`D*UJ z*cuSX4)-JaL5B@_7!wWIxLrFAri$=J_!O{oia1#_exGdIcoGDjKMLz_BU`G}f2}oK zfD9?*xlW&!3BHCwa{v@<&Gpw=oIq;nz13lYNpgdalPnb-x@UvbYWQRuQG%0_xtC8b z^OZn_kUcO_V@j8-eJS*Rj`p8siT&~~>`xy*_jDXix0Xz8dS z&GJVAaj{RC4bOFz&I(;_ocyDWUnBPkzZhI000@QXi%%U1=LW@59$fcqahu#~tBsmZ z-CvzenW8Y`3EG?m*Gb+!KHA|yMNL{!p~8jZZ)9EZXhLqyETKBXv`pqjtl0kBd#q^j zSs!zIag$;O;@8FD7FxZ5Kdm$y4sj$n8fo+-pC?B+{@v1u&Qw$$a;VmHcPiv4kF9Z~L>VaS5V;3?_O@LB@JdUM}Rv4PwNC;MY5b%csq@`a+|3R)dgs}KS8<+3C8uptB_m#5De=SE)eQt$H=a>N)%}) zZck8oj88S1Zv^~FjjLW+lOxYA=$mis?or|k&rcV*tDl}OlTsw1` z9Fa#V&g>T-fc08p4cHc2Mc{ihQIb|UJE{oIPOW77yJ|>wcxg&lB3(ai%xWj@kFl&V zaZTZPy+iOMVt-aeF}W=ix}m5j#!ivI&xM~3Mv7@Tn_D?s_W!M{rp^^VcyRL3_O1wLKE}Q#~3Fjn%Ju%ixuT(Dm???xP=>$PUjLI;Hw6}i4vq@<;X#T^?d&}XC zC$!qF0h}SF92iLoy?oIITDaO{GPkYNc~3o!|D81hs3-#x0)p*prUaRZF0LLVBo~lg z>wOBR4d=ehKh|zj)6Ps9XIGy;Zisa}54QLR;O~E~;(d(k9KwEoxaodAqtT58w#9cB z{=TseZ~&6;?(b8ga&>k<;ARGs4O)jq=e{la`w!o-_?>}bn| zY_}-0bJ%A1BXSiP@#4l6v}RWb%?9S}Uu8kf_dMGgpT1In8|8z! zaBd71$4#bwk)-B;;Qsaz)FI=oIJhFxY@G7tT^8;e(|RRia0(AAT)CnC&g* z79r7i%|R@OT)wy~xV51ajx@6nyMNLi9PwNn7Wk4qM zN6=MHDt@)8{g)Th6ju*d<-yNR6+(zTZ-?2xT`HU(iDATpPMq~Tbd!471{n!xW4&&5 zbhMLy_%hLm&kQUlu>${u+-+-qte=QfQ?u--p5$-Q+zkF^j8Cv`!l7kzx-Xk4JjFG( zS+cSISzD*he^-v{5&iXLGt*n$UukQSXoD}uk!?#6ppeIJbakidZa##t zxaFD3s?507fT>Rq44xW?PEC~A7Ti%R@fl+n)GC#&yiUuG9`yB3fsr9NqNGk0Hu5uN z6w;3Aw`(+LgQG^&H`C&7@F)&6vRP%FR$t>NbH{2KZr85;H>G}$>?j(dUy=X0_<*jv zUD(|d)?Oao_&!+jHO#DGnB8b0+)o*2ccIdr0CiC%Qz^jfqa?2`S0iH){(t%ZfBFCa HIsgA3TR*cj diff --git a/openpype/resources/app_icons/nukex.png b/openpype/resources/app_icons/nukex.png index 1c5a83c8ab2e22da3e6afe8a9561441c4e0e4022..980f150124faae3715eb4d0018d3368f1091a60f 100644 GIT binary patch literal 99787 zcmeFZ^;;ZG@GrW%EN%+~2oPYA;O@>M!Gb4ff&?c>aJNN*yZZ(SA;C4lNg%kpYjAg2 zmdpD+=iKur++S`#Pd{BVTQk#L)m>ems+o<{R9D2up~L|I0QkyE^4b6Z=!paYurQvQ z3$Mw$rv_{-qb36YRLA2YKcGJyL(P@6)c^n=CIBEP6ae^-C1?i#aOVL4_Dlf)u?zr! z+$pC?OXBGVFH3!8D>XF$+Y^li00Suj|J?!jRHcAa|1YfoWC5W4&-tgPhuZ?c|C>ks zsr|1gJ=OoN`Cly>0{FkVpUxpb|A!j{K%o6!`oGIWJmED@4Yre#fhz!jOY&a@0DqfHP*;O9H|`jKuKjBXV05cN!@nv8vZT^(uz zX4Nf%!z8+d`c!I?>aEWoMp}fM&)<72A1y5T2HtyE9WDNHsmO7SH=-v3|NrIxBMG>W zgS3xy)h1P8PbDPi?4dP`Mo&3)YMl-0Tb*msoM~TNBR%?_!`rX~f_}!ROsUGK44gBB zCCxFCuqJgoYBRF0d^9Nb>K;ejJUeT+1NDA>*3FGMPjy;d0B)g52&}cQeU#N+Q`q~4 ztBF`rJj`B@TuxK?;rWlrHrpWk4TlmV2?PS!d5MJuLZkz@qevm6Pz2I5EjfxQmdW}< z^@yRBU^1V;s>AZDv_YMyzDy0W5_GJ#6i}PX>WW{VylYOyk)q(kP$ZY$`uTAh9oBf_b>s05L12bcR42ay)|)$ zc9K>mYG{I!NVY>KOF3)ab%iH+AbRK!LpwCw`p;OPJPA-li*IJ^-$SYC?FzQ^!B@9Q z?H{<6YvLGQ8@E~Tk+Mo1JN4NQGi2u~37t!cM|kN#*Q7eyVb(35gF~KYlsK1_E~b7b znFew@x}UyR2VlLYoymMI%ol-&jR*E${aFQt9|xV)=f_+i-<)sOoNVl|A_&C{l}~*P zl`(h`V4=juQe`c_*Q9UOn3+r*Na`IHRol1&6aPw$66uC|JqZ!Q^8!?@i~k3ghyn)- zj}eQ7-T1lUaDQijo$;^DPm-ERMumL`9N;Y~R)exz3wLnmm-P=wbH)Qq7tCP2$t6Fm z%Z1?RBaA_>(TXA2ZIRpx>$eS?P3G<2I7ty%}gh@<__%Y8Z)#!p(vZI1end;O)K=X#Rx!C;V-c8vT5g5s&b>QVv za4@!>allQ_p=>UUoF(sytjI{FEsawJVVP?0GWgPD!bfA`4!^;<{;ok67n08^%7fXvzJG?@&?`*9K%A{ zh&Ll3!GwTBe!gp&&7%;OT%x8=>_m`X>r0{53OBQA?5g0^T-x6JN^FDM$ge&Ho?pbA zFAHRYJr|8Bx`OV|US!y41`;H3=*XvOL$IL`RWEBUJd_i6W+#w0L}~OVHu2f+EY9za z+E+>cELD8v%H`qle#D%7jpgr{V@0$e=voi-^kA@d2&^D8-;jVlHK+ps531QYH8WWr z?b~WuCyMIveP(d}8K4!LY=0%|-ZNnDqp;qFB|m~?Y*}d6>|OK$eAH8sL)1bp?oOM~ zR6-Epr~{chKg!zxsqrFysJ$CbKdv{4DQcx84G!<0>X4NFC?D5mJjlxPZv(Re-baAZ zp+wIVXEs|nCc?#A-4tX8{8P&~(&w7km0pUDYERX(0DH!qj#60h=QyA~@Z06zvA_*vNxPZB!vm(u$u7|TF10lX(MW$d>Mfjd<{IkpNmq@cJb@hcrcFM zkhoJGb@FP=4v7(qMB{w(KGUva5lBWg%M6)gMT;NGhF`E$N+k6>srWiepr0~Ms*Emp^nIduGkh%}H$ka*{%xd_V?R~- zSSvN{hT<=}iB?pYAz}t#ARLYflmXziNYRdR>ZX~doA6jR2PuoG@-<&4Sxi{CG&y`X zlVIO@b$H_KSxM*D`%h93b~7dK@B@BAjy6|YK(qdq2*#3Y3?5zivQ(%fO~Jzu0aACw zNbk_b;_7_vg!wmNlZL<%D1v?<_ocoM%_!y-@HlP6;+1!FR-hqE?5_y%H&49)0{`np zgd@#t>|0$4{+Kg`Lk!M~4TOFJTFy?4?%W+mvvuf5L_7ZpSAL>Yg}n-9w}gB6N)3JJ zN%f!WkldAD&6wkQp4Yh)7mwLFvUhzQv29bXIJ57^K*h2Uu=cV({S1o}7UErpdFQ@C zaowj|nWPqO?TxcX74wnzi5#A%#xMl891H^R|8*_Dv>}V2Zn$X(UTc=|M>2LFWYEv$ zu41AYn18{fdafiB-u*Uoq}j8p`j^rD-YP-+zi=oJF+bs|rTW524255XK+{`s5EZ6R zg0g%bhEXV2urmIO$0Y_^WiWZvO(irQ`nId&9!1<923ZVi810wLr zi54*F5M_SuKUXAeXy+^>4QfO@8N+dpT*n}3|M5q`giMcMzKYbh^KD^* zJ+6~2XC$_p%I$v#)Vgr7F8*g1=Dtk(X7>!7@^2(1*=}~(!a2W8Kc`uRK#w%vsIcbB z0IoqRP&6WRMO}qwXP~Y!aNX(K(4#kZM>$T5cw-KO`ad8dep-8WvrS>as&46GV4K0z z5jYL-qYl&2u(sHw%73qKY`o&K0t}&U81$>mH22TI7&~k;C3i}1g+QSG7XQIE?8c+G zT-0t-cWdRQQ=De&V6}wwvU+}Ez`Ypsp}WZbflP@u%FjNp=im|EGGIMR;8sM-+tx%Q zw+Nj24X_G#^5q~ukbnD=6Dve!_1PF(M9kKm#JhY!?zUT3+rLOGp*MQ!>*_ElIUN)$ z==}kyR!Ig#^SiZ1Xv-DWtA8M?vU3O0f|e| z3|8!&HG}|`yq-akK#Bl5`w(R=3o}#Spo$b7l{P)31NV*Lbh|lKz-B=kwuaU|9OJ*q zT}N|leyD@GCQom-*u$ICa!Rvs9t4bY(@T??bI0Z5zu7eJmN?k*h93o`x0WYte|jKj zM(ND%{?TiL-F4<2E9YdUR;S_O!>9$!VD9n)F-7aP^0y0Ss$0VA9mVlWal*L5_Z^u_lw-dZ0g}9$0L(BRPL50hf=-svg1G#bdtMEN9}f4g zv=lpE7GZ%vztEpXx2YS5cQem2b$*4Vf6kp!HXOnj-kc(M(?`|wz*T#*pi3+mAg8jg zF}f9wYfOf2Lmvdk8+!jYllAhoi9KK+uPOruzqIupO+t9-#q{*w!C>ARU*iImy2S(Y zw9jAB1K~75K~R7Xptm5R3!<5}lz%+3aO#viPG)v#jF%AL47jRYK}sK7 zwd2hnoPQ9HxzOp#|5+CB(7?SuSVgdVj=%E}Ke}KxF~y!?PL%i~s5#k;h8+5)lNqQn zVE%Q>vbMOo{S$uWfv0LbQg!eRSK=@{ZF;g7nBFSeD3Td>(c)?Fjf z*47=6e-q2dljf0zyI2#M2EqwR+6cguy7Xp!CI17UdG}nTc0?H37bJHtdk3l^b&QPm zfLC#hGk{rcz@iKYP-d56zR#cDyqPZ-_(Yk)+m1oX3nc;^9ez%eNg{ zJt}kPRI#xmdAC{QN#i#Lnhmrz_%5Kg6Rj1SXnX3IU)$!6q*j9PIthLYLDq3LH^6_^ zC0BiICJl4mUr5y3`@N_1L7WI>&{=d_XUaTTy39^Bzf>SNrT$ozPVZl_g$IoWfoCGy zLVV4_HnpU{on&@!v;V$`BPg>Q$U*ljit zPju{pCJ45fas;P;MTCY9in&p12xhnZasIqU^pKaRooVG$Sn6b&##)vq-R6VoJ--YO zU=CUHdD2o9NQS@@R>*>LymOSgvcjlws(lhrbYu#X?bOnE5~ z1jvA;Ss?PoU?}(0CC%AQ@JQl@rb4FNK$(T?ubkj$T0<*_#y_DI3EyhQ6_^aZCV_Ab z28l_34W(=gXnk3bz4f+o*;ux)><7c8w-EL4NbpwBxjQtpE}P;RVQ-oQ1hfzWnJqPP zi3z}$KX27u3wtNPvo3QRSt z!~PO@j3d~@Q%Ma%C5l+wnJUqe`;JW`ImJ_JO=J^oiG!wK4*Ct)|Q4$Pq~lo)jM3A z_m|hzH5o09eX_mKrVeW&LocD<7RC4seb6r?r}z`2nZ{Z50zhZF@=Mtz*1vNj@widvkp4Y>4=6L@ zH5Eu=O8yDgl7UlnTop8BZo8xAyy2U@Au+V#D3*BtFVyW(H`1VWlPJFG!zV2@MvY{~ z7bZx0)L+haavM!3*aJl`@-*LzYK{j)kEJ(deT)$O1hyfBm$oM zx7qe=R)P|P{=1jZoD6VLNH5-v63xPfMqh1(9fC zk_6Hs5;EnFrY#=%9|JOrYJ=EJ3rshm;)kvNz(XG1e5P2pu@f1BB!k-oJ$?unE}{}= zn?AR%B&{1O7aBw%uZi-4AQm1DJT+Q2!s~roc-+5(KN&oM)FOhX=~_gGj^z-@ugtAnbNWYxx+-DA1UFKEB9NhL}8Q(jPgeI!>^$C}0-B-BJeD9-42&C#t%9h5;>o2K_59meRLw=Z8rAeLVf5{sK+&*|lXYcOk zQ`!!bU)Gr!jX?brRw=a`4a+U6I}Qk>@jLD0gBohByRuc31dZ6dowMFzVX6Q5$G1N7 z*dj8Q7^wQp3nssZ{rPAz89SJFlv>1T#w<&yIOwbTU}1ro%_Kkmvv~0Y${TI;*Mud1 zI^{IUQeL1J>Hqa9MjqzxA^JS$!jI}&!rAGg_Cp{LFpTz~F0ZVnW<4<&hVn?48xEup zJ?}RSA^THA*zEg>8uIvFo081*iv!-_jccISDU&{5c*>xua629!@{t4l`>{n`XZ9UN zQGR&`mXX|1%#MRl_FdWG!KL_Bub3OAvclHXn~_K?j97kzGrpLZSVW)?h%VVDj$&Pt z6qhj1SfyEgxC9$Qp+t5e{0T9(!aA;=`D_VoWrvj zo$xQ`2UIgriUAlCz?!qvIIP`rt@>A}9W_%I&NbW?3Y>YdtSKGX%07WQp8vcjycg^6Cp?4Rykf-r znzvaU)NG<#MS_d_J%<)|Y?@ZN)EDzC!0XhUDCqktc^aQJ&4swX!rjWlxIAruj>adh zZz1mk+3}tyah8<|hk2g($dB|Io9`=bB@uw4ksb&MMPiIaM<$$`p7+5G4GroAK^xlG zLecUh3O{$MmZEkMODZ%w@zobqet!7^A9o}ftzD3WrH(qdEXp`Scgo^ z>cI=V;SRT!R>$UwMTZyCH+-QN>1exDMV!OExkPP$)-(hCMx0f#c8FR~0Ud-wXaEE= z-tz{ZVA#g9B3VdPe~)|wM%;XDXx!`%j$VqOcji;>yj|c{5i(F9%vqS^91>xS6u0@QG))z+F=$lJw2_?CBugc-ojI*`1f_=n!7~Kllx1b`D!+;^LIgD z3RW#uO?rEb5DSoknDiDsdBnT6?w*RhW`Qix*W^F1{Z5wKvO2Cm{d#+1)>bb;@R73S z*V$W-_Poc^xkKScd6h#dnVHt_3A~M)$Xplyj_(+GS5kK$`CCsq6?7WfE$yy{hQ0Kj zq7TcvwW=oZPT=1o>(C({yYj%B{wA<0gvT0DTwMGn9jY~rz6w%ZX$Vpm`<8RHQXl%= z)Umfy-yeQJ_t4u=>Iy~?KNI-^7G90dZ#=x~ilM00PR^9KV#>Qm2ZMp5+)cc-3M~68 z`4vCe9@&0Hd2|ywSGV4HBbH3AxmaUyGdL4m4+0z-X>G12^r}Vzk6L3duBPyL?VA0p4*7r2Y~Fy@!tuWZoOXcWf2mEifb1kf!g8u$E*S|eIB5XAM_!7e zHI448@Ix&X9i3GiKsu)OIO&dLQKd≶y*UWJeRW_RcYBCht7M?Kk$4b?RkwBCiuA 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 a`-EZgF*J|OX($H!`pHTvNz{oM2mc>0LFUT< literal 44709 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