Merge remote-tracking branch 'origin/develop' into bugfix/set-certifi-for-all-platforms

This commit is contained in:
Ondrej Samohel 2021-05-27 23:18:05 +02:00
parent f260cf4daf
commit d96ddefc3a
No known key found for this signature in database
GPG key ID: 8A29C663C672C2B7
35 changed files with 708 additions and 319 deletions

View file

@ -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]:

View file

@ -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

View file

@ -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()

View file

@ -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))

View file

@ -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.

View file

@ -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

View file

@ -55,6 +55,7 @@
"ftrack"
]
},
"overscan_crop": "",
"width": 0,
"height": 0,
"bg_color": [

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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.

View file

@ -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."

View file

@ -3,6 +3,7 @@
"key": "ftrack",
"label": "Ftrack",
"collapsible": true,
"require_restart": true,
"checkbox_key": "enabled",
"children": [
{

View file

@ -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
}
]
}

View file

@ -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": [

View file

@ -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()

View file

@ -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)

View file

@ -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()

View file

@ -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()

View file

@ -1,3 +1,3 @@
# -*- coding: utf-8 -*-
"""Package declaring Pype version."""
__version__ = "3.0.0-rc.5"
__version__ = "3.0.0-rc.6"

View file

@ -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 <info@openpype.io>"]
license = "MIT License"

123
start.py
View file

@ -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<version>\d+\.\d+\.\d*.+?)", arg)
m = re.search(
r"--use-version=(?P<version>\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
# ------------------------------------------------------------------------

View file

@ -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

View file

@ -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}"
}

View file

@ -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

View file

@ -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}"
}

View file

@ -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

View file

@ -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:

View file

@ -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

View file

@ -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}"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Before After
Before After

View file

@ -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