Repository: ynput/OpenPype
Issue: Maya: `<camera>` token position

 ### Bug

if `<camera>` token is not used in file name part of the file prefix, publishing will fail. We need to allow using it on arbitrary position.

[cuID:PYPE-1275]

Changed files:
openpype/hosts/maya/plugins/publish/validate_rendersettings.py
openpype/modules/default_modules/deadline/plugins/publish/submit_maya_deadline.py
openpype/hosts/maya/api/lib_renderproducts.py
openpype/modules/default_modules/deadline/plugins/publish/submit_publish_job.py
openpype/hosts/maya/plugins/publish/collect_render.py

diff --git a/openpype/hosts/maya/api/lib_renderproducts.py b/openpype/hosts/maya/api/lib_renderproducts.py
index fb99584c5d..4983109d58 100644
--- a/openpype/hosts/maya/api/lib_renderproducts.py
+++ b/openpype/hosts/maya/api/lib_renderproducts.py
@@ -114,6 +114,8 @@ class RenderProduct(object):
     aov = attr.ib(default=None)                 # source aov
     driver = attr.ib(default=None)              # source driver
     multipart = attr.ib(default=False)          # multichannel file
+    camera = attr.ib(default=None)              # used only when rendering
+    #                                             from multiple cameras
 
 
 def get(layer, render_instance=None):
@@ -183,6 +185,16 @@ class ARenderProducts:
         self.layer_data = self._get_layer_data()
         self.layer_data.products = self.get_render_products()
 
+    def has_camera_token(self):
+        # type: () -> bool
+        """Check if camera token is in image prefix.
+
+        Returns:
+            bool: True/False if camera token is present.
+
+        """
+        return "<camera>" in self.layer_data.filePrefix.lower()
+
     @abstractmethod
     def get_render_products(self):
         """To be implemented by renderer class.
@@ -307,7 +319,7 @@ class ARenderProducts:
         #       Deadline allows submitting renders with a custom frame list
         #       to support those cases we might want to allow 'custom frames'
         #       to be overridden to `ExpectFiles` class?
-        layer_data = LayerMetadata(
+        return LayerMetadata(
             frameStart=int(self.get_render_attribute("startFrame")),
             frameEnd=int(self.get_render_attribute("endFrame")),
             frameStep=int(self.get_render_attribute("byFrameStep")),
@@ -321,7 +333,6 @@ class ARenderProducts:
             defaultExt=self._get_attr("defaultRenderGlobals.imfPluginKey"),
             filePrefix=file_prefix
         )
-        return layer_data
 
     def _generate_file_sequence(
             self, layer_data,
@@ -330,7 +341,7 @@ class ARenderProducts:
             force_cameras=None):
         # type: (LayerMetadata, str, str, list) -> list
         expected_files = []
-        cameras = force_cameras if force_cameras else layer_data.cameras
+        cameras = force_cameras or layer_data.cameras
         ext = force_ext or layer_data.defaultExt
         for cam in cameras:
             file_prefix = layer_data.filePrefix
@@ -361,8 +372,8 @@ class ARenderProducts:
                 )
         return expected_files
 
-    def get_files(self, product, camera):
-        # type: (RenderProduct, str) -> list
+    def get_files(self, product):
+        # type: (RenderProduct) -> list
         """Return list of expected files.
 
         It will translate render token strings  ('<RenderPass>', etc.) to
@@ -373,7 +384,6 @@ class ARenderProducts:
         Args:
             product (RenderProduct): Render product to be used for file
                 generation.
-            camera (str): Camera name.
 
         Returns:
             List of files
@@ -383,7 +393,7 @@ class ARenderProducts:
             self.layer_data,
             force_aov_name=product.productName,
             force_ext=product.ext,
-            force_cameras=[camera]
+            force_cameras=[product.camera]
         )
 
     def get_renderable_cameras(self):
@@ -460,15 +470,21 @@ class RenderProductsArnold(ARenderProducts):
 
         return prefix
 
-    def _get_aov_render_products(self, aov):
+    def _get_aov_render_products(self, aov, cameras=None):
         """Return all render products for the AOV"""
 
-        products = list()
+        products = []
         aov_name = self._get_attr(aov, "name")
         ai_drivers = cmds.listConnections("{}.outputs".format(aov),
                                           source=True,
                                           destination=False,
                                           type="aiAOVDriver") or []
+        if not cameras:
+            cameras = [
+                self.sanitize_camera_name(
+                    self.get_renderable_cameras()[0]
+                )
+            ]
 
         for ai_driver in ai_drivers:
             # todo: check aiAOVDriver.prefix as it could have
@@ -497,30 +513,37 @@ class RenderProductsArnold(ARenderProducts):
                 name = "beauty"
 
             # Support Arnold light groups for AOVs
-            # Global AOV: When disabled the main layer is not written: `{pass}`
+            # Global AOV: When disabled the main layer is
+            #             not written: `{pass}`
             # All Light Groups: When enabled, a `{pass}_lgroups` file is
-            #                   written and is always merged into a single file
-            # Light Groups List: When set, a product per light group is written
+            #                   written and is always merged into a
+            #                   single file
+            # Light Groups List: When set, a product per light
+            #                    group is written
             #                    e.g. {pass}_front, {pass}_rim
             global_aov = self._get_attr(aov, "globalAov")
             if global_aov:
-                product = RenderProduct(productName=name,
-                                        ext=ext,
-                                        aov=aov_name,
-                                        driver=ai_driver)
-                products.append(product)
+                for camera in cameras:
+                    product = RenderProduct(productName=name,
+                                            ext=ext,
+                                            aov=aov_name,
+                                            driver=ai_driver,
+                                            camera=camera)
+                    products.append(product)
 
             all_light_groups = self._get_attr(aov, "lightGroups")
             if all_light_groups:
                 # All light groups is enabled. A single multipart
                 # Render Product
-                product = RenderProduct(productName=name + "_lgroups",
-                                        ext=ext,
-                                        aov=aov_name,
-                                        driver=ai_driver,
-                                        # Always multichannel output
-                                        multipart=True)
-                products.append(product)
+                for camera in cameras:
+                    product = RenderProduct(productName=name + "_lgroups",
+                                            ext=ext,
+                                            aov=aov_name,
+                                            driver=ai_driver,
+                                            # Always multichannel output
+                                            multipart=True,
+                                            camera=camera)
+                    products.append(product)
             else:
                 value = self._get_attr(aov, "lightGroupsList")
                 if not value:
@@ -529,11 +552,15 @@ class RenderProductsArnold(ARenderProducts):
                 for light_group in selected_light_groups:
                     # Render Product per selected light group
                     aov_light_group_name = "{}_{}".format(name, light_group)
-                    product = RenderProduct(productName=aov_light_group_name,
-                                            aov=aov_name,
-                                            driver=ai_driver,
-                                            ext=ext)
-                    products.append(product)
+                    for camera in cameras:
+                        product = RenderProduct(
+                            productName=aov_light_group_name,
+                            aov=aov_name,
+                            driver=ai_driver,
+                            ext=ext,
+                            camera=camera
+                        )
+                        products.append(product)
 
         return products
 
@@ -556,17 +583,26 @@ class RenderProductsArnold(ARenderProducts):
             # anyway.
             return []
 
-        default_ext = self._get_attr("defaultRenderGlobals.imfPluginKey")
-        beauty_product = RenderProduct(productName="beauty",
-                                       ext=default_ext,
-                                       driver="defaultArnoldDriver")
+        # check if camera token is in prefix. If so, and we have list of
+        # renderable cameras, generate render product for each and every
+        # of them.
+        cameras = [
+            self.sanitize_camera_name(c)
+            for c in self.get_renderable_cameras()
+        ]
 
+        default_ext = self._get_attr("defaultRenderGlobals.imfPluginKey")
+        beauty_products = [RenderProduct(
+            productName="beauty",
+            ext=default_ext,
+            driver="defaultArnoldDriver",
+            camera=camera) for camera in cameras]
         # AOVs > Legacy > Maya Render View > Mode
         aovs_enabled = bool(
             self._get_attr("defaultArnoldRenderOptions.aovMode")
         )
         if not aovs_enabled:
-            return [beauty_product]
+            return beauty_products
 
         # Common > File Output > Merge AOVs or <RenderPass>
         # We don't need to check for Merge AOVs due to overridden
@@ -575,8 +611,9 @@ class RenderProductsArnold(ARenderProducts):
             "<renderpass>" in self.layer_data.filePrefix.lower()
         )
         if not has_renderpass_token:
-            beauty_product.multipart = True
-            return [beauty_product]
+            for product in beauty_products:
+                product.multipart = True
+            return beauty_products
 
         # AOVs are set to be rendered separately. We should expect
         # <RenderPass> token in path.
@@ -598,14 +635,14 @@ class RenderProductsArnold(ARenderProducts):
                 continue
 
             # For now stick to the legacy output format.
-            aov_products = self._get_aov_render_products(aov)
+            aov_products = self._get_aov_render_products(aov, cameras)
             products.extend(aov_products)
 
-        if not any(product.aov == "RGBA" for product in products):
+        if all(product.aov != "RGBA" for product in products):
             # Append default 'beauty' as this is arnolds default.
             # However, it is excluded whenever a RGBA pass is enabled.
             # For legibility add the beauty layer as first entry
-            products.insert(0, beauty_product)
+            products += beauty_products
 
         # TODO: Output Denoising AOVs?
 
@@ -670,6 +707,11 @@ class RenderProductsVray(ARenderProducts):
             # anyway.
             return []
 
+        cameras = [
+            self.sanitize_camera_name(c)
+            for c in self.get_renderable_cameras()
+        ]
+
         image_format_str = self._get_attr("vraySettings.imageFormatStr")
         default_ext = image_format_str
         if default_ext in {"exr (multichannel)", "exr (deep)"}:
@@ -680,13 +722,21 @@ class RenderProductsVray(ARenderProducts):
         # add beauty as default when not disabled
         dont_save_rgb = self._get_attr("vraySettings.dontSaveRgbChannel")
         if not dont_save_rgb:
-            products.append(RenderProduct(productName="", ext=default_ext))
+            for camera in cameras:
+                products.append(
+                    RenderProduct(productName="",
+                                  ext=default_ext,
+                                  camera=camera))
 
         # separate alpha file
         separate_alpha = self._get_attr("vraySettings.separateAlpha")
         if separate_alpha:
-            products.append(RenderProduct(productName="Alpha",
-                                          ext=default_ext))
+            for camera in cameras:
+                products.append(
+                    RenderProduct(productName="Alpha",
+                                  ext=default_ext,
+                                  camera=camera)
+                )
 
         if image_format_str == "exr (multichannel)":
             # AOVs are merged in m-channel file, only main layer is rendered
@@ -716,19 +766,23 @@ class RenderProductsVray(ARenderProducts):
                 # instead seems to output multiple Render Products,
                 # specifically "Self_Illumination" and "Environment"
                 product_names = ["Self_Illumination", "Environment"]
-                for name in product_names:
-                    product = RenderProduct(productName=name,
-                                            ext=default_ext,
-                                            aov=aov)
-                    products.append(product)
+                for camera in cameras:
+                    for name in product_names:
+                        product = RenderProduct(productName=name,
+                                                ext=default_ext,
+                                                aov=aov,
+                                                camera=camera)
+                        products.append(product)
                 # Continue as we've processed this special case AOV
                 continue
 
             aov_name = self._get_vray_aov_name(aov)
-            product = RenderProduct(productName=aov_name,
-                                    ext=default_ext,
-                                    aov=aov)
-            products.append(product)
+            for camera in cameras:
+                product = RenderProduct(productName=aov_name,
+                                        ext=default_ext,
+                                        aov=aov,
+                                        camera=camera)
+                products.append(product)
 
         return products
 
@@ -875,6 +929,11 @@ class RenderProductsRedshift(ARenderProducts):
             # anyway.
             return []
 
+        cameras = [
+            self.sanitize_camera_name(c)
+            for c in self.get_renderable_cameras()
+        ]
+
         # For Redshift we don't directly return upon forcing multilayer
         # due to some AOVs still being written into separate files,
         # like Cryptomatte.
@@ -933,11 +992,14 @@ class RenderProductsRedshift(ARenderProducts):
                 for light_group in light_groups:
                     aov_light_group_name = "{}_{}".format(aov_name,
                                                           light_group)
-                    product = RenderProduct(productName=aov_light_group_name,
-                                            aov=aov_name,
-                                            ext=ext,
-                                            multipart=aov_multipart)
-                    products.append(product)
+                    for camera in cameras:
+                        product = RenderProduct(
+                            productName=aov_light_group_name,
+                            aov=aov_name,
+                            ext=ext,
+                            multipart=aov_multipart,
+                            camera=camera)
+                        products.append(product)
 
             if light_groups:
                 light_groups_enabled = True
@@ -945,11 +1007,13 @@ class RenderProductsRedshift(ARenderProducts):
             # Redshift AOV Light Select always renders the global AOV
             # even when light groups are present so we don't need to
             # exclude it when light groups are active
-            product = RenderProduct(productName=aov_name,
-                                    aov=aov_name,
-                                    ext=ext,
-                                    multipart=aov_multipart)
-            products.append(product)
+            for camera in cameras:
+                product = RenderProduct(productName=aov_name,
+                                        aov=aov_name,
+                                        ext=ext,
+                                        multipart=aov_multipart,
+                                        camera=camera)
+                products.append(product)
 
         # When a Beauty AOV is added manually, it will be rendered as
         # 'Beauty_other' in file name and "standard" beauty will have
@@ -959,10 +1023,12 @@ class RenderProductsRedshift(ARenderProducts):
             return products
 
         beauty_name = "Beauty_other" if has_beauty_aov else ""
-        products.insert(0,
-                        RenderProduct(productName=beauty_name,
-                                      ext=ext,
-                                      multipart=multipart))
+        for camera in cameras:
+            products.insert(0,
+                            RenderProduct(productName=beauty_name,
+                                          ext=ext,
+                                          multipart=multipart,
+                                          camera=camera))
 
         return products
 
@@ -987,6 +1053,16 @@ class RenderProductsRenderman(ARenderProducts):
             :func:`ARenderProducts.get_render_products()`
 
         """
+        cameras = [
+            self.sanitize_camera_name(c)
+            for c in self.get_renderable_cameras()
+        ]
+
+        if not cameras:
+            cameras = [
+                self.sanitize_camera_name(
+                    self.get_renderable_cameras()[0])
+            ]
         products = []
 
         default_ext = "exr"
@@ -1000,9 +1076,11 @@ class RenderProductsRenderman(ARenderProducts):
             if aov_name == "rmanDefaultDisplay":
                 aov_name = "beauty"
 
-            product = RenderProduct(productName=aov_name,
-                                    ext=default_ext)
-            products.append(product)
+            for camera in cameras:
+                product = RenderProduct(productName=aov_name,
+                                        ext=default_ext,
+                                        camera=camera)
+                products.append(product)
 
         return products
 
diff --git a/openpype/hosts/maya/plugins/publish/collect_render.py b/openpype/hosts/maya/plugins/publish/collect_render.py
index 5049647ff9..575cc2456b 100644
--- a/openpype/hosts/maya/plugins/publish/collect_render.py
+++ b/openpype/hosts/maya/plugins/publish/collect_render.py
@@ -174,10 +174,16 @@ class CollectMayaRender(pyblish.api.ContextPlugin):
             assert render_products, "no render products generated"
             exp_files = []
             for product in render_products:
-                for camera in layer_render_products.layer_data.cameras:
-                    exp_files.append(
-                        {product.productName: layer_render_products.get_files(
-                            product, camera)})
+                product_name = product.productName
+                if product.camera and layer_render_products.has_camera_token():
+                    product_name = "{}{}".format(
+                        product.camera,
+                        "_" + product_name if product_name else "")
+                exp_files.append(
+                    {
+                        product_name: layer_render_products.get_files(
+                            product)
+                    })
 
             self.log.info("multipart: {}".format(
                 layer_render_products.multipart))
@@ -199,12 +205,14 @@ class CollectMayaRender(pyblish.api.ContextPlugin):
 
             # replace relative paths with absolute. Render products are
             # returned as list of dictionaries.
+            publish_meta_path = None
             for aov in exp_files:
                 full_paths = []
                 for file in aov[aov.keys()[0]]:
                     full_path = os.path.join(workspace, "renders", file)
                     full_path = full_path.replace("\\\\", "/")
                     full_paths.append(full_path)
+                    publish_meta_path = os.path.dirname(full_path)
                 aov_dict[aov.keys()[0]] = full_paths
 
             frame_start_render = int(self.get_render_attribute(
@@ -230,6 +238,26 @@ class CollectMayaRender(pyblish.api.ContextPlugin):
                 frame_end_handle = frame_end_render
 
             full_exp_files.append(aov_dict)
+
+            # find common path to store metadata
+            # so if image prefix is branching to many directories
+            # metadata file will be located in top-most common
+            # directory.
+            # TODO: use `os.path.commonpath()` after switch to Python 3
+            common_publish_meta_path = os.path.splitdrive(
+                publish_meta_path)[0]
+            if common_publish_meta_path:
+                common_publish_meta_path += os.path.sep
+            for part in publish_meta_path.split("/"):
+                common_publish_meta_path = os.path.join(
+                    common_publish_meta_path, part)
+                if part == expected_layer_name:
+                    break
+            common_publish_meta_path = common_publish_meta_path.replace(
+                "\\\\", "/")
+            self.log.info(
+                "Publish meta path: {}".format(common_publish_meta_path))
+
             self.log.info(full_exp_files)
             self.log.info("collecting layer: {}".format(layer_name))
             # Get layer specific settings, might be overrides
@@ -262,6 +290,7 @@ class CollectMayaRender(pyblish.api.ContextPlugin):
                 # which was submitted originally
                 "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"),
diff --git a/openpype/hosts/maya/plugins/publish/validate_rendersettings.py b/openpype/hosts/maya/plugins/publish/validate_rendersettings.py
index 7c795db43d..65ddacfc57 100644
--- a/openpype/hosts/maya/plugins/publish/validate_rendersettings.py
+++ b/openpype/hosts/maya/plugins/publish/validate_rendersettings.py
@@ -76,7 +76,7 @@ class ValidateRenderSettings(pyblish.api.InstancePlugin):
         r'%a|<aov>|<renderpass>', re.IGNORECASE)
     R_LAYER_TOKEN = re.compile(
         r'%l|<layer>|<renderlayer>', re.IGNORECASE)
-    R_CAMERA_TOKEN = re.compile(r'%c|<camera>', re.IGNORECASE)
+    R_CAMERA_TOKEN = re.compile(r'%c|Camera>')
     R_SCENE_TOKEN = re.compile(r'%s|<scene>', re.IGNORECASE)
 
     DEFAULT_PADDING = 4
@@ -126,7 +126,9 @@ class ValidateRenderSettings(pyblish.api.InstancePlugin):
         if len(cameras) > 1 and not re.search(cls.R_CAMERA_TOKEN, prefix):
             invalid = True
             cls.log.error("Wrong image prefix [ {} ] - "
-                          "doesn't have: '<camera>' token".format(prefix))
+                          "doesn't have: '<Camera>' token".format(prefix))
+            cls.log.error(
+                "Note that to needs to have capital 'C' at the beginning")
 
         # renderer specific checks
         if renderer == "vray":
diff --git a/openpype/modules/default_modules/deadline/plugins/publish/submit_maya_deadline.py b/openpype/modules/default_modules/deadline/plugins/publish/submit_maya_deadline.py
index 1ab3dc2554..2d43b0d085 100644
--- a/openpype/modules/default_modules/deadline/plugins/publish/submit_maya_deadline.py
+++ b/openpype/modules/default_modules/deadline/plugins/publish/submit_maya_deadline.py
@@ -351,6 +351,11 @@ class MayaSubmitDeadline(pyblish.api.InstancePlugin):
                             f.replace(orig_scene, new_scene)
                         )
                     instance.data["expectedFiles"] = [new_exp]
+
+                if instance.data.get("publishRenderMetadataFolder"):
+                    instance.data["publishRenderMetadataFolder"] = \\
+                        instance.data["publishRenderMetadataFolder"].replace(
+                            orig_scene, new_scene)
                 self.log.info("Scene name was switched {} -> {}".format(
                     orig_scene, new_scene
                 ))
diff --git a/openpype/modules/default_modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/default_modules/deadline/plugins/publish/submit_publish_job.py
index 19e3174384..6b07749819 100644
--- a/openpype/modules/default_modules/deadline/plugins/publish/submit_publish_job.py
+++ b/openpype/modules/default_modules/deadline/plugins/publish/submit_publish_job.py
@@ -385,6 +385,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin):
         """
         task = os.environ["AVALON_TASK"]
         subset = instance_data["subset"]
+        cameras = instance_data.get("cameras", [])
         instances = []
         # go through aovs in expected files
         for aov, files in exp_files[0].items():
@@ -410,7 +411,11 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin):
                 task[0].upper(), task[1:],
                 subset[0].upper(), subset[1:])
 
-            subset_name = '{}_{}'.format(group_name, aov)
+            cam = [c for c in cameras if c in col.head]
+            if cam:
+                subset_name = '{}_{}_{}'.format(group_name, cam, aov)
+            else:
+                subset_name = '{}_{}'.format(group_name, aov)
 
             if isinstance(col, (list, tuple)):
                 staging = os.path.dirname(col[0])