diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 646a2dd1ee..c79ca69fca 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -35,6 +35,8 @@ body: label: Version description: What version are you running? Look to AYON Tray options: + - 1.6.7 + - 1.6.6 - 1.6.5 - 1.6.4 - 1.6.3 diff --git a/client/ayon_core/addon/base.py b/client/ayon_core/addon/base.py index 9207bb74c0..a04aedb8cc 100644 --- a/client/ayon_core/addon/base.py +++ b/client/ayon_core/addon/base.py @@ -141,6 +141,9 @@ def _get_ayon_bundle_data() -> tuple[ ]: studio_bundle_name = os.environ.get("AYON_STUDIO_BUNDLE_NAME") project_bundle_name = os.getenv("AYON_BUNDLE_NAME") + # If AYON launcher <1.4.0 was used + if not studio_bundle_name: + studio_bundle_name = project_bundle_name bundles = ayon_api.get_bundles()["bundles"] studio_bundle = next( ( diff --git a/client/ayon_core/lib/__init__.py b/client/ayon_core/lib/__init__.py index 5ccc8d03e5..d5629cbf3d 100644 --- a/client/ayon_core/lib/__init__.py +++ b/client/ayon_core/lib/__init__.py @@ -11,6 +11,7 @@ from .local_settings import ( get_launcher_storage_dir, get_addons_resources_dir, get_local_site_id, + get_ayon_user_entity, get_ayon_username, ) from .ayon_connection import initialize_ayon_connection @@ -73,6 +74,7 @@ from .log import ( ) from .path_templates import ( + DefaultKeysDict, TemplateUnsolved, StringTemplate, FormatObject, @@ -148,6 +150,7 @@ __all__ = [ "get_launcher_storage_dir", "get_addons_resources_dir", "get_local_site_id", + "get_ayon_user_entity", "get_ayon_username", "initialize_ayon_connection", @@ -228,6 +231,7 @@ __all__ = [ "get_version_from_path", "get_last_version_from_path", + "DefaultKeysDict", "TemplateUnsolved", "StringTemplate", "FormatObject", diff --git a/client/ayon_core/lib/local_settings.py b/client/ayon_core/lib/local_settings.py index 1edfc3c1b6..8a17b7af38 100644 --- a/client/ayon_core/lib/local_settings.py +++ b/client/ayon_core/lib/local_settings.py @@ -5,6 +5,7 @@ import json import platform import configparser import warnings +import copy from datetime import datetime from abc import ABC, abstractmethod from functools import lru_cache @@ -13,6 +14,8 @@ from typing import Optional, Any import platformdirs import ayon_api +from .cache import NestedCacheItem, CacheItem + _PLACEHOLDER = object() @@ -23,6 +26,7 @@ class RegistryItemNotFound(ValueError): class _Cache: username = None + user_entities_by_name = NestedCacheItem() def _get_ayon_appdirs(*args: str) -> str: @@ -569,6 +573,68 @@ def get_local_site_id(): return site_id +def _get_ayon_service_username() -> Optional[str]: + # TODO @iLLiCiTiT - do not use private attribute of 'ServerAPI', rather + # use public method to get username from connection stack. + con = ayon_api.get_server_api_connection() + user_stack = getattr(con, "_as_user_stack", None) + if user_stack is None: + return None + return user_stack.username + + +def get_ayon_user_entity(username: Optional[str] = None) -> dict[str, Any]: + """AYON user entity used for templates and publishing. + + Note: + Usually only service and admin users can receive the full user entity. + + Args: + username (Optional[str]): Username of the user. If not passed, then + the current user in 'ayon_api' is used. + + Returns: + dict[str, Any]: User entity. + + """ + service_username = _get_ayon_service_username() + # Handle service user handling first + if service_username: + if username is None: + username = service_username + cache: CacheItem = _Cache.user_entities_by_name[username] + if not cache.is_valid: + if username == service_username: + user = ayon_api.get_user() + else: + user = ayon_api.get_user(username) + cache.update_data(user) + return copy.deepcopy(cache.get_data()) + + # Cache current user + current_user = None + if _Cache.username is None: + current_user = ayon_api.get_user() + _Cache.username = current_user["name"] + + if username is None: + username = _Cache.username + + cache: CacheItem = _Cache.user_entities_by_name[username] + if not cache.is_valid: + user = None + if username == _Cache.username: + if current_user is None: + current_user = ayon_api.get_user() + user = current_user + + if user is None: + user = ayon_api.get_user(username) + cache.update_data(user) + + return copy.deepcopy(cache.get_data()) + + def get_ayon_username(): """AYON username used for templates and publishing. @@ -578,20 +644,5 @@ def get_ayon_username(): str: Username. """ - # Look for username in the connection stack - # - this is used when service is working as other user - # (e.g. in background sync) - # TODO @iLLiCiTiT - do not use private attribute of 'ServerAPI', rather - # use public method to get username from connection stack. - con = ayon_api.get_server_api_connection() - user_stack = getattr(con, "_as_user_stack", None) - if user_stack is not None: - username = user_stack.username - if username is not None: - return username - - # Cache the username to avoid multiple API calls - # - it is not expected that user would change - if _Cache.username is None: - _Cache.username = ayon_api.get_user()["name"] - return _Cache.username + user = get_ayon_user_entity() + return user["name"] diff --git a/client/ayon_core/lib/path_templates.py b/client/ayon_core/lib/path_templates.py index c6e9e14eac..aba2f296e3 100644 --- a/client/ayon_core/lib/path_templates.py +++ b/client/ayon_core/lib/path_templates.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import os import re import copy @@ -5,11 +7,7 @@ import numbers import warnings import platform from string import Formatter -import typing -from typing import List, Dict, Any, Set - -if typing.TYPE_CHECKING: - from typing import Union +from typing import Any, Union, Iterable SUB_DICT_PATTERN = re.compile(r"([^\[\]]+)") OPTIONAL_PATTERN = re.compile(r"(<.*?[^{0]*>)[^0-9]*?") @@ -44,6 +42,54 @@ class TemplateUnsolved(Exception): ) +class DefaultKeysDict(dict): + """Dictionary that supports the default key to use for str conversion. + + Is helpful for changes of a key in a template from string to dictionary + for example '{folder}' -> '{folder[name]}'. + >>> data = DefaultKeysDict( + >>> "name", + >>> {"folder": {"name": "FolderName"}} + >>> ) + >>> print("{folder[name]}".format_map(data)) + FolderName + >>> print("{folder}".format_map(data)) + FolderName + + Args: + default_key (Union[str, Iterable[str]]): Default key to use for str + conversion. Can also expect multiple keys for more nested + dictionary. + + """ + def __init__( + self, default_keys: Union[str, Iterable[str]], *args, **kwargs + ) -> None: + if isinstance(default_keys, str): + default_keys = [default_keys] + else: + default_keys = list(default_keys) + if not default_keys: + raise ValueError( + "Default key must be set. Got empty default keys." + ) + + self._default_keys = default_keys + super().__init__(*args, **kwargs) + + def __str__(self) -> str: + return str(self.get_default_value()) + + def get_default_keys(self) -> list[str]: + return list(self._default_keys) + + def get_default_value(self) -> Any: + value = self + for key in self._default_keys: + value = value[key] + return value + + class StringTemplate: """String that can be formatted.""" def __init__(self, template: str): @@ -84,7 +130,7 @@ class StringTemplate: if substr: new_parts.append(substr) - self._parts: List["Union[str, OptionalPart, FormattingPart]"] = ( + self._parts: list[Union[str, OptionalPart, FormattingPart]] = ( self.find_optional_parts(new_parts) ) @@ -105,7 +151,7 @@ class StringTemplate: def template(self) -> str: return self._template - def format(self, data: Dict[str, Any]) -> "TemplateResult": + def format(self, data: dict[str, Any]) -> "TemplateResult": """ Figure out with whole formatting. Separate advanced keys (*Like '{project[name]}') from string which must @@ -145,29 +191,29 @@ class StringTemplate: invalid_types ) - def format_strict(self, data: Dict[str, Any]) -> "TemplateResult": + def format_strict(self, data: dict[str, Any]) -> "TemplateResult": result = self.format(data) result.validate() return result @classmethod def format_template( - cls, template: str, data: Dict[str, Any] + cls, template: str, data: dict[str, Any] ) -> "TemplateResult": objected_template = cls(template) return objected_template.format(data) @classmethod def format_strict_template( - cls, template: str, data: Dict[str, Any] + cls, template: str, data: dict[str, Any] ) -> "TemplateResult": objected_template = cls(template) return objected_template.format_strict(data) @staticmethod def find_optional_parts( - parts: List["Union[str, FormattingPart]"] - ) -> List["Union[str, OptionalPart, FormattingPart]"]: + parts: list[Union[str, FormattingPart]] + ) -> list[Union[str, OptionalPart, FormattingPart]]: new_parts = [] tmp_parts = {} counted_symb = -1 @@ -192,7 +238,7 @@ class StringTemplate: len(parts) == 1 and isinstance(parts[0], str) ): - value = "<{}>".format(parts[0]) + value = f"<{parts[0]}>" else: value = OptionalPart(parts) @@ -223,7 +269,7 @@ class TemplateResult(str): only used keys. solved (bool): For check if all required keys were filled. template (str): Original template. - missing_keys (Iterable[str]): Missing keys that were not in the data. + missing_keys (list[str]): Missing keys that were not in the data. Include missing optional keys. invalid_types (dict): When key was found in data, but value had not allowed DataType. Allowed data types are `numbers`, @@ -232,11 +278,11 @@ class TemplateResult(str): of number. """ - used_values: Dict[str, Any] = None + used_values: dict[str, Any] = None solved: bool = None template: str = None - missing_keys: List[str] = None - invalid_types: Dict[str, Any] = None + missing_keys: list[str] = None + invalid_types: dict[str, Any] = None def __new__( cls, filled_template, template, solved, @@ -296,21 +342,21 @@ class TemplatePartResult: """Result to store result of template parts.""" def __init__(self, optional: bool = False): # Missing keys or invalid value types of required keys - self._missing_keys: Set[str] = set() - self._invalid_types: Dict[str, Any] = {} + self._missing_keys: set[str] = set() + self._invalid_types: dict[str, Any] = {} # Missing keys or invalid value types of optional keys - self._missing_optional_keys: Set[str] = set() - self._invalid_optional_types: Dict[str, Any] = {} + self._missing_optional_keys: set[str] = set() + self._invalid_optional_types: dict[str, Any] = {} # Used values stored by key with origin type # - key without any padding or key modifiers # - value from filling data # Example: {"version": 1} - self._used_values: Dict[str, Any] = {} + self._used_values: dict[str, Any] = {} # Used values stored by key with all modifirs # - value is already formatted string # Example: {"version:0>3": "001"} - self._really_used_values: Dict[str, Any] = {} + self._really_used_values: dict[str, Any] = {} # Concatenated string output after formatting self._output: str = "" # Is this result from optional part @@ -336,8 +382,9 @@ class TemplatePartResult: self._really_used_values.update(other.really_used_values) else: - raise TypeError("Cannot add data from \"{}\" to \"{}\"".format( - str(type(other)), self.__class__.__name__) + raise TypeError( + f"Cannot add data from \"{type(other)}\"" + f" to \"{self.__class__.__name__}\"" ) @property @@ -362,40 +409,41 @@ class TemplatePartResult: return self._output @property - def missing_keys(self) -> Set[str]: + def missing_keys(self) -> set[str]: return self._missing_keys @property - def missing_optional_keys(self) -> Set[str]: + def missing_optional_keys(self) -> set[str]: return self._missing_optional_keys @property - def invalid_types(self) -> Dict[str, Any]: + def invalid_types(self) -> dict[str, Any]: return self._invalid_types @property - def invalid_optional_types(self) -> Dict[str, Any]: + def invalid_optional_types(self) -> dict[str, Any]: return self._invalid_optional_types @property - def really_used_values(self) -> Dict[str, Any]: + def really_used_values(self) -> dict[str, Any]: return self._really_used_values @property - def realy_used_values(self) -> Dict[str, Any]: + def realy_used_values(self) -> dict[str, Any]: warnings.warn( "Property 'realy_used_values' is deprecated." " Use 'really_used_values' instead.", - DeprecationWarning + DeprecationWarning, + stacklevel=2, ) return self._really_used_values @property - def used_values(self) -> Dict[str, Any]: + def used_values(self) -> dict[str, Any]: return self._used_values @staticmethod - def split_keys_to_subdicts(values: Dict[str, Any]) -> Dict[str, Any]: + def split_keys_to_subdicts(values: dict[str, Any]) -> dict[str, Any]: output = {} formatter = Formatter() for key, value in values.items(): @@ -410,7 +458,7 @@ class TemplatePartResult: data[last_key] = value return output - def get_clean_used_values(self) -> Dict[str, Any]: + def get_clean_used_values(self) -> dict[str, Any]: new_used_values = {} for key, value in self.used_values.items(): if isinstance(value, FormatObject): @@ -426,7 +474,8 @@ class TemplatePartResult: warnings.warn( "Method 'add_realy_used_value' is deprecated." " Use 'add_really_used_value' instead.", - DeprecationWarning + DeprecationWarning, + stacklevel=2, ) self.add_really_used_value(key, value) @@ -479,7 +528,7 @@ class FormattingPart: self, field_name: str, format_spec: str, - conversion: "Union[str, None]", + conversion: Union[str, None], ): format_spec_v = "" if format_spec: @@ -546,7 +595,7 @@ class FormattingPart: return not queue @staticmethod - def keys_to_template_base(keys: List[str]): + def keys_to_template_base(keys: list[str]): if not keys: return None # Create copy of keys @@ -556,7 +605,7 @@ class FormattingPart: return f"{template_base}{joined_keys}" def format( - self, data: Dict[str, Any], result: TemplatePartResult + self, data: dict[str, Any], result: TemplatePartResult ) -> TemplatePartResult: """Format the formattings string. @@ -635,6 +684,12 @@ class FormattingPart: result.add_output(self.template) return result + if isinstance(value, DefaultKeysDict): + try: + value = value.get_default_value() + except KeyError: + pass + if not self.validate_value_type(value): result.add_invalid_type(key, value) result.add_output(self.template) @@ -687,23 +742,25 @@ class OptionalPart: def __init__( self, - parts: List["Union[str, OptionalPart, FormattingPart]"] + parts: list[Union[str, OptionalPart, FormattingPart]] ): - self._parts: List["Union[str, OptionalPart, FormattingPart]"] = parts + self._parts: list[Union[str, OptionalPart, FormattingPart]] = parts @property - def parts(self) -> List["Union[str, OptionalPart, FormattingPart]"]: + def parts(self) -> list[Union[str, OptionalPart, FormattingPart]]: return self._parts def __str__(self) -> str: - return "<{}>".format("".join([str(p) for p in self._parts])) + joined_parts = "".join([str(p) for p in self._parts]) + return f"<{joined_parts}>" def __repr__(self) -> str: - return "".format("".join([str(p) for p in self._parts])) + joined_parts = "".join([str(p) for p in self._parts]) + return f"" def format( self, - data: Dict[str, Any], + data: dict[str, Any], result: TemplatePartResult, ) -> TemplatePartResult: new_result = TemplatePartResult(True) diff --git a/client/ayon_core/pipeline/editorial.py b/client/ayon_core/pipeline/editorial.py index b553fae3fb..21468e6ddd 100644 --- a/client/ayon_core/pipeline/editorial.py +++ b/client/ayon_core/pipeline/editorial.py @@ -202,7 +202,8 @@ def is_clip_from_media_sequence(otio_clip): def remap_range_on_file_sequence(otio_clip, otio_range): - """ + """ Remap the provided range on a file sequence clip. + Args: otio_clip (otio.schema.Clip): The OTIO clip to check. otio_range (otio.schema.TimeRange): The trim range to apply. @@ -249,7 +250,11 @@ def remap_range_on_file_sequence(otio_clip, otio_range): if ( is_clip_from_media_sequence(otio_clip) and available_range_start_frame == media_ref.start_frame - and conformed_src_in.to_frames() < media_ref.start_frame + + # source range should be included in available range from media + # using round instead of conformed_src_in.to_frames() to avoid + # any precision issue with frame rate. + and round(conformed_src_in.value) < media_ref.start_frame ): media_in = otio.opentime.RationalTime( 0, rate=available_range_rate diff --git a/client/ayon_core/pipeline/publish/lib.py b/client/ayon_core/pipeline/publish/lib.py index 7152ec78fa..1f983808b0 100644 --- a/client/ayon_core/pipeline/publish/lib.py +++ b/client/ayon_core/pipeline/publish/lib.py @@ -983,7 +983,26 @@ def get_instance_expected_output_path( "version": version }) - path_template_obj = anatomy.get_template_item("publish", "default")["path"] + # Get instance publish template name + task_name = task_type = None + task_entity = instance.data.get("taskEntity") + if task_entity: + task_name = task_entity["name"] + task_type = task_entity["taskType"] + + template_name = get_publish_template_name( + project_name=instance.context.data["projectName"], + host_name=instance.context.data["hostName"], + product_type=instance.data["productType"], + task_name=task_name, + task_type=task_type, + project_settings=instance.context.data["project_settings"], + ) + + path_template_obj = anatomy.get_template_item( + "publish", + template_name + )["path"] template_filled = path_template_obj.format_strict(template_data) return os.path.normpath(template_filled) diff --git a/client/ayon_core/pipeline/template_data.py b/client/ayon_core/pipeline/template_data.py index 0a95a98be8..dc7e95c788 100644 --- a/client/ayon_core/pipeline/template_data.py +++ b/client/ayon_core/pipeline/template_data.py @@ -1,27 +1,50 @@ +from __future__ import annotations + +from typing import Optional, Any + import ayon_api from ayon_core.settings import get_studio_settings -from ayon_core.lib.local_settings import get_ayon_username +from ayon_core.lib import DefaultKeysDict +from ayon_core.lib.local_settings import get_ayon_user_entity -def get_general_template_data(settings=None, username=None): +def get_general_template_data( + settings: Optional[dict[str, Any]] = None, + username: Optional[str] = None, + user_entity: Optional[dict[str, Any]] = None, +): """General template data based on system settings or machine. Output contains formatting keys: - - 'studio[name]' - Studio name filled from system settings - - 'studio[code]' - Studio code filled from system settings - - 'user' - User's name using 'get_ayon_username' + - 'studio[name]' - Studio name filled from system settings + - 'studio[code]' - Studio code filled from system settings + - 'user[name]' - User's name + - 'user[attrib][...]' - User's attributes + - 'user[data][...]' - User's data Args: settings (Dict[str, Any]): Studio or project settings. username (Optional[str]): AYON Username. - """ + user_entity (Optional[dict[str, Any]]): User entity. + """ if not settings: settings = get_studio_settings() - if username is None: - username = get_ayon_username() + if user_entity is None: + user_entity = get_ayon_user_entity(username) + + # Use dictionary with default value for backwards compatibility + # - we did support '{user}' now it should be '{user[name]}' + user_data = DefaultKeysDict( + "name", + { + "name": user_entity["name"], + "attrib": user_entity["attrib"], + "data": user_entity["data"], + } + ) core_settings = settings["core"] return { @@ -29,7 +52,7 @@ def get_general_template_data(settings=None, username=None): "name": core_settings["studio_name"], "code": core_settings["studio_code"] }, - "user": username + "user": user_data, } @@ -150,7 +173,8 @@ def get_template_data( task_entity=None, host_name=None, settings=None, - username=None + username=None, + user_entity=None, ): """Prepare data for templates filling from entered documents and info. @@ -173,13 +197,18 @@ def get_template_data( host_name (Optional[str]): Used to fill '{app}' key. settings (Union[Dict, None]): Prepared studio or project settings. They're queried if not passed (may be slower). - username (Optional[str]): AYON Username. + username (Optional[str]): DEPRECATED AYON Username. + user_entity (Optional[dict[str, Any]): AYON user entity. Returns: Dict[str, Any]: Data prepared for filling workdir template. """ - template_data = get_general_template_data(settings, username=username) + template_data = get_general_template_data( + settings, + username=username, + user_entity=user_entity, + ) template_data.update(get_project_template_data(project_entity)) if folder_entity: template_data.update(get_folder_template_data( diff --git a/client/ayon_core/plugins/publish/collect_anatomy_context_data.py b/client/ayon_core/plugins/publish/collect_anatomy_context_data.py index cccf392e40..5d2ecec433 100644 --- a/client/ayon_core/plugins/publish/collect_anatomy_context_data.py +++ b/client/ayon_core/plugins/publish/collect_anatomy_context_data.py @@ -16,6 +16,7 @@ Provides: import json import pyblish.api +from ayon_core.lib import get_ayon_user_entity from ayon_core.pipeline.template_data import get_template_data @@ -55,17 +56,18 @@ class CollectAnatomyContextData(pyblish.api.ContextPlugin): if folder_entity: task_entity = context.data["taskEntity"] + username = context.data["user"] + user_entity = get_ayon_user_entity(username) anatomy_data = get_template_data( project_entity, folder_entity, task_entity, - host_name, - project_settings + host_name=host_name, + settings=project_settings, + user_entity=user_entity, ) anatomy_data.update(context.data.get("datetimeData") or {}) - username = context.data["user"] - anatomy_data["user"] = username # Backwards compatibility for 'username' key anatomy_data["username"] = username diff --git a/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py b/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py index d68970d428..543277f37e 100644 --- a/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py +++ b/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py @@ -71,6 +71,12 @@ class CollectOtioRanges(pyblish.api.InstancePlugin): import opentimelineio as otio otio_clip = instance.data["otioClip"] + if isinstance( + otio_clip.media_reference, + otio.schema.MissingReference + ): + self.log.info("Clip has no media reference") + return # Collect timeline ranges if workfile start frame is available if "workfileFrameStart" in instance.data: diff --git a/client/ayon_core/plugins/publish/collect_otio_subset_resources.py b/client/ayon_core/plugins/publish/collect_otio_subset_resources.py index 275b8a7f55..4d3c1cfb13 100644 --- a/client/ayon_core/plugins/publish/collect_otio_subset_resources.py +++ b/client/ayon_core/plugins/publish/collect_otio_subset_resources.py @@ -60,6 +60,13 @@ class CollectOtioSubsetResources( # get basic variables otio_clip = instance.data["otioClip"] + if isinstance( + otio_clip.media_reference, + otio.schema.MissingReference + ): + self.log.info("Clip has no media reference") + return + otio_available_range = otio_clip.available_range() media_fps = otio_available_range.start_time.rate available_duration = otio_available_range.duration.value diff --git a/client/ayon_core/plugins/publish/extract_otio_review.py b/client/ayon_core/plugins/publish/extract_otio_review.py index 90215bd2c9..f338fba746 100644 --- a/client/ayon_core/plugins/publish/extract_otio_review.py +++ b/client/ayon_core/plugins/publish/extract_otio_review.py @@ -130,7 +130,7 @@ class ExtractOTIOReview( # NOTE it looks like it is set only in hiero integration res_data = {"width": self.to_width, "height": self.to_height} for key in res_data: - for meta_prefix in ("ayon.source.", "openpype.source."): + for meta_prefix in ("ayon.source", "openpype.source"): meta_key = f"{meta_prefix}.{key}" value = media_metadata.get(meta_key) if value is not None: diff --git a/client/ayon_core/plugins/publish/integrate.py b/client/ayon_core/plugins/publish/integrate.py index f1e066018c..d18e546392 100644 --- a/client/ayon_core/plugins/publish/integrate.py +++ b/client/ayon_core/plugins/publish/integrate.py @@ -121,7 +121,6 @@ class IntegrateAsset(pyblish.api.InstancePlugin): "version", "representation", "username", - "user", "output", # OpenPype keys - should be removed "asset", # folder[name] @@ -796,6 +795,14 @@ class IntegrateAsset(pyblish.api.InstancePlugin): if value is not None: repre_context[key] = value + # Keep only username + # NOTE This is to avoid storing all user attributes and data + # to representation + if "user" not in repre_context: + repre_context["user"] = { + "name": template_data["user"]["name"] + } + # Use previous representation's id if there is a name match existing = existing_repres_by_name.get(repre["name"].lower()) repre_id = None diff --git a/client/ayon_core/plugins/publish/integrate_hero_version.py b/client/ayon_core/plugins/publish/integrate_hero_version.py index 90e6f15568..a591cfe880 100644 --- a/client/ayon_core/plugins/publish/integrate_hero_version.py +++ b/client/ayon_core/plugins/publish/integrate_hero_version.py @@ -89,7 +89,6 @@ class IntegrateHeroVersion( "family", "representation", "username", - "user", "output" ] # QUESTION/TODO this process should happen on server if crashed due to @@ -364,6 +363,14 @@ class IntegrateHeroVersion( if value is not None: repre_context[key] = value + # Keep only username + # NOTE This is to avoid storing all user attributes and data + # to representation + if "user" not in repre_context: + repre_context["user"] = { + "name": anatomy_data["user"]["name"] + } + # Prepare new repre repre_entity = copy.deepcopy(repre_info["representation"]) repre_entity.pop("id", None) diff --git a/client/ayon_core/tools/push_to_project/control.py b/client/ayon_core/tools/push_to_project/control.py index b4e0d56dfd..a24cedf455 100644 --- a/client/ayon_core/tools/push_to_project/control.py +++ b/client/ayon_core/tools/push_to_project/control.py @@ -41,6 +41,7 @@ class PushToContextController: self._process_item_id = None self._use_original_name = False + self._version_up = False self.set_source(project_name, version_ids) @@ -212,7 +213,7 @@ class PushToContextController: self._user_values.variant, comment=self._user_values.comment, new_folder_name=self._user_values.new_folder_name, - dst_version=1, + version_up=self._version_up, use_original_name=self._use_original_name, ) item_ids.append(item_id) @@ -229,6 +230,9 @@ class PushToContextController: thread.start() return item_ids + def set_version_up(self, state): + self._version_up = state + def wait_for_process_thread(self): if self._process_thread is None: return diff --git a/client/ayon_core/tools/push_to_project/models/integrate.py b/client/ayon_core/tools/push_to_project/models/integrate.py index ef49838152..6d6dd35a9d 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -3,9 +3,10 @@ import re import copy import itertools import sys +import tempfile import traceback import uuid -from typing import Optional, Dict +from typing import Optional, Any import ayon_api from ayon_api.utils import create_entity_id @@ -88,7 +89,7 @@ class ProjectPushItem: variant, comment, new_folder_name, - dst_version, + version_up, item_id=None, use_original_name=False ): @@ -99,7 +100,7 @@ class ProjectPushItem: self.dst_project_name = dst_project_name self.dst_folder_id = dst_folder_id self.dst_task_name = dst_task_name - self.dst_version = dst_version + self.version_up = version_up self.variant = variant self.new_folder_name = new_folder_name self.comment = comment or "" @@ -117,7 +118,7 @@ class ProjectPushItem: str(self.dst_folder_id), str(self.new_folder_name), str(self.dst_task_name), - str(self.dst_version), + str(self.version_up), self.use_original_name ]) return self._repr_value @@ -132,7 +133,7 @@ class ProjectPushItem: "dst_project_name": self.dst_project_name, "dst_folder_id": self.dst_folder_id, "dst_task_name": self.dst_task_name, - "dst_version": self.dst_version, + "version_up": self.version_up, "variant": self.variant, "comment": self.comment, "new_folder_name": self.new_folder_name, @@ -225,8 +226,8 @@ class ProjectPushRepreItem: but filenames are not template based. Args: - repre_entity (Dict[str, Ant]): Representation entity. - roots (Dict[str, str]): Project roots (based on project anatomy). + repre_entity (dict[str, Ant]): Representation entity. + roots (dict[str, str]): Project roots (based on project anatomy). """ def __init__(self, repre_entity, roots): @@ -482,6 +483,8 @@ class ProjectPushItemProcess: self._log_info("Destination project was found") self._fill_or_create_destination_folder() self._log_info("Destination folder was determined") + self._fill_or_create_destination_task() + self._log_info("Destination task was determined") self._determine_product_type() self._determine_publish_template_name() self._determine_product_name() @@ -650,10 +653,10 @@ class ProjectPushItemProcess: def _create_folder( self, - src_folder_entity, - project_entity, - parent_folder_entity, - folder_name + src_folder_entity: dict[str, Any], + project_entity: dict[str, Any], + parent_folder_entity: dict[str, Any], + folder_name: str ): parent_id = None if parent_folder_entity: @@ -702,12 +705,19 @@ class ProjectPushItemProcess: if new_folder_name != folder_name: folder_label = folder_name - # TODO find out how to define folder type + src_folder_type = src_folder_entity["folderType"] + dst_folder_type = self._get_dst_folder_type( + project_entity, + src_folder_type + ) + new_thumbnail_id = self._create_new_folder_thumbnail( + project_entity, src_folder_entity) folder_entity = new_folder_entity( folder_name, - "Folder", + dst_folder_type, parent_id=parent_id, - attribs=new_folder_attrib + attribs=new_folder_attrib, + thumbnail_id=new_thumbnail_id ) if folder_label: folder_entity["label"] = folder_label @@ -727,10 +737,59 @@ class ProjectPushItemProcess: folder_entity["path"] = "/".join([parent_path, folder_name]) return folder_entity + def _create_new_folder_thumbnail( + self, + project_entity: dict[str, Any], + src_folder_entity: dict[str, Any] + ) -> Optional[str]: + """Copy thumbnail possibly set on folder. + + Could be different from representation thumbnails, and it is only shown + when folder is selected. + """ + if not src_folder_entity["thumbnailId"]: + return None + + thumbnail = ayon_api.get_folder_thumbnail( + self._item.src_project_name, + src_folder_entity["id"], + src_folder_entity["thumbnailId"] + ) + if not thumbnail.id: + return None + + with tempfile.NamedTemporaryFile(delete=False) as tmp_file: + tmp_file.write(thumbnail.content) + temp_file_path = tmp_file.name + + new_thumbnail_id = None + try: + new_thumbnail_id = ayon_api.create_thumbnail( + project_entity["name"], temp_file_path) + finally: + if os.path.exists(temp_file_path): + os.remove(temp_file_path) + return new_thumbnail_id + + def _get_dst_folder_type( + self, + project_entity: dict[str, Any], + src_folder_type: str + ) -> str: + """Get new folder type.""" + for folder_type in project_entity["folderTypes"]: + if folder_type["name"].lower() == src_folder_type.lower(): + return folder_type["name"] + + self._status.set_failed( + f"'{src_folder_type}' folder type is not configured in " + f"project Anatomy." + ) + raise PushToProjectError(self._status.fail_reason) + def _fill_or_create_destination_folder(self): dst_project_name = self._item.dst_project_name dst_folder_id = self._item.dst_folder_id - dst_task_name = self._item.dst_task_name new_folder_name = self._item.new_folder_name if not dst_folder_id and not new_folder_name: self._status.set_failed( @@ -761,9 +820,11 @@ class ProjectPushItemProcess: new_folder_name ) self._folder_entity = folder_entity - if not dst_task_name: - self._task_info = {} - return + + def _fill_or_create_destination_task(self): + folder_entity = self._folder_entity + dst_task_name = self._item.dst_task_name + dst_project_name = self._item.dst_project_name folder_path = folder_entity["path"] folder_tasks = { @@ -772,6 +833,20 @@ class ProjectPushItemProcess: dst_project_name, folder_ids=[folder_entity["id"]] ) } + + if not dst_task_name: + src_task_info = self._get_src_task_info() + if not src_task_info: # really no task selected nor on source + self._task_info = {} + return + + dst_task_name = src_task_info["name"] + if dst_task_name.lower() not in folder_tasks: + task_info = self._make_sure_task_exists( + folder_entity, src_task_info + ) + folder_tasks[dst_task_name.lower()] = task_info + task_info = folder_tasks.get(dst_task_name.lower()) if not task_info: self._status.set_failed( @@ -790,7 +865,10 @@ class ProjectPushItemProcess: task_type["name"]: task_type for task_type in self._project_entity["taskTypes"] } - task_type_info = task_types_by_name.get(task_type_name, {}) + task_type_info = copy.deepcopy( + task_types_by_name.get(task_type_name, {}) + ) + task_type_info.pop("name") # do not overwrite real task name task_info.update(task_type_info) self._task_info = task_info @@ -870,10 +948,22 @@ class ProjectPushItemProcess: self._product_entity = product_entity return product_entity + src_attrib = self._src_product_entity["attrib"] + + dst_attrib = {} + for key in { + "description", + "productGroup", + }: + value = src_attrib.get(key) + if value: + dst_attrib[key] = value + product_entity = new_product_entity( product_name, product_type, folder_id, + attribs=dst_attrib ) self._operations.create_entity( project_name, "product", product_entity @@ -884,7 +974,7 @@ class ProjectPushItemProcess: """Make sure version document exits in database.""" project_name = self._item.dst_project_name - version = self._item.dst_version + version_up = self._item.version_up src_version_entity = self._src_version_entity product_entity = self._product_entity product_id = product_entity["id"] @@ -912,27 +1002,29 @@ class ProjectPushItemProcess: "description", "intent", }: - if key in src_attrib: - dst_attrib[key] = src_attrib[key] + value = src_attrib.get(key) + if value: + dst_attrib[key] = value - if version is None: - last_version_entity = ayon_api.get_last_version_by_product_id( - project_name, product_id + last_version_entity = ayon_api.get_last_version_by_product_id( + project_name, product_id + ) + if last_version_entity is None: + dst_version = get_versioning_start( + project_name, + self.host_name, + task_name=self._task_info.get("name"), + task_type=self._task_info.get("taskType"), + product_type=product_type, + product_name=product_entity["name"], ) - if last_version_entity: - version = int(last_version_entity["version"]) + 1 - else: - version = get_versioning_start( - project_name, - self.host_name, - task_name=self._task_info["name"], - task_type=self._task_info["taskType"], - product_type=product_type, - product_name=product_entity["name"], - ) + else: + dst_version = int(last_version_entity["version"]) + if version_up: + dst_version += 1 existing_version_entity = ayon_api.get_version_by_name( - project_name, version, product_id + project_name, dst_version, product_id ) thumbnail_id = self._copy_version_thumbnail() @@ -950,10 +1042,16 @@ class ProjectPushItemProcess: existing_version_entity["attrib"].update(dst_attrib) self._version_entity = existing_version_entity return + copied_tags = self._get_transferable_tags(src_version_entity) + copied_status = self._get_transferable_status(src_version_entity) version_entity = new_version_entity( - version, + dst_version, product_id, + author=src_version_entity["author"], + status=copied_status, + tags=copied_tags, + task_id=self._task_info.get("id"), attribs=dst_attrib, thumbnail_id=thumbnail_id, ) @@ -962,6 +1060,47 @@ class ProjectPushItemProcess: ) self._version_entity = version_entity + def _make_sure_task_exists( + self, + folder_entity: dict[str, Any], + task_info: dict[str, Any], + ) -> dict[str, Any]: + """Creates destination task from source task information""" + project_name = self._item.dst_project_name + found_task_type = False + src_task_type = task_info["taskType"] + for task_type in self._project_entity["taskTypes"]: + if task_type["name"].lower() == src_task_type.lower(): + found_task_type = True + break + + if not found_task_type: + self._status.set_failed( + f"'{src_task_type}' task type is not configured in " + "project Anatomy." + ) + + raise PushToProjectError(self._status.fail_reason) + + task_info = self._operations.create_task( + project_name, + task_info["name"], + folder_id=folder_entity["id"], + task_type=src_task_type, + attrib=task_info["attrib"], + ) + self._task_info = task_info.data + return self._task_info + + def _get_src_task_info(self): + src_version_entity = self._src_version_entity + if not src_version_entity["taskId"]: + return None + src_task = ayon_api.get_task_by_id( + self._item.src_project_name, src_version_entity["taskId"] + ) + return src_task + def _integrate_representations(self): try: self._real_integrate_representations() @@ -1197,18 +1336,42 @@ class ProjectPushItemProcess: if context_value and isinstance(context_value, dict): for context_sub_key in context_value.keys(): value_to_update = formatting_data.get(context_key, {}).get( - context_sub_key) + context_sub_key + ) if value_to_update: - repre_context[context_key][ - context_sub_key] = value_to_update + repre_context[context_key][context_sub_key] = ( + value_to_update + ) else: value_to_update = formatting_data.get(context_key) if value_to_update: repre_context[context_key] = value_to_update if "task" not in formatting_data: - repre_context.pop("task") + repre_context.pop("task", None) return repre_context + def _get_transferable_tags(self, src_version_entity): + """Copy over only tags present in destination project""" + dst_project_tags = [ + tag["name"] for tag in self._project_entity["tags"] + ] + copied_tags = [] + for src_tag in src_version_entity["tags"]: + if src_tag in dst_project_tags: + copied_tags.append(src_tag) + return copied_tags + + def _get_transferable_status(self, src_version_entity): + """Copy over status, first status if not matching found""" + dst_project_statuses = { + status["name"]: status + for status in self._project_entity["statuses"] + } + copied_status = dst_project_statuses.get(src_version_entity["status"]) + if copied_status: + return copied_status["name"] + return None + class IntegrateModel: def __init__(self, controller): @@ -1231,7 +1394,7 @@ class IntegrateModel: variant, comment, new_folder_name, - dst_version, + version_up, use_original_name ): """Create new item for integration. @@ -1245,7 +1408,7 @@ class IntegrateModel: variant (str): Variant name. comment (Union[str, None]): Comment. new_folder_name (Union[str, None]): New folder name. - dst_version (int): Destination version number. + version_up (bool): Should destination product be versioned up use_original_name (bool): If original product names should be used Returns: @@ -1262,7 +1425,7 @@ class IntegrateModel: variant, comment=comment, new_folder_name=new_folder_name, - dst_version=dst_version, + version_up=version_up, use_original_name=use_original_name ) process_item = ProjectPushItemProcess(self, item) @@ -1281,6 +1444,6 @@ class IntegrateModel: return item.integrate() - def get_items(self) -> Dict[str, ProjectPushItemProcess]: + def get_items(self) -> dict[str, ProjectPushItemProcess]: """Returns dict of all ProjectPushItemProcess items """ return self._process_items diff --git a/client/ayon_core/tools/push_to_project/ui/window.py b/client/ayon_core/tools/push_to_project/ui/window.py index f382ccce64..b77cca0e09 100644 --- a/client/ayon_core/tools/push_to_project/ui/window.py +++ b/client/ayon_core/tools/push_to_project/ui/window.py @@ -144,6 +144,8 @@ class PushToContextSelectWindow(QtWidgets.QWidget): variant_input.setPlaceholderText("< Variant >") variant_input.setObjectName("ValidatedLineEdit") + version_up_checkbox = NiceCheckbox(True, parent=inputs_widget) + comment_input = PlaceholderLineEdit(inputs_widget) comment_input.setPlaceholderText("< Publish comment >") @@ -153,7 +155,11 @@ class PushToContextSelectWindow(QtWidgets.QWidget): inputs_layout.addRow("New folder name", folder_name_input) inputs_layout.addRow("Variant", variant_input) inputs_layout.addRow( - "Use original product names", original_names_checkbox) + "Use original product names", original_names_checkbox + ) + inputs_layout.addRow( + "Version up existing Product", version_up_checkbox + ) inputs_layout.addRow("Comment", comment_input) main_splitter.addWidget(context_widget) @@ -209,8 +215,11 @@ class PushToContextSelectWindow(QtWidgets.QWidget): "Show error detail dialog to copy full error." ) original_names_checkbox.setToolTip( - "Required for multi copy, doesn't allow changes " - "variant values." + "Required for multi copy, doesn't allow changes variant values." + ) + version_up_checkbox.setToolTip( + "Version up existing product. If not selected version will be " + "updated." ) overlay_close_btn = QtWidgets.QPushButton( @@ -259,6 +268,8 @@ class PushToContextSelectWindow(QtWidgets.QWidget): library_only_checkbox.stateChanged.connect(self._on_library_only_change) original_names_checkbox.stateChanged.connect( self._on_original_names_change) + version_up_checkbox.stateChanged.connect( + self._on_version_up_checkbox_change) publish_btn.clicked.connect(self._on_select_click) cancel_btn.clicked.connect(self._on_close_click) @@ -308,6 +319,7 @@ class PushToContextSelectWindow(QtWidgets.QWidget): self._folder_name_input = folder_name_input self._comment_input = comment_input self._use_original_names_checkbox = original_names_checkbox + self._library_only_checkbox = library_only_checkbox self._publish_btn = publish_btn @@ -328,6 +340,7 @@ class PushToContextSelectWindow(QtWidgets.QWidget): self._new_folder_name_input_text = None self._variant_input_text = None self._comment_input_text = None + self._version_up_checkbox = version_up_checkbox self._first_show = True self._show_timer = show_timer @@ -344,6 +357,7 @@ class PushToContextSelectWindow(QtWidgets.QWidget): show_detail_btn.setVisible(False) overlay_close_btn.setVisible(False) overlay_try_btn.setVisible(False) + version_up_checkbox.setChecked(False) # Support of public api function of controller def set_source(self, project_name, version_ids): @@ -376,7 +390,6 @@ class PushToContextSelectWindow(QtWidgets.QWidget): self._invalidate_new_folder_name( new_folder_name, user_values["is_new_folder_name_valid"] ) - self._controller._invalidate() self._projects_combobox.refresh() def _on_first_show(self): @@ -415,14 +428,18 @@ class PushToContextSelectWindow(QtWidgets.QWidget): self._comment_input_text = text self._user_input_changed_timer.start() - def _on_library_only_change(self, state: int) -> None: + def _on_library_only_change(self) -> None: """Change toggle state, reset filter, recalculate dropdown""" - state = bool(state) - self._projects_combobox.set_standard_filter_enabled(state) + is_checked = self._library_only_checkbox.isChecked() + self._projects_combobox.set_standard_filter_enabled(is_checked) - def _on_original_names_change(self, state: int) -> None: - use_original_name = bool(state) - self._invalidate_use_original_names(use_original_name) + def _on_original_names_change(self) -> None: + is_checked = self._use_original_names_checkbox.isChecked() + self._invalidate_use_original_names(is_checked) + + def _on_version_up_checkbox_change(self) -> None: + is_checked = self._version_up_checkbox.isChecked() + self._controller.set_version_up(is_checked) def _on_user_input_timer(self): folder_name_enabled = self._new_folder_name_enabled diff --git a/client/ayon_core/tools/workfiles/widgets/window.py b/client/ayon_core/tools/workfiles/widgets/window.py index 3f96f0bb15..00362ea866 100644 --- a/client/ayon_core/tools/workfiles/widgets/window.py +++ b/client/ayon_core/tools/workfiles/widgets/window.py @@ -358,9 +358,8 @@ class WorkfilesToolWindow(QtWidgets.QWidget): if not self._host_is_valid: return - self._folders_widget.set_project_name( - self._controller.get_current_project_name() - ) + self._project_name = self._controller.get_current_project_name() + self._folders_widget.set_project_name(self._project_name) def _on_save_as_finished(self, event): if event["failed"]: diff --git a/client/ayon_core/version.py b/client/ayon_core/version.py index d3b3454fd1..6aa30b935a 100644 --- a/client/ayon_core/version.py +++ b/client/ayon_core/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring AYON addon 'core' version.""" -__version__ = "1.6.5+dev" +__version__ = "1.6.7+dev" diff --git a/client/pyproject.toml b/client/pyproject.toml index 6416d9b8e1..c98591b707 100644 --- a/client/pyproject.toml +++ b/client/pyproject.toml @@ -19,3 +19,6 @@ OpenTimelineIO = "0.16.0" opencolorio = "^2.3.2,<2.4.0" Pillow = "9.5.0" websocket-client = ">=0.40.0,<2" + +[ayon.runtimeDependencies.darwin] +pyobjc-core = "^11.1" diff --git a/package.py b/package.py index 2889039502..ff3fad5b19 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,6 @@ name = "core" title = "Core" -version = "1.6.5+dev" +version = "1.6.7+dev" client_dir = "ayon_core" diff --git a/pyproject.toml b/pyproject.toml index f43846ec2b..6656f15249 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ [tool.poetry] name = "ayon-core" -version = "1.6.5+dev" +version = "1.6.7+dev" description = "" authors = ["Ynput Team "] readme = "README.md" diff --git a/tests/client/ayon_core/pipeline/editorial/test_extract_otio_review.py b/tests/client/ayon_core/pipeline/editorial/test_extract_otio_review.py index 6a74df7f43..ed441edc63 100644 --- a/tests/client/ayon_core/pipeline/editorial/test_extract_otio_review.py +++ b/tests/client/ayon_core/pipeline/editorial/test_extract_otio_review.py @@ -246,75 +246,75 @@ def test_multiple_review_clips_no_gap(): expected = [ # 10 head black frames generated from gap (991-1000) '/path/to/ffmpeg -t 0.4 -r 25.0 -f lavfi' - ' -i color=c=black:s=1280x720 -tune ' + ' -i color=c=black:s=1920x1080 -tune ' 'stillimage -start_number 991 -pix_fmt rgba C:/result/output.%04d.png', # Alternance 25fps tiff sequence and 24fps exr sequence # for 100 frames each '/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i ' f'C:\\no_tc{os.sep}output.%04d.tif ' - '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-vf scale=1920:1080:flags=lanczos -compression_level 5 ' '-start_number 1001 -pix_fmt rgba C:/result/output.%04d.png', '/path/to/ffmpeg -start_number 1000 -framerate 24.0 -i ' f'C:\\with_tc{os.sep}output.%04d.exr ' - '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-vf scale=1920:1080:flags=lanczos -compression_level 5 ' '-start_number 1102 -pix_fmt rgba C:/result/output.%04d.png', '/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i ' f'C:\\no_tc{os.sep}output.%04d.tif ' - '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-vf scale=1920:1080:flags=lanczos -compression_level 5 ' '-start_number 1198 -pix_fmt rgba C:/result/output.%04d.png', '/path/to/ffmpeg -start_number 1000 -framerate 24.0 -i ' f'C:\\with_tc{os.sep}output.%04d.exr ' - '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-vf scale=1920:1080:flags=lanczos -compression_level 5 ' '-start_number 1299 -pix_fmt rgba C:/result/output.%04d.png', # Repeated 25fps tiff sequence multiple times till the end '/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i ' f'C:\\no_tc{os.sep}output.%04d.tif ' - '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-vf scale=1920:1080:flags=lanczos -compression_level 5 ' '-start_number 1395 -pix_fmt rgba C:/result/output.%04d.png', '/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i ' f'C:\\no_tc{os.sep}output.%04d.tif ' - '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-vf scale=1920:1080:flags=lanczos -compression_level 5 ' '-start_number 1496 -pix_fmt rgba C:/result/output.%04d.png', '/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i ' f'C:\\no_tc{os.sep}output.%04d.tif ' - '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-vf scale=1920:1080:flags=lanczos -compression_level 5 ' '-start_number 1597 -pix_fmt rgba C:/result/output.%04d.png', '/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i ' f'C:\\no_tc{os.sep}output.%04d.tif ' - '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-vf scale=1920:1080:flags=lanczos -compression_level 5 ' '-start_number 1698 -pix_fmt rgba C:/result/output.%04d.png', '/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i ' f'C:\\no_tc{os.sep}output.%04d.tif ' - '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-vf scale=1920:1080:flags=lanczos -compression_level 5 ' '-start_number 1799 -pix_fmt rgba C:/result/output.%04d.png', '/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i ' f'C:\\no_tc{os.sep}output.%04d.tif ' - '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-vf scale=1920:1080:flags=lanczos -compression_level 5 ' '-start_number 1900 -pix_fmt rgba C:/result/output.%04d.png', '/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i ' f'C:\\no_tc{os.sep}output.%04d.tif ' - '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-vf scale=1920:1080:flags=lanczos -compression_level 5 ' '-start_number 2001 -pix_fmt rgba C:/result/output.%04d.png', '/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i ' f'C:\\no_tc{os.sep}output.%04d.tif ' - '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-vf scale=1920:1080:flags=lanczos -compression_level 5 ' '-start_number 2102 -pix_fmt rgba C:/result/output.%04d.png', '/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i ' f'C:\\no_tc{os.sep}output.%04d.tif ' - '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-vf scale=1920:1080:flags=lanczos -compression_level 5 ' '-start_number 2203 -pix_fmt rgba C:/result/output.%04d.png' ] @@ -348,12 +348,12 @@ def test_multiple_review_clips_with_gap(): '/path/to/ffmpeg -start_number 1000 -framerate 24.0 -i ' f'C:\\with_tc{os.sep}output.%04d.exr ' - '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-vf scale=1920:1080:flags=lanczos -compression_level 5 ' '-start_number 1003 -pix_fmt rgba C:/result/output.%04d.png', '/path/to/ffmpeg -start_number 1000 -framerate 24.0 -i ' f'C:\\with_tc{os.sep}output.%04d.exr ' - '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-vf scale=1920:1080:flags=lanczos -compression_level 5 ' '-start_number 1091 -pix_fmt rgba C:/result/output.%04d.png' ]