diff --git a/igniter/bootstrap_repos.py b/igniter/bootstrap_repos.py index b44689ba89..7c4f8b4b69 100644 --- a/igniter/bootstrap_repos.py +++ b/igniter/bootstrap_repos.py @@ -203,6 +203,12 @@ class OpenPypeVersion(semver.VersionInfo): openpype_version.staging = True return openpype_version + def __hash__(self): + if self.path: + return hash(self.path) + else: + return hash(str(self)) + class BootstrapRepos: """Class for bootstrapping local OpenPype installation. @@ -650,6 +656,9 @@ class BootstrapRepos: v for v in openpype_versions if v.path.suffix != ".zip" ] + # remove duplicates + openpype_versions = list(set(openpype_versions)) + return openpype_versions def process_entered_location(self, location: str) -> Union[Path, None]: 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_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 d3b8c55d07..b7498209ca 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/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/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/openpype/version.py b/openpype/version.py index a88ae329d0..202cb9348e 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.0.0-rc.5" +__version__ = "3.0.0-rc.6" diff --git a/pyproject.toml b/pyproject.toml index f7eeafd04f..7ba869e50e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "OpenPype" -version = "3.0.0-rc.5" +version = "3.0.0-rc.6" description = "Open VFX and Animation pipeline with support." authors = ["OpenPype Team "] license = "MIT License" diff --git a/start.py b/start.py index 3481a8722d..7b7e6885e4 100644 --- a/start.py +++ b/start.py @@ -6,10 +6,11 @@ Bootstrapping process of OpenPype is as follows: `OPENPYPE_PATH` is checked for existence - either one from environment or from user settings. Precedence takes the one set by environment. -On this path we try to find OpenPype in directories version string in their names. -For example: `openpype-v3.0.1-foo` is valid name, or even `foo_3.0.2` - as long -as version can be determined from its name _AND_ file `openpype/openpype/version.py` -can be found inside, it is considered OpenPype installation. +On this path we try to find OpenPype in directories version string in their +names. For example: `openpype-v3.0.1-foo` is valid name, or +even `foo_3.0.2` - as long as version can be determined from its name +_AND_ file `openpype/openpype/version.py` can be found inside, it is +considered OpenPype installation. If no OpenPype repositories are found in `OPENPYPE_PATH` (user data dir) then **Igniter** (OpenPype setup tool) will launch its GUI. @@ -20,19 +21,19 @@ appdata dir in user home and extract it there. Version will be determined by version specified in OpenPype module. If OpenPype repository directories are found in default install location -(user data dir) or in `OPENPYPE_PATH`, it will get list of those dirs there and -use latest one or the one specified with optional `--use-version` command -line argument. If the one specified doesn't exist then latest available -version will be used. All repositories in that dir will be added +(user data dir) or in `OPENPYPE_PATH`, it will get list of those dirs +there and use latest one or the one specified with optional `--use-version` +command line argument. If the one specified doesn't exist then latest +available version will be used. All repositories in that dir will be added to `sys.path` and `PYTHONPATH`. -If OpenPype is live (not frozen) then current version of OpenPype module will be -used. All directories under `repos` will be added to `sys.path` and +If OpenPype is live (not frozen) then current version of OpenPype module +will be used. All directories under `repos` will be added to `sys.path` and `PYTHONPATH`. -OpenPype depends on connection to `MongoDB`_. You can specify MongoDB connection -string via `OPENPYPE_MONGO` set in environment or it can be set in user -settings or via **Igniter** GUI. +OpenPype depends on connection to `MongoDB`_. You can specify MongoDB +connection string via `OPENPYPE_MONGO` set in environment or it can be set +in user settings or via **Igniter** GUI. So, bootstrapping OpenPype looks like this:: @@ -305,7 +306,8 @@ def _process_arguments() -> tuple: _print(" --use-version=3.0.0") sys.exit(1) - m = re.search(r"--use-version=(?P\d+\.\d+\.\d*.+?)", arg) + m = re.search( + r"--use-version=(?P\d+\.\d+\.\d+(?:\S*)?)", arg) if m and m.group('version'): use_version = m.group('version') sys.argv.remove(arg) @@ -437,6 +439,7 @@ def _find_frozen_openpype(use_version: str = None, (if requested). """ + version_path = None openpype_version = None openpype_versions = bootstrap.find_openpype(include_zips=True, staging=use_staging) @@ -456,7 +459,6 @@ def _find_frozen_openpype(use_version: str = None, if local_version == openpype_versions[-1]: os.environ["OPENPYPE_TRYOUT"] = "1" openpype_versions = [] - else: _print("!!! Warning: cannot determine current running version.") @@ -503,17 +505,25 @@ def _find_frozen_openpype(use_version: str = None, return version_path # get path of version specified in `--use-version` - version_path = BootstrapRepos.get_version_path_from_list( - use_version, openpype_versions) + local_version = bootstrap.get_version(OPENPYPE_ROOT) + if use_version and use_version != local_version: + # force the one user has selected + openpype_version = None + openpype_versions = bootstrap.find_openpype(include_zips=True, + staging=use_staging) + v: OpenPypeVersion + found = [v for v in openpype_versions if str(v) == use_version] + if found: + openpype_version = sorted(found)[-1] + if not openpype_version: + _print(f"!!! requested version {use_version} was not found.") + if openpype_versions: + _print(" - found: ") + for v in sorted(openpype_versions): + _print(f" - {v}: {v.path}") - if not version_path: - if use_version is not None and openpype_version: - _print(("!!! Specified version was not found, using " - "latest available")) - # specified version was not found so use latest detected. - version_path = openpype_version.path - _print(f">>> Using version [ {openpype_version} ]") - _print(f" From {version_path}") + _print(f" - local version {local_version}") + sys.exit(1) # test if latest detected is installed (in user data dir) is_inside = False @@ -544,7 +554,7 @@ def _find_frozen_openpype(use_version: str = None, openpype_version.path = version_path _initialize_environment(openpype_version) - return version_path + return openpype_version.path def _bootstrap_from_code(use_version): @@ -559,36 +569,53 @@ def _bootstrap_from_code(use_version): """ # run through repos and add them to `sys.path` and `PYTHONPATH` # set root + _openpype_root = OPENPYPE_ROOT if getattr(sys, 'frozen', False): - local_version = bootstrap.get_version(Path(OPENPYPE_ROOT)) + local_version = bootstrap.get_version(Path(_openpype_root)) _print(f" - running version: {local_version}") assert local_version else: # get current version of OpenPype local_version = bootstrap.get_local_live_version() - os.environ["OPENPYPE_VERSION"] = local_version if use_version and use_version != local_version: + version_to_use = None openpype_versions = bootstrap.find_openpype(include_zips=True) - version_path = BootstrapRepos.get_version_path_from_list( - use_version, openpype_versions) - if version_path: - # use specified - bootstrap.add_paths_from_directory(version_path) - os.environ["OPENPYPE_VERSION"] = use_version - else: - version_path = OPENPYPE_ROOT + v: OpenPypeVersion + found = [v for v in openpype_versions if str(v) == use_version] + if found: + version_to_use = sorted(found)[-1] - repos = os.listdir(os.path.join(OPENPYPE_ROOT, "repos")) - repos = [os.path.join(OPENPYPE_ROOT, "repos", repo) for repo in repos] + if version_to_use: + # use specified + if version_to_use.path.is_file(): + version_to_use.path = bootstrap.extract_openpype( + version_to_use) + bootstrap.add_paths_from_directory(version_to_use.path) + os.environ["OPENPYPE_VERSION"] = use_version + version_path = version_to_use.path + 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.") + if openpype_versions: + _print(" - found: ") + for v in sorted(openpype_versions): + _print(f" - {v}: {v.path}") + + _print(f" - local version {local_version}") + sys.exit(1) + else: + os.environ["OPENPYPE_VERSION"] = local_version + version_path = Path(_openpype_root) + os.environ["OPENPYPE_REPOS_ROOT"] = _openpype_root + + repos = os.listdir(os.path.join(_openpype_root, "repos")) + repos = [os.path.join(_openpype_root, "repos", repo) for repo in repos] # add self to python paths - repos.insert(0, OPENPYPE_ROOT) + repos.insert(0, _openpype_root) for repo in repos: sys.path.insert(0, repo) - - # Set OPENPYPE_REPOS_ROOT to code root - os.environ["OPENPYPE_REPOS_ROOT"] = OPENPYPE_ROOT - # add venv 'site-packages' to PYTHONPATH python_path = os.getenv("PYTHONPATH", "") split_paths = python_path.split(os.pathsep) @@ -603,11 +630,11 @@ def _bootstrap_from_code(use_version): # point to same hierarchy from code and from frozen OpenPype additional_paths = [ # add OpenPype tools - os.path.join(OPENPYPE_ROOT, "openpype", "tools"), + os.path.join(_openpype_root, "openpype", "tools"), # add common OpenPype vendor # (common for multiple Python interpreter versions) os.path.join( - OPENPYPE_ROOT, + _openpype_root, "openpype", "vendor", "python", @@ -620,7 +647,7 @@ def _bootstrap_from_code(use_version): os.environ["PYTHONPATH"] = os.pathsep.join(split_paths) - return Path(version_path) + return version_path def boot(): @@ -647,6 +674,10 @@ def boot(): use_version, use_staging = _process_arguments() + if os.getenv("OPENPYPE_VERSION"): + use_staging = "staging" in os.getenv("OPENPYPE_VERSION") + use_version = os.getenv("OPENPYPE_VERSION") + # ------------------------------------------------------------------------ # Determine mongodb connection # ------------------------------------------------------------------------ diff --git a/tools/build.ps1 b/tools/build.ps1 index d9fef0f471..c8c2f392ad 100644 --- a/tools/build.ps1 +++ b/tools/build.ps1 @@ -175,9 +175,9 @@ if (-not (Test-Path -PathType Container -Path "$openpype_root\.poetry\bin")) { Write-Host ">>> " -NoNewline -ForegroundColor green Write-Host "Cleaning cache files ... " -NoNewline -Get-ChildItem $openpype_root -Filter "*.pyc" -Force -Recurse | Remove-Item -Force -Get-ChildItem $openpype_root -Filter "*.pyo" -Force -Recurse | Remove-Item -Force -Get-ChildItem $openpype_root -Filter "__pycache__" -Force -Recurse | Remove-Item -Force -Recurse +Get-ChildItem $openpype_root -Filter "*.pyc" -Force -Recurse | Where-Object { $_.FullName -inotmatch 'build' } | Remove-Item -Force +Get-ChildItem $openpype_root -Filter "*.pyo" -Force -Recurse | Where-Object { $_.FullName -inotmatch 'build' } | Remove-Item -Force +Get-ChildItem $openpype_root -Filter "__pycache__" -Force -Recurse | Where-Object { $_.FullName -inotmatch 'build' } | Remove-Item -Force -Recurse Write-Host "OK" -ForegroundColor green Write-Host ">>> " -NoNewline -ForegroundColor green diff --git a/tools/build.sh b/tools/build.sh index ccd97ea4c1..aa8f0121ea 100755 --- a/tools/build.sh +++ b/tools/build.sh @@ -122,7 +122,8 @@ clean_pyc () { local path path=$openpype_root echo -e "${BIGreen}>>>${RST} Cleaning pyc at [ ${BIWhite}$path${RST} ] ... \c" - find "$path" -regex '^.*\(__pycache__\|\.py[co]\)$' -delete + find "$path" -path ./build -prune -o -regex '^.*\(__pycache__\|\.py[co]\)$' -delete + echo -e "${BIGreen}DONE${RST}" } diff --git a/tools/create_env.ps1 b/tools/create_env.ps1 index 7ada92c1e8..94a91ce48f 100644 --- a/tools/create_env.ps1 +++ b/tools/create_env.ps1 @@ -76,16 +76,20 @@ print('{0}.{1}'.format(sys.version_info[0], sys.version_info[1])) Set-Location -Path $current_dir Exit-WithCode 1 } - # We are supporting python 3.6 and up - if(($matches[1] -lt 3) -or ($matches[2] -lt 7)) { + # We are supporting python 3.7 only + if (($matches[1] -lt 3) -or ($matches[2] -lt 7)) { Write-Host "FAILED Version [ $p ] is old and unsupported" -ForegroundColor red Set-Location -Path $current_dir Exit-WithCode 1 + } elseif (($matches[1] -eq 3) -and ($matches[2] -gt 7)) { + Write-Host "WARNING Version [ $p ] is unsupported, use at your own risk." -ForegroundColor yellow + Write-Host "*** " -NoNewline -ForegroundColor yellow + Write-Host "OpenPype supports only Python 3.7" -ForegroundColor white + } else { + Write-Host "OK [ $p ]" -ForegroundColor green } - Write-Host "OK [ $p ]" -ForegroundColor green } - $current_dir = Get-Location $script_dir = Split-Path -Path $MyInvocation.MyCommand.Definition -Parent $openpype_root = (Get-Item $script_dir).parent.FullName diff --git a/tools/create_env.sh b/tools/create_env.sh index d6a6828718..226a26e199 100755 --- a/tools/create_env.sh +++ b/tools/create_env.sh @@ -126,7 +126,7 @@ clean_pyc () { local path path=$openpype_root echo -e "${BIGreen}>>>${RST} Cleaning pyc at [ ${BIWhite}$path${RST} ] ... \c" - find "$path" -regex '^.*\(__pycache__\|\.py[co]\)$' -delete + find "$path" -path ./build -prune -o -regex '^.*\(__pycache__\|\.py[co]\)$' -delete echo -e "${BIGreen}DONE${RST}" } diff --git a/tools/create_zip.ps1 b/tools/create_zip.ps1 index a34af89159..1a7520eb11 100644 --- a/tools/create_zip.ps1 +++ b/tools/create_zip.ps1 @@ -98,9 +98,9 @@ if (-not (Test-Path -PathType Container -Path "$openpype_root\.poetry\bin")) { Write-Host ">>> " -NoNewline -ForegroundColor green Write-Host "Cleaning cache files ... " -NoNewline -Get-ChildItem $openpype_root -Filter "*.pyc" -Force -Recurse | Remove-Item -Force -Get-ChildItem $openpype_root -Filter "*.pyo" -Force -Recurse | Remove-Item -Force -Get-ChildItem $openpype_root -Filter "__pycache__" -Force -Recurse | Remove-Item -Force -Recurse +Get-ChildItem $openpype_root -Filter "*.pyc" -Force -Recurse | Where-Object { $_.FullName -inotmatch 'build' } | Remove-Item -Force +Get-ChildItem $openpype_root -Filter "*.pyo" -Force -Recurse | Where-Object { $_.FullName -inotmatch 'build' } | Remove-Item -Force +Get-ChildItem $openpype_root -Filter "__pycache__" -Force -Recurse| Where-Object { $_.FullName -inotmatch 'build' } | Remove-Item -Force -Recurse Write-Host "OK" -ForegroundColor green Write-Host ">>> " -NoNewline -ForegroundColor green diff --git a/tools/create_zip.sh b/tools/create_zip.sh index adaf9431a7..ec0276b040 100755 --- a/tools/create_zip.sh +++ b/tools/create_zip.sh @@ -89,23 +89,6 @@ detect_python () { fi } -############################################################################## -# Clean pyc files in specified directory -# Globals: -# None -# Arguments: -# Optional path to clean -# Returns: -# None -############################################################################### -clean_pyc () { - local path - path=$openpype_root - echo -e "${BIGreen}>>>${RST} Cleaning pyc at [ ${BIWhite}$path${RST} ] ... \c" - find "$path" -regex '^.*\(__pycache__\|\.py[co]\)$' -delete - echo -e "${BIGreen}DONE${RST}" -} - ############################################################################## # Return absolute path # Globals: diff --git a/tools/run_tests.ps1 b/tools/run_tests.ps1 index 30e1f29e59..a6882e2a09 100644 --- a/tools/run_tests.ps1 +++ b/tools/run_tests.ps1 @@ -94,8 +94,8 @@ if (-not (Test-Path -PathType Container -Path "$openpype_root\.poetry\bin")) { Write-Host ">>> " -NoNewline -ForegroundColor green Write-Host "Cleaning cache files ... " -NoNewline -Get-ChildItem $openpype_root -Filter "*.pyc" -Force -Recurse | Remove-Item -Force -Get-ChildItem $openpype_root -Filter "__pycache__" -Force -Recurse | Remove-Item -Force -Recurse +Get-ChildItem $openpype_root -Filter "*.pyc" -Force -Recurse | Where-Object { $_.FullName -inotmatch 'build' } | Remove-Item -Force +Get-ChildItem $openpype_root -Filter "__pycache__" -Force -Recurse | Where-Object { $_.FullName -inotmatch 'build' } | Remove-Item -Force -Recurse Write-Host "OK" -ForegroundColor green Write-Host ">>> " -NoNewline -ForegroundColor green diff --git a/tools/run_tests.sh b/tools/run_tests.sh index 3620ebc0e5..90977edc83 100755 --- a/tools/run_tests.sh +++ b/tools/run_tests.sh @@ -70,7 +70,7 @@ clean_pyc () { local path path=$openpype_root echo -e "${BIGreen}>>>${RST} Cleaning pyc at [ ${BIWhite}$path${RST} ] ... \c" - find "$path" -regex '^.*\(__pycache__\|\.py[co]\)$' -delete + find "$path" -path ./build -prune -o -regex '^.*\(__pycache__\|\.py[co]\)$' -delete echo -e "${BIGreen}DONE${RST}" } 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 4fee57d575..6a08c79582 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