Merge branch 'develop' of github.com:ynput/OpenPype into bugfix/OP-3951_Deadline-checking-existing-frames-fails-when-there-is-number-in-file-name

This commit is contained in:
Petr Kalis 2023-03-24 11:22:39 +01:00
commit 9b2a01bc99
31 changed files with 659 additions and 133 deletions

23
.github/workflows/project_actions.yml vendored Normal file
View file

@ -0,0 +1,23 @@
name: project-actions
on:
pull_request:
types: [review_requested, closed]
pull_request_review:
types: [submitted]
jobs:
pr_review_requested:
name: pr_review_requested
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' && github.event.action == 'review_requested'
steps:
- name: Move PR to 'Change Requested'
uses: leonsteinhaeuser/project-beta-automations@v2.1.0
with:
gh_token: ${{ secrets.YNPUT_BOT_TOKEN }}
user: ${{ secrets.CI_USER }}
organization: ynput
project_id: 11
resource_node_id: ${{ github.event.pull_request.node_id }}
status_value: Change Requested

77
ARCHITECTURE.md Normal file
View file

@ -0,0 +1,77 @@
# Architecture
OpenPype is a monolithic Python project that bundles several parts, this document will try to give a birds eye overview of the project and, to a certain degree, each of the sub-projects.
The current file structure looks like this:
```
.
├── common - Code in this folder is backend portion of Addon distribution logic for v4 server.
├── docs - Documentation of the source code.
├── igniter - The OpenPype bootstrapper, deals with running version resolution and setting up the connection to the mongodb.
├── openpype - The actual OpenPype core package.
├── schema - Collection of JSON files describing schematics of objects. This follows Avalon's convention.
├── tests - Integration and unit tests.
├── tools - Conveninece scripts to perform common actions (in both bash and ps1).
├── vendor - When using the igniter, it deploys third party tools in here, such as ffmpeg.
└── website - Source files for https://openpype.io/ which is Docusaursus (https://docusaurus.io/).
```
The core functionality of the pipeline can be found in `igniter` and `openpype`, which in turn rely on the `schema` files, whenever you build (or download a pre-built) version of OpenPype, these two are bundled in there, and `Igniter` is the entry point.
## Igniter
It's the setup and update tool for OpenPype, unless you want to package `openpype` separately and deal with all the config manually, this will most likely be your entry point.
```
igniter/
├── bootstrap_repos.py - Module that will find or install OpenPype versions in the system.
├── __init__.py - Igniter entry point.
├── install_dialog.py- Show dialog for choosing central pype repository.
├── install_thread.py - Threading helpers for the install process.
├── __main__.py - Like `__init__.py` ?
├── message_dialog.py - Qt Dialog with a message and "Ok" button.
├── nice_progress_bar.py - Fancy Qt progress bar.
├── splash.txt - ASCII art for the terminal installer.
├── stylesheet.css - Installer Qt styles.
├── terminal_splash.py - Terminal installer animation, relies in `splash.txt`.
├── tools.py - Collection of methods that don't fit in other modules.
├── update_thread.py - Threading helper to update existing OpenPype installs.
├── update_window.py - Qt UI to update OpenPype installs.
├── user_settings.py - Interface for the OpenPype user settings.
└── version.py - Igniter's version number.
```
## OpenPype
This is the main package of the OpenPype logic, it could be loosely described as a combination of [Avalon](https://getavalon.github.io), [Pyblish](https://pyblish.com/) and glue around those with custom OpenPype only elements, things are in progress of being moved around to better prepare for V4, which will be released under a new name AYON.
```
openpype/
├── client - Interface for the MongoDB.
├── hooks - Hooks to be executed on certain OpenPype Applications defined in `openpype.lib.applications`.
├── host - Base class for the different hosts.
├── hosts - Integration with the different DCCs (hosts) using the `host` base class.
├── lib - Libraries that stitch together the package, some have been moved into other parts.
├── modules - OpenPype modules should contain separated logic of specific kind of implementation, such as Ftrack connection and its python API.
├── pipeline - Core of the OpenPype pipeline, handles creation of data, publishing, etc.
├── plugins - Global/core plugins for loader and publisher tool.
├── resources - Icons, fonts, etc.
├── scripts - Loose scipts that get run by tools/publishers.
├── settings - OpenPype settings interface.
├── style - Qt styling.
├── tests - Unit tests.
├── tools - Core tools, check out https://openpype.io/docs/artist_tools.
├── vendor - Vendoring of needed required Python packes.
├── widgets - Common re-usable Qt Widgets.
├── action.py - LEGACY: Lives now in `openpype.pipeline.publish.action` Pyblish actions.
├── cli.py - Command line interface, leverages `click`.
├── __init__.py - Sets two constants.
├── __main__.py - Entry point, calls the `cli.py`
├── plugin.py - Pyblish plugins.
├── pype_commands.py - Implementation of OpenPype commands.
└── version.py - Current version number.
```

View file

@ -7,7 +7,7 @@ class LaunchFoundryAppsWindows(PreLaunchHook):
Nuke is executed "like" python process so it is required to pass
`CREATE_NEW_CONSOLE` flag on windows to trigger creation of new console.
At the same time the newly created console won't create it's own stdout
At the same time the newly created console won't create its own stdout
and stderr handlers so they should not be redirected to DEVNULL.
"""
@ -18,7 +18,7 @@ class LaunchFoundryAppsWindows(PreLaunchHook):
def execute(self):
# Change `creationflags` to CREATE_NEW_CONSOLE
# - on Windows will nuke create new window using it's console
# - on Windows nuke will create new window using its console
# Set `stdout` and `stderr` to None so new created console does not
# have redirected output to DEVNULL in build
self.launch_context.kwargs.update({

View file

@ -84,11 +84,11 @@ class MainThreadItem:
self.kwargs = kwargs
def execute(self):
"""Execute callback and store it's result.
"""Execute callback and store its result.
Method must be called from main thread. Item is marked as `done`
when callback execution finished. Store output of callback of exception
information when callback raise one.
information when callback raises one.
"""
print("Executing process in main thread")
if self.done:

View file

@ -38,8 +38,9 @@ class CelactionPrelaunchHook(PreLaunchHook):
)
path_to_cli = os.path.join(CELACTION_SCRIPTS_DIR, "publish_cli.py")
subproces_args = get_openpype_execute_args("run", path_to_cli)
openpype_executable = subproces_args.pop(0)
subprocess_args = get_openpype_execute_args("run", path_to_cli)
openpype_executable = subprocess_args.pop(0)
workfile_settings = self.get_workfile_settings()
winreg.SetValueEx(
hKey,
@ -49,20 +50,34 @@ class CelactionPrelaunchHook(PreLaunchHook):
openpype_executable
)
parameters = subproces_args + [
"--currentFile", "*SCENE*",
"--chunk", "*CHUNK*",
"--frameStart", "*START*",
"--frameEnd", "*END*",
"--resolutionWidth", "*X*",
"--resolutionHeight", "*Y*"
# add required arguments for workfile path
parameters = subprocess_args + [
"--currentFile", "*SCENE*"
]
# Add custom parameters from workfile settings
if "render_chunk" in workfile_settings["submission_overrides"]:
parameters += [
"--chunk", "*CHUNK*"
]
if "resolution" in workfile_settings["submission_overrides"]:
parameters += [
"--resolutionWidth", "*X*",
"--resolutionHeight", "*Y*"
]
if "frame_range" in workfile_settings["submission_overrides"]:
parameters += [
"--frameStart", "*START*",
"--frameEnd", "*END*"
]
winreg.SetValueEx(
hKey, "SubmitParametersTitle", 0, winreg.REG_SZ,
subprocess.list2cmdline(parameters)
)
self.log.debug(f"__ parameters: \"{parameters}\"")
# setting resolution parameters
path_submit = "\\".join([
path_user_settings, "Dialogs", "SubmitOutput"
@ -135,3 +150,6 @@ class CelactionPrelaunchHook(PreLaunchHook):
self.log.info(f"Workfile to open: \"{workfile_path}\"")
return workfile_path
def get_workfile_settings(self):
return self.data["project_settings"]["celaction"]["workfile"]

View file

@ -39,7 +39,7 @@ class CollectCelactionCliKwargs(pyblish.api.Collector):
passing_kwargs[key] = value
if missing_kwargs:
raise RuntimeError("Missing arguments {}".format(
self.log.debug("Missing arguments {}".format(
", ".join(
[f'"{key}"' for key in missing_kwargs]
)

View file

@ -0,0 +1,19 @@
import pyblish.api
from openpype.lib import version_up
from pymxs import runtime as rt
class IncrementWorkfileVersion(pyblish.api.ContextPlugin):
"""Increment current workfile version."""
order = pyblish.api.IntegratorOrder + 0.9
label = "Increment Workfile Version"
hosts = ["max"]
families = ["workfile"]
def process(self, context):
path = context.data["currentFile"]
filepath = version_up(path)
rt.saveMaxFile(filepath)
self.log.info("Incrementing file version")

View file

@ -80,21 +80,7 @@ def get_all_asset_nodes():
Returns:
list: list of dictionaries
"""
host = registered_host()
nodes = []
for container in host.ls():
# We are not interested in looks but assets!
if container["loader"] == "LookLoader":
continue
# Gather all information
container_name = container["objectName"]
nodes += lib.get_container_members(container_name)
nodes = list(set(nodes))
return nodes
return cmds.ls(dag=True, noIntermediate=True, long=True)
def create_asset_id_hash(nodes):

View file

@ -66,11 +66,11 @@ class MainThreadItem:
return self._result
def execute(self):
"""Execute callback and store it's result.
"""Execute callback and store its result.
Method must be called from main thread. Item is marked as `done`
when callback execution finished. Store output of callback of exception
information when callback raise one.
information when callback raises one.
"""
log.debug("Executing process in main thread")
if self.done:

View file

@ -389,11 +389,11 @@ class MainThreadItem:
self.kwargs = kwargs
def execute(self):
"""Execute callback and store it's result.
"""Execute callback and store its result.
Method must be called from main thread. Item is marked as `done`
when callback execution finished. Store output of callback of exception
information when callback raise one.
information when callback raises one.
"""
log.debug("Executing process in main thread")
if self.done:

View file

@ -106,7 +106,7 @@ class CelactionSubmitDeadline(pyblish.api.InstancePlugin):
# define chunk and priority
chunk_size = instance.context.data.get("chunk")
if chunk_size == 0:
if not chunk_size:
chunk_size = self.deadline_chunk_size
# search for %02d pattern in name, and padding number

View file

@ -44,7 +44,7 @@ class AddonSettingsDef(JsonFilesSettingsDef):
class ExampleAddon(OpenPypeAddOn, IPluginPaths, ITrayAction):
"""This Addon has defined it's settings and interface.
"""This Addon has defined its settings and interface.
This example has system settings with an enabled option. And use
few other interfaces:

View file

@ -29,7 +29,7 @@ class CollectKitsuEntities(pyblish.api.ContextPlugin):
if not zou_asset_data:
raise ValueError("Zou asset data not found in OpenPype!")
task_name = instance.data.get("task")
task_name = instance.data.get("task", context.data.get("task"))
if not task_name:
continue

View file

@ -48,7 +48,10 @@ class IntegrateKitsuNote(pyblish.api.ContextPlugin):
def process(self, context):
for instance in context:
# Check if instance is a review by checking its family
if "review" not in instance.data["families"]:
# Allow a match to primary family or any of families
families = set([instance.data["family"]] +
instance.data.get("families", []))
if "review" not in families:
continue
kitsu_task = instance.data.get("kitsu_task")

View file

@ -12,17 +12,17 @@ class IntegrateKitsuReview(pyblish.api.InstancePlugin):
optional = True
def process(self, instance):
task = instance.data["kitsu_task"]["id"]
comment = instance.data["kitsu_comment"]["id"]
# Check comment has been created
if not comment:
comment_id = instance.data.get("kitsu_comment", {}).get("id")
if not comment_id:
self.log.debug(
"Comment not created, review not pushed to preview."
)
return
# Add review representations as preview of comment
task_id = instance.data["kitsu_task"]["id"]
for representation in instance.data.get("representations", []):
# Skip if not tagged as review
if "kitsureview" not in representation.get("tags", []):
@ -31,6 +31,6 @@ class IntegrateKitsuReview(pyblish.api.InstancePlugin):
self.log.debug("Found review at: {}".format(review_path))
gazu.task.add_preview(
task, comment, review_path, normalize_movie=True
task_id, comment_id, review_path, normalize_movie=True
)
self.log.info("Review upload on comment")

View file

@ -22,7 +22,7 @@ from openpype.lib.attribute_definitions import (
deserialize_attr_defs,
get_default_values,
)
from openpype.host import IPublishHost
from openpype.host import IPublishHost, IWorkfileHost
from openpype.pipeline import legacy_io
from openpype.pipeline.plugin_discover import DiscoverResult
@ -1374,6 +1374,7 @@ class CreateContext:
self._current_project_name = None
self._current_asset_name = None
self._current_task_name = None
self._current_workfile_path = None
self._host_is_valid = host_is_valid
# Currently unused variable
@ -1503,14 +1504,62 @@ class CreateContext:
return os.environ["AVALON_APP"]
def get_current_project_name(self):
"""Project name which was used as current context on context reset.
Returns:
Union[str, None]: Project name.
"""
return self._current_project_name
def get_current_asset_name(self):
"""Asset name which was used as current context on context reset.
Returns:
Union[str, None]: Asset name.
"""
return self._current_asset_name
def get_current_task_name(self):
"""Task name which was used as current context on context reset.
Returns:
Union[str, None]: Task name.
"""
return self._current_task_name
def get_current_workfile_path(self):
"""Workfile path which was opened on context reset.
Returns:
Union[str, None]: Workfile path.
"""
return self._current_workfile_path
@property
def context_has_changed(self):
"""Host context has changed.
As context is used project, asset, task name and workfile path if
host does support workfiles.
Returns:
bool: Context changed.
"""
project_name, asset_name, task_name, workfile_path = (
self._get_current_host_context()
)
return (
self._current_project_name != project_name
or self._current_asset_name != asset_name
or self._current_task_name != task_name
or self._current_workfile_path != workfile_path
)
project_name = property(get_current_project_name)
@property
@ -1575,6 +1624,28 @@ class CreateContext:
self._collection_shared_data = None
self.refresh_thumbnails()
def _get_current_host_context(self):
project_name = asset_name = task_name = workfile_path = None
if hasattr(self.host, "get_current_context"):
host_context = self.host.get_current_context()
if host_context:
project_name = host_context.get("project_name")
asset_name = host_context.get("asset_name")
task_name = host_context.get("task_name")
if isinstance(self.host, IWorkfileHost):
workfile_path = self.host.get_current_workfile()
# --- TODO remove these conditions ---
if not project_name:
project_name = legacy_io.Session.get("AVALON_PROJECT")
if not asset_name:
asset_name = legacy_io.Session.get("AVALON_ASSET")
if not task_name:
task_name = legacy_io.Session.get("AVALON_TASK")
# ---
return project_name, asset_name, task_name, workfile_path
def reset_current_context(self):
"""Refresh current context.
@ -1593,24 +1664,14 @@ class CreateContext:
are stored. We should store the workfile (if is available) too.
"""
project_name = asset_name = task_name = None
if hasattr(self.host, "get_current_context"):
host_context = self.host.get_current_context()
if host_context:
project_name = host_context.get("project_name")
asset_name = host_context.get("asset_name")
task_name = host_context.get("task_name")
if not project_name:
project_name = legacy_io.Session.get("AVALON_PROJECT")
if not asset_name:
asset_name = legacy_io.Session.get("AVALON_ASSET")
if not task_name:
task_name = legacy_io.Session.get("AVALON_TASK")
project_name, asset_name, task_name, workfile_path = (
self._get_current_host_context()
)
self._current_project_name = project_name
self._current_asset_name = asset_name
self._current_task_name = task_name
self._current_workfile_path = workfile_path
def reset_plugins(self, discover_publish_plugins=True):
"""Reload plugins.

View file

@ -9,6 +9,13 @@
"rules": {}
}
},
"workfile": {
"submission_overrides": [
"render_chunk",
"frame_range",
"resolution"
]
},
"publish": {
"CollectRenderPath": {
"output_extension": "png",

View file

@ -22,6 +22,31 @@
]
},
{
"type": "dict",
"collapsible": true,
"key": "workfile",
"label": "Workfile",
"children": [
{
"key": "submission_overrides",
"label": "Submission workfile overrides",
"type": "enum",
"multiselection": true,
"enum_items": [
{
"render_chunk": "Pass chunk size"
},
{
"frame_range": "Pass frame range"
},
{
"resolution": "Pass resolution"
}
]
}
]
},
{
"type": "dict",
"collapsible": true,

View file

@ -1,4 +1,4 @@
from qtpy import QtCore
from qtpy import QtCore, QtGui
# ID of context item in instance view
CONTEXT_ID = "context"
@ -26,6 +26,9 @@ GROUP_ROLE = QtCore.Qt.UserRole + 7
CONVERTER_IDENTIFIER_ROLE = QtCore.Qt.UserRole + 8
CREATOR_SORT_ROLE = QtCore.Qt.UserRole + 9
ResetKeySequence = QtGui.QKeySequence(
QtCore.Qt.ControlModifier | QtCore.Qt.Key_R
)
__all__ = (
"CONTEXT_ID",

View file

@ -6,7 +6,7 @@ import collections
import uuid
import tempfile
import shutil
from abc import ABCMeta, abstractmethod, abstractproperty
from abc import ABCMeta, abstractmethod
import six
import pyblish.api
@ -964,7 +964,8 @@ class AbstractPublisherController(object):
access objects directly but by using wrappers that can be serialized.
"""
@abstractproperty
@property
@abstractmethod
def log(self):
"""Controller's logger object.
@ -974,13 +975,15 @@ class AbstractPublisherController(object):
pass
@abstractproperty
@property
@abstractmethod
def event_system(self):
"""Inner event system for publisher controller."""
pass
@abstractproperty
@property
@abstractmethod
def project_name(self):
"""Current context project name.
@ -990,7 +993,8 @@ class AbstractPublisherController(object):
pass
@abstractproperty
@property
@abstractmethod
def current_asset_name(self):
"""Current context asset name.
@ -1000,7 +1004,8 @@ class AbstractPublisherController(object):
pass
@abstractproperty
@property
@abstractmethod
def current_task_name(self):
"""Current context task name.
@ -1010,7 +1015,21 @@ class AbstractPublisherController(object):
pass
@abstractproperty
@property
@abstractmethod
def host_context_has_changed(self):
"""Host context changed after last reset.
'CreateContext' has this option available using 'context_has_changed'.
Returns:
bool: Context has changed.
"""
pass
@property
@abstractmethod
def host_is_valid(self):
"""Host is valid for creation part.
@ -1023,7 +1042,8 @@ class AbstractPublisherController(object):
pass
@abstractproperty
@property
@abstractmethod
def instances(self):
"""Collected/created instances.
@ -1134,7 +1154,13 @@ class AbstractPublisherController(object):
@abstractmethod
def save_changes(self):
"""Save changes in create context."""
"""Save changes in create context.
Save can crash because of unexpected errors.
Returns:
bool: Save was successful.
"""
pass
@ -1145,7 +1171,19 @@ class AbstractPublisherController(object):
pass
@abstractproperty
@property
@abstractmethod
def publish_has_started(self):
"""Has publishing finished.
Returns:
bool: If publishing finished and all plugins were iterated.
"""
pass
@property
@abstractmethod
def publish_has_finished(self):
"""Has publishing finished.
@ -1155,7 +1193,8 @@ class AbstractPublisherController(object):
pass
@abstractproperty
@property
@abstractmethod
def publish_is_running(self):
"""Publishing is running right now.
@ -1165,7 +1204,8 @@ class AbstractPublisherController(object):
pass
@abstractproperty
@property
@abstractmethod
def publish_has_validated(self):
"""Publish validation passed.
@ -1175,7 +1215,8 @@ class AbstractPublisherController(object):
pass
@abstractproperty
@property
@abstractmethod
def publish_has_crashed(self):
"""Publishing crashed for any reason.
@ -1185,7 +1226,8 @@ class AbstractPublisherController(object):
pass
@abstractproperty
@property
@abstractmethod
def publish_has_validation_errors(self):
"""During validation happened at least one validation error.
@ -1195,7 +1237,8 @@ class AbstractPublisherController(object):
pass
@abstractproperty
@property
@abstractmethod
def publish_max_progress(self):
"""Get maximum possible progress number.
@ -1205,7 +1248,8 @@ class AbstractPublisherController(object):
pass
@abstractproperty
@property
@abstractmethod
def publish_progress(self):
"""Current progress number.
@ -1215,7 +1259,8 @@ class AbstractPublisherController(object):
pass
@abstractproperty
@property
@abstractmethod
def publish_error_msg(self):
"""Current error message which cause fail of publishing.
@ -1267,7 +1312,8 @@ class AbstractPublisherController(object):
pass
@abstractproperty
@property
@abstractmethod
def convertor_items(self):
pass
@ -1356,6 +1402,7 @@ class BasePublisherController(AbstractPublisherController):
self._publish_has_validation_errors = False
self._publish_has_crashed = False
# All publish plugins are processed
self._publish_has_started = False
self._publish_has_finished = False
self._publish_max_progress = 0
self._publish_progress = 0
@ -1386,7 +1433,8 @@ class BasePublisherController(AbstractPublisherController):
"show.card.message" - Show card message request (UI related).
"instances.refresh.finished" - Instances are refreshed.
"plugins.refresh.finished" - Plugins refreshed.
"publish.reset.finished" - Publish context reset finished.
"publish.reset.finished" - Reset finished.
"controller.reset.started" - Controller reset started.
"controller.reset.finished" - Controller reset finished.
"publish.process.started" - Publishing started. Can be started from
paused state.
@ -1425,7 +1473,16 @@ class BasePublisherController(AbstractPublisherController):
def _set_host_is_valid(self, value):
if self._host_is_valid != value:
self._host_is_valid = value
self._emit_event("publish.host_is_valid.changed", {"value": value})
self._emit_event(
"publish.host_is_valid.changed", {"value": value}
)
def _get_publish_has_started(self):
return self._publish_has_started
def _set_publish_has_started(self, value):
if value != self._publish_has_started:
self._publish_has_started = value
def _get_publish_has_finished(self):
return self._publish_has_finished
@ -1449,7 +1506,9 @@ class BasePublisherController(AbstractPublisherController):
def _set_publish_has_validated(self, value):
if self._publish_has_validated != value:
self._publish_has_validated = value
self._emit_event("publish.has_validated.changed", {"value": value})
self._emit_event(
"publish.has_validated.changed", {"value": value}
)
def _get_publish_has_crashed(self):
return self._publish_has_crashed
@ -1497,6 +1556,9 @@ class BasePublisherController(AbstractPublisherController):
host_is_valid = property(
_get_host_is_valid, _set_host_is_valid
)
publish_has_started = property(
_get_publish_has_started, _set_publish_has_started
)
publish_has_finished = property(
_get_publish_has_finished, _set_publish_has_finished
)
@ -1526,6 +1588,7 @@ class BasePublisherController(AbstractPublisherController):
"""Reset most of attributes that can be reset."""
self.publish_is_running = False
self.publish_has_started = False
self.publish_has_validated = False
self.publish_has_crashed = False
self.publish_has_validation_errors = False
@ -1645,10 +1708,7 @@ class PublisherController(BasePublisherController):
str: Project name.
"""
if not hasattr(self._host, "get_current_context"):
return legacy_io.active_project()
return self._host.get_current_context()["project_name"]
return self._create_context.get_current_project_name()
@property
def current_asset_name(self):
@ -1658,10 +1718,7 @@ class PublisherController(BasePublisherController):
Union[str, None]: Asset name or None if asset is not set.
"""
if not hasattr(self._host, "get_current_context"):
return legacy_io.Session["AVALON_ASSET"]
return self._host.get_current_context()["asset_name"]
return self._create_context.get_current_asset_name()
@property
def current_task_name(self):
@ -1671,10 +1728,11 @@ class PublisherController(BasePublisherController):
Union[str, None]: Task name or None if task is not set.
"""
if not hasattr(self._host, "get_current_context"):
return legacy_io.Session["AVALON_TASK"]
return self._create_context.get_current_task_name()
return self._host.get_current_context()["task_name"]
@property
def host_context_has_changed(self):
return self._create_context.context_has_changed
@property
def instances(self):
@ -1751,6 +1809,8 @@ class PublisherController(BasePublisherController):
"""Reset everything related to creation and publishing."""
self.stop_publish()
self._emit_event("controller.reset.started")
self.host_is_valid = self._create_context.host_is_valid
self._create_context.reset_preparation()
@ -1992,7 +2052,15 @@ class PublisherController(BasePublisherController):
)
def trigger_convertor_items(self, convertor_identifiers):
self.save_changes()
"""Trigger legacy item convertors.
This functionality requires to save and reset CreateContext. The reset
is needed so Creators can collect converted items.
Args:
convertor_identifiers (list[str]): Identifiers of convertor
plugins.
"""
success = True
try:
@ -2039,13 +2107,33 @@ class PublisherController(BasePublisherController):
self._on_create_instance_change()
return success
def save_changes(self):
"""Save changes happened during creation."""
def save_changes(self, show_message=True):
"""Save changes happened during creation.
Trigger save of changes using host api. This functionality does not
validate anything. It is required to do checks before this method is
called to be able to give user actionable response e.g. check of
context using 'host_context_has_changed'.
Args:
show_message (bool): Show message that changes were
saved successfully.
Returns:
bool: Save of changes was successful.
"""
if not self._create_context.host_is_valid:
return
# TODO remove
# Fake success save when host is not valid for CreateContext
# this is for testing as experimental feature
return True
try:
self._create_context.save_changes()
if show_message:
self.emit_card_message("Saved changes..")
return True
except CreatorsOperationFailed as exc:
self._emit_event(
@ -2056,16 +2144,17 @@ class PublisherController(BasePublisherController):
}
)
return False
def remove_instances(self, instance_ids):
"""Remove instances based on instance ids.
Args:
instance_ids (List[str]): List of instance ids to remove.
"""
# QUESTION Expect that instances are really removed? In that case save
# reset is not required and save changes too.
self.save_changes()
# QUESTION Expect that instances are really removed? In that case reset
# is not required.
self._remove_instances_from_context(instance_ids)
self._on_create_instance_change()
@ -2136,12 +2225,22 @@ class PublisherController(BasePublisherController):
self._publish_comment_is_set = True
def publish(self):
"""Run publishing."""
"""Run publishing.
Make sure all changes are saved before method is called (Call
'save_changes' and check output).
"""
self._publish_up_validation = False
self._start_publish()
def validate(self):
"""Run publishing and stop after Validation."""
"""Run publishing and stop after Validation.
Make sure all changes are saved before method is called (Call
'save_changes' and check output).
"""
if self.publish_has_validated:
return
self._publish_up_validation = True
@ -2152,10 +2251,8 @@ class PublisherController(BasePublisherController):
if self.publish_is_running:
return
# Make sure changes are saved
self.save_changes()
self.publish_is_running = True
self.publish_has_started = True
self._emit_event("publish.process.started")

View file

@ -4,8 +4,9 @@ from .icons import (
get_icon
)
from .widgets import (
StopBtn,
SaveBtn,
ResetBtn,
StopBtn,
ValidateBtn,
PublishBtn,
CreateNextPageOverlay,
@ -25,8 +26,9 @@ __all__ = (
"get_pixmap",
"get_icon",
"StopBtn",
"SaveBtn",
"ResetBtn",
"StopBtn",
"ValidateBtn",
"PublishBtn",
"CreateNextPageOverlay",

View file

@ -164,6 +164,11 @@ class BaseGroupWidget(QtWidgets.QWidget):
def _on_widget_selection(self, instance_id, group_id, selection_type):
self.selected.emit(instance_id, group_id, selection_type)
def set_active_toggle_enabled(self, enabled):
for widget in self._widgets_by_id.values():
if isinstance(widget, InstanceCardWidget):
widget.set_active_toggle_enabled(enabled)
class ConvertorItemsGroupWidget(BaseGroupWidget):
def update_items(self, items_by_id):
@ -437,6 +442,9 @@ class InstanceCardWidget(CardWidget):
self.update_instance_values()
def set_active_toggle_enabled(self, enabled):
self._active_checkbox.setEnabled(enabled)
def set_active(self, new_value):
"""Set instance as active."""
checkbox_value = self._active_checkbox.isChecked()
@ -551,6 +559,7 @@ class InstanceCardView(AbstractInstanceView):
self._context_widget = None
self._convertor_items_group = None
self._active_toggle_enabled = True
self._widgets_by_group = {}
self._ordered_groups = []
@ -667,6 +676,9 @@ class InstanceCardView(AbstractInstanceView):
group_widget.update_instances(
instances_by_group[group_name]
)
group_widget.set_active_toggle_enabled(
self._active_toggle_enabled
)
self._update_ordered_group_names()
@ -1091,3 +1103,10 @@ class InstanceCardView(AbstractInstanceView):
self._explicitly_selected_groups = selected_groups
self._explicitly_selected_instance_ids = selected_instances
def set_active_toggle_enabled(self, enabled):
if self._active_toggle_enabled is enabled:
return
self._active_toggle_enabled = enabled
for group_widget in self._widgets_by_group.values():
group_widget.set_active_toggle_enabled(enabled)

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

View file

@ -198,6 +198,9 @@ class InstanceListItemWidget(QtWidgets.QWidget):
self.instance["active"] = new_value
self.active_changed.emit(self.instance.id, new_value)
def set_active_toggle_enabled(self, enabled):
self._active_checkbox.setEnabled(enabled)
class ListContextWidget(QtWidgets.QFrame):
"""Context (or global attributes) widget."""
@ -302,6 +305,9 @@ class InstanceListGroupWidget(QtWidgets.QFrame):
else:
self.expand_btn.setArrowType(QtCore.Qt.RightArrow)
def set_active_toggle_enabled(self, enabled):
self.toggle_checkbox.setEnabled(enabled)
class InstanceTreeView(QtWidgets.QTreeView):
"""View showing instances and their groups."""
@ -461,6 +467,8 @@ class InstanceListView(AbstractInstanceView):
self._instance_model = instance_model
self._proxy_model = proxy_model
self._active_toggle_enabled = True
def _on_expand(self, index):
self._update_widget_expand_state(index, True)
@ -667,6 +675,9 @@ class InstanceListView(AbstractInstanceView):
widget = InstanceListItemWidget(
instance, self._instance_view
)
widget.set_active_toggle_enabled(
self._active_toggle_enabled
)
widget.active_changed.connect(self._on_active_changed)
self._instance_view.setIndexWidget(proxy_index, widget)
self._widgets_by_id[instance.id] = widget
@ -802,6 +813,9 @@ class InstanceListView(AbstractInstanceView):
proxy_index = self._proxy_model.mapFromSource(index)
group_name = group_item.data(GROUP_ROLE)
widget = InstanceListGroupWidget(group_name, self._instance_view)
widget.set_active_toggle_enabled(
self._active_toggle_enabled
)
widget.expand_changed.connect(self._on_group_expand_request)
widget.toggle_requested.connect(self._on_group_toggle_request)
self._group_widgets[group_name] = widget
@ -1051,3 +1065,16 @@ class InstanceListView(AbstractInstanceView):
QtCore.QItemSelectionModel.Select
| QtCore.QItemSelectionModel.Rows
)
def set_active_toggle_enabled(self, enabled):
if self._active_toggle_enabled is enabled:
return
self._active_toggle_enabled = enabled
for widget in self._widgets_by_id.values():
if isinstance(widget, InstanceListItemWidget):
widget.set_active_toggle_enabled(enabled)
for widget in self._group_widgets.values():
if isinstance(widget, InstanceListGroupWidget):
widget.set_active_toggle_enabled(enabled)

View file

@ -17,6 +17,7 @@ class OverviewWidget(QtWidgets.QFrame):
active_changed = QtCore.Signal()
instance_context_changed = QtCore.Signal()
create_requested = QtCore.Signal()
convert_requested = QtCore.Signal()
anim_end_value = 200
anim_duration = 200
@ -132,6 +133,9 @@ class OverviewWidget(QtWidgets.QFrame):
controller.event_system.add_callback(
"publish.process.started", self._on_publish_start
)
controller.event_system.add_callback(
"controller.reset.started", self._on_controller_reset_start
)
controller.event_system.add_callback(
"publish.reset.finished", self._on_publish_reset
)
@ -336,13 +340,31 @@ class OverviewWidget(QtWidgets.QFrame):
self.instance_context_changed.emit()
def _on_convert_requested(self):
_, _, convertor_identifiers = self.get_selected_items()
self._controller.trigger_convertor_items(convertor_identifiers)
self.convert_requested.emit()
def get_selected_items(self):
"""Selected items in current view widget.
Returns:
tuple[list[str], bool, list[str]]: Selected items. List of
instance ids, context is selected, list of selected legacy
convertor plugins.
"""
view = self._subset_views_layout.currentWidget()
return view.get_selected_items()
def get_selected_legacy_convertors(self):
"""Selected legacy convertor identifiers.
Returns:
list[str]: Selected legacy convertor identifiers.
Example: ['io.openpype.creators.houdini.legacy']
"""
_, _, convertor_identifiers = self.get_selected_items()
return convertor_identifiers
def _change_view_type(self):
idx = self._subset_views_layout.currentIndex()
new_idx = (idx + 1) % self._subset_views_layout.count()
@ -391,9 +413,19 @@ class OverviewWidget(QtWidgets.QFrame):
self._create_btn.setEnabled(False)
self._subset_attributes_wrap.setEnabled(False)
for idx in range(self._subset_views_layout.count()):
widget = self._subset_views_layout.widget(idx)
widget.set_active_toggle_enabled(False)
def _on_controller_reset_start(self):
"""Controller reset started."""
for idx in range(self._subset_views_layout.count()):
widget = self._subset_views_layout.widget(idx)
widget.set_active_toggle_enabled(True)
def _on_publish_reset(self):
"""Context in controller has been refreshed."""
"""Context in controller has been reseted."""
self._create_btn.setEnabled(True)
self._subset_attributes_wrap.setEnabled(True)

View file

@ -34,7 +34,8 @@ from .icons import (
)
from ..constants import (
VARIANT_TOOLTIP
VARIANT_TOOLTIP,
ResetKeySequence,
)
@ -198,12 +199,26 @@ class CreateBtn(PublishIconBtn):
self.setLayoutDirection(QtCore.Qt.RightToLeft)
class SaveBtn(PublishIconBtn):
"""Save context and instances information."""
def __init__(self, parent=None):
icon_path = get_icon_path("save")
super(SaveBtn, self).__init__(icon_path, parent)
self.setToolTip(
"Save changes ({})".format(
QtGui.QKeySequence(QtGui.QKeySequence.Save).toString()
)
)
class ResetBtn(PublishIconBtn):
"""Publish reset button."""
def __init__(self, parent=None):
icon_path = get_icon_path("refresh")
super(ResetBtn, self).__init__(icon_path, parent)
self.setToolTip("Refresh publishing")
self.setToolTip(
"Reset & discard changes ({})".format(ResetKeySequence.toString())
)
class StopBtn(PublishIconBtn):
@ -348,6 +363,19 @@ class AbstractInstanceView(QtWidgets.QWidget):
"{} Method 'set_selected_items' is not implemented."
).format(self.__class__.__name__))
def set_active_toggle_enabled(self, enabled):
"""Instances are disabled for changing enabled state.
Active state should stay the same until is "unset".
Args:
enabled (bool): Instance state can be changed.
"""
raise NotImplementedError((
"{} Method 'set_active_toggle_enabled' is not implemented."
).format(self.__class__.__name__))
class ClickableLineEdit(QtWidgets.QLineEdit):
"""QLineEdit capturing left mouse click.
@ -1533,7 +1561,7 @@ class SubsetAttributesWidget(QtWidgets.QWidget):
attributes Thumbnail TOP
Family Publish
Creator Publish
attributes plugin BOTTOM
attributes

View file

@ -13,6 +13,7 @@ from openpype.tools.utils import (
PixmapLabel,
)
from .constants import ResetKeySequence
from .publish_report_viewer import PublishReportViewerWidget
from .control_qt import QtPublisherController
from .widgets import (
@ -22,8 +23,9 @@ from .widgets import (
PublisherTabsWidget,
StopBtn,
SaveBtn,
ResetBtn,
StopBtn,
ValidateBtn,
PublishBtn,
@ -121,6 +123,7 @@ class PublisherWindow(QtWidgets.QDialog):
"Attach a comment to your publish"
)
save_btn = SaveBtn(footer_widget)
reset_btn = ResetBtn(footer_widget)
stop_btn = StopBtn(footer_widget)
validate_btn = ValidateBtn(footer_widget)
@ -129,6 +132,7 @@ class PublisherWindow(QtWidgets.QDialog):
footer_bottom_layout = QtWidgets.QHBoxLayout(footer_bottom_widget)
footer_bottom_layout.setContentsMargins(0, 0, 0, 0)
footer_bottom_layout.addStretch(1)
footer_bottom_layout.addWidget(save_btn, 0)
footer_bottom_layout.addWidget(reset_btn, 0)
footer_bottom_layout.addWidget(stop_btn, 0)
footer_bottom_layout.addWidget(validate_btn, 0)
@ -250,7 +254,11 @@ class PublisherWindow(QtWidgets.QDialog):
overview_widget.create_requested.connect(
self._on_create_request
)
overview_widget.convert_requested.connect(
self._on_convert_requested
)
save_btn.clicked.connect(self._on_save_clicked)
reset_btn.clicked.connect(self._on_reset_clicked)
stop_btn.clicked.connect(self._on_stop_clicked)
validate_btn.clicked.connect(self._on_validate_clicked)
@ -330,8 +338,9 @@ class PublisherWindow(QtWidgets.QDialog):
self._comment_input = comment_input
self._footer_spacer = footer_spacer
self._stop_btn = stop_btn
self._save_btn = save_btn
self._reset_btn = reset_btn
self._stop_btn = stop_btn
self._validate_btn = validate_btn
self._publish_btn = publish_btn
@ -388,7 +397,9 @@ class PublisherWindow(QtWidgets.QDialog):
def closeEvent(self, event):
self._window_is_visible = False
self._uninstall_app_event_listener()
self.save_changes()
# TODO capture changes and ask user if wants to save changes on close
if not self._controller.host_context_has_changed:
self._save_changes(False)
self._reset_on_show = True
self._controller.clear_thumbnail_temp_dir_path()
super(PublisherWindow, self).closeEvent(event)
@ -421,6 +432,21 @@ class PublisherWindow(QtWidgets.QDialog):
if event.key() == QtCore.Qt.Key_Escape:
event.accept()
return
if event.matches(QtGui.QKeySequence.Save):
if not self._controller.publish_has_started:
self._save_changes(True)
event.accept()
return
if ResetKeySequence.matches(
QtGui.QKeySequence(event.key() | event.modifiers())
):
if not self.controller.publish_is_running:
self.reset()
event.accept()
return
super(PublisherWindow, self).keyPressEvent(event)
def _on_overlay_message(self, event):
@ -455,8 +481,65 @@ class PublisherWindow(QtWidgets.QDialog):
self._reset_on_show = False
self.reset()
def save_changes(self):
self._controller.save_changes()
def _checks_before_save(self, explicit_save):
"""Save of changes may trigger some issues.
Check if context did change and ask user if he is really sure the
save should happen. A dialog can be shown during this method.
Args:
explicit_save (bool): Method was called when user explicitly asked
for save. Value affects shown message.
Returns:
bool: Save can happen.
"""
if not self._controller.host_context_has_changed:
return True
title = "Host context changed"
if explicit_save:
message = (
"Context has changed since Publisher window was refreshed last"
" time.\n\nAre you sure you want to save changes?"
)
else:
message = (
"Your action requires save of changes but context has changed"
" since Publisher window was refreshed last time.\n\nAre you"
" sure you want to continue and save changes?"
)
result = QtWidgets.QMessageBox.question(
self,
title,
message,
QtWidgets.QMessageBox.Save | QtWidgets.QMessageBox.Cancel
)
return result == QtWidgets.QMessageBox.Save
def _save_changes(self, explicit_save):
"""Save changes of Creation part.
All possible triggers of save changes were moved to main window (here),
so it can handle possible issues with save at one place. Do checks,
so user don't accidentally save changes to different file or using
different context.
Moving responsibility to this place gives option to show the dialog and
wait for user's response without breaking action he wanted to do.
Args:
explicit_save (bool): Method was called when user explicitly asked
for save. Value affects shown message.
Returns:
bool: Save happened successfully.
"""
if not self._checks_before_save(explicit_save):
return False
return self._controller.save_changes()
def reset(self):
self._controller.reset()
@ -491,15 +574,18 @@ class PublisherWindow(QtWidgets.QDialog):
self._help_dialog.show()
window = self.window()
desktop = QtWidgets.QApplication.desktop()
screen_idx = desktop.screenNumber(window)
screen = desktop.screen(screen_idx)
screen_rect = screen.geometry()
if hasattr(QtWidgets.QApplication, "desktop"):
desktop = QtWidgets.QApplication.desktop()
screen_idx = desktop.screenNumber(window)
screen_geo = desktop.screenGeometry(screen_idx)
else:
screen = window.screen()
screen_geo = screen.geometry()
window_geo = window.geometry()
dialog_x = window_geo.x() + window_geo.width()
dialog_right = (dialog_x + self._help_dialog.width()) - 1
diff = dialog_right - screen_rect.right()
diff = dialog_right - screen_geo.right()
if diff > 0:
dialog_x -= diff
@ -549,6 +635,14 @@ class PublisherWindow(QtWidgets.QDialog):
def _on_create_request(self):
self._go_to_create_tab()
def _on_convert_requested(self):
if not self._save_changes(False):
return
convertor_identifiers = (
self._overview_widget.get_selected_legacy_convertors()
)
self._controller.trigger_convertor_items(convertor_identifiers)
def _set_current_tab(self, identifier):
self._tabs_widget.set_current_tab(identifier)
@ -599,8 +693,10 @@ class PublisherWindow(QtWidgets.QDialog):
self._publish_frame.setVisible(visible)
self._update_publish_frame_rect()
def _on_save_clicked(self):
self._save_changes(True)
def _on_reset_clicked(self):
self.save_changes()
self.reset()
def _on_stop_clicked(self):
@ -610,14 +706,17 @@ class PublisherWindow(QtWidgets.QDialog):
self._controller.set_comment(self._comment_input.text())
def _on_validate_clicked(self):
self._set_publish_comment()
self._controller.validate()
if self._save_changes(False):
self._set_publish_comment()
self._controller.validate()
def _on_publish_clicked(self):
self._set_publish_comment()
self._controller.publish()
if self._save_changes(False):
self._set_publish_comment()
self._controller.publish()
def _set_footer_enabled(self, enabled):
self._save_btn.setEnabled(True)
self._reset_btn.setEnabled(True)
if enabled:
self._stop_btn.setEnabled(False)

View file

@ -247,7 +247,7 @@ class TrayPublishWindow(PublisherWindow):
def _on_project_select(self, project_name):
# TODO register project specific plugin paths
self._controller.save_changes()
self._controller.save_changes(False)
self._controller.reset_project_data_cache()
self.reset()

View file

@ -862,11 +862,11 @@ class WrappedCallbackItem:
return self._result
def execute(self):
"""Execute callback and store it's result.
"""Execute callback and store its result.
Method must be called from main thread. Item is marked as `done`
when callback execution finished. Store output of callback of exception
information when callback raise one.
information when callback raises one.
"""
if self.done:
self.log.warning("- item is already processed")

View file

@ -82,8 +82,8 @@ All context filters are lists which may contain strings or Regular expressions (
- **`tasks`** - Currently processed task. `["modeling", "animation"]`
:::important Filtering
Filters are optional. In case when multiple profiles match current context, profile with higher number of matched filters has higher priority that profile without filters.
(Eg. order of when filter is added doesn't matter, only the precision of matching does.)
Filters are optional. In case when multiple profiles match current context, profile with higher number of matched filters has higher priority than profile without filters.
(The order the profiles in settings doesn't matter, only the precision of matching does.)
:::
## Publish plugins
@ -94,7 +94,7 @@ Publish plugins used across all integrations.
### Extract Review
Plugin responsible for automatic FFmpeg conversion to variety of formats.
Extract review is using [profile filtering](#profile-filters) to be able render different outputs for different situations.
Extract review uses [profile filtering](#profile-filters) to render different outputs for different situations.
Applicable context filters:
**`hosts`** - Host from which publishing was triggered. `["maya", "nuke"]`
@ -104,7 +104,7 @@ Applicable context filters:
**Output Definitions**
Profile may generate multiple outputs from a single input. Each output must define unique name and output extension (use the extension without a dot e.g. **mp4**). All other settings of output definition are optional.
A profile may generate multiple outputs from a single input. Each output must define unique name and output extension (use the extension without a dot e.g. **mp4**). All other settings of output definition are optional.
![global_extract_review_output_defs](assets/global_extract_review_output_defs.png)
- **`Tags`**
@ -118,7 +118,7 @@ Profile may generate multiple outputs from a single input. Each output must defi
- **Output arguments** other FFmpeg output arguments like codec definition.
- **`Output width`** and **`Output height`**
- it is possible to rescale output to specified resolution and keep aspect ratio.
- 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`**

View file

@ -36,7 +36,7 @@ All context filters are lists which may contain strings or Regular expressions (
- **families** - Main family of processed instance. `["plate", "model"]`
:::important Filtering
Filters are optional and may not be set. In case when multiple profiles match current context, profile with filters has higher priority that profile without filters.
Filters are optional and may not be set. In case when multiple profiles match current context, profile with filters has higher priority than profile without filters.
:::
#### Profile outputs