Merge branch 'feature/1605-settings-task-types-enum-entity' into feature/895-add-option-to-define-paht-to-workfile-template

This commit is contained in:
Jakub Jezek 2021-05-28 17:04:26 +02:00
commit 8019196a5f
No known key found for this signature in database
GPG key ID: D8548FBF690B100A
29 changed files with 717 additions and 287 deletions

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

@ -44,6 +44,7 @@ class ExtractBurnin(openpype.api.Extractor):
"harmony",
"fusion",
"aftereffects",
"tvpaint"
# "resolve"
]
optional = True

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

@ -103,6 +103,7 @@ from .enum_entity import (
EnumEntity,
AppsEnumEntity,
ToolsEnumEntity,
TaskTypeEnumEntity,
ProvidersEnum
)
@ -154,6 +155,7 @@ __all__ = (
"EnumEntity",
"AppsEnumEntity",
"ToolsEnumEntity",
"TaskTypeEnumEntity",
"ProvidersEnum",
"ListEntity",

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

@ -219,6 +219,40 @@ class ToolsEnumEntity(BaseEnumEntity):
self._current_value = new_value
class TaskTypeEnumEntity(BaseEnumEntity):
schema_types = ["task-types-enum"]
def _item_initalization(self):
self.multiselection = True
self.value_on_not_set = []
self.enum_items = []
self.valid_keys = set()
self.valid_value_types = (list, )
self.placeholder = None
def _get_enum_values(self):
from ..lib import get_default_anatomy_settings
anatomy_settings = get_default_anatomy_settings()
valid_keys = set()
enum_items = []
for task_type, _task_attr in anatomy_settings["tasks"].items():
enum_items.append({task_type: task_type})
valid_keys.add(task_type)
return enum_items, valid_keys
def set_override_state(self, *args, **kwargs):
super(TaskTypeEnumEntity, self).set_override_state(*args, **kwargs)
self.enum_items, self.valid_keys = self._get_enum_values()
new_value = []
for key in self._current_value:
if key in self.valid_keys:
new_value.append(key)
self._current_value = new_value
class ProvidersEnum(BaseEnumEntity):
schema_types = ["providers-enum"]

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