From b8ce6e9e9c10383c7e7e0c36fba7bb603a5d9ee7 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 2 May 2023 11:19:50 +0200 Subject: [PATCH] Photoshop: add autocreators for review and flat image (#4871) * OP-5656 - added auto creator for review in PS Review instance should be togglable. Review instance needs to be created for non publisher based workflows. * OP-5656 - refactored names * OP-5656 - refactored names * OP-5656 - new auto creator for flat image In old version flat image was created if no instances were created. Explicit auto creator added for clarity. Standardization of state of plugins * OP-5656 - updated according to auto image creator Subset template should be used from autocreator and not be separate. * OP-5656 - fix proper creator name * OP-5656 - fix log message * OP-5656 - fix use enable state * OP-5656 - fix formatting * OP-5656 - add review toggle to image instance For special cases where each image should have separate review. * OP-5656 - fix description * OP-5656 - fix not present asset and task in instance context * OP-5656 - refactor - both auto creators should use same class Provided separate description. * OP-5656 - fix - propagate review to families Image and auto image could have now review flag. Bottom logic is only for Webpublisher. * OP-5656 - fix - rename review files to avaid collision Image family produces jpg and png, jpg review would clash with name. It should be replaced by 'jpg_jpg'. * OP-5656 - fix - limit additional auto created only on WP In artist based publishing auto image would be created by auto creator (if enabled). Artist might want to disable image creation. * OP-5656 - added mark_for_review flag to Publish tab * OP-5656 - fixes for auto creator * OP-5656 - fixe - outputDef not needed outputDef should contain dict of output definition. In PS it doesn't make sense as it has separate extract_review without output definitions. * OP-5656 - added persistency of changes to auto creators Changes as enabling/disabling, changing review flag should persist. * OP-5656 - added documentation for admins * OP-5656 - added link to new documentation for admins * OP-5656 - Hound * OP-5656 - Hound * OP-5656 - fix shared families list * OP-5656 - added default variant for review and workfile creator For workfile Main was default variant, "" was for review. * OP-5656 - fix - use values from Settings * OP-5656 - fix - use original name of review for main review family outputName cannot be in repre or file would have ..._jpg.jpg * OP-5656 - refactor - standardized settings Active by default denotes if created instance is active (eg. publishable) when created. * OP-5656 - fixes for skipping collecting auto_image data["ids"] are necessary for extracting. Members are physical layers in image, ids are "virtual" items, won't get grouped into real image instance. * OP-5656 - reworked auto collectors This allows to use automatic test for proper testing. * OP-5656 - added automatic tests * OP-5656 - fixes for auto collectors * OP-5656 - removed unnecessary collector Logic moved to auto collectors. * OP-5656 - Hound --- .../create/workfile_creator.py => lib.py} | 23 +-- .../plugins/create/create_flatten_image.py | 120 ++++++++++++++ .../photoshop/plugins/create/create_image.py | 47 +++++- .../photoshop/plugins/create/create_review.py | 28 ++++ .../plugins/create/create_workfile.py | 28 ++++ .../plugins/publish/collect_auto_image.py | 101 ++++++++++++ .../plugins/publish/collect_auto_review.py | 92 +++++++++++ .../plugins/publish/collect_auto_workfile.py | 99 ++++++++++++ .../plugins/publish/collect_instances.py | 116 -------------- .../plugins/publish/collect_review.py | 32 +--- .../plugins/publish/collect_workfile.py | 57 ++----- .../plugins/publish/extract_review.py | 34 ++-- .../defaults/project_settings/photoshop.json | 29 +++- .../schema_project_photoshop.json | 151 +++++++++++++++--- .../test_publish_in_photoshop_auto_image.py | 93 +++++++++++ .../test_publish_in_photoshop_review.py | 111 +++++++++++++ website/docs/admin_hosts_photoshop.md | 127 +++++++++++++++ .../assets/admin_hosts_photoshop_settings.png | Bin 0 -> 14364 bytes website/sidebars.js | 1 + 19 files changed, 1044 insertions(+), 245 deletions(-) rename openpype/hosts/photoshop/{plugins/create/workfile_creator.py => lib.py} (83%) create mode 100644 openpype/hosts/photoshop/plugins/create/create_flatten_image.py create mode 100644 openpype/hosts/photoshop/plugins/create/create_review.py create mode 100644 openpype/hosts/photoshop/plugins/create/create_workfile.py create mode 100644 openpype/hosts/photoshop/plugins/publish/collect_auto_image.py create mode 100644 openpype/hosts/photoshop/plugins/publish/collect_auto_review.py create mode 100644 openpype/hosts/photoshop/plugins/publish/collect_auto_workfile.py delete mode 100644 openpype/hosts/photoshop/plugins/publish/collect_instances.py create mode 100644 tests/integration/hosts/photoshop/test_publish_in_photoshop_auto_image.py create mode 100644 tests/integration/hosts/photoshop/test_publish_in_photoshop_review.py create mode 100644 website/docs/admin_hosts_photoshop.md create mode 100644 website/docs/assets/admin_hosts_photoshop_settings.png diff --git a/openpype/hosts/photoshop/plugins/create/workfile_creator.py b/openpype/hosts/photoshop/lib.py similarity index 83% rename from openpype/hosts/photoshop/plugins/create/workfile_creator.py rename to openpype/hosts/photoshop/lib.py index f5d56adcbc..ae7a33b7b6 100644 --- a/openpype/hosts/photoshop/plugins/create/workfile_creator.py +++ b/openpype/hosts/photoshop/lib.py @@ -7,28 +7,26 @@ from openpype.pipeline import ( from openpype.hosts.photoshop.api.pipeline import cache_and_get_instances -class PSWorkfileCreator(AutoCreator): - identifier = "workfile" - family = "workfile" - - default_variant = "Main" - +class PSAutoCreator(AutoCreator): + """Generic autocreator to extend.""" def get_instance_attr_defs(self): return [] def collect_instances(self): for instance_data in cache_and_get_instances(self): creator_id = instance_data.get("creator_identifier") + if creator_id == self.identifier: - subset_name = instance_data["subset"] - instance = CreatedInstance( - self.family, subset_name, instance_data, self + instance = CreatedInstance.from_existing( + instance_data, self ) self._add_instance_to_context(instance) def update_instances(self, update_list): - # nothing to change on workfiles - pass + self.log.debug("update_list:: {}".format(update_list)) + for created_inst, _changes in update_list: + api.stub().imprint(created_inst.get("instance_id"), + created_inst.data_to_store()) def create(self, options=None): existing_instance = None @@ -58,6 +56,9 @@ class PSWorkfileCreator(AutoCreator): project_name, host_name, None )) + if not self.active_on_create: + data["active"] = False + new_instance = CreatedInstance( self.family, subset_name, data, self ) diff --git a/openpype/hosts/photoshop/plugins/create/create_flatten_image.py b/openpype/hosts/photoshop/plugins/create/create_flatten_image.py new file mode 100644 index 0000000000..3bc61c8184 --- /dev/null +++ b/openpype/hosts/photoshop/plugins/create/create_flatten_image.py @@ -0,0 +1,120 @@ +from openpype.pipeline import CreatedInstance + +from openpype.lib import BoolDef +import openpype.hosts.photoshop.api as api +from openpype.hosts.photoshop.lib import PSAutoCreator +from openpype.pipeline.create import get_subset_name +from openpype.client import get_asset_by_name + + +class AutoImageCreator(PSAutoCreator): + """Creates flatten image from all visible layers. + + Used in simplified publishing as auto created instance. + Must be enabled in Setting and template for subset name provided + """ + identifier = "auto_image" + family = "image" + + # Settings + default_variant = "" + # - Mark by default instance for review + mark_for_review = True + active_on_create = True + + def create(self, options=None): + existing_instance = None + for instance in self.create_context.instances: + if instance.creator_identifier == self.identifier: + existing_instance = instance + break + + context = self.create_context + project_name = context.get_current_project_name() + asset_name = context.get_current_asset_name() + task_name = context.get_current_task_name() + host_name = context.host_name + asset_doc = get_asset_by_name(project_name, asset_name) + + if existing_instance is None: + subset_name = get_subset_name( + self.family, self.default_variant, task_name, asset_doc, + project_name, host_name + ) + + publishable_ids = [layer.id for layer in api.stub().get_layers() + if layer.visible] + data = { + "asset": asset_name, + "task": task_name, + # ids are "virtual" layers, won't get grouped as 'members' do + # same difference in color coded layers in WP + "ids": publishable_ids + } + + if not self.active_on_create: + data["active"] = False + + creator_attributes = {"mark_for_review": self.mark_for_review} + data.update({"creator_attributes": creator_attributes}) + + new_instance = CreatedInstance( + self.family, subset_name, data, self + ) + self._add_instance_to_context(new_instance) + api.stub().imprint(new_instance.get("instance_id"), + new_instance.data_to_store()) + + elif ( # existing instance from different context + existing_instance["asset"] != asset_name + or existing_instance["task"] != task_name + ): + subset_name = get_subset_name( + self.family, self.default_variant, task_name, asset_doc, + project_name, host_name + ) + + existing_instance["asset"] = asset_name + existing_instance["task"] = task_name + existing_instance["subset"] = subset_name + + api.stub().imprint(existing_instance.get("instance_id"), + existing_instance.data_to_store()) + + def get_pre_create_attr_defs(self): + return [ + BoolDef( + "mark_for_review", + label="Review", + default=self.mark_for_review + ) + ] + + def get_instance_attr_defs(self): + return [ + BoolDef( + "mark_for_review", + label="Review" + ) + ] + + def apply_settings(self, project_settings, system_settings): + plugin_settings = ( + project_settings["photoshop"]["create"]["AutoImageCreator"] + ) + + self.active_on_create = plugin_settings["active_on_create"] + self.default_variant = plugin_settings["default_variant"] + self.mark_for_review = plugin_settings["mark_for_review"] + self.enabled = plugin_settings["enabled"] + + def get_detail_description(self): + return """Creator for flatten image. + + Studio might configure simple publishing workflow. In that case + `image` instance is automatically created which will publish flat + image from all visible layers. + + Artist might disable this instance from publishing or from creating + review for it though. + """ diff --git a/openpype/hosts/photoshop/plugins/create/create_image.py b/openpype/hosts/photoshop/plugins/create/create_image.py index 3d82d6b6f0..f3165fca57 100644 --- a/openpype/hosts/photoshop/plugins/create/create_image.py +++ b/openpype/hosts/photoshop/plugins/create/create_image.py @@ -23,6 +23,11 @@ class ImageCreator(Creator): family = "image" description = "Image creator" + # Settings + default_variants = "" + mark_for_review = False + active_on_create = True + def create(self, subset_name_from_ui, data, pre_create_data): groups_to_create = [] top_layers_to_wrap = [] @@ -94,6 +99,12 @@ class ImageCreator(Creator): data.update({"layer_name": layer_name}) data.update({"long_name": "_".join(layer_names_in_hierarchy)}) + creator_attributes = {"mark_for_review": self.mark_for_review} + data.update({"creator_attributes": creator_attributes}) + + if not self.active_on_create: + data["active"] = False + new_instance = CreatedInstance(self.family, subset_name, data, self) @@ -134,11 +145,6 @@ class ImageCreator(Creator): self.host.remove_instance(instance) self._remove_instance_from_context(instance) - def get_default_variants(self): - return [ - "Main" - ] - def get_pre_create_attr_defs(self): output = [ BoolDef("use_selection", default=True, @@ -148,10 +154,34 @@ class ImageCreator(Creator): label="Create separate instance for each selected"), BoolDef("use_layer_name", default=False, - label="Use layer name in subset") + label="Use layer name in subset"), + BoolDef( + "mark_for_review", + label="Create separate review", + default=False + ) ] return output + def get_instance_attr_defs(self): + return [ + BoolDef( + "mark_for_review", + label="Review" + ) + ] + + def apply_settings(self, project_settings, system_settings): + plugin_settings = ( + project_settings["photoshop"]["create"]["ImageCreator"] + ) + + self.active_on_create = plugin_settings["active_on_create"] + self.default_variants = plugin_settings["default_variants"] + self.mark_for_review = plugin_settings["mark_for_review"] + self.enabled = plugin_settings["enabled"] + + def get_detail_description(self): return """Creator for Image instances @@ -180,6 +210,11 @@ class ImageCreator(Creator): but layer name should be used (set explicitly in UI or implicitly if multiple images should be created), it is added in capitalized form as a suffix to subset name. + + Each image could have its separate review created if necessary via + `Create separate review` toggle. + But more use case is to use separate `review` instance to create review + from all published items. """ def _handle_legacy(self, instance_data): diff --git a/openpype/hosts/photoshop/plugins/create/create_review.py b/openpype/hosts/photoshop/plugins/create/create_review.py new file mode 100644 index 0000000000..064485d465 --- /dev/null +++ b/openpype/hosts/photoshop/plugins/create/create_review.py @@ -0,0 +1,28 @@ +from openpype.hosts.photoshop.lib import PSAutoCreator + + +class ReviewCreator(PSAutoCreator): + """Creates review instance which might be disabled from publishing.""" + identifier = "review" + family = "review" + + default_variant = "Main" + + def get_detail_description(self): + return """Auto creator for review. + + Photoshop review is created from all published images or from all + visible layers if no `image` instances got created. + + Review might be disabled by an artist (instance shouldn't be deleted as + it will get recreated in next publish either way). + """ + + def apply_settings(self, project_settings, system_settings): + plugin_settings = ( + project_settings["photoshop"]["create"]["ReviewCreator"] + ) + + self.default_variant = plugin_settings["default_variant"] + self.active_on_create = plugin_settings["active_on_create"] + self.enabled = plugin_settings["enabled"] diff --git a/openpype/hosts/photoshop/plugins/create/create_workfile.py b/openpype/hosts/photoshop/plugins/create/create_workfile.py new file mode 100644 index 0000000000..d498f0549c --- /dev/null +++ b/openpype/hosts/photoshop/plugins/create/create_workfile.py @@ -0,0 +1,28 @@ +from openpype.hosts.photoshop.lib import PSAutoCreator + + +class WorkfileCreator(PSAutoCreator): + identifier = "workfile" + family = "workfile" + + default_variant = "Main" + + def get_detail_description(self): + return """Auto creator for workfile. + + It is expected that each publish will also publish its source workfile + for safekeeping. This creator triggers automatically without need for + an artist to remember and trigger it explicitly. + + Workfile instance could be disabled if it is not required to publish + workfile. (Instance shouldn't be deleted though as it will be recreated + in next publish automatically). + """ + + def apply_settings(self, project_settings, system_settings): + plugin_settings = ( + project_settings["photoshop"]["create"]["WorkfileCreator"] + ) + + self.active_on_create = plugin_settings["active_on_create"] + self.enabled = plugin_settings["enabled"] diff --git a/openpype/hosts/photoshop/plugins/publish/collect_auto_image.py b/openpype/hosts/photoshop/plugins/publish/collect_auto_image.py new file mode 100644 index 0000000000..ce408f8d01 --- /dev/null +++ b/openpype/hosts/photoshop/plugins/publish/collect_auto_image.py @@ -0,0 +1,101 @@ +import pyblish.api + +from openpype.hosts.photoshop import api as photoshop +from openpype.pipeline.create import get_subset_name + + +class CollectAutoImage(pyblish.api.ContextPlugin): + """Creates auto image in non artist based publishes (Webpublisher). + + 'remotepublish' should be renamed to 'autopublish' or similar in the future + """ + + label = "Collect Auto Image" + order = pyblish.api.CollectorOrder + hosts = ["photoshop"] + order = pyblish.api.CollectorOrder + 0.2 + + targets = ["remotepublish"] + + def process(self, context): + family = "image" + for instance in context: + creator_identifier = instance.data.get("creator_identifier") + if creator_identifier and creator_identifier == "auto_image": + self.log.debug("Auto image instance found, won't create new") + return + + project_name = context.data["anatomyData"]["project"]["name"] + proj_settings = context.data["project_settings"] + task_name = context.data["anatomyData"]["task"]["name"] + host_name = context.data["hostName"] + asset_doc = context.data["assetEntity"] + asset_name = asset_doc["name"] + + auto_creator = proj_settings.get( + "photoshop", {}).get( + "create", {}).get( + "AutoImageCreator", {}) + + if not auto_creator or not auto_creator["enabled"]: + self.log.debug("Auto image creator disabled, won't create new") + return + + stub = photoshop.stub() + stored_items = stub.get_layers_metadata() + for item in stored_items: + if item.get("creator_identifier") == "auto_image": + if not item.get("active"): + self.log.debug("Auto_image instance disabled") + return + + layer_items = stub.get_layers() + + publishable_ids = [layer.id for layer in layer_items + if layer.visible] + + # collect stored image instances + instance_names = [] + for layer_item in layer_items: + layer_meta_data = stub.read(layer_item, stored_items) + + # Skip layers without metadata. + if layer_meta_data is None: + continue + + # Skip containers. + if "container" in layer_meta_data["id"]: + continue + + # active might not be in legacy meta + if layer_meta_data.get("active", True) and layer_item.visible: + instance_names.append(layer_meta_data["subset"]) + + if len(instance_names) == 0: + variants = proj_settings.get( + "photoshop", {}).get( + "create", {}).get( + "CreateImage", {}).get( + "default_variants", ['']) + family = "image" + + variant = context.data.get("variant") or variants[0] + + subset_name = get_subset_name( + family, variant, task_name, asset_doc, + project_name, host_name + ) + + instance = context.create_instance(subset_name) + instance.data["family"] = family + instance.data["asset"] = asset_name + instance.data["subset"] = subset_name + instance.data["ids"] = publishable_ids + instance.data["publish"] = True + instance.data["creator_identifier"] = "auto_image" + + if auto_creator["mark_for_review"]: + instance.data["creator_attributes"] = {"mark_for_review": True} + instance.data["families"] = ["review"] + + self.log.info("auto image instance: {} ".format(instance.data)) diff --git a/openpype/hosts/photoshop/plugins/publish/collect_auto_review.py b/openpype/hosts/photoshop/plugins/publish/collect_auto_review.py new file mode 100644 index 0000000000..7de4adcaf4 --- /dev/null +++ b/openpype/hosts/photoshop/plugins/publish/collect_auto_review.py @@ -0,0 +1,92 @@ +""" +Requires: + None + +Provides: + instance -> family ("review") +""" +import pyblish.api + +from openpype.hosts.photoshop import api as photoshop +from openpype.pipeline.create import get_subset_name + + +class CollectAutoReview(pyblish.api.ContextPlugin): + """Create review instance in non artist based workflow. + + Called only if PS is triggered in Webpublisher or in tests. + """ + + label = "Collect Auto Review" + hosts = ["photoshop"] + order = pyblish.api.CollectorOrder + 0.2 + targets = ["remotepublish"] + + publish = True + + def process(self, context): + family = "review" + has_review = False + for instance in context: + if instance.data["family"] == family: + self.log.debug("Review instance found, won't create new") + has_review = True + + creator_attributes = instance.data.get("creator_attributes", {}) + if (creator_attributes.get("mark_for_review") and + "review" not in instance.data["families"]): + instance.data["families"].append("review") + + if has_review: + return + + stub = photoshop.stub() + stored_items = stub.get_layers_metadata() + for item in stored_items: + if item.get("creator_identifier") == family: + if not item.get("active"): + self.log.debug("Review instance disabled") + return + + auto_creator = context.data["project_settings"].get( + "photoshop", {}).get( + "create", {}).get( + "ReviewCreator", {}) + + if not auto_creator or not auto_creator["enabled"]: + self.log.debug("Review creator disabled, won't create new") + return + + variant = (context.data.get("variant") or + auto_creator["default_variant"]) + + project_name = context.data["anatomyData"]["project"]["name"] + proj_settings = context.data["project_settings"] + task_name = context.data["anatomyData"]["task"]["name"] + host_name = context.data["hostName"] + asset_doc = context.data["assetEntity"] + asset_name = asset_doc["name"] + + subset_name = get_subset_name( + family, + variant, + task_name, + asset_doc, + project_name, + host_name=host_name, + project_settings=proj_settings + ) + + instance = context.create_instance(subset_name) + instance.data.update({ + "subset": subset_name, + "label": subset_name, + "name": subset_name, + "family": family, + "families": [], + "representations": [], + "asset": asset_name, + "publish": self.publish + }) + + self.log.debug("auto review created::{}".format(instance.data)) diff --git a/openpype/hosts/photoshop/plugins/publish/collect_auto_workfile.py b/openpype/hosts/photoshop/plugins/publish/collect_auto_workfile.py new file mode 100644 index 0000000000..d10cf62c67 --- /dev/null +++ b/openpype/hosts/photoshop/plugins/publish/collect_auto_workfile.py @@ -0,0 +1,99 @@ +import os +import pyblish.api + +from openpype.hosts.photoshop import api as photoshop +from openpype.pipeline.create import get_subset_name + + +class CollectAutoWorkfile(pyblish.api.ContextPlugin): + """Collect current script for publish.""" + + order = pyblish.api.CollectorOrder + 0.2 + label = "Collect Workfile" + hosts = ["photoshop"] + + targets = ["remotepublish"] + + def process(self, context): + family = "workfile" + file_path = context.data["currentFile"] + _, ext = os.path.splitext(file_path) + staging_dir = os.path.dirname(file_path) + base_name = os.path.basename(file_path) + workfile_representation = { + "name": ext[1:], + "ext": ext[1:], + "files": base_name, + "stagingDir": staging_dir, + } + + for instance in context: + if instance.data["family"] == family: + self.log.debug("Workfile instance found, won't create new") + instance.data.update({ + "label": base_name, + "name": base_name, + "representations": [], + }) + + # creating representation + _, ext = os.path.splitext(file_path) + instance.data["representations"].append( + workfile_representation) + + return + + stub = photoshop.stub() + stored_items = stub.get_layers_metadata() + for item in stored_items: + if item.get("creator_identifier") == family: + if not item.get("active"): + self.log.debug("Workfile instance disabled") + return + + project_name = context.data["anatomyData"]["project"]["name"] + proj_settings = context.data["project_settings"] + auto_creator = proj_settings.get( + "photoshop", {}).get( + "create", {}).get( + "WorkfileCreator", {}) + + if not auto_creator or not auto_creator["enabled"]: + self.log.debug("Workfile creator disabled, won't create new") + return + + # context.data["variant"] might come only from collect_batch_data + variant = (context.data.get("variant") or + auto_creator["default_variant"]) + + task_name = context.data["anatomyData"]["task"]["name"] + host_name = context.data["hostName"] + asset_doc = context.data["assetEntity"] + asset_name = asset_doc["name"] + + subset_name = get_subset_name( + family, + variant, + task_name, + asset_doc, + project_name, + host_name=host_name, + project_settings=proj_settings + ) + + # Create instance + instance = context.create_instance(subset_name) + instance.data.update({ + "subset": subset_name, + "label": base_name, + "name": base_name, + "family": family, + "families": [], + "representations": [], + "asset": asset_name + }) + + # creating representation + instance.data["representations"].append(workfile_representation) + + self.log.debug("auto workfile review created:{}".format(instance.data)) diff --git a/openpype/hosts/photoshop/plugins/publish/collect_instances.py b/openpype/hosts/photoshop/plugins/publish/collect_instances.py deleted file mode 100644 index 5bf12379b1..0000000000 --- a/openpype/hosts/photoshop/plugins/publish/collect_instances.py +++ /dev/null @@ -1,116 +0,0 @@ -import pprint - -import pyblish.api - -from openpype.settings import get_project_settings -from openpype.hosts.photoshop import api as photoshop -from openpype.lib import prepare_template_data -from openpype.pipeline import legacy_io - - -class CollectInstances(pyblish.api.ContextPlugin): - """Gather instances by LayerSet and file metadata - - Collects publishable instances from file metadata or enhance - already collected by creator (family == "image"). - - If no image instances are explicitly created, it looks if there is value - in `flatten_subset_template` (configurable in Settings), in that case it - produces flatten image with all visible layers. - - Identifier: - id (str): "pyblish.avalon.instance" - """ - - label = "Collect Instances" - order = pyblish.api.CollectorOrder - hosts = ["photoshop"] - families_mapping = { - "image": [] - } - # configurable in Settings - flatten_subset_template = "" - - def process(self, context): - instance_by_layer_id = {} - for instance in context: - if ( - instance.data["family"] == "image" and - instance.data.get("members")): - layer_id = str(instance.data["members"][0]) - instance_by_layer_id[layer_id] = instance - - stub = photoshop.stub() - layer_items = stub.get_layers() - layers_meta = stub.get_layers_metadata() - instance_names = [] - - all_layer_ids = [] - for layer_item in layer_items: - layer_meta_data = stub.read(layer_item, layers_meta) - all_layer_ids.append(layer_item.id) - - # Skip layers without metadata. - if layer_meta_data is None: - continue - - # Skip containers. - if "container" in layer_meta_data["id"]: - continue - - # active might not be in legacy meta - if not layer_meta_data.get("active", True): - continue - - instance = instance_by_layer_id.get(str(layer_item.id)) - if instance is None: - instance = context.create_instance(layer_meta_data["subset"]) - - instance.data["layer"] = layer_item - instance.data.update(layer_meta_data) - instance.data["families"] = self.families_mapping[ - layer_meta_data["family"] - ] - instance.data["publish"] = layer_item.visible - instance_names.append(layer_meta_data["subset"]) - - # Produce diagnostic message for any graphical - # user interface interested in visualising it. - self.log.info("Found: \"%s\" " % instance.data["name"]) - self.log.info("instance: {} ".format( - pprint.pformat(instance.data, indent=4))) - - if len(instance_names) != len(set(instance_names)): - self.log.warning("Duplicate instances found. " + - "Remove unwanted via Publisher") - - if len(instance_names) == 0 and self.flatten_subset_template: - project_name = context.data["projectEntity"]["name"] - variants = get_project_settings(project_name).get( - "photoshop", {}).get( - "create", {}).get( - "CreateImage", {}).get( - "defaults", ['']) - family = "image" - task_name = legacy_io.Session["AVALON_TASK"] - asset_name = context.data["assetEntity"]["name"] - - variant = context.data.get("variant") or variants[0] - fill_pairs = { - "variant": variant, - "family": family, - "task": task_name - } - - subset = self.flatten_subset_template.format( - **prepare_template_data(fill_pairs)) - - instance = context.create_instance(subset) - instance.data["family"] = family - instance.data["asset"] = asset_name - instance.data["subset"] = subset - instance.data["ids"] = all_layer_ids - instance.data["families"] = self.families_mapping[family] - instance.data["publish"] = True - - self.log.info("flatten instance: {} ".format(instance.data)) diff --git a/openpype/hosts/photoshop/plugins/publish/collect_review.py b/openpype/hosts/photoshop/plugins/publish/collect_review.py index 7e598a8250..87ec4ee3f1 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_review.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_review.py @@ -14,10 +14,7 @@ from openpype.pipeline.create import get_subset_name class CollectReview(pyblish.api.ContextPlugin): - """Gather the active document as review instance. - - Triggers once even if no 'image' is published as by defaults it creates - flatten image from a workfile. + """Adds review to families for instances marked to be reviewable. """ label = "Collect Review" @@ -28,25 +25,8 @@ class CollectReview(pyblish.api.ContextPlugin): publish = True def process(self, context): - family = "review" - subset = get_subset_name( - family, - context.data.get("variant", ''), - context.data["anatomyData"]["task"]["name"], - context.data["assetEntity"], - context.data["anatomyData"]["project"]["name"], - host_name=context.data["hostName"], - project_settings=context.data["project_settings"] - ) - - instance = context.create_instance(subset) - instance.data.update({ - "subset": subset, - "label": subset, - "name": subset, - "family": family, - "families": [], - "representations": [], - "asset": os.environ["AVALON_ASSET"], - "publish": self.publish - }) + for instance in context: + creator_attributes = instance.data["creator_attributes"] + if (creator_attributes.get("mark_for_review") and + "review" not in instance.data["families"]): + instance.data["families"].append("review") diff --git a/openpype/hosts/photoshop/plugins/publish/collect_workfile.py b/openpype/hosts/photoshop/plugins/publish/collect_workfile.py index 9a5aad5569..9625464499 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_workfile.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_workfile.py @@ -14,50 +14,19 @@ class CollectWorkfile(pyblish.api.ContextPlugin): default_variant = "Main" def process(self, context): - existing_instance = None for instance in context: if instance.data["family"] == "workfile": - self.log.debug("Workfile instance found, won't create new") - existing_instance = instance - break + file_path = context.data["currentFile"] + _, ext = os.path.splitext(file_path) + staging_dir = os.path.dirname(file_path) + base_name = os.path.basename(file_path) - family = "workfile" - # context.data["variant"] might come only from collect_batch_data - variant = context.data.get("variant") or self.default_variant - subset = get_subset_name( - family, - variant, - context.data["anatomyData"]["task"]["name"], - context.data["assetEntity"], - context.data["anatomyData"]["project"]["name"], - host_name=context.data["hostName"], - project_settings=context.data["project_settings"] - ) - - file_path = context.data["currentFile"] - staging_dir = os.path.dirname(file_path) - base_name = os.path.basename(file_path) - - # Create instance - if existing_instance is None: - instance = context.create_instance(subset) - instance.data.update({ - "subset": subset, - "label": base_name, - "name": base_name, - "family": family, - "families": [], - "representations": [], - "asset": os.environ["AVALON_ASSET"] - }) - else: - instance = existing_instance - - # creating representation - _, ext = os.path.splitext(file_path) - instance.data["representations"].append({ - "name": ext[1:], - "ext": ext[1:], - "files": base_name, - "stagingDir": staging_dir, - }) + # creating representation + _, ext = os.path.splitext(file_path) + instance.data["representations"].append({ + "name": ext[1:], + "ext": ext[1:], + "files": base_name, + "stagingDir": staging_dir, + }) + return diff --git a/openpype/hosts/photoshop/plugins/publish/extract_review.py b/openpype/hosts/photoshop/plugins/publish/extract_review.py index 9d7eff0211..d5416a389d 100644 --- a/openpype/hosts/photoshop/plugins/publish/extract_review.py +++ b/openpype/hosts/photoshop/plugins/publish/extract_review.py @@ -47,32 +47,42 @@ class ExtractReview(publish.Extractor): layers = self._get_layers_from_image_instances(instance) self.log.info("Layers image instance found: {}".format(layers)) + repre_name = "jpg" + repre_skeleton = { + "name": repre_name, + "ext": "jpg", + "stagingDir": staging_dir, + "tags": self.jpg_options['tags'], + } + + if instance.data["family"] != "review": + # enable creation of review, without this jpg review would clash + # with jpg of the image family + output_name = repre_name + repre_name = "{}_{}".format(repre_name, output_name) + repre_skeleton.update({"name": repre_name, + "outputName": output_name}) + if self.make_image_sequence and len(layers) > 1: self.log.info("Extract layers to image sequence.") img_list = self._save_sequence_images(staging_dir, layers) - instance.data["representations"].append({ - "name": "jpg", - "ext": "jpg", - "files": img_list, + repre_skeleton.update({ "frameStart": 0, "frameEnd": len(img_list), "fps": fps, - "stagingDir": staging_dir, - "tags": self.jpg_options['tags'], + "files": img_list, }) + instance.data["representations"].append(repre_skeleton) processed_img_names = img_list else: self.log.info("Extract layers to flatten image.") img_list = self._save_flatten_image(staging_dir, layers) - instance.data["representations"].append({ - "name": "jpg", - "ext": "jpg", - "files": img_list, # cannot be [] for single frame - "stagingDir": staging_dir, - "tags": self.jpg_options['tags'] + repre_skeleton.update({ + "files": img_list, }) + instance.data["representations"].append(repre_skeleton) processed_img_names = [img_list] ffmpeg_path = get_ffmpeg_tool_path("ffmpeg") diff --git a/openpype/settings/defaults/project_settings/photoshop.json b/openpype/settings/defaults/project_settings/photoshop.json index bcf21f55dd..2454691958 100644 --- a/openpype/settings/defaults/project_settings/photoshop.json +++ b/openpype/settings/defaults/project_settings/photoshop.json @@ -10,23 +10,40 @@ } }, "create": { - "CreateImage": { - "defaults": [ + "ImageCreator": { + "enabled": true, + "active_on_create": true, + "mark_for_review": false, + "default_variants": [ "Main" ] + }, + "AutoImageCreator": { + "enabled": false, + "active_on_create": true, + "mark_for_review": false, + "default_variant": "" + }, + "ReviewCreator": { + "enabled": true, + "active_on_create": true, + "default_variant": "" + }, + "WorkfileCreator": { + "enabled": true, + "active_on_create": true, + "default_variant": "Main" } }, "publish": { "CollectColorCodedInstances": { + "enabled": true, "create_flatten_image": "no", "flatten_subset_template": "", "color_code_mapping": [] }, - "CollectInstances": { - "flatten_subset_template": "" - }, "CollectReview": { - "publish": true + "enabled": true }, "CollectVersion": { "enabled": false diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_photoshop.json b/openpype/settings/entities/schemas/projects_schema/schema_project_photoshop.json index 0071e632af..f6c46aba8b 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_photoshop.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_photoshop.json @@ -31,16 +31,126 @@ { "type": "dict", "collapsible": true, - "key": "CreateImage", + "key": "ImageCreator", "label": "Create Image", + "checkbox_key": "enabled", "children": [ + { + "type": "label", + "label": "Manually create instance from layer or group of layers. \n Separate review could be created for this image to be sent to Asset Management System." + }, + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "boolean", + "key": "active_on_create", + "label": "Active by default" + }, + { + "type": "boolean", + "key": "mark_for_review", + "label": "Review by default" + }, { "type": "list", - "key": "defaults", - "label": "Default Subsets", + "key": "default_variants", + "label": "Default Variants", "object_type": "text" } ] + }, + { + "type": "dict", + "collapsible": true, + "key": "AutoImageCreator", + "label": "Create Flatten Image", + "checkbox_key": "enabled", + "children": [ + { + "type": "label", + "label": "Auto create image for all visible layers, used for simplified processing. \n Separate review could be created for this image to be sent to Asset Management System." + }, + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "boolean", + "key": "active_on_create", + "label": "Active by default" + }, + { + "type": "boolean", + "key": "mark_for_review", + "label": "Review by default" + }, + { + "type": "text", + "key": "default_variant", + "label": "Default variant" + } + ] + }, + { + "type": "dict", + "collapsible": true, + "key": "ReviewCreator", + "label": "Create Review", + "checkbox_key": "enabled", + "children": [ + { + "type": "label", + "label": "Auto create review instance containing all published image instances or visible layers if no image instance." + }, + { + "type": "boolean", + "key": "enabled", + "label": "Enabled", + "default": true + }, + { + "type": "boolean", + "key": "active_on_create", + "label": "Active by default" + }, + { + "type": "text", + "key": "default_variant", + "label": "Default variant" + } + ] + }, + { + "type": "dict", + "collapsible": true, + "key": "WorkfileCreator", + "label": "Create Workfile", + "checkbox_key": "enabled", + "children": [ + { + "type": "label", + "label": "Auto create workfile instance" + }, + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "boolean", + "key": "active_on_create", + "label": "Active by default" + }, + { + "type": "text", + "key": "default_variant", + "label": "Default variant" + } + ] } ] }, @@ -56,11 +166,18 @@ "is_group": true, "key": "CollectColorCodedInstances", "label": "Collect Color Coded Instances", + "checkbox_key": "enabled", "children": [ { "type": "label", "label": "Set color for publishable layers, set its resulting family and template for subset name. \nCan create flatten image from published instances.(Applicable only for remote publishing!)" }, + { + "type": "boolean", + "key": "enabled", + "label": "Enabled", + "default": true + }, { "key": "create_flatten_image", "label": "Create flatten image", @@ -131,40 +248,26 @@ } ] }, - { - "type": "dict", - "collapsible": true, - "key": "CollectInstances", - "label": "Collect Instances", - "children": [ - { - "type": "label", - "label": "Name for flatten image created if no image instance present" - }, - { - "type": "text", - "key": "flatten_subset_template", - "label": "Subset template for flatten image" - } - ] - }, { "type": "dict", "collapsible": true, "key": "CollectReview", "label": "Collect Review", + "checkbox_key": "enabled", "children": [ { "type": "boolean", - "key": "publish", - "label": "Active" - } - ] + "key": "enabled", + "label": "Enabled", + "default": true + } + ] }, { "type": "dict", "key": "CollectVersion", "label": "Collect Version", + "checkbox_key": "enabled", "children": [ { "type": "label", diff --git a/tests/integration/hosts/photoshop/test_publish_in_photoshop_auto_image.py b/tests/integration/hosts/photoshop/test_publish_in_photoshop_auto_image.py new file mode 100644 index 0000000000..1594b36dec --- /dev/null +++ b/tests/integration/hosts/photoshop/test_publish_in_photoshop_auto_image.py @@ -0,0 +1,93 @@ +import logging + +from tests.lib.assert_classes import DBAssert +from tests.integration.hosts.photoshop.lib import PhotoshopTestClass + +log = logging.getLogger("test_publish_in_photoshop") + + +class TestPublishInPhotoshopAutoImage(PhotoshopTestClass): + """Test for publish in Phohoshop with different review configuration. + + Workfile contains 3 layers, auto image and review instances created. + + Test contains updates to Settings!!! + + """ + PERSIST = True + + TEST_FILES = [ + ("1iLF6aNI31qlUCD1rGg9X9eMieZzxL-rc", + "test_photoshop_publish_auto_image.zip", "") + ] + + APP_GROUP = "photoshop" + # keep empty to locate latest installed variant or explicit + APP_VARIANT = "" + + APP_NAME = "{}/{}".format(APP_GROUP, APP_VARIANT) + + TIMEOUT = 120 # publish timeout + + def test_db_asserts(self, dbcon, publish_finished): + """Host and input data dependent expected results in DB.""" + print("test_db_asserts") + failures = [] + + failures.append(DBAssert.count_of_types(dbcon, "version", 3)) + + failures.append( + DBAssert.count_of_types(dbcon, "version", 0, name={"$ne": 1})) + + failures.append( + DBAssert.count_of_types(dbcon, "subset", 0, + name="imageMainForeground")) + + failures.append( + DBAssert.count_of_types(dbcon, "subset", 0, + name="imageMainBackground")) + + failures.append( + DBAssert.count_of_types(dbcon, "subset", 1, + name="workfileTest_task")) + + failures.append( + DBAssert.count_of_types(dbcon, "representation", 5)) + + additional_args = {"context.subset": "imageMainForeground", + "context.ext": "png"} + failures.append( + DBAssert.count_of_types(dbcon, "representation", 0, + additional_args=additional_args)) + + additional_args = {"context.subset": "imageMainBackground", + "context.ext": "png"} + failures.append( + DBAssert.count_of_types(dbcon, "representation", 0, + additional_args=additional_args)) + + # review from image + additional_args = {"context.subset": "imageBeautyMain", + "context.ext": "jpg", + "name": "jpg_jpg"} + failures.append( + DBAssert.count_of_types(dbcon, "representation", 1, + additional_args=additional_args)) + + additional_args = {"context.subset": "imageBeautyMain", + "context.ext": "jpg", + "name": "jpg"} + failures.append( + DBAssert.count_of_types(dbcon, "representation", 1, + additional_args=additional_args)) + + additional_args = {"context.subset": "review"} + failures.append( + DBAssert.count_of_types(dbcon, "representation", 1, + additional_args=additional_args)) + + assert not any(failures) + + +if __name__ == "__main__": + test_case = TestPublishInPhotoshopAutoImage() diff --git a/tests/integration/hosts/photoshop/test_publish_in_photoshop_review.py b/tests/integration/hosts/photoshop/test_publish_in_photoshop_review.py new file mode 100644 index 0000000000..64b6868d7c --- /dev/null +++ b/tests/integration/hosts/photoshop/test_publish_in_photoshop_review.py @@ -0,0 +1,111 @@ +import logging + +from tests.lib.assert_classes import DBAssert +from tests.integration.hosts.photoshop.lib import PhotoshopTestClass + +log = logging.getLogger("test_publish_in_photoshop") + + +class TestPublishInPhotoshopImageReviews(PhotoshopTestClass): + """Test for publish in Phohoshop with different review configuration. + + Workfile contains 2 image instance, one has review flag, second doesn't. + + Regular `review` family is disabled. + + Expected result is to `imageMainForeground` to have additional file with + review, `imageMainBackground` without. No separate `review` family. + + `test_project_test_asset_imageMainForeground_v001_jpg.jpg` is expected name + of imageForeground review, `_jpg` suffix is needed to differentiate between + image and review file. + + """ + PERSIST = True + + TEST_FILES = [ + ("12WGbNy9RJ3m9jlnk0Ib9-IZmONoxIz_p", + "test_photoshop_publish_review.zip", "") + ] + + APP_GROUP = "photoshop" + # keep empty to locate latest installed variant or explicit + APP_VARIANT = "" + + APP_NAME = "{}/{}".format(APP_GROUP, APP_VARIANT) + + TIMEOUT = 120 # publish timeout + + def test_db_asserts(self, dbcon, publish_finished): + """Host and input data dependent expected results in DB.""" + print("test_db_asserts") + failures = [] + + failures.append(DBAssert.count_of_types(dbcon, "version", 3)) + + failures.append( + DBAssert.count_of_types(dbcon, "version", 0, name={"$ne": 1})) + + failures.append( + DBAssert.count_of_types(dbcon, "subset", 1, + name="imageMainForeground")) + + failures.append( + DBAssert.count_of_types(dbcon, "subset", 1, + name="imageMainBackground")) + + failures.append( + DBAssert.count_of_types(dbcon, "subset", 1, + name="workfileTest_task")) + + failures.append( + DBAssert.count_of_types(dbcon, "representation", 6)) + + additional_args = {"context.subset": "imageMainForeground", + "context.ext": "png"} + failures.append( + DBAssert.count_of_types(dbcon, "representation", 1, + additional_args=additional_args)) + + additional_args = {"context.subset": "imageMainForeground", + "context.ext": "jpg"} + failures.append( + DBAssert.count_of_types(dbcon, "representation", 2, + additional_args=additional_args)) + + additional_args = {"context.subset": "imageMainForeground", + "context.ext": "jpg", + "context.representation": "jpg_jpg"} + failures.append( + DBAssert.count_of_types(dbcon, "representation", 1, + additional_args=additional_args)) + + additional_args = {"context.subset": "imageMainBackground", + "context.ext": "png"} + failures.append( + DBAssert.count_of_types(dbcon, "representation", 1, + additional_args=additional_args)) + + additional_args = {"context.subset": "imageMainBackground", + "context.ext": "jpg"} + failures.append( + DBAssert.count_of_types(dbcon, "representation", 1, + additional_args=additional_args)) + + additional_args = {"context.subset": "imageMainBackground", + "context.ext": "jpg", + "context.representation": "jpg_jpg"} + failures.append( + DBAssert.count_of_types(dbcon, "representation", 0, + additional_args=additional_args)) + + additional_args = {"context.subset": "review"} + failures.append( + DBAssert.count_of_types(dbcon, "representation", 0, + additional_args=additional_args)) + + assert not any(failures) + + +if __name__ == "__main__": + test_case = TestPublishInPhotoshopImageReviews() diff --git a/website/docs/admin_hosts_photoshop.md b/website/docs/admin_hosts_photoshop.md new file mode 100644 index 0000000000..de684f01d2 --- /dev/null +++ b/website/docs/admin_hosts_photoshop.md @@ -0,0 +1,127 @@ +--- +id: admin_hosts_photoshop +title: Photoshop Settings +sidebar_label: Photoshop +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +## Photoshop settings + +There is a couple of settings that could configure publishing process for **Photoshop**. +All of them are Project based, eg. each project could have different configuration. + +Location: Settings > Project > Photoshop + +![AfterEffects Project Settings](assets/admin_hosts_photoshop_settings.png) + +## Color Management (ImageIO) + +Placeholder for Color Management. Currently not implemented yet. + +## Creator plugins + +Contains configurable items for creators used during publishing from Photoshop. + +### Create Image + +Provides list of [variants](artist_concepts.md#variant) that will be shown to an artist in Publisher. Default value `Main`. + +### Create Flatten Image + +Provides simplified publishing process. It will create single `image` instance for artist automatically. This instance will +produce flatten image from all visible layers in a workfile. + +- Subset template for flatten image - provide template for subset name for this instance (example `imageBeauty`) +- Review - should be separate review created for this instance + +### Create Review + +Creates single `review` instance automatically. This allows artists to disable it if needed. + +### Create Workfile + +Creates single `workfile` instance automatically. This allows artists to disable it if needed. + +## Publish plugins + +Contains configurable items for publish plugins used during publishing from Photoshop. + +### Collect Color Coded Instances + +Used only in remote publishing! + +Allows to create automatically `image` instances for configurable highlight color set on layer or group in the workfile. + +#### Create flatten image + - Flatten with images - produce additional `image` with all published `image` instances merged + - Flatten only - produce only merged `image` instance + - No - produce only separate `image` instances + +#### Subset template for flatten image + +Template used to create subset name automatically (example `image{layer}Main` - uses layer name in subset name) + +### Collect Review + +Disable if no review should be created + +### Collect Version + +If enabled it will push version from workfile name to all published items. Eg. if artist is publishing `test_asset_workfile_v005.psd` +produced `image` and `review` files will contain `v005` (even if some previous version were skipped for particular family). + +### Validate Containers + +Checks if all imported assets to the workfile through `Loader` are in latest version. Limits cases that older version of asset would be used. + +If enabled, artist might still decide to disable validation for each publish (for special use cases). +Limit this optionality by toggling `Optional`. +`Active` toggle denotes that by default artists sees that optional validation as enabled. + +### Validate naming of subsets and layers + +Subset cannot contain invalid characters or extract to file would fail + +#### Regex pattern of invalid characters + +Contains weird characters like `/`, `/`, these might cause an issue when file (which contains subset name) is created on OS disk. + +#### Replacement character + +Replace all offending characters with this one. `_` is default. + +### Extract Image + +Controls extension formats of published instances of `image` family. `png` and `jpg` are by default. + +### Extract Review + +Controls output definitions of extracted reviews to upload on Asset Management (AM). + +#### Makes an image sequence instead of flatten image + +If multiple `image` instances are produced, glue created images into image sequence (`mov`) to review all of them separetely. +Without it only flatten image would be produced. + +#### Maximum size of sources for review + +Set Byte limit for review file. Applicable if gigantic `image` instances are produced, full image size is unnecessary to upload to AM. + +#### Extract jpg Options + +Handles tags for produced `.jpg` representation. `Create review` and `Add review to Ftrack` are defaults. + +#### Extract mov Options + +Handles tags for produced `.mov` representation. `Create review` and `Add review to Ftrack` are defaults. + + +### Workfile Builder + +Allows to open prepared workfile for an artist when no workfile exists. Useful to share standards, additional helpful content in the workfile. + +Could be configured per `Task type`, eg. `composition` task type could use different `.psd` template file than `art` task. +Workfile template must be accessible for all artists. +(Currently not handled by [SiteSync](module_site_sync.md)) \ No newline at end of file diff --git a/website/docs/assets/admin_hosts_photoshop_settings.png b/website/docs/assets/admin_hosts_photoshop_settings.png new file mode 100644 index 0000000000000000000000000000000000000000..aaa6ecbed7b353733f8424abe3c2df0cb903935a GIT binary patch literal 14364 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