diff --git a/openpype/cli.py b/openpype/cli.py index df38c74a21..12997cc7f4 100644 --- a/openpype/cli.py +++ b/openpype/cli.py @@ -60,13 +60,6 @@ def tray(debug=False): help="Ftrack api user") @click.option("--ftrack-api-key", envvar="FTRACK_API_KEY", help="Ftrack api key") -@click.option("--ftrack-events-path", - envvar="FTRACK_EVENTS_PATH", - help=("path to ftrack event handlers")) -@click.option("--no-stored-credentials", is_flag=True, - help="don't use stored credentials") -@click.option("--store-credentials", is_flag=True, - help="store provided credentials") @click.option("--legacy", is_flag=True, help="run event server without mongo storing") @click.option("--clockify-api-key", envvar="CLOCKIFY_API_KEY", @@ -77,9 +70,6 @@ def eventserver(debug, ftrack_url, ftrack_user, ftrack_api_key, - ftrack_events_path, - no_stored_credentials, - store_credentials, legacy, clockify_api_key, clockify_workspace): @@ -87,10 +77,6 @@ def eventserver(debug, This should be ideally used by system service (such us systemd or upstart on linux and window service). - - You have to set either proper environment variables to provide URL and - credentials or use option to specify them. If you use --store_credentials - provided credentials will be stored for later use. """ if debug: os.environ['OPENPYPE_DEBUG'] = "3" @@ -99,9 +85,6 @@ def eventserver(debug, ftrack_url, ftrack_user, ftrack_api_key, - ftrack_events_path, - no_stored_credentials, - store_credentials, legacy, clockify_api_key, clockify_workspace diff --git a/openpype/modules/base.py b/openpype/modules/base.py index b8d76aa028..50bd6411ce 100644 --- a/openpype/modules/base.py +++ b/openpype/modules/base.py @@ -680,6 +680,10 @@ class TrayModulesManager(ModulesManager): output.append(module) return output + def restart_tray(self): + if self.tray_manager: + self.tray_manager.restart() + def tray_init(self): report = {} time_start = time.time() diff --git a/openpype/modules/ftrack/ftrack_server/event_server_cli.py b/openpype/modules/ftrack/ftrack_server/event_server_cli.py index b5cc1bef3e..8bba22b475 100644 --- a/openpype/modules/ftrack/ftrack_server/event_server_cli.py +++ b/openpype/modules/ftrack/ftrack_server/event_server_cli.py @@ -422,17 +422,18 @@ def run_event_server( ftrack_url, ftrack_user, ftrack_api_key, - ftrack_events_path, - no_stored_credentials, - store_credentials, legacy, clockify_api_key, clockify_workspace ): - if not no_stored_credentials: + if not ftrack_user or not ftrack_api_key: + print(( + "Ftrack user/api key were not passed." + " Trying to use credentials from user keyring." + )) cred = credentials.get_credentials(ftrack_url) - username = cred.get('username') - api_key = cred.get('api_key') + ftrack_user = cred.get("username") + ftrack_api_key = cred.get("api_key") if clockify_workspace and clockify_api_key: os.environ["CLOCKIFY_WORKSPACE"] = clockify_workspace @@ -445,209 +446,16 @@ def run_event_server( return 1 # Validate entered credentials - if not validate_credentials(ftrack_url, username, api_key): + if not validate_credentials(ftrack_url, ftrack_user, ftrack_api_key): print('Exiting! < Please enter valid credentials >') return 1 - if store_credentials: - credentials.save_credentials(username, api_key, ftrack_url) - # Set Ftrack environments os.environ["FTRACK_SERVER"] = ftrack_url - os.environ["FTRACK_API_USER"] = username - os.environ["FTRACK_API_KEY"] = api_key - # TODO This won't work probably - if ftrack_events_path: - if isinstance(ftrack_events_path, (list, tuple)): - ftrack_events_path = os.pathsep.join(ftrack_events_path) - os.environ["FTRACK_EVENTS_PATH"] = ftrack_events_path + os.environ["FTRACK_API_USER"] = ftrack_user + os.environ["FTRACK_API_KEY"] = ftrack_api_key if legacy: return legacy_server(ftrack_url) return main_loop(ftrack_url) - - -def main(argv): - ''' - There are 4 values neccessary for event server: - 1.) Ftrack url - "studio.ftrackapp.com" - 2.) Username - "my.username" - 3.) API key - "apikey-long11223344-6665588-5565" - 4.) Path/s to events - "X:/path/to/folder/with/events" - - All these values can be entered with arguments or environment variables. - - arguments: - "-ftrackurl {url}" - "-ftrackuser {username}" - "-ftrackapikey {api key}" - "-ftrackeventpaths {path to events}" - - environment variables: - FTRACK_SERVER - FTRACK_API_USER - FTRACK_API_KEY - FTRACK_EVENTS_PATH - - Credentials (Username & API key): - - Credentials can be stored for auto load on next start - - To *Store/Update* these values add argument "-storecred" - - They will be stored to appsdir file when login is successful - - To *Update/Override* values with enviromnet variables is also needed to: - - *don't enter argument for that value* - - add argument "-noloadcred" (currently stored credentials won't be loaded) - - Order of getting values: - 1.) Arguments are always used when entered. - - entered values through args have most priority! (in each case) - 2.) Credentials are tried to load from appsdir file. - - skipped when credentials were entered through args or credentials - are not stored yet - - can be skipped with "-noloadcred" argument - 3.) Environment variables are last source of values. - - will try to get not yet set values from environments - - Best practice: - - set environment variables FTRACK_SERVER & FTRACK_EVENTS_PATH - - launch event_server_cli with args: - ~/event_server_cli.py -ftrackuser "{username}" -ftrackapikey "{API key}" -storecred - - next time launch event_server_cli.py only with set environment variables - FTRACK_SERVER & FTRACK_EVENTS_PATH - ''' - parser = argparse.ArgumentParser(description='Ftrack event server') - parser.add_argument( - "-ftrackurl", type=str, metavar='FTRACKURL', - help=( - "URL to ftrack server where events should handle" - " (default from environment: $FTRACK_SERVER)" - ) - ) - parser.add_argument( - "-ftrackuser", type=str, - help=( - "Username should be the username of the user in ftrack" - " to record operations against." - " (default from environment: $FTRACK_API_USER)" - ) - ) - parser.add_argument( - "-ftrackapikey", type=str, - help=( - "Should be the API key to use for authentication" - " (default from environment: $FTRACK_API_KEY)" - ) - ) - parser.add_argument( - "-ftrackeventpaths", nargs='+', - help=( - "List of paths where events are stored." - " (default from environment: $FTRACK_EVENTS_PATH)" - ) - ) - parser.add_argument( - '-storecred', - help=( - "Entered credentials will be also stored" - " to apps dir for future usage" - ), - action="store_true" - ) - parser.add_argument( - '-noloadcred', - help="Load creadentials from apps dir", - action="store_true" - ) - parser.add_argument( - '-legacy', - help="Load creadentials from apps dir", - action="store_true" - ) - parser.add_argument( - "-clockifyapikey", type=str, - help=( - "Enter API key for Clockify actions." - " (default from environment: $CLOCKIFY_API_KEY)" - ) - ) - parser.add_argument( - "-clockifyworkspace", type=str, - help=( - "Enter workspace for Clockify." - " (default from module presets or " - "environment: $CLOCKIFY_WORKSPACE)" - ) - ) - ftrack_url = os.environ.get("FTRACK_SERVER") - username = os.environ.get("FTRACK_API_USER") - api_key = os.environ.get("FTRACK_API_KEY") - - kwargs, args = parser.parse_known_args(argv) - - if kwargs.ftrackurl: - ftrack_url = kwargs.ftrackurl - - # Load Ftrack url from settings if not set - if not ftrack_url: - ftrack_url = get_ftrack_url_from_settings() - - event_paths = None - if kwargs.ftrackeventpaths: - event_paths = kwargs.ftrackeventpaths - - if not kwargs.noloadcred: - cred = credentials.get_credentials(ftrack_url) - username = cred.get('username') - api_key = cred.get('api_key') - - if kwargs.ftrackuser: - username = kwargs.ftrackuser - - if kwargs.ftrackapikey: - api_key = kwargs.ftrackapikey - - if kwargs.clockifyworkspace: - os.environ["CLOCKIFY_WORKSPACE"] = kwargs.clockifyworkspace - - if kwargs.clockifyapikey: - os.environ["CLOCKIFY_API_KEY"] = kwargs.clockifyapikey - - legacy = kwargs.legacy - - # Check url regex and accessibility - ftrack_url = check_ftrack_url(ftrack_url) - if not ftrack_url: - print('Exiting! < Please enter Ftrack server url >') - return 1 - - # Validate entered credentials - if not validate_credentials(ftrack_url, username, api_key): - print('Exiting! < Please enter valid credentials >') - return 1 - - if kwargs.storecred: - credentials.save_credentials(username, api_key, ftrack_url) - - # Set Ftrack environments - os.environ["FTRACK_SERVER"] = ftrack_url - os.environ["FTRACK_API_USER"] = username - os.environ["FTRACK_API_KEY"] = api_key - if event_paths: - if isinstance(event_paths, (list, tuple)): - event_paths = os.pathsep.join(event_paths) - os.environ["FTRACK_EVENTS_PATH"] = event_paths - - if legacy: - return legacy_server(ftrack_url) - - return main_loop(ftrack_url) - - -if __name__ == "__main__": - # Register interupt signal - def signal_handler(sig, frame): - print("You pressed Ctrl+C. Process ended.") - sys.exit(0) - - signal.signal(signal.SIGINT, signal_handler) - signal.signal(signal.SIGTERM, signal_handler) - - sys.exit(main(sys.argv)) diff --git a/openpype/modules/settings_action.py b/openpype/modules/settings_action.py index 5651868f68..f5bcb5342d 100644 --- a/openpype/modules/settings_action.py +++ b/openpype/modules/settings_action.py @@ -67,6 +67,10 @@ class SettingsAction(PypeModule, ITrayAction): return from openpype.tools.settings import MainWidget self.settings_window = MainWidget(self.user_role) + self.settings_window.trigger_restart.connect(self._on_trigger_restart) + + def _on_trigger_restart(self): + self.manager.restart_tray() def show_settings_window(self): """Show settings tool window. diff --git a/openpype/plugins/publish/extract_burnin.py b/openpype/plugins/publish/extract_burnin.py index 76f6ffc608..ef52d51325 100644 --- a/openpype/plugins/publish/extract_burnin.py +++ b/openpype/plugins/publish/extract_burnin.py @@ -44,6 +44,7 @@ class ExtractBurnin(openpype.api.Extractor): "harmony", "fusion", "aftereffects", + "tvpaint" # "resolve" ] optional = True diff --git a/openpype/plugins/publish/extract_review.py b/openpype/plugins/publish/extract_review.py index 892b8c86bf..47abced457 100644 --- a/openpype/plugins/publish/extract_review.py +++ b/openpype/plugins/publish/extract_review.py @@ -3,6 +3,9 @@ import re import copy import json +from abc import ABCMeta, abstractmethod +import six + import clique import pyblish.api @@ -873,12 +876,6 @@ class ExtractReview(pyblish.api.InstancePlugin): """ filters = [] - letter_box_def = output_def["letter_box"] - letter_box_enabled = letter_box_def["enabled"] - - # Get instance data - pixel_aspect = temp_data["pixel_aspect"] - # NOTE Skipped using instance's resolution full_input_path_single_file = temp_data["full_input_path_single_file"] input_data = ffprobe_streams( @@ -887,6 +884,33 @@ class ExtractReview(pyblish.api.InstancePlugin): input_width = int(input_data["width"]) input_height = int(input_data["height"]) + # NOTE Setting only one of `width` or `heigth` is not allowed + # - settings value can't have None but has value of 0 + output_width = output_def.get("width") or None + output_height = output_def.get("height") or None + + # Convert overscan value video filters + overscan_crop = output_def.get("overscan_crop") + overscan = OverscanCrop(input_width, input_height, overscan_crop) + overscan_crop_filters = overscan.video_filters() + # Add overscan filters to filters if are any and modify input + # resolution by it's values + if overscan_crop_filters: + filters.extend(overscan_crop_filters) + input_width = overscan.width() + input_height = overscan.height() + # Use output resolution as inputs after cropping to skip usage of + # instance data resolution + if output_width is None or output_height is None: + output_width = input_width + output_height = input_height + + letter_box_def = output_def["letter_box"] + letter_box_enabled = letter_box_def["enabled"] + + # Get instance data + pixel_aspect = temp_data["pixel_aspect"] + # Make sure input width and height is not an odd number input_width_is_odd = bool(input_width % 2 != 0) input_height_is_odd = bool(input_height % 2 != 0) @@ -911,10 +935,6 @@ class ExtractReview(pyblish.api.InstancePlugin): self.log.debug("input_width: `{}`".format(input_width)) self.log.debug("input_height: `{}`".format(input_height)) - # NOTE Setting only one of `width` or `heigth` is not allowed - # - settings value can't have None but has value of 0 - output_width = output_def.get("width") or None - output_height = output_def.get("height") or None # Use instance resolution if output definition has not set it. if output_width is None or output_height is None: output_width = temp_data["resolution_width"] @@ -1438,3 +1458,291 @@ class ExtractReview(pyblish.api.InstancePlugin): vf_back = "-vf " + ",".join(vf_fixed) return vf_back + + +@six.add_metaclass(ABCMeta) +class _OverscanValue: + def __repr__(self): + return "<{}> {}".format(self.__class__.__name__, str(self)) + + @abstractmethod + def copy(self): + """Create a copy of object.""" + pass + + @abstractmethod + def size_for(self, value): + """Calculate new value for passed value.""" + pass + + +class PixValueExplicit(_OverscanValue): + def __init__(self, value): + self._value = int(value) + + def __str__(self): + return "{}px".format(self._value) + + def copy(self): + return PixValueExplicit(self._value) + + def size_for(self, value): + if self._value == 0: + return value + return self._value + + +class PercentValueExplicit(_OverscanValue): + def __init__(self, value): + self._value = float(value) + + def __str__(self): + return "{}%".format(abs(self._value)) + + def copy(self): + return PercentValueExplicit(self._value) + + def size_for(self, value): + if self._value == 0: + return value + return int((value / 100) * self._value) + + +class PixValueRelative(_OverscanValue): + def __init__(self, value): + self._value = int(value) + + def __str__(self): + sign = "-" if self._value < 0 else "+" + return "{}{}px".format(sign, abs(self._value)) + + def copy(self): + return PixValueRelative(self._value) + + def size_for(self, value): + return value + self._value + + +class PercentValueRelative(_OverscanValue): + def __init__(self, value): + self._value = float(value) + + def __str__(self): + return "{}%".format(self._value) + + def copy(self): + return PercentValueRelative(self._value) + + def size_for(self, value): + if self._value == 0: + return value + + offset = int((value / 100) * self._value) + + return value + offset + + +class PercentValueRelativeSource(_OverscanValue): + def __init__(self, value, source_sign): + self._value = float(value) + if source_sign not in ("-", "+"): + raise ValueError( + "Invalid sign value \"{}\" expected \"-\" or \"+\"".format( + source_sign + ) + ) + self._source_sign = source_sign + + def __str__(self): + return "{}%{}".format(self._value, self._source_sign) + + def copy(self): + return PercentValueRelativeSource(self._value, self._source_sign) + + def size_for(self, value): + if self._value == 0: + return value + return int((value * 100) / (100 - self._value)) + + +class OverscanCrop: + """Helper class to read overscan string and calculate output resolution. + + It is possible to enter single value for both width and heigh or two values + for width and height. Overscan string may have a few variants. Each variant + define output size for input size. + + ### Example + For input size: 2200px + + | String | Output | Description | + |----------|--------|-------------------------------------------------| + | "" | 2200px | Empty string does nothing. | + | "10%" | 220px | Explicit percent size. | + | "-10%" | 1980px | Relative percent size (decrease). | + | "+10%" | 2420px | Relative percent size (increase). | + | "-10%+" | 2000px | Relative percent size to output size. | + | "300px" | 300px | Explicit output size cropped or expanded. | + | "-300px" | 1900px | Relative pixel size (decrease). | + | "+300px" | 2500px | Relative pixel size (increase). | + | "300" | 300px | Value without "%" and "px" is used as has "px". | + + Value without sign (+/-) in is always explicit and value with sign is + relative. Output size for "200px" and "+200px" are not the same. + Values "0", "0px" or "0%" are ignored. + + All values that cause output resolution smaller than 1 pixel are invalid. + + Value "-10%+" is a special case which says that input's resolution is + bigger by 10% than expected output. + + It is possible to combine these variants to define different output for + width and height. + + Resolution: 2000px 1000px + + | String | Output | + |---------------|---------------| + | "100px 120px" | 2100px 1120px | + | "-10% -200px" | 1800px 800px | + """ + + item_regex = re.compile(r"([\+\-])?([0-9]+)(.+)?") + relative_source_regex = re.compile(r"%([\+\-])") + + def __init__(self, input_width, input_height, string_value): + # Make sure that is not None + string_value = string_value or "" + + self.input_width = input_width + self.input_height = input_height + + width, height = self._convert_string_to_values(string_value) + self._width_value = width + self._height_value = height + + self._string_value = string_value + + def __str__(self): + return "{}".format(self._string_value) + + def __repr__(self): + return "<{}>".format(self.__class__.__name__) + + def width(self): + """Calculated width.""" + return self._width_value.size_for(self.input_width) + + def height(self): + """Calculated height.""" + return self._height_value.size_for(self.input_height) + + def video_filters(self): + """FFmpeg video filters to achieve expected result. + + Filter may be empty, use "crop" filter, "pad" filter or combination of + "crop" and "pad". + + Returns: + list: FFmpeg video filters. + """ + # crop=width:height:x:y - explicit start x, y position + # crop=width:height - x, y are related to center by width/height + # pad=width:heigth:x:y - explicit start x, y position + # pad=width:heigth - x, y are set to 0 by default + + width = self.width() + height = self.height() + + output = [] + if self.input_width == width and self.input_height == height: + return output + + # Make sure resolution has odd numbers + if width % 2 == 1: + width -= 1 + + if height % 2 == 1: + height -= 1 + + if width <= self.input_width and height <= self.input_height: + output.append("crop={}:{}".format(width, height)) + + elif width >= self.input_width and height >= self.input_height: + output.append( + "pad={}:{}:(iw-ow)/2:(ih-oh)/2".format(width, height) + ) + + elif width > self.input_width and height < self.input_height: + output.append("crop=iw:{}".format(height)) + output.append("pad={}:ih:(iw-ow)/2:(ih-oh)/2".format(width)) + + elif width < self.input_width and height > self.input_height: + output.append("crop={}:ih".format(width)) + output.append("pad=iw:{}:(iw-ow)/2:(ih-oh)/2".format(height)) + + return output + + def _convert_string_to_values(self, orig_string_value): + string_value = orig_string_value.strip().lower() + if not string_value: + return + + # Replace "px" (and spaces before) with single space + string_value = re.sub(r"([ ]+)?px", " ", string_value) + string_value = re.sub(r"([ ]+)%", "%", string_value) + # Make sure +/- sign at the beggining of string is next to number + string_value = re.sub(r"^([\+\-])[ ]+", "\g<1>", string_value) + # Make sure +/- sign in the middle has zero spaces before number under + # which belongs + string_value = re.sub( + r"[ ]([\+\-])[ ]+([0-9])", + r" \g<1>\g<2>", + string_value + ) + string_parts = [ + part + for part in string_value.split(" ") + if part + ] + + error_msg = "Invalid string for rescaling \"{}\"".format( + orig_string_value + ) + if 1 > len(string_parts) > 2: + raise ValueError(error_msg) + + output = [] + for item in string_parts: + groups = self.item_regex.findall(item) + if not groups: + raise ValueError(error_msg) + + relative_sign, value, ending = groups[0] + if not relative_sign: + if not ending: + output.append(PixValueExplicit(value)) + else: + output.append(PercentValueExplicit(value)) + else: + source_sign_group = self.relative_source_regex.findall(ending) + if not ending: + output.append(PixValueRelative(int(relative_sign + value))) + + elif source_sign_group: + source_sign = source_sign_group[0] + output.append(PercentValueRelativeSource( + float(relative_sign + value), source_sign + )) + else: + output.append( + PercentValueRelative(float(relative_sign + value)) + ) + + if len(output) == 1: + width = output.pop(0) + height = width.copy() + else: + width, height = output + + return width, height diff --git a/openpype/settings/defaults/project_settings/global.json b/openpype/settings/defaults/project_settings/global.json index e3934bd537..5f779fccfa 100644 --- a/openpype/settings/defaults/project_settings/global.json +++ b/openpype/settings/defaults/project_settings/global.json @@ -55,6 +55,7 @@ "ftrack" ] }, + "overscan_crop": "", "width": 0, "height": 0, "bg_color": [ diff --git a/openpype/settings/entities/__init__.py b/openpype/settings/entities/__init__.py index 33881a6097..f64ca1e98d 100644 --- a/openpype/settings/entities/__init__.py +++ b/openpype/settings/entities/__init__.py @@ -103,6 +103,7 @@ from .enum_entity import ( EnumEntity, AppsEnumEntity, ToolsEnumEntity, + TaskTypeEnumEntity, ProvidersEnum ) @@ -154,6 +155,7 @@ __all__ = ( "EnumEntity", "AppsEnumEntity", "ToolsEnumEntity", + "TaskTypeEnumEntity", "ProvidersEnum", "ListEntity", diff --git a/openpype/settings/entities/base_entity.py b/openpype/settings/entities/base_entity.py index 90efb73fbc..c6bff1ff47 100644 --- a/openpype/settings/entities/base_entity.py +++ b/openpype/settings/entities/base_entity.py @@ -111,6 +111,8 @@ class BaseItemEntity(BaseEntity): self.file_item = None # Reference to `RootEntity` self.root_item = None + # Change of value requires restart of OpenPype + self._require_restart_on_change = False # Entity is in hierarchy of dynamically created entity self.is_in_dynamic_item = False @@ -171,6 +173,14 @@ class BaseItemEntity(BaseEntity): roles = [roles] self.roles = roles + @property + def require_restart_on_change(self): + return self._require_restart_on_change + + @property + def require_restart(self): + return False + @property def has_studio_override(self): """Says if entity or it's children has studio overrides.""" @@ -261,6 +271,14 @@ class BaseItemEntity(BaseEntity): self, "Dynamic entity has set `is_group` to true." ) + if ( + self.require_restart_on_change + and (self.is_dynamic_item or self.is_in_dynamic_item) + ): + raise EntitySchemaError( + self, "Dynamic entity can't require restart." + ) + @abstractmethod def set_override_state(self, state): """Set override state and trigger it on children. @@ -788,6 +806,15 @@ class ItemEntity(BaseItemEntity): # Root item reference self.root_item = self.parent.root_item + # Item require restart on value change + require_restart_on_change = self.schema_data.get("require_restart") + if ( + require_restart_on_change is None + and not (self.is_dynamic_item or self.is_in_dynamic_item) + ): + require_restart_on_change = self.parent.require_restart_on_change + self._require_restart_on_change = require_restart_on_change + # File item reference if self.parent.is_file: self.file_item = self.parent diff --git a/openpype/settings/entities/dict_mutable_keys_entity.py b/openpype/settings/entities/dict_mutable_keys_entity.py index 907bf98784..4b221720c3 100644 --- a/openpype/settings/entities/dict_mutable_keys_entity.py +++ b/openpype/settings/entities/dict_mutable_keys_entity.py @@ -439,10 +439,10 @@ class DictMutableKeysEntity(EndpointEntity): new_initial_value = [] for key, value in _settings_value: if key in initial_value: - new_initial_value.append(key, initial_value.pop(key)) + new_initial_value.append([key, initial_value.pop(key)]) for key, value in initial_value.items(): - new_initial_value.append(key, value) + new_initial_value.append([key, value]) initial_value = new_initial_value else: initial_value = _settings_value diff --git a/openpype/settings/entities/enum_entity.py b/openpype/settings/entities/enum_entity.py index c6021b68de..ee909bc1a5 100644 --- a/openpype/settings/entities/enum_entity.py +++ b/openpype/settings/entities/enum_entity.py @@ -219,6 +219,40 @@ class ToolsEnumEntity(BaseEnumEntity): self._current_value = new_value +class TaskTypeEnumEntity(BaseEnumEntity): + schema_types = ["task-types-enum"] + + def _item_initalization(self): + self.multiselection = True + self.value_on_not_set = [] + self.enum_items = [] + self.valid_keys = set() + self.valid_value_types = (list, ) + self.placeholder = None + + def _get_enum_values(self): + from ..lib import get_default_anatomy_settings + anatomy_settings = get_default_anatomy_settings() + + valid_keys = set() + enum_items = [] + for task_type, _task_attr in anatomy_settings["tasks"].items(): + enum_items.append({task_type: task_type}) + valid_keys.add(task_type) + + return enum_items, valid_keys + + def set_override_state(self, *args, **kwargs): + super(TaskTypeEnumEntity, self).set_override_state(*args, **kwargs) + + self.enum_items, self.valid_keys = self._get_enum_values() + new_value = [] + for key in self._current_value: + if key in self.valid_keys: + new_value.append(key) + self._current_value = new_value + + class ProvidersEnum(BaseEnumEntity): schema_types = ["providers-enum"] diff --git a/openpype/settings/entities/input_entities.py b/openpype/settings/entities/input_entities.py index 409e6a66b4..295333eb60 100644 --- a/openpype/settings/entities/input_entities.py +++ b/openpype/settings/entities/input_entities.py @@ -68,8 +68,18 @@ class EndpointEntity(ItemEntity): def on_change(self): for callback in self.on_change_callbacks: callback() + + if self.require_restart_on_change: + if self.require_restart: + self.root_item.add_item_require_restart(self) + else: + self.root_item.remove_item_require_restart(self) self.parent.on_child_change(self) + @property + def require_restart(self): + return self.has_unsaved_changes + def update_default_value(self, value): value = self._check_update_value(value, "default") self._default_value = value @@ -115,6 +125,10 @@ class InputEntity(EndpointEntity): """Entity's value without metadata.""" return self._current_value + @property + def require_restart(self): + return self._value_is_modified + def _settings_value(self): return copy.deepcopy(self.value) diff --git a/openpype/settings/entities/root_entities.py b/openpype/settings/entities/root_entities.py index b89473d9fb..401d3980c9 100644 --- a/openpype/settings/entities/root_entities.py +++ b/openpype/settings/entities/root_entities.py @@ -55,6 +55,8 @@ class RootEntity(BaseItemEntity): def __init__(self, schema_data, reset): super(RootEntity, self).__init__(schema_data) + self._require_restart_callbacks = [] + self._item_ids_require_restart = set() self._item_initalization() if reset: self.reset() @@ -64,6 +66,31 @@ class RootEntity(BaseItemEntity): """Current OverrideState.""" return self._override_state + @property + def require_restart(self): + return bool(self._item_ids_require_restart) + + def add_require_restart_change_callback(self, callback): + self._require_restart_callbacks.append(callback) + + def _on_require_restart_change(self): + for callback in self._require_restart_callbacks: + callback() + + def add_item_require_restart(self, item): + was_empty = len(self._item_ids_require_restart) == 0 + self._item_ids_require_restart.add(item.id) + if was_empty: + self._on_require_restart_change() + + def remove_item_require_restart(self, item): + if item.id not in self._item_ids_require_restart: + return + + self._item_ids_require_restart.remove(item.id) + if not self._item_ids_require_restart: + self._on_require_restart_change() + @abstractmethod def reset(self): """Reset values and entities to initial state. diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_publish.json index 11b95862fa..0c89575d74 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_publish.json @@ -173,6 +173,15 @@ { "type": "separator" }, + { + "type": "label", + "label": "Crop input overscan. See the documentation for more information." + }, + { + "type": "text", + "key": "overscan_crop", + "label": "Overscan crop" + }, { "type": "label", "label": "Width and Height must be both set to higher value than 0 else source resolution is used." diff --git a/openpype/settings/entities/schemas/system_schema/module_settings/schema_ftrack.json b/openpype/settings/entities/schemas/system_schema/module_settings/schema_ftrack.json index 50ec330a11..5f659522c3 100644 --- a/openpype/settings/entities/schemas/system_schema/module_settings/schema_ftrack.json +++ b/openpype/settings/entities/schemas/system_schema/module_settings/schema_ftrack.json @@ -3,6 +3,7 @@ "key": "ftrack", "label": "Ftrack", "collapsible": true, + "require_restart": true, "checkbox_key": "enabled", "children": [ { diff --git a/openpype/settings/entities/schemas/system_schema/schema_general.json b/openpype/settings/entities/schemas/system_schema/schema_general.json index 568ccad5b9..fe5a8d8203 100644 --- a/openpype/settings/entities/schemas/system_schema/schema_general.json +++ b/openpype/settings/entities/schemas/system_schema/schema_general.json @@ -34,7 +34,8 @@ "key": "environment", "label": "Environment", "type": "raw-json", - "env_group_key": "global" + "env_group_key": "global", + "require_restart": true }, { "type": "splitter" @@ -44,7 +45,8 @@ "key": "openpype_path", "label": "Versions Repository", "multiplatform": true, - "multipath": true + "multipath": true, + "require_restart": true } ] } diff --git a/openpype/settings/entities/schemas/system_schema/schema_modules.json b/openpype/settings/entities/schemas/system_schema/schema_modules.json index b643293c87..16251b5f27 100644 --- a/openpype/settings/entities/schemas/system_schema/schema_modules.json +++ b/openpype/settings/entities/schemas/system_schema/schema_modules.json @@ -10,6 +10,7 @@ "key": "avalon", "label": "Avalon", "collapsible": true, + "require_restart": true, "children": [ { "type": "number", @@ -35,6 +36,7 @@ "key": "timers_manager", "label": "Timers Manager", "collapsible": true, + "require_restart": true, "checkbox_key": "enabled", "children": [ { @@ -66,6 +68,7 @@ "key": "clockify", "label": "Clockify", "collapsible": true, + "require_restart": true, "checkbox_key": "enabled", "children": [ { @@ -84,6 +87,7 @@ "key": "sync_server", "label": "Site Sync", "collapsible": true, + "require_restart": true, "checkbox_key": "enabled", "children": [ { @@ -114,6 +118,7 @@ "type": "dict", "key": "deadline", "label": "Deadline", + "require_restart": true, "collapsible": true, "checkbox_key": "enabled", "children": [ @@ -133,6 +138,7 @@ "type": "dict", "key": "muster", "label": "Muster", + "require_restart": true, "collapsible": true, "checkbox_key": "enabled", "children": [ diff --git a/openpype/tools/settings/settings/categories.py b/openpype/tools/settings/settings/categories.py index 01d4babd0f..b072a7f337 100644 --- a/openpype/tools/settings/settings/categories.py +++ b/openpype/tools/settings/settings/categories.py @@ -73,6 +73,7 @@ class IgnoreInputChangesObj: class SettingsCategoryWidget(QtWidgets.QWidget): state_changed = QtCore.Signal() saved = QtCore.Signal(QtWidgets.QWidget) + restart_required_trigger = QtCore.Signal() def __init__(self, user_role, parent=None): super(SettingsCategoryWidget, self).__init__(parent) @@ -185,9 +186,10 @@ class SettingsCategoryWidget(QtWidgets.QWidget): if self.user_role == "developer": self._add_developer_ui(footer_layout) - save_btn = QtWidgets.QPushButton("Save") - spacer_widget = QtWidgets.QWidget() - footer_layout.addWidget(spacer_widget, 1) + save_btn = QtWidgets.QPushButton("Save", footer_widget) + require_restart_label = QtWidgets.QLabel(footer_widget) + require_restart_label.setAlignment(QtCore.Qt.AlignCenter) + footer_layout.addWidget(require_restart_label, 1) footer_layout.addWidget(save_btn, 0) configurations_layout = QtWidgets.QVBoxLayout(configurations_widget) @@ -205,6 +207,7 @@ class SettingsCategoryWidget(QtWidgets.QWidget): save_btn.clicked.connect(self._save) self.save_btn = save_btn + self.require_restart_label = require_restart_label self.scroll_widget = scroll_widget self.content_layout = content_layout self.content_widget = content_widget @@ -323,6 +326,15 @@ class SettingsCategoryWidget(QtWidgets.QWidget): def _on_reset_start(self): return + def _on_require_restart_change(self): + value = "" + if self.entity.require_restart: + value = ( + "Your changes require restart of" + " all running OpenPype processes to take affect." + ) + self.require_restart_label.setText(value) + def reset(self): self.set_state(CategoryState.Working) @@ -339,6 +351,9 @@ class SettingsCategoryWidget(QtWidgets.QWidget): dialog = None try: self._create_root_entity() + self.entity.add_require_restart_change_callback( + self._on_require_restart_change + ) self.add_children_gui() @@ -433,6 +448,15 @@ class SettingsCategoryWidget(QtWidgets.QWidget): return def _save(self): + # Don't trigger restart if defaults are modified + if ( + self.modify_defaults_checkbox + and self.modify_defaults_checkbox.isChecked() + ): + require_restart = False + else: + require_restart = self.entity.require_restart + self.set_state(CategoryState.Working) if self.items_are_valid(): @@ -442,6 +466,10 @@ class SettingsCategoryWidget(QtWidgets.QWidget): self.saved.emit(self) + if require_restart: + self.restart_required_trigger.emit() + self.require_restart_label.setText("") + def _on_refresh(self): self.reset() diff --git a/openpype/tools/settings/settings/widgets.py b/openpype/tools/settings/settings/widgets.py index 249b4e305d..b20ce5ed66 100644 --- a/openpype/tools/settings/settings/widgets.py +++ b/openpype/tools/settings/settings/widgets.py @@ -275,8 +275,6 @@ class UnsavedChangesDialog(QtWidgets.QDialog): layout.addWidget(message_label) layout.addWidget(btns_widget) - self.state = None - def on_cancel_pressed(self): self.done(0) @@ -287,6 +285,48 @@ class UnsavedChangesDialog(QtWidgets.QDialog): self.done(2) +class RestartDialog(QtWidgets.QDialog): + message = ( + "Your changes require restart of process to take effect." + " Do you want to restart now?" + ) + + def __init__(self, parent=None): + super(RestartDialog, self).__init__(parent) + message_label = QtWidgets.QLabel(self.message) + + btns_widget = QtWidgets.QWidget(self) + btns_layout = QtWidgets.QHBoxLayout(btns_widget) + + btn_restart = QtWidgets.QPushButton("Restart") + btn_restart.clicked.connect(self.on_restart_pressed) + btn_cancel = QtWidgets.QPushButton("Cancel") + btn_cancel.clicked.connect(self.on_cancel_pressed) + + btns_layout.addStretch(1) + btns_layout.addWidget(btn_restart) + btns_layout.addWidget(btn_cancel) + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(message_label) + layout.addWidget(btns_widget) + + self.btn_cancel = btn_cancel + self.btn_restart = btn_restart + + def showEvent(self, event): + super(RestartDialog, self).showEvent(event) + btns_width = max(self.btn_cancel.width(), self.btn_restart.width()) + self.btn_cancel.setFixedWidth(btns_width) + self.btn_restart.setFixedWidth(btns_width) + + def on_cancel_pressed(self): + self.done(0) + + def on_restart_pressed(self): + self.done(1) + + class SpacerWidget(QtWidgets.QWidget): def __init__(self, parent=None): super(SpacerWidget, self).__init__(parent) diff --git a/openpype/tools/settings/settings/window.py b/openpype/tools/settings/settings/window.py index 9b368588c3..7a6536fd78 100644 --- a/openpype/tools/settings/settings/window.py +++ b/openpype/tools/settings/settings/window.py @@ -4,7 +4,7 @@ from .categories import ( SystemWidget, ProjectWidget ) -from .widgets import ShadowWidget +from .widgets import ShadowWidget, RestartDialog from . import style from openpype.tools.settings import ( @@ -14,6 +14,8 @@ from openpype.tools.settings import ( class MainWidget(QtWidgets.QWidget): + trigger_restart = QtCore.Signal() + widget_width = 1000 widget_height = 600 @@ -60,6 +62,9 @@ class MainWidget(QtWidgets.QWidget): for tab_widget in tab_widgets: tab_widget.saved.connect(self._on_tab_save) tab_widget.state_changed.connect(self._on_state_change) + tab_widget.restart_required_trigger.connect( + self._on_restart_required + ) self.tab_widgets = tab_widgets @@ -132,3 +137,15 @@ class MainWidget(QtWidgets.QWidget): for tab_widget in self.tab_widgets: tab_widget.reset() + + def _on_restart_required(self): + # Don't show dialog if there are not registered slots for + # `trigger_restart` signal. + # - For example when settings are runnin as standalone tool + if self.receivers(self.trigger_restart) < 1: + return + + dialog = RestartDialog(self) + result = dialog.exec_() + if result == 1: + self.trigger_restart.emit() diff --git a/openpype/tools/tray/pype_tray.py b/openpype/tools/tray/pype_tray.py index 534c99bd90..fa16dbf855 100644 --- a/openpype/tools/tray/pype_tray.py +++ b/openpype/tools/tray/pype_tray.py @@ -1,10 +1,13 @@ import os import sys +import atexit +import subprocess import platform from avalon import style from Qt import QtCore, QtGui, QtWidgets from openpype.api import Logger, resources +from openpype.lib import get_pype_execute_args from openpype.modules import TrayModulesManager, ITrayService from openpype.settings.lib import get_system_settings import openpype.version @@ -92,6 +95,34 @@ class TrayManager: self.tray_widget.menu.addAction(version_action) self.tray_widget.menu.addSeparator() + def restart(self): + """Restart Tray tool. + + First creates new process with same argument and close current tray. + """ + args = get_pype_execute_args() + # Create a copy of sys.argv + additional_args = list(sys.argv) + # Check last argument from `get_pype_execute_args` + # - when running from code it is the same as first from sys.argv + if args[-1] == additional_args[0]: + additional_args.pop(0) + args.extend(additional_args) + + kwargs = {} + if platform.system().lower() == "windows": + flags = ( + subprocess.CREATE_NEW_PROCESS_GROUP + | subprocess.DETACHED_PROCESS + ) + kwargs["creationflags"] = flags + + subprocess.Popen(args, **kwargs) + self.exit() + + def exit(self): + self.tray_widget.exit() + def on_exit(self): self.modules_manager.on_exit() @@ -116,6 +147,8 @@ class SystemTrayIcon(QtWidgets.QSystemTrayIcon): super(SystemTrayIcon, self).__init__(icon, parent) + self._exited = False + # Store parent - QtWidgets.QMainWindow() self.parent = parent @@ -134,6 +167,8 @@ class SystemTrayIcon(QtWidgets.QSystemTrayIcon): # Add menu to Context of SystemTrayIcon self.setContextMenu(self.menu) + atexit.register(self.exit) + def on_systray_activated(self, reason): # show contextMenu if left click if reason == QtWidgets.QSystemTrayIcon.Trigger: @@ -145,6 +180,10 @@ class SystemTrayIcon(QtWidgets.QSystemTrayIcon): - Icon won't stay in tray after exit. """ + if self._exited: + return + self._exited = True + self.hide() self.tray_man.on_exit() QtCore.QCoreApplication.exit() diff --git a/repos/avalon-core b/repos/avalon-core index 0d9a228fdb..cfd4191e36 160000 --- a/repos/avalon-core +++ b/repos/avalon-core @@ -1 +1 @@ -Subproject commit 0d9a228fdb2eb08fe6caa30f25fe2a34fead1a03 +Subproject commit cfd4191e364b47de7364096f45d9d9d9a901692a diff --git a/start.py b/start.py index 8ee9775ce8..7b7e6885e4 100644 --- a/start.py +++ b/start.py @@ -100,7 +100,6 @@ import traceback import subprocess import site from pathlib import Path -import platform # OPENPYPE_ROOT is variable pointing to build (or code) directory @@ -113,17 +112,6 @@ if not getattr(sys, 'frozen', False): else: OPENPYPE_ROOT = os.path.dirname(sys.executable) - # FIX #1469: Certificates from certifi are not available in some - # macos builds, so connection to ftrack/mongo will fail with - # unable to verify certificate issuer error. This will add certifi - # certificates so ssl can see them. - # WARNING: this can break stuff if custom certificates are used. In that - # case they need to be merged to certificate bundle and SSL_CERT_FILE - # should point to them. - if not os.getenv("SSL_CERT_FILE") and platform.system().lower() == "darwin": # noqa: E501 - ssl_cert_file = Path(OPENPYPE_ROOT) / "dependencies" / "certifi" / "cacert.pem" # noqa: E501 - os.environ["SSL_CERT_FILE"] = ssl_cert_file.as_posix() - # add dependencies folder to sys.pat for frozen code frozen_libs = os.path.normpath( os.path.join(OPENPYPE_ROOT, "dependencies") @@ -136,6 +124,41 @@ else: paths.append(frozen_libs) os.environ["PYTHONPATH"] = os.pathsep.join(paths) + +import blessed # noqa: E402 +import certifi # noqa: E402 + + +term = blessed.Terminal() + + +def _print(message: str): + if message.startswith("!!! "): + print("{}{}".format(term.orangered2("!!! "), message[4:])) + if message.startswith(">>> "): + print("{}{}".format(term.aquamarine3(">>> "), message[4:])) + if message.startswith("--- "): + print("{}{}".format(term.darkolivegreen3("--- "), message[4:])) + if message.startswith(" "): + print("{}{}".format(term.darkseagreen3(" "), message[4:])) + if message.startswith("*** "): + print("{}{}".format(term.gold("*** "), message[4:])) + if message.startswith(" - "): + print("{}{}".format(term.wheat(" - "), message[4:])) + if message.startswith(" . "): + print("{}{}".format(term.tan(" . "), message[4:])) + + +# if SSL_CERT_FILE is not set prior to OpenPype launch, we set it to point +# to certifi bundle to make sure we have reasonably new CA certificates. +if os.getenv("SSL_CERT_FILE") and \ + os.getenv("SSL_CERT_FILE") != certifi.where(): + _print("--- your system is set to use custom CA certificate bundle.") +else: + ssl_cert_file = certifi.where() + os.environ["SSL_CERT_FILE"] = ssl_cert_file + + import igniter # noqa: E402 from igniter import BootstrapRepos # noqa: E402 from igniter.tools import ( @@ -198,7 +221,7 @@ def run(arguments: list, env: dict = None) -> int: p = subprocess.Popen(interpreter, env=env) p.wait() - print(f">>> done [{p.returncode}]") + _print(f">>> done [{p.returncode}]") return p.returncode @@ -279,8 +302,8 @@ def _process_arguments() -> tuple: use_staging = False for arg in sys.argv: if arg == "--use-version": - print("!!! Please use option --use-version like:") - print(" --use-version=3.0.0") + _print("!!! Please use option --use-version like:") + _print(" --use-version=3.0.0") sys.exit(1) m = re.search( @@ -338,12 +361,12 @@ def _determine_mongodb() -> str: if openpype_mongo: result, msg = validate_mongo_connection(openpype_mongo) if not result: - print(msg) + _print(msg) openpype_mongo = None if not openpype_mongo: - print("*** No DB connection string specified.") - print("--- launching setup UI ...") + _print("*** No DB connection string specified.") + _print("--- launching setup UI ...") result = igniter.open_dialog() if result == 0: @@ -368,7 +391,7 @@ def _initialize_environment(openpype_version: OpenPypeVersion) -> None: version_path.as_posix() ) # inject version to Python environment (sys.path, ...) - print(">>> Injecting OpenPype version to running environment ...") + _print(">>> Injecting OpenPype version to running environment ...") bootstrap.add_paths_from_directory(version_path) # Additional sys paths related to OPENPYPE_REPOS_ROOT directory @@ -437,7 +460,7 @@ def _find_frozen_openpype(use_version: str = None, os.environ["OPENPYPE_TRYOUT"] = "1" openpype_versions = [] else: - print("!!! Warning: cannot determine current running version.") + _print("!!! Warning: cannot determine current running version.") if not os.getenv("OPENPYPE_TRYOUT"): try: @@ -445,8 +468,8 @@ def _find_frozen_openpype(use_version: str = None, openpype_version = openpype_versions[-1] except IndexError: # no OpenPype version found, run Igniter and ask for them. - print('*** No OpenPype versions found.') - print("--- launching setup UI ...") + _print('*** No OpenPype versions found.') + _print("--- launching setup UI ...") import igniter return_code = igniter.open_dialog() if return_code == 2: @@ -454,25 +477,25 @@ def _find_frozen_openpype(use_version: str = None, if return_code == 3: # run OpenPype after installation - print('>>> Finding OpenPype again ...') + _print('>>> Finding OpenPype again ...') openpype_versions = bootstrap.find_openpype( staging=use_staging) try: openpype_version = openpype_versions[-1] except IndexError: - print(("!!! Something is wrong and we didn't " + _print(("!!! Something is wrong and we didn't " "found it again.")) sys.exit(1) elif return_code != 2: - print(f" . finished ({return_code})") + _print(f" . finished ({return_code})") sys.exit(return_code) if not openpype_versions: # no openpype versions found anyway, lets use then the one # shipped with frozen OpenPype if not os.getenv("OPENPYPE_TRYOUT"): - print("*** Still no luck finding OpenPype.") - print(("*** We'll try to use the one coming " + _print("*** Still no luck finding OpenPype.") + _print(("*** We'll try to use the one coming " "with OpenPype installation.")) version_path = _bootstrap_from_code(use_version) openpype_version = OpenPypeVersion( @@ -493,13 +516,13 @@ def _find_frozen_openpype(use_version: str = None, if found: openpype_version = sorted(found)[-1] if not openpype_version: - print(f"!!! requested version {use_version} was not found.") + _print(f"!!! requested version {use_version} was not found.") if openpype_versions: - print(" - found: ") + _print(" - found: ") for v in sorted(openpype_versions): - print(f" - {v}: {v.path}") + _print(f" - {v}: {v.path}") - print(f" - local version {local_version}") + _print(f" - local version {local_version}") sys.exit(1) # test if latest detected is installed (in user data dir) @@ -518,11 +541,11 @@ def _find_frozen_openpype(use_version: str = None, openpype_version, force=True) if openpype_version.path.is_file(): - print(">>> Extracting zip file ...") + _print(">>> Extracting zip file ...") try: version_path = bootstrap.extract_openpype(openpype_version) except OSError as e: - print("!!! failed: {}".format(str(e))) + _print("!!! failed: {}".format(str(e))) sys.exit(1) else: # cleanup zip after extraction @@ -549,7 +572,7 @@ def _bootstrap_from_code(use_version): _openpype_root = OPENPYPE_ROOT if getattr(sys, 'frozen', False): local_version = bootstrap.get_version(Path(_openpype_root)) - print(f" - running version: {local_version}") + _print(f" - running version: {local_version}") assert local_version else: # get current version of OpenPype @@ -574,13 +597,13 @@ def _bootstrap_from_code(use_version): os.environ["OPENPYPE_REPOS_ROOT"] = (version_path / "openpype").as_posix() # noqa: E501 _openpype_root = version_to_use.path.as_posix() else: - print(f"!!! requested version {use_version} was not found.") + _print(f"!!! requested version {use_version} was not found.") if openpype_versions: - print(" - found: ") + _print(" - found: ") for v in sorted(openpype_versions): - print(f" - {v}: {v.path}") + _print(f" - {v}: {v.path}") - print(f" - local version {local_version}") + _print(f" - local version {local_version}") sys.exit(1) else: os.environ["OPENPYPE_VERSION"] = local_version @@ -663,7 +686,7 @@ def boot(): openpype_mongo = _determine_mongodb() except RuntimeError as e: # without mongodb url we are done for. - print(f"!!! {e}") + _print(f"!!! {e}") sys.exit(1) os.environ["OPENPYPE_MONGO"] = openpype_mongo @@ -673,7 +696,7 @@ def boot(): # find its versions there and bootstrap them. openpype_path = get_openpype_path_from_db(openpype_mongo) if not openpype_path: - print("*** Cannot get OpenPype path from database.") + _print("*** Cannot get OpenPype path from database.") if not os.getenv("OPENPYPE_PATH") and openpype_path: os.environ["OPENPYPE_PATH"] = openpype_path @@ -689,7 +712,7 @@ def boot(): version_path = _find_frozen_openpype(use_version, use_staging) except RuntimeError as e: # no version to run - print(f"!!! {e}") + _print(f"!!! {e}") sys.exit(1) else: version_path = _bootstrap_from_code(use_version) @@ -714,13 +737,13 @@ def boot(): except KeyError: pass - print(">>> loading environments ...") + _print(">>> loading environments ...") # Avalon environments must be set before avalon module is imported - print(" - for Avalon ...") + _print(" - for Avalon ...") set_avalon_environments() - print(" - global OpenPype ...") + _print(" - global OpenPype ...") set_openpype_global_environments() - print(" - for modules ...") + _print(" - for modules ...") set_modules_environments() from openpype import cli @@ -750,7 +773,7 @@ def boot(): cli.main(obj={}, prog_name="openpype") except Exception: # noqa exc_info = sys.exc_info() - print("!!! OpenPype crashed:") + _print("!!! OpenPype crashed:") traceback.print_exception(*exc_info) sys.exit(1) diff --git a/website/docs/artist_hosts_maya.md b/website/docs/artist_hosts_maya.md index fc94f20f02..6fbd59ae1e 100644 --- a/website/docs/artist_hosts_maya.md +++ b/website/docs/artist_hosts_maya.md @@ -531,7 +531,10 @@ OpenPype supports creating review video for almost any type of data you want to What we call review video is actually _playblast_ or _capture_ (depending on terminology you are familiar with) made from pre-defined camera in scene. This is very useful in cases where you want to add turntable preview of your model for example. But it can -be used to generate preview for animation, simulations, and so on. +be used to generate preview for animation, simulations, and so on. You can either +publish review as separate subset version, or you can attach generated video to subset you +are publishing - for example attach video of turntable rotation to published model as in +following example. ### Setting scene for review extraction @@ -570,10 +573,14 @@ on this set to control review video generation: * `Step` - number of steps * `Fps` - framerate +Next step is to move your model set to review set so it will be connected to each other. + This is my scene: ![Maya - Review model setup](assets/maya-model_review_setup.jpg) +You see that `modelMain` in under `reviewMain` with `reviewCamera`. + _* note that I had to fix UVs and normals on Stanford dragon model as it wouldn't pass model validators_ @@ -588,6 +595,8 @@ version. All parts of this process - like what burnins, what type of video file, settings for Maya playblast - can be customized by your TDs. For more information about customizing review process refer to [admin section](admin_presets_plugins). +If you don't move `modelMain` into `reviewMain`, review will be generated but it will +be published as separate entity. ## Working with Yeti in OpenPype diff --git a/website/docs/assets/maya-model_review_setup.jpg b/website/docs/assets/maya-model_review_setup.jpg index 6c43807596..16576894b1 100644 Binary files a/website/docs/assets/maya-model_review_setup.jpg and b/website/docs/assets/maya-model_review_setup.jpg differ diff --git a/website/docs/project_settings/assets/global_extract_review_letter_box.png b/website/docs/project_settings/assets/global_extract_review_letter_box.png index 7cd9ecbdd6..45c1942f24 100644 Binary files a/website/docs/project_settings/assets/global_extract_review_letter_box.png and b/website/docs/project_settings/assets/global_extract_review_letter_box.png differ diff --git a/website/docs/project_settings/assets/global_extract_review_letter_box_settings.png b/website/docs/project_settings/assets/global_extract_review_letter_box_settings.png index 9ad9c05f43..80e00702e6 100644 Binary files a/website/docs/project_settings/assets/global_extract_review_letter_box_settings.png and b/website/docs/project_settings/assets/global_extract_review_letter_box_settings.png differ diff --git a/website/docs/project_settings/assets/global_extract_review_output_defs.png b/website/docs/project_settings/assets/global_extract_review_output_defs.png index 0dc8329324..ce3c00ca40 100644 Binary files a/website/docs/project_settings/assets/global_extract_review_output_defs.png and b/website/docs/project_settings/assets/global_extract_review_output_defs.png differ diff --git a/website/docs/project_settings/settings_project_global.md b/website/docs/project_settings/settings_project_global.md index 22f0eb5cc2..5d23dd75e6 100644 --- a/website/docs/project_settings/settings_project_global.md +++ b/website/docs/project_settings/settings_project_global.md @@ -69,6 +69,49 @@ Profile may generate multiple outputs from a single input. Each output must defi - it is possible to rescale output to specified resolution and keep aspect ratio. - If value is set to 0, source resolution will be used. +- **`Overscan crop`** + - Crop input resolution before rescaling. + + - Value is text may have a few variants. Each variant define output size for input size. + + - All values that cause output resolution smaller than 1 pixel are invalid. + + - Value without sign (+/-) in is always explicit and value with sign is + relative. Output size for values "200px" and "+200px" are not the same "+200px" will add 200 pixels to source and "200px" will keep only 200px from source. Value of "0", "0px" or "0%" are automatically converted to "+0px" as 0px is invalid ouput. + + - Cropped value is related to center. It is better to avoid odd numbers if + possible. + + **Example outputs for input size: 2200px** + + | String | Output | Description | + |---|---|---| + | ` ` | 2200px | Empty string keep resolution unchanged. | + | `50%` | 1100px | Crop 25% of input width on left and right side. | + | `300px` | 300px | Keep 300px in center of input and crop rest on left adn right. | + | `300` | 300px | Values without units are used as pixels (`px`). | + | `+0px` | 2200px | Keep resolution unchanged. | + | `0px` | 2200px | Same as `+0px`. | + | `+300px` | 2500px | Add black pillars of 150px width on left and right side. | + | `-300px` | 1900px | Crop 150px on left and right side | + | `+10%` | 2420px | Add black pillars of 5% size of input on left and right side. | + | `-10%` | 1980px | Crop 5% of input size by on left and right side. | + | `-10%+` | 2000px | Input width is 110% of output width. | + + **Value "-10%+" is a special case which says that input's resolution is + bigger by 10% than expected output.** + + - It is possible to enter single value for both width and height or + combination of two variants for width and height separated with space. + + **Example for resolution: 2000px 1000px** + + | String | Output | + |---------------|---------------| + | "100px 120px" | 2100px 1120px | + | "-10% -200px" | 1800px 800px | + | "-10% -0px" | 1800px 1000px | + - **`Letter Box`** - **Enabled** - Enable letter boxes - **Ratio** - Ratio of letter boxes