diff --git a/client/ayon_core/lib/__init__.py b/client/ayon_core/lib/__init__.py index 1f864284cd..12c391d867 100644 --- a/client/ayon_core/lib/__init__.py +++ b/client/ayon_core/lib/__init__.py @@ -109,6 +109,7 @@ from .transcoding import ( convert_ffprobe_fps_value, convert_ffprobe_fps_to_float, get_rescaled_command_arguments, + get_media_mime_type, ) from .plugin_tools import ( @@ -209,6 +210,7 @@ __all__ = [ "convert_ffprobe_fps_value", "convert_ffprobe_fps_to_float", "get_rescaled_command_arguments", + "get_media_mime_type", "compile_list_of_regexes", diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index bff28614ea..ead8b621b9 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -6,6 +6,7 @@ import collections import tempfile import subprocess import platform +from typing import Optional import xml.etree.ElementTree @@ -1455,3 +1456,87 @@ def get_oiio_input_and_channel_args(oiio_input_info, alpha_default=None): input_arg += ":ch={}".format(input_channels_str) return input_arg, channels_arg + + +def _get_media_mime_type_from_ftyp(content): + if content[8:10] == b"qt": + return "video/quicktime" + + if content[8:12] == b"isom": + return "video/mp4" + if content[8:12] in (b"M4V\x20", b"mp42"): + return "video/mp4v" + # ( + # b"avc1", b"iso2", b"isom", b"mmp4", b"mp41", b"mp71", + # b"msnv", b"ndas", b"ndsc", b"ndsh", b"ndsm", b"ndsp", b"ndss", + # b"ndxc", b"ndxh", b"ndxm", b"ndxp", b"ndxs" + # ) + return None + + +def get_media_mime_type(filepath: str) -> Optional[str]: + """Determine Mime-Type of a file. + + Args: + filepath (str): Path to file. + + Returns: + Optional[str]: Mime type or None if is unknown mime type. + + """ + if not filepath or not os.path.exists(filepath): + return None + + with open(filepath, "rb") as stream: + content = stream.read() + + content_len = len(content) + # Pre-validation (largest definition check) + # - hopefully there cannot be media defined in less than 12 bytes + if content_len < 12: + return None + + # FTYP + if content[4:8] == b"ftyp": + return _get_media_mime_type_from_ftyp(content) + + # BMP + if content[0:2] == b"BM": + return "image/bmp" + + # Tiff + if content[0:2] in (b"MM", b"II"): + return "tiff" + + # PNG + if content[0:4] == b"\211PNG": + return "image/png" + + # SVG + if b'xmlns="http://www.w3.org/2000/svg"' in content: + return "image/svg+xml" + + # JPEG, JFIF or Exif + if ( + content[0:4] == b"\xff\xd8\xff\xdb" + or content[6:10] in (b"JFIF", b"Exif") + ): + return "image/jpeg" + + # Webp + if content[0:4] == b"RIFF" and content[8:12] == b"WEBP": + return "image/webp" + + # Gif + if content[0:6] in (b"GIF87a", b"GIF89a"): + return "gif" + + # Adobe PhotoShop file (8B > Adobe, PS > PhotoShop) + if content[0:4] == b"8BPS": + return "image/vnd.adobe.photoshop" + + # Windows ICO > this might be wild guess as multiple files can start + # with this header + if content[0:4] == b"\x00\x00\x01\x00": + return "image/x-icon" + return None diff --git a/client/ayon_core/plugins/publish/integrate.py b/client/ayon_core/plugins/publish/integrate.py index a2cf910fa6..69c14465eb 100644 --- a/client/ayon_core/plugins/publish/integrate.py +++ b/client/ayon_core/plugins/publish/integrate.py @@ -114,18 +114,19 @@ class IntegrateAsset(pyblish.api.InstancePlugin): # the database even if not used by the destination template db_representation_context_keys = [ "project", - "asset", "hierarchy", "folder", "task", "product", - "subset", - "family", "version", "representation", "username", "user", - "output" + "output", + # OpenPype keys - should be removed + "asset", # folder[name] + "subset", # product[name] + "family", # product[type] ] def process(self, instance): diff --git a/client/ayon_core/plugins/publish/integrate_hero_version.py b/client/ayon_core/plugins/publish/integrate_hero_version.py index 4fb8b886a9..2163596864 100644 --- a/client/ayon_core/plugins/publish/integrate_hero_version.py +++ b/client/ayon_core/plugins/publish/integrate_hero_version.py @@ -265,6 +265,9 @@ class IntegrateHeroVersion( project_name, "version", new_hero_version ) + # Store hero entity to 'instance.data' + instance.data["heroVersionEntity"] = new_hero_version + # Separate old representations into `to replace` and `to delete` old_repres_to_replace = {} old_repres_to_delete = {} diff --git a/client/ayon_core/plugins/publish/integrate_review.py b/client/ayon_core/plugins/publish/integrate_review.py new file mode 100644 index 0000000000..0a6b24adb4 --- /dev/null +++ b/client/ayon_core/plugins/publish/integrate_review.py @@ -0,0 +1,102 @@ +import os + +import pyblish.api +import ayon_api +from ayon_api.server_api import RequestTypes + +from ayon_core.lib import get_media_mime_type +from ayon_core.pipeline.publish import get_publish_repre_path + + +class IntegrateAYONReview(pyblish.api.InstancePlugin): + label = "Integrate AYON Review" + # Must happen after IntegrateAsset + order = pyblish.api.IntegratorOrder + 0.15 + + def process(self, instance): + project_name = instance.context.data["projectName"] + src_version_entity = instance.data.get("versionEntity") + src_hero_version_entity = instance.data.get("heroVersionEntity") + for version_entity in ( + src_version_entity, + src_hero_version_entity, + ): + if not version_entity: + continue + + version_id = version_entity["id"] + self._upload_reviewable(project_name, version_id, instance) + + def _upload_reviewable(self, project_name, version_id, instance): + ayon_con = ayon_api.get_server_api_connection() + major, minor, _, _, _ = ayon_con.get_server_version_tuple() + if (major, minor) < (1, 3): + self.log.info( + "Skipping reviewable upload, supported from server 1.3.x." + f" Current server version {ayon_con.get_server_version()}" + ) + return + + uploaded_labels = set() + for repre in instance.data["representations"]: + repre_tags = repre.get("tags") or [] + # Ignore representations that are not reviewable + if "webreview" not in repre_tags: + continue + + # exclude representations with are going to be published on farm + if "publish_on_farm" in repre_tags: + continue + + # Skip thumbnails + if repre.get("thumbnail") or "thumbnail" in repre_tags: + continue + + repre_path = get_publish_repre_path( + instance, repre, False + ) + if not repre_path or not os.path.exists(repre_path): + # TODO log skipper path + continue + + content_type = get_media_mime_type(repre_path) + if not content_type: + self.log.warning( + f"Could not determine Content-Type for {repre_path}" + ) + continue + + label = self._get_review_label(repre, uploaded_labels) + query = "" + if label: + query = f"?label={label}" + + endpoint = ( + f"/projects/{project_name}" + f"/versions/{version_id}/reviewables{query}" + ) + filename = os.path.basename(repre_path) + # Upload the reviewable + self.log.info(f"Uploading reviewable '{label or filename}' ...") + + headers = ayon_con.get_headers(content_type) + headers["x-file-name"] = filename + self.log.info(f"Uploading reviewable {repre_path}") + ayon_con.upload_file( + endpoint, + repre_path, + headers=headers, + request_type=RequestTypes.post, + ) + + def _get_review_label(self, repre, uploaded_labels): + # Use output name as label if available + label = repre.get("outputName") + if not label: + return None + orig_label = label + idx = 0 + while label in uploaded_labels: + idx += 1 + label = f"{orig_label}_{idx}" + return label diff --git a/server/settings/publish_plugins.py b/server/settings/publish_plugins.py index 1ca487969f..a5ea7bd762 100644 --- a/server/settings/publish_plugins.py +++ b/server/settings/publish_plugins.py @@ -1012,7 +1012,8 @@ DEFAULT_PUBLISH_VALUES = { "ext": "png", "tags": [ "ftrackreview", - "kitsureview" + "kitsureview", + "webreview" ], "burnins": [], "ffmpeg_args": { @@ -1052,7 +1053,8 @@ DEFAULT_PUBLISH_VALUES = { "tags": [ "burnin", "ftrackreview", - "kitsureview" + "kitsureview", + "webreview" ], "burnins": [], "ffmpeg_args": { @@ -1064,7 +1066,10 @@ DEFAULT_PUBLISH_VALUES = { "output": [ "-pix_fmt yuv420p", "-crf 18", - "-intra" + "-c:a acc", + "-b:a 192k", + "-g 1", + "-movflags faststart" ] }, "filter": {