mirror of
https://github.com/ynput/ayon-core.git
synced 2025-12-24 12:54:40 +01:00
Merge branch 'enhancement/collect_scene_loaded_versions_add_more_hosts' of https://github.com/BigRoy/ayon-core into enhancement/collect_scene_loaded_versions_add_more_hosts
This commit is contained in:
commit
8be8f245d4
120 changed files with 4819 additions and 3554 deletions
6
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
6
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
|
|
@ -35,6 +35,12 @@ body:
|
|||
label: Version
|
||||
description: What version are you running? Look to AYON Tray
|
||||
options:
|
||||
- 1.6.1
|
||||
- 1.6.0
|
||||
- 1.5.3
|
||||
- 1.5.2
|
||||
- 1.5.1
|
||||
- 1.5.0
|
||||
- 1.4.1
|
||||
- 1.4.0
|
||||
- 1.3.2
|
||||
|
|
|
|||
18
.github/workflows/deploy_mkdocs.yml
vendored
Normal file
18
.github/workflows/deploy_mkdocs.yml
vendored
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
name: Deploy MkDocs
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "*"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-mk-docs:
|
||||
# FIXME: Update @develop to @main after `ops-repo-automation` release.
|
||||
uses: ynput/ops-repo-automation/.github/workflows/deploy_mkdocs.yml@develop
|
||||
with:
|
||||
repo: ${{ github.repository }}
|
||||
secrets:
|
||||
YNPUT_BOT_TOKEN: ${{ secrets.YNPUT_BOT_TOKEN }}
|
||||
CI_USER: ${{ secrets.CI_USER }}
|
||||
CI_EMAIL: ${{ secrets.CI_EMAIL }}
|
||||
|
|
@ -8,6 +8,7 @@ import inspect
|
|||
import logging
|
||||
import threading
|
||||
import collections
|
||||
import warnings
|
||||
from uuid import uuid4
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional
|
||||
|
|
@ -155,18 +156,33 @@ def load_addons(force=False):
|
|||
|
||||
|
||||
def _get_ayon_bundle_data():
|
||||
studio_bundle_name = os.environ.get("AYON_STUDIO_BUNDLE_NAME")
|
||||
project_bundle_name = os.getenv("AYON_BUNDLE_NAME")
|
||||
bundles = ayon_api.get_bundles()["bundles"]
|
||||
|
||||
bundle_name = os.getenv("AYON_BUNDLE_NAME")
|
||||
|
||||
return next(
|
||||
project_bundle = next(
|
||||
(
|
||||
bundle
|
||||
for bundle in bundles
|
||||
if bundle["name"] == bundle_name
|
||||
if bundle["name"] == project_bundle_name
|
||||
),
|
||||
None
|
||||
)
|
||||
studio_bundle = None
|
||||
if studio_bundle_name and project_bundle_name != studio_bundle_name:
|
||||
studio_bundle = next(
|
||||
(
|
||||
bundle
|
||||
for bundle in bundles
|
||||
if bundle["name"] == studio_bundle_name
|
||||
),
|
||||
None
|
||||
)
|
||||
|
||||
if project_bundle and studio_bundle:
|
||||
addons = copy.deepcopy(studio_bundle["addons"])
|
||||
addons.update(project_bundle["addons"])
|
||||
project_bundle["addons"] = addons
|
||||
return project_bundle
|
||||
|
||||
|
||||
def _get_ayon_addons_information(bundle_info):
|
||||
|
|
@ -286,6 +302,11 @@ def _load_ayon_addons(log):
|
|||
milestone_version = MOVED_ADDON_MILESTONE_VERSIONS.get(addon_name)
|
||||
if use_dev_path:
|
||||
addon_dir = dev_addon_info["path"]
|
||||
if addon_dir:
|
||||
addon_dir = os.path.expandvars(
|
||||
addon_dir.format_map(os.environ)
|
||||
)
|
||||
|
||||
if not addon_dir or not os.path.exists(addon_dir):
|
||||
log.warning((
|
||||
"Dev addon {} {} path does not exists. Path \"{}\""
|
||||
|
|
@ -815,10 +836,26 @@ class AddonsManager:
|
|||
|
||||
Unknown keys are logged out.
|
||||
|
||||
Deprecated:
|
||||
Use targeted methods 'collect_launcher_action_paths',
|
||||
'collect_create_plugin_paths', 'collect_load_plugin_paths',
|
||||
'collect_publish_plugin_paths' and
|
||||
'collect_inventory_action_paths' to collect plugin paths.
|
||||
|
||||
Returns:
|
||||
dict: Output is dictionary with keys "publish", "create", "load",
|
||||
"actions" and "inventory" each containing list of paths.
|
||||
|
||||
"""
|
||||
warnings.warn(
|
||||
"Used deprecated method 'collect_plugin_paths'. Please use"
|
||||
" targeted methods 'collect_launcher_action_paths',"
|
||||
" 'collect_create_plugin_paths', 'collect_load_plugin_paths'"
|
||||
" 'collect_publish_plugin_paths' and"
|
||||
" 'collect_inventory_action_paths'",
|
||||
DeprecationWarning,
|
||||
stacklevel=2
|
||||
)
|
||||
# Output structure
|
||||
output = {
|
||||
"publish": [],
|
||||
|
|
@ -874,23 +911,27 @@ class AddonsManager:
|
|||
if not isinstance(addon, IPluginPaths):
|
||||
continue
|
||||
|
||||
paths = None
|
||||
method = getattr(addon, method_name)
|
||||
try:
|
||||
paths = method(*args, **kwargs)
|
||||
except Exception:
|
||||
self.log.warning(
|
||||
(
|
||||
"Failed to get plugin paths from addon"
|
||||
" '{}' using '{}'."
|
||||
).format(addon.__class__.__name__, method_name),
|
||||
f" '{addon.name}' using '{method_name}'.",
|
||||
exc_info=True
|
||||
)
|
||||
|
||||
if not paths:
|
||||
continue
|
||||
|
||||
if paths:
|
||||
# Convert to list if value is not list
|
||||
if not isinstance(paths, (list, tuple, set)):
|
||||
if isinstance(paths, str):
|
||||
paths = [paths]
|
||||
self.log.warning(
|
||||
f"Addon '{addon.name}' returned invalid output type"
|
||||
f" from '{method_name}'."
|
||||
f" Got 'str' expected 'list[str]'."
|
||||
)
|
||||
output.extend(paths)
|
||||
return output
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"""Addon interfaces for AYON."""
|
||||
from __future__ import annotations
|
||||
|
||||
import warnings
|
||||
from abc import ABCMeta, abstractmethod
|
||||
from typing import TYPE_CHECKING, Callable, Optional, Type
|
||||
|
||||
|
|
@ -39,26 +40,29 @@ class AYONInterface(metaclass=_AYONInterfaceMeta):
|
|||
|
||||
|
||||
class IPluginPaths(AYONInterface):
|
||||
"""Addon has plugin paths to return.
|
||||
"""Addon wants to register plugin paths."""
|
||||
|
||||
Expected result is dictionary with keys "publish", "create", "load",
|
||||
"actions" or "inventory" and values as list or string.
|
||||
{
|
||||
"publish": ["path/to/publish_plugins"]
|
||||
}
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def get_plugin_paths(self) -> dict[str, list[str]]:
|
||||
"""Return plugin paths for addon.
|
||||
|
||||
This method was abstract (required) in the past, so raise the required
|
||||
'core' addon version when 'get_plugin_paths' is removed from
|
||||
addon.
|
||||
|
||||
Deprecated:
|
||||
Please implement specific methods 'get_create_plugin_paths',
|
||||
'get_load_plugin_paths', 'get_inventory_action_paths' and
|
||||
'get_publish_plugin_paths' to return plugin paths.
|
||||
|
||||
Returns:
|
||||
dict[str, list[str]]: Plugin paths for addon.
|
||||
|
||||
"""
|
||||
return {}
|
||||
|
||||
def _get_plugin_paths_by_type(
|
||||
self, plugin_type: str) -> list[str]:
|
||||
self, plugin_type: str
|
||||
) -> list[str]:
|
||||
"""Get plugin paths by type.
|
||||
|
||||
Args:
|
||||
|
|
@ -78,6 +82,24 @@ class IPluginPaths(AYONInterface):
|
|||
|
||||
if not isinstance(paths, (list, tuple, set)):
|
||||
paths = [paths]
|
||||
|
||||
new_function_name = "get_launcher_action_paths"
|
||||
if plugin_type == "create":
|
||||
new_function_name = "get_create_plugin_paths"
|
||||
elif plugin_type == "load":
|
||||
new_function_name = "get_load_plugin_paths"
|
||||
elif plugin_type == "publish":
|
||||
new_function_name = "get_publish_plugin_paths"
|
||||
elif plugin_type == "inventory":
|
||||
new_function_name = "get_inventory_action_paths"
|
||||
|
||||
warnings.warn(
|
||||
f"Addon '{self.name}' returns '{plugin_type}' paths using"
|
||||
" 'get_plugin_paths' method. Please implement"
|
||||
f" '{new_function_name}' instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2
|
||||
)
|
||||
return paths
|
||||
|
||||
def get_launcher_action_paths(self) -> list[str]:
|
||||
|
|
|
|||
|
|
@ -27,25 +27,40 @@ from ayon_core.lib.env_tools import (
|
|||
|
||||
@click.group(invoke_without_command=True)
|
||||
@click.pass_context
|
||||
@click.option("--use-staging", is_flag=True,
|
||||
expose_value=False, help="use staging variants")
|
||||
@click.option("--debug", is_flag=True, expose_value=False,
|
||||
@click.option(
|
||||
"--use-staging",
|
||||
is_flag=True,
|
||||
expose_value=False,
|
||||
help="use staging variants")
|
||||
@click.option(
|
||||
"--debug",
|
||||
is_flag=True,
|
||||
expose_value=False,
|
||||
help="Enable debug")
|
||||
@click.option("--verbose", expose_value=False,
|
||||
help=("Change AYON log level (debug - critical or 0-50)"))
|
||||
@click.option("--force", is_flag=True, hidden=True)
|
||||
def main_cli(ctx, force):
|
||||
@click.option(
|
||||
"--project",
|
||||
help="Project name")
|
||||
@click.option(
|
||||
"--verbose",
|
||||
expose_value=False,
|
||||
help="Change AYON log level (debug - critical or 0-50)")
|
||||
@click.option(
|
||||
"--use-dev",
|
||||
is_flag=True,
|
||||
expose_value=False,
|
||||
help="use dev bundle")
|
||||
def main_cli(ctx, *_args, **_kwargs):
|
||||
"""AYON is main command serving as entry point to pipeline system.
|
||||
|
||||
It wraps different commands together.
|
||||
"""
|
||||
|
||||
if ctx.invoked_subcommand is None:
|
||||
# Print help if headless mode is used
|
||||
if os.getenv("AYON_HEADLESS_MODE") == "1":
|
||||
print(ctx.get_help())
|
||||
sys.exit(0)
|
||||
else:
|
||||
ctx.params.pop("project")
|
||||
ctx.forward(tray)
|
||||
|
||||
|
||||
|
|
@ -60,7 +75,6 @@ def tray(force):
|
|||
Default action of AYON command is to launch tray widget to control basic
|
||||
aspects of AYON. See documentation for more information.
|
||||
"""
|
||||
|
||||
from ayon_core.tools.tray import main
|
||||
|
||||
main(force)
|
||||
|
|
@ -306,6 +320,43 @@ def _add_addons(addons_manager):
|
|||
)
|
||||
|
||||
|
||||
def _cleanup_project_args():
|
||||
rem_args = list(sys.argv[1:])
|
||||
if "--project" not in rem_args:
|
||||
return
|
||||
|
||||
cmd = None
|
||||
current_ctx = None
|
||||
parent_name = "ayon"
|
||||
parent_cmd = main_cli
|
||||
while hasattr(parent_cmd, "resolve_command"):
|
||||
if current_ctx is None:
|
||||
current_ctx = main_cli.make_context(parent_name, rem_args)
|
||||
else:
|
||||
current_ctx = parent_cmd.make_context(
|
||||
parent_name,
|
||||
rem_args,
|
||||
parent=current_ctx
|
||||
)
|
||||
if not rem_args:
|
||||
break
|
||||
cmd_name, cmd, rem_args = parent_cmd.resolve_command(
|
||||
current_ctx, rem_args
|
||||
)
|
||||
parent_name = cmd_name
|
||||
parent_cmd = cmd
|
||||
|
||||
if cmd is None:
|
||||
return
|
||||
|
||||
param_names = {param.name for param in cmd.params}
|
||||
if "project" in param_names:
|
||||
return
|
||||
idx = sys.argv.index("--project")
|
||||
sys.argv.pop(idx)
|
||||
sys.argv.pop(idx)
|
||||
|
||||
|
||||
def main(*args, **kwargs):
|
||||
logging.basicConfig()
|
||||
|
||||
|
|
@ -332,10 +383,14 @@ def main(*args, **kwargs):
|
|||
addons_manager = AddonsManager()
|
||||
_set_addons_environments(addons_manager)
|
||||
_add_addons(addons_manager)
|
||||
|
||||
_cleanup_project_args()
|
||||
|
||||
try:
|
||||
main_cli(
|
||||
prog_name="ayon",
|
||||
obj={"addons_manager": addons_manager},
|
||||
args=(sys.argv[1:]),
|
||||
)
|
||||
except Exception: # noqa
|
||||
exc_info = sys.exc_info()
|
||||
|
|
|
|||
|
|
@ -33,22 +33,25 @@ class AddLastWorkfileToLaunchArgs(PreLaunchHook):
|
|||
"cinema4d",
|
||||
"silhouette",
|
||||
"gaffer",
|
||||
"loki",
|
||||
}
|
||||
launch_types = {LaunchTypes.local}
|
||||
|
||||
def execute(self):
|
||||
workfile_path = self.data.get("workfile_path")
|
||||
if not workfile_path:
|
||||
if not self.data.get("start_last_workfile"):
|
||||
self.log.info("It is set to not start last workfile on start.")
|
||||
return
|
||||
|
||||
last_workfile = self.data.get("last_workfile_path")
|
||||
if not last_workfile:
|
||||
workfile_path = self.data.get("last_workfile_path")
|
||||
if not workfile_path:
|
||||
self.log.warning("Last workfile was not collected.")
|
||||
return
|
||||
|
||||
if not os.path.exists(last_workfile):
|
||||
if not os.path.exists(workfile_path):
|
||||
self.log.info("Current context does not have any workfile yet.")
|
||||
return
|
||||
|
||||
# Add path to workfile to arguments
|
||||
self.launch_context.launch_args.append(last_workfile)
|
||||
self.launch_context.launch_args.append(workfile_path)
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ class OCIOEnvHook(PreLaunchHook):
|
|||
"fusion",
|
||||
"blender",
|
||||
"aftereffects",
|
||||
"3dsmax",
|
||||
"max",
|
||||
"houdini",
|
||||
"maya",
|
||||
"nuke",
|
||||
|
|
@ -24,6 +24,7 @@ class OCIOEnvHook(PreLaunchHook):
|
|||
"cinema4d",
|
||||
"silhouette",
|
||||
"gaffer",
|
||||
"loki",
|
||||
}
|
||||
launch_types = set()
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
from .constants import ContextChangeReason
|
||||
from .abstract import AbstractHost, ApplicationInformation
|
||||
from .host import (
|
||||
HostBase,
|
||||
ContextChangeData,
|
||||
)
|
||||
|
||||
from .interfaces import (
|
||||
|
|
@ -18,7 +20,11 @@ from .dirmap import HostDirmap
|
|||
__all__ = (
|
||||
"ContextChangeReason",
|
||||
|
||||
"AbstractHost",
|
||||
"ApplicationInformation",
|
||||
|
||||
"HostBase",
|
||||
"ContextChangeData",
|
||||
|
||||
"IWorkfileHost",
|
||||
"WorkfileInfo",
|
||||
|
|
|
|||
120
client/ayon_core/host/abstract.py
Normal file
120
client/ayon_core/host/abstract.py
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
import typing
|
||||
from typing import Optional, Any
|
||||
|
||||
from .constants import ContextChangeReason
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from ayon_core.pipeline import Anatomy
|
||||
|
||||
from .typing import HostContextData
|
||||
|
||||
|
||||
@dataclass
|
||||
class ApplicationInformation:
|
||||
"""Application information.
|
||||
|
||||
Attributes:
|
||||
app_name (Optional[str]): Application name. e.g. Maya, NukeX, Nuke
|
||||
app_version (Optional[str]): Application version. e.g. 15.2.1
|
||||
|
||||
"""
|
||||
app_name: Optional[str] = None
|
||||
app_version: Optional[str] = None
|
||||
|
||||
|
||||
class AbstractHost(ABC):
|
||||
"""Abstract definition of host implementation."""
|
||||
@property
|
||||
@abstractmethod
|
||||
def log(self) -> logging.Logger:
|
||||
pass
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def name(self) -> str:
|
||||
"""Host name."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_app_information(self) -> ApplicationInformation:
|
||||
"""Information about the application where host is running.
|
||||
|
||||
Returns:
|
||||
ApplicationInformation: Application information.
|
||||
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_current_context(self) -> HostContextData:
|
||||
"""Get the current context of the host.
|
||||
|
||||
Current context is defined by project name, folder path and task name.
|
||||
|
||||
Returns:
|
||||
HostContextData: The current context of the host.
|
||||
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def set_current_context(
|
||||
self,
|
||||
folder_entity: dict[str, Any],
|
||||
task_entity: dict[str, Any],
|
||||
*,
|
||||
reason: ContextChangeReason = ContextChangeReason.undefined,
|
||||
project_entity: Optional[dict[str, Any]] = None,
|
||||
anatomy: Optional[Anatomy] = None,
|
||||
) -> HostContextData:
|
||||
"""Change context of the host.
|
||||
|
||||
Args:
|
||||
folder_entity (dict[str, Any]): Folder entity.
|
||||
task_entity (dict[str, Any]): Task entity.
|
||||
reason (ContextChangeReason): Reason for change.
|
||||
project_entity (dict[str, Any]): Project entity.
|
||||
anatomy (Anatomy): Anatomy entity.
|
||||
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_current_project_name(self) -> str:
|
||||
"""Get the current project name.
|
||||
|
||||
Returns:
|
||||
Optional[str]: The current project name.
|
||||
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_current_folder_path(self) -> Optional[str]:
|
||||
"""Get the current folder path.
|
||||
|
||||
Returns:
|
||||
Optional[str]: The current folder path.
|
||||
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_current_task_name(self) -> Optional[str]:
|
||||
"""Get the current task name.
|
||||
|
||||
Returns:
|
||||
Optional[str]: The current task name.
|
||||
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_context_title(self) -> str:
|
||||
"""Get the context title used in UIs."""
|
||||
pass
|
||||
|
|
@ -3,26 +3,21 @@ from __future__ import annotations
|
|||
import os
|
||||
import logging
|
||||
import contextlib
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
import typing
|
||||
from typing import Optional, Any
|
||||
from dataclasses import dataclass
|
||||
|
||||
import ayon_api
|
||||
|
||||
from ayon_core.lib import emit_event
|
||||
|
||||
from .constants import ContextChangeReason
|
||||
from .abstract import AbstractHost, ApplicationInformation
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from ayon_core.pipeline import Anatomy
|
||||
|
||||
from typing import TypedDict
|
||||
|
||||
class HostContextData(TypedDict):
|
||||
project_name: str
|
||||
folder_path: Optional[str]
|
||||
task_name: Optional[str]
|
||||
from .typing import HostContextData
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -34,7 +29,7 @@ class ContextChangeData:
|
|||
anatomy: Anatomy
|
||||
|
||||
|
||||
class HostBase(ABC):
|
||||
class HostBase(AbstractHost):
|
||||
"""Base of host implementation class.
|
||||
|
||||
Host is pipeline implementation of DCC application. This class should help
|
||||
|
|
@ -101,6 +96,18 @@ class HostBase(ABC):
|
|||
|
||||
pass
|
||||
|
||||
def get_app_information(self) -> ApplicationInformation:
|
||||
"""Running application information.
|
||||
|
||||
Host integration should override this method and return correct
|
||||
information.
|
||||
|
||||
Returns:
|
||||
ApplicationInformation: Application information.
|
||||
|
||||
"""
|
||||
return ApplicationInformation()
|
||||
|
||||
def install(self):
|
||||
"""Install host specific functionality.
|
||||
|
||||
|
|
@ -109,48 +116,41 @@ class HostBase(ABC):
|
|||
|
||||
It is called automatically when 'ayon_core.pipeline.install_host' is
|
||||
triggered.
|
||||
"""
|
||||
|
||||
"""
|
||||
pass
|
||||
|
||||
@property
|
||||
def log(self):
|
||||
def log(self) -> logging.Logger:
|
||||
if self._log is None:
|
||||
self._log = logging.getLogger(self.__class__.__name__)
|
||||
return self._log
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def name(self) -> str:
|
||||
"""Host name."""
|
||||
|
||||
pass
|
||||
|
||||
def get_current_project_name(self):
|
||||
def get_current_project_name(self) -> str:
|
||||
"""
|
||||
Returns:
|
||||
Union[str, None]: Current project name.
|
||||
"""
|
||||
str: Current project name.
|
||||
|
||||
return os.environ.get("AYON_PROJECT_NAME")
|
||||
"""
|
||||
return os.environ["AYON_PROJECT_NAME"]
|
||||
|
||||
def get_current_folder_path(self) -> Optional[str]:
|
||||
"""
|
||||
Returns:
|
||||
Union[str, None]: Current asset name.
|
||||
"""
|
||||
Optional[str]: Current asset name.
|
||||
|
||||
"""
|
||||
return os.environ.get("AYON_FOLDER_PATH")
|
||||
|
||||
def get_current_task_name(self) -> Optional[str]:
|
||||
"""
|
||||
Returns:
|
||||
Union[str, None]: Current task name.
|
||||
"""
|
||||
Optional[str]: Current task name.
|
||||
|
||||
"""
|
||||
return os.environ.get("AYON_TASK_NAME")
|
||||
|
||||
def get_current_context(self) -> "HostContextData":
|
||||
def get_current_context(self) -> HostContextData:
|
||||
"""Get current context information.
|
||||
|
||||
This method should be used to get current context of host. Usage of
|
||||
|
|
@ -159,10 +159,10 @@ class HostBase(ABC):
|
|||
can't be caught properly.
|
||||
|
||||
Returns:
|
||||
Dict[str, Union[str, None]]: Context with 3 keys 'project_name',
|
||||
'folder_path' and 'task_name'. All of them can be 'None'.
|
||||
"""
|
||||
HostContextData: Current context with 'project_name',
|
||||
'folder_path' and 'task_name'.
|
||||
|
||||
"""
|
||||
return {
|
||||
"project_name": self.get_current_project_name(),
|
||||
"folder_path": self.get_current_folder_path(),
|
||||
|
|
@ -177,7 +177,7 @@ class HostBase(ABC):
|
|||
reason: ContextChangeReason = ContextChangeReason.undefined,
|
||||
project_entity: Optional[dict[str, Any]] = None,
|
||||
anatomy: Optional[Anatomy] = None,
|
||||
) -> "HostContextData":
|
||||
) -> HostContextData:
|
||||
"""Set current context information.
|
||||
|
||||
This method should be used to set current context of host. Usage of
|
||||
|
|
@ -290,7 +290,7 @@ class HostBase(ABC):
|
|||
project_name: str,
|
||||
folder_path: Optional[str],
|
||||
task_name: Optional[str],
|
||||
) -> "HostContextData":
|
||||
) -> HostContextData:
|
||||
"""Emit context change event.
|
||||
|
||||
Args:
|
||||
|
|
@ -302,7 +302,7 @@ class HostBase(ABC):
|
|||
HostContextData: Data send to context change event.
|
||||
|
||||
"""
|
||||
data = {
|
||||
data: HostContextData = {
|
||||
"project_name": project_name,
|
||||
"folder_path": folder_path,
|
||||
"task_name": task_name,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
from abc import abstractmethod
|
||||
|
||||
from ayon_core.host.abstract import AbstractHost
|
||||
|
||||
from .exceptions import MissingMethodsError
|
||||
|
||||
|
||||
class ILoadHost:
|
||||
class ILoadHost(AbstractHost):
|
||||
"""Implementation requirements to be able use reference of representations.
|
||||
|
||||
The load plugins can do referencing even without implementation of methods
|
||||
|
|
@ -24,7 +26,7 @@ class ILoadHost:
|
|||
loading. Checks only existence of methods.
|
||||
|
||||
Args:
|
||||
Union[ModuleType, HostBase]: Object of host where to look for
|
||||
Union[ModuleType, AbstractHost]: Object of host where to look for
|
||||
required methods.
|
||||
|
||||
Returns:
|
||||
|
|
@ -46,7 +48,7 @@ class ILoadHost:
|
|||
"""Validate implemented methods of "old type" host for load workflow.
|
||||
|
||||
Args:
|
||||
Union[ModuleType, HostBase]: Object of host to validate.
|
||||
Union[ModuleType, AbstractHost]: Object of host to validate.
|
||||
|
||||
Raises:
|
||||
MissingMethodsError: If there are missing methods on host
|
||||
|
|
@ -83,7 +85,7 @@ class ILoadHost:
|
|||
return self.get_containers()
|
||||
|
||||
|
||||
class IPublishHost:
|
||||
class IPublishHost(AbstractHost):
|
||||
"""Functions related to new creation system in new publisher.
|
||||
|
||||
New publisher is not storing information only about each created instance
|
||||
|
|
@ -99,7 +101,7 @@ class IPublishHost:
|
|||
new publish creation. Checks only existence of methods.
|
||||
|
||||
Args:
|
||||
Union[ModuleType, HostBase]: Host module where to look for
|
||||
Union[ModuleType, AbstractHost]: Host module where to look for
|
||||
required methods.
|
||||
|
||||
Returns:
|
||||
|
|
@ -127,7 +129,7 @@ class IPublishHost:
|
|||
"""Validate implemented methods of "old type" host.
|
||||
|
||||
Args:
|
||||
Union[ModuleType, HostBase]: Host module to validate.
|
||||
Union[ModuleType, AbstractHost]: Host module to validate.
|
||||
|
||||
Raises:
|
||||
MissingMethodsError: If there are missing methods on host
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import arrow
|
|||
|
||||
from ayon_core.lib import emit_event
|
||||
from ayon_core.settings import get_project_settings
|
||||
from ayon_core.host.abstract import AbstractHost
|
||||
from ayon_core.host.constants import ContextChangeReason
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
|
|
@ -54,7 +55,7 @@ class _WorkfileOptionalData:
|
|||
):
|
||||
if kwargs:
|
||||
cls_name = self.__class__.__name__
|
||||
keys = ", ".join(['"{}"'.format(k) for k in kwargs.keys()])
|
||||
keys = ", ".join([f'"{k}"' for k in kwargs.keys()])
|
||||
warnings.warn(
|
||||
f"Unknown keywords passed to {cls_name}: {keys}",
|
||||
)
|
||||
|
|
@ -821,7 +822,7 @@ class PublishedWorkfileInfo:
|
|||
return PublishedWorkfileInfo(**data)
|
||||
|
||||
|
||||
class IWorkfileHost:
|
||||
class IWorkfileHost(AbstractHost):
|
||||
"""Implementation requirements to be able to use workfiles utils and tool.
|
||||
|
||||
Some of the methods are pre-implemented as they generally do the same in
|
||||
|
|
@ -944,6 +945,8 @@ class IWorkfileHost:
|
|||
self._emit_workfile_save_event(event_data, after_save=False)
|
||||
|
||||
workdir = os.path.dirname(filepath)
|
||||
if not os.path.exists(workdir):
|
||||
os.makedirs(workdir, exist_ok=True)
|
||||
|
||||
# Set 'AYON_WORKDIR' environment variable
|
||||
os.environ["AYON_WORKDIR"] = workdir
|
||||
|
|
@ -1072,10 +1075,13 @@ class IWorkfileHost:
|
|||
prepared_data=prepared_data,
|
||||
)
|
||||
|
||||
workfile_entities_by_path = {
|
||||
workfile_entity["path"]: workfile_entity
|
||||
for workfile_entity in list_workfiles_context.workfile_entities
|
||||
}
|
||||
workfile_entities_by_path = {}
|
||||
for workfile_entity in list_workfiles_context.workfile_entities:
|
||||
rootless_path = workfile_entity["path"]
|
||||
path = os.path.normpath(
|
||||
list_workfiles_context.anatomy.fill_root(rootless_path)
|
||||
)
|
||||
workfile_entities_by_path[path] = workfile_entity
|
||||
|
||||
workdir_data = get_template_data(
|
||||
list_workfiles_context.project_entity,
|
||||
|
|
@ -1114,10 +1120,10 @@ class IWorkfileHost:
|
|||
|
||||
rootless_path = f"{rootless_workdir}/{filename}"
|
||||
workfile_entity = workfile_entities_by_path.pop(
|
||||
rootless_path, None
|
||||
filepath, None
|
||||
)
|
||||
version = comment = None
|
||||
if workfile_entity:
|
||||
if workfile_entity is not None:
|
||||
_data = workfile_entity["data"]
|
||||
version = _data.get("version")
|
||||
comment = _data.get("comment")
|
||||
|
|
@ -1137,7 +1143,7 @@ class IWorkfileHost:
|
|||
)
|
||||
items.append(item)
|
||||
|
||||
for workfile_entity in workfile_entities_by_path.values():
|
||||
for filepath, workfile_entity in workfile_entities_by_path.items():
|
||||
# Workfile entity is not in the filesystem
|
||||
# but it is in the database
|
||||
rootless_path = workfile_entity["path"]
|
||||
|
|
@ -1154,13 +1160,13 @@ class IWorkfileHost:
|
|||
version = parsed_data.version
|
||||
comment = parsed_data.comment
|
||||
|
||||
filepath = list_workfiles_context.anatomy.fill_root(rootless_path)
|
||||
available = os.path.exists(filepath)
|
||||
items.append(WorkfileInfo.new(
|
||||
filepath,
|
||||
rootless_path,
|
||||
version=version,
|
||||
comment=comment,
|
||||
available=False,
|
||||
available=available,
|
||||
workfile_entity=workfile_entity,
|
||||
))
|
||||
|
||||
|
|
@ -1548,6 +1554,27 @@ class IWorkfileHost:
|
|||
if platform.system().lower() == "windows":
|
||||
rootless_path = rootless_path.replace("\\", "/")
|
||||
|
||||
# Get application information
|
||||
app_info = self.get_app_information()
|
||||
data = {}
|
||||
if app_info.app_name:
|
||||
data["app_name"] = app_info.app_name
|
||||
if app_info.app_version:
|
||||
data["app_version"] = app_info.app_version
|
||||
|
||||
# Use app group and app variant from applications addon (if available)
|
||||
app_addon_name = os.environ.get("AYON_APP_NAME")
|
||||
if not app_addon_name:
|
||||
app_addon_name = None
|
||||
|
||||
app_addon_tools_s = os.environ.get("AYON_APP_TOOLS")
|
||||
app_addon_tools = []
|
||||
if app_addon_tools_s:
|
||||
app_addon_tools = app_addon_tools_s.split(";")
|
||||
|
||||
data["ayon_app_name"] = app_addon_name
|
||||
data["ayon_app_tools"] = app_addon_tools
|
||||
|
||||
workfile_info = save_workfile_info(
|
||||
project_name,
|
||||
save_workfile_context.task_entity["id"],
|
||||
|
|
@ -1556,6 +1583,7 @@ class IWorkfileHost:
|
|||
version,
|
||||
comment,
|
||||
description,
|
||||
data=data,
|
||||
workfile_entities=save_workfile_context.workfile_entities,
|
||||
)
|
||||
return workfile_info
|
||||
|
|
|
|||
7
client/ayon_core/host/typing.py
Normal file
7
client/ayon_core/host/typing.py
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
from typing import Optional, TypedDict
|
||||
|
||||
|
||||
class HostContextData(TypedDict):
|
||||
project_name: str
|
||||
folder_path: Optional[str]
|
||||
task_name: Optional[str]
|
||||
|
|
@ -8,6 +8,7 @@ import warnings
|
|||
from datetime import datetime
|
||||
from abc import ABC, abstractmethod
|
||||
from functools import lru_cache
|
||||
from typing import Optional, Any
|
||||
|
||||
import platformdirs
|
||||
import ayon_api
|
||||
|
|
@ -15,26 +16,31 @@ import ayon_api
|
|||
_PLACEHOLDER = object()
|
||||
|
||||
|
||||
# TODO should use 'KeyError' or 'Exception' as base
|
||||
class RegistryItemNotFound(ValueError):
|
||||
"""Raised when the item is not found in the keyring."""
|
||||
|
||||
|
||||
class _Cache:
|
||||
username = None
|
||||
|
||||
|
||||
def _get_ayon_appdirs(*args):
|
||||
def _get_ayon_appdirs(*args: str) -> str:
|
||||
return os.path.join(
|
||||
platformdirs.user_data_dir("AYON", "Ynput"),
|
||||
*args
|
||||
)
|
||||
|
||||
|
||||
def get_ayon_appdirs(*args):
|
||||
def get_ayon_appdirs(*args: str) -> str:
|
||||
"""Local app data directory of AYON client.
|
||||
|
||||
Deprecated:
|
||||
Use 'get_launcher_local_dir' or 'get_launcher_storage_dir' based on
|
||||
use-case. Deprecation added 24/08/09 (0.4.4-dev.1).
|
||||
a use-case. Deprecation added 24/08/09 (0.4.4-dev.1).
|
||||
|
||||
Args:
|
||||
*args (Iterable[str]): Subdirectories/files in local app data dir.
|
||||
*args (Iterable[str]): Subdirectories/files in the local app data dir.
|
||||
|
||||
Returns:
|
||||
str: Path to directory/file in local app data dir.
|
||||
|
|
@ -52,7 +58,7 @@ def get_ayon_appdirs(*args):
|
|||
|
||||
|
||||
def get_launcher_storage_dir(*subdirs: str) -> str:
|
||||
"""Get storage directory for launcher.
|
||||
"""Get a storage directory for launcher.
|
||||
|
||||
Storage directory is used for storing shims, addons, dependencies, etc.
|
||||
|
||||
|
|
@ -77,14 +83,14 @@ def get_launcher_storage_dir(*subdirs: str) -> str:
|
|||
|
||||
|
||||
def get_launcher_local_dir(*subdirs: str) -> str:
|
||||
"""Get local directory for launcher.
|
||||
"""Get a local directory for launcher.
|
||||
|
||||
Local directory is used for storing machine or user specific data.
|
||||
Local directory is used for storing machine or user-specific data.
|
||||
|
||||
The location is user specific.
|
||||
The location is user-specific.
|
||||
|
||||
Note:
|
||||
This function should be called at least once on bootstrap.
|
||||
This function should be called at least once on the bootstrap.
|
||||
|
||||
Args:
|
||||
*subdirs (str): Subdirectories relative to local dir.
|
||||
|
|
@ -101,7 +107,7 @@ def get_launcher_local_dir(*subdirs: str) -> str:
|
|||
|
||||
|
||||
def get_addons_resources_dir(addon_name: str, *args) -> str:
|
||||
"""Get directory for storing resources for addons.
|
||||
"""Get a directory for storing resources for addons.
|
||||
|
||||
Some addons might need to store ad-hoc resources that are not part of
|
||||
addon client package (e.g. because of size). Studio might define
|
||||
|
|
@ -111,7 +117,7 @@ def get_addons_resources_dir(addon_name: str, *args) -> str:
|
|||
|
||||
Args:
|
||||
addon_name (str): Addon name.
|
||||
*args (str): Subfolders in resources directory.
|
||||
*args (str): Subfolders in the resources directory.
|
||||
|
||||
Returns:
|
||||
str: Path to resources directory.
|
||||
|
|
@ -124,6 +130,10 @@ def get_addons_resources_dir(addon_name: str, *args) -> str:
|
|||
return os.path.join(addons_resources_dir, addon_name, *args)
|
||||
|
||||
|
||||
class _FakeException(Exception):
|
||||
"""Placeholder exception used if real exception is not available."""
|
||||
|
||||
|
||||
class AYONSecureRegistry:
|
||||
"""Store information using keyring.
|
||||
|
||||
|
|
@ -134,9 +144,10 @@ class AYONSecureRegistry:
|
|||
identify which data were created by AYON.
|
||||
|
||||
Args:
|
||||
name(str): Name of registry used as identifier for data.
|
||||
name(str): Name of registry used as the identifier for data.
|
||||
|
||||
"""
|
||||
def __init__(self, name):
|
||||
def __init__(self, name: str) -> None:
|
||||
try:
|
||||
import keyring
|
||||
|
||||
|
|
@ -152,13 +163,12 @@ class AYONSecureRegistry:
|
|||
keyring.set_keyring(Windows.WinVaultKeyring())
|
||||
|
||||
# Force "AYON" prefix
|
||||
self._name = "/".join(("AYON", name))
|
||||
self._name = f"AYON/{name}"
|
||||
|
||||
def set_item(self, name, value):
|
||||
# type: (str, str) -> None
|
||||
"""Set sensitive item into system's keyring.
|
||||
def set_item(self, name: str, value: str) -> None:
|
||||
"""Set sensitive item into the system's keyring.
|
||||
|
||||
This uses `Keyring module`_ to save sensitive stuff into system's
|
||||
This uses `Keyring module`_ to save sensitive stuff into the system's
|
||||
keyring.
|
||||
|
||||
Args:
|
||||
|
|
@ -172,22 +182,26 @@ class AYONSecureRegistry:
|
|||
import keyring
|
||||
|
||||
keyring.set_password(self._name, name, value)
|
||||
self.get_item.cache_clear()
|
||||
|
||||
@lru_cache(maxsize=32)
|
||||
def get_item(self, name, default=_PLACEHOLDER):
|
||||
"""Get value of sensitive item from system's keyring.
|
||||
def get_item(
|
||||
self, name: str, default: Any = _PLACEHOLDER
|
||||
) -> Optional[str]:
|
||||
"""Get value of sensitive item from the system's keyring.
|
||||
|
||||
See also `Keyring module`_
|
||||
|
||||
Args:
|
||||
name (str): Name of the item.
|
||||
default (Any): Default value if item is not available.
|
||||
default (Any): Default value if the item is not available.
|
||||
|
||||
Returns:
|
||||
value (str): Value of the item.
|
||||
|
||||
Raises:
|
||||
ValueError: If item doesn't exist and default is not defined.
|
||||
RegistryItemNotFound: If the item doesn't exist and default
|
||||
is not defined.
|
||||
|
||||
.. _Keyring module:
|
||||
https://github.com/jaraco/keyring
|
||||
|
|
@ -195,21 +209,29 @@ class AYONSecureRegistry:
|
|||
"""
|
||||
import keyring
|
||||
|
||||
# Capture 'ItemNotFoundException' exception (on linux)
|
||||
try:
|
||||
from secretstorage.exceptions import ItemNotFoundException
|
||||
except ImportError:
|
||||
ItemNotFoundException = _FakeException
|
||||
|
||||
try:
|
||||
value = keyring.get_password(self._name, name)
|
||||
except ItemNotFoundException:
|
||||
value = None
|
||||
|
||||
if value is not None:
|
||||
return value
|
||||
|
||||
if default is not _PLACEHOLDER:
|
||||
return default
|
||||
|
||||
# NOTE Should raise `KeyError`
|
||||
raise ValueError(
|
||||
"Item {}:{} does not exist in keyring.".format(self._name, name)
|
||||
raise RegistryItemNotFound(
|
||||
f"Item {self._name}:{name} not found in keyring."
|
||||
)
|
||||
|
||||
def delete_item(self, name):
|
||||
# type: (str) -> None
|
||||
"""Delete value stored in system's keyring.
|
||||
def delete_item(self, name: str) -> None:
|
||||
"""Delete value stored in the system's keyring.
|
||||
|
||||
See also `Keyring module`_
|
||||
|
||||
|
|
@ -227,47 +249,38 @@ class AYONSecureRegistry:
|
|||
|
||||
|
||||
class ASettingRegistry(ABC):
|
||||
"""Abstract class defining structure of **SettingRegistry** class.
|
||||
|
||||
It is implementing methods to store secure items into keyring, otherwise
|
||||
mechanism for storing common items must be implemented in abstract
|
||||
methods.
|
||||
|
||||
Attributes:
|
||||
_name (str): Registry names.
|
||||
"""Abstract class to defining structure of registry class.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, name):
|
||||
# type: (str) -> ASettingRegistry
|
||||
super(ASettingRegistry, self).__init__()
|
||||
|
||||
def __init__(self, name: str) -> None:
|
||||
self._name = name
|
||||
self._items = {}
|
||||
|
||||
def set_item(self, name, value):
|
||||
# type: (str, str) -> None
|
||||
"""Set item to settings registry.
|
||||
|
||||
Args:
|
||||
name (str): Name of the item.
|
||||
value (str): Value of the item.
|
||||
|
||||
"""
|
||||
self._set_item(name, value)
|
||||
|
||||
@abstractmethod
|
||||
def _set_item(self, name, value):
|
||||
# type: (str, str) -> None
|
||||
# Implement it
|
||||
pass
|
||||
def _get_item(self, name: str) -> Any:
|
||||
"""Get item value from registry."""
|
||||
|
||||
def __setitem__(self, name, value):
|
||||
self._items[name] = value
|
||||
@abstractmethod
|
||||
def _set_item(self, name: str, value: str) -> None:
|
||||
"""Set item value to registry."""
|
||||
|
||||
@abstractmethod
|
||||
def _delete_item(self, name: str) -> None:
|
||||
"""Delete item from registry."""
|
||||
|
||||
def __getitem__(self, name: str) -> Any:
|
||||
return self._get_item(name)
|
||||
|
||||
def __setitem__(self, name: str, value: str) -> None:
|
||||
self._set_item(name, value)
|
||||
|
||||
def get_item(self, name):
|
||||
# type: (str) -> str
|
||||
def __delitem__(self, name: str) -> None:
|
||||
self._delete_item(name)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self._name
|
||||
|
||||
def get_item(self, name: str) -> str:
|
||||
"""Get item from settings registry.
|
||||
|
||||
Args:
|
||||
|
|
@ -277,22 +290,22 @@ class ASettingRegistry(ABC):
|
|||
value (str): Value of the item.
|
||||
|
||||
Raises:
|
||||
ValueError: If item doesn't exist.
|
||||
RegistryItemNotFound: If the item doesn't exist.
|
||||
|
||||
"""
|
||||
return self._get_item(name)
|
||||
|
||||
@abstractmethod
|
||||
def _get_item(self, name):
|
||||
# type: (str) -> str
|
||||
# Implement it
|
||||
pass
|
||||
def set_item(self, name: str, value: str) -> None:
|
||||
"""Set item to settings registry.
|
||||
|
||||
def __getitem__(self, name):
|
||||
return self._get_item(name)
|
||||
Args:
|
||||
name (str): Name of the item.
|
||||
value (str): Value of the item.
|
||||
|
||||
def delete_item(self, name):
|
||||
# type: (str) -> None
|
||||
"""
|
||||
self._set_item(name, value)
|
||||
|
||||
def delete_item(self, name: str) -> None:
|
||||
"""Delete item from settings registry.
|
||||
|
||||
Args:
|
||||
|
|
@ -301,16 +314,6 @@ class ASettingRegistry(ABC):
|
|||
"""
|
||||
self._delete_item(name)
|
||||
|
||||
@abstractmethod
|
||||
def _delete_item(self, name):
|
||||
# type: (str) -> None
|
||||
"""Delete item from settings."""
|
||||
pass
|
||||
|
||||
def __delitem__(self, name):
|
||||
del self._items[name]
|
||||
self._delete_item(name)
|
||||
|
||||
|
||||
class IniSettingRegistry(ASettingRegistry):
|
||||
"""Class using :mod:`configparser`.
|
||||
|
|
@ -318,20 +321,17 @@ class IniSettingRegistry(ASettingRegistry):
|
|||
This class is using :mod:`configparser` (ini) files to store items.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, name, path):
|
||||
# type: (str, str) -> IniSettingRegistry
|
||||
super(IniSettingRegistry, self).__init__(name)
|
||||
def __init__(self, name: str, path: str) -> None:
|
||||
super().__init__(name)
|
||||
# get registry file
|
||||
self._registry_file = os.path.join(path, "{}.ini".format(name))
|
||||
self._registry_file = os.path.join(path, f"{name}.ini")
|
||||
if not os.path.exists(self._registry_file):
|
||||
with open(self._registry_file, mode="w") as cfg:
|
||||
print("# Settings registry", cfg)
|
||||
now = datetime.now().strftime("%d/%m/%Y %H:%M:%S")
|
||||
print("# {}".format(now), cfg)
|
||||
print(f"# {now}", cfg)
|
||||
|
||||
def set_item_section(self, section, name, value):
|
||||
# type: (str, str, str) -> None
|
||||
def set_item_section(self, section: str, name: str, value: str) -> None:
|
||||
"""Set item to specific section of ini registry.
|
||||
|
||||
If section doesn't exists, it is created.
|
||||
|
|
@ -354,12 +354,10 @@ class IniSettingRegistry(ASettingRegistry):
|
|||
with open(self._registry_file, mode="w") as cfg:
|
||||
config.write(cfg)
|
||||
|
||||
def _set_item(self, name, value):
|
||||
# type: (str, str) -> None
|
||||
def _set_item(self, name: str, value: str) -> None:
|
||||
self.set_item_section("MAIN", name, value)
|
||||
|
||||
def set_item(self, name, value):
|
||||
# type: (str, str) -> None
|
||||
def set_item(self, name: str, value: str) -> None:
|
||||
"""Set item to settings ini file.
|
||||
|
||||
This saves item to ``DEFAULT`` section of ini as each item there
|
||||
|
|
@ -372,10 +370,9 @@ class IniSettingRegistry(ASettingRegistry):
|
|||
"""
|
||||
# this does the some, overridden just for different docstring.
|
||||
# we cast value to str as ini options values must be strings.
|
||||
super(IniSettingRegistry, self).set_item(name, str(value))
|
||||
super().set_item(name, str(value))
|
||||
|
||||
def get_item(self, name):
|
||||
# type: (str) -> str
|
||||
def get_item(self, name: str) -> str:
|
||||
"""Gets item from settings ini file.
|
||||
|
||||
This gets settings from ``DEFAULT`` section of ini file as each item
|
||||
|
|
@ -388,19 +385,18 @@ class IniSettingRegistry(ASettingRegistry):
|
|||
str: Value of item.
|
||||
|
||||
Raises:
|
||||
ValueError: If value doesn't exist.
|
||||
RegistryItemNotFound: If value doesn't exist.
|
||||
|
||||
"""
|
||||
return super(IniSettingRegistry, self).get_item(name)
|
||||
return super().get_item(name)
|
||||
|
||||
@lru_cache(maxsize=32)
|
||||
def get_item_from_section(self, section, name):
|
||||
# type: (str, str) -> str
|
||||
def get_item_from_section(self, section: str, name: str) -> str:
|
||||
"""Get item from section of ini file.
|
||||
|
||||
This will read ini file and try to get item value from specified
|
||||
section. If that section or item doesn't exist, :exc:`ValueError`
|
||||
is risen.
|
||||
section. If that section or item doesn't exist,
|
||||
:exc:`RegistryItemNotFound` is risen.
|
||||
|
||||
Args:
|
||||
section (str): Name of ini section.
|
||||
|
|
@ -410,7 +406,7 @@ class IniSettingRegistry(ASettingRegistry):
|
|||
str: Item value.
|
||||
|
||||
Raises:
|
||||
ValueError: If value doesn't exist.
|
||||
RegistryItemNotFound: If value doesn't exist.
|
||||
|
||||
"""
|
||||
config = configparser.ConfigParser()
|
||||
|
|
@ -418,16 +414,15 @@ class IniSettingRegistry(ASettingRegistry):
|
|||
try:
|
||||
value = config[section][name]
|
||||
except KeyError:
|
||||
raise ValueError(
|
||||
"Registry doesn't contain value {}:{}".format(section, name))
|
||||
raise RegistryItemNotFound(
|
||||
f"Registry doesn't contain value {section}:{name}"
|
||||
)
|
||||
return value
|
||||
|
||||
def _get_item(self, name):
|
||||
# type: (str) -> str
|
||||
def _get_item(self, name: str) -> str:
|
||||
return self.get_item_from_section("MAIN", name)
|
||||
|
||||
def delete_item_from_section(self, section, name):
|
||||
# type: (str, str) -> None
|
||||
def delete_item_from_section(self, section: str, name: str) -> None:
|
||||
"""Delete item from section in ini file.
|
||||
|
||||
Args:
|
||||
|
|
@ -435,7 +430,7 @@ class IniSettingRegistry(ASettingRegistry):
|
|||
name (str): Name of the item.
|
||||
|
||||
Raises:
|
||||
ValueError: If item doesn't exist.
|
||||
RegistryItemNotFound: If the item doesn't exist.
|
||||
|
||||
"""
|
||||
self.get_item_from_section.cache_clear()
|
||||
|
|
@ -444,8 +439,9 @@ class IniSettingRegistry(ASettingRegistry):
|
|||
try:
|
||||
_ = config[section][name]
|
||||
except KeyError:
|
||||
raise ValueError(
|
||||
"Registry doesn't contain value {}:{}".format(section, name))
|
||||
raise RegistryItemNotFound(
|
||||
f"Registry doesn't contain value {section}:{name}"
|
||||
)
|
||||
config.remove_option(section, name)
|
||||
|
||||
# if section is empty, delete it
|
||||
|
|
@ -461,29 +457,28 @@ class IniSettingRegistry(ASettingRegistry):
|
|||
|
||||
|
||||
class JSONSettingRegistry(ASettingRegistry):
|
||||
"""Class using json file as storage."""
|
||||
"""Class using a json file as storage."""
|
||||
|
||||
def __init__(self, name, path):
|
||||
# type: (str, str) -> JSONSettingRegistry
|
||||
super(JSONSettingRegistry, self).__init__(name)
|
||||
#: str: name of registry file
|
||||
self._registry_file = os.path.join(path, "{}.json".format(name))
|
||||
def __init__(self, name: str, path: str) -> None:
|
||||
super().__init__(name)
|
||||
self._registry_file = os.path.join(path, f"{name}.json")
|
||||
now = datetime.now().strftime("%d/%m/%Y %H:%M:%S")
|
||||
header = {
|
||||
"__metadata__": {"generated": now},
|
||||
"registry": {}
|
||||
}
|
||||
|
||||
if not os.path.exists(os.path.dirname(self._registry_file)):
|
||||
os.makedirs(os.path.dirname(self._registry_file), exist_ok=True)
|
||||
# Use 'os.path.dirname' in case someone uses slashes in 'name'
|
||||
dirpath = os.path.dirname(self._registry_file)
|
||||
if not os.path.exists(dirpath):
|
||||
os.makedirs(dirpath, exist_ok=True)
|
||||
if not os.path.exists(self._registry_file):
|
||||
with open(self._registry_file, mode="w") as cfg:
|
||||
json.dump(header, cfg, indent=4)
|
||||
|
||||
@lru_cache(maxsize=32)
|
||||
def _get_item(self, name):
|
||||
# type: (str) -> object
|
||||
"""Get item value from registry json.
|
||||
def _get_item(self, name: str) -> str:
|
||||
"""Get item value from the registry.
|
||||
|
||||
Note:
|
||||
See :meth:`ayon_core.lib.JSONSettingRegistry.get_item`
|
||||
|
|
@ -494,29 +489,13 @@ class JSONSettingRegistry(ASettingRegistry):
|
|||
try:
|
||||
value = data["registry"][name]
|
||||
except KeyError:
|
||||
raise ValueError(
|
||||
"Registry doesn't contain value {}".format(name))
|
||||
raise RegistryItemNotFound(
|
||||
f"Registry doesn't contain value {name}"
|
||||
)
|
||||
return value
|
||||
|
||||
def get_item(self, name):
|
||||
# type: (str) -> object
|
||||
"""Get item value from registry json.
|
||||
|
||||
Args:
|
||||
name (str): Name of the item.
|
||||
|
||||
Returns:
|
||||
value of the item
|
||||
|
||||
Raises:
|
||||
ValueError: If item is not found in registry file.
|
||||
|
||||
"""
|
||||
return self._get_item(name)
|
||||
|
||||
def _set_item(self, name, value):
|
||||
# type: (str, object) -> None
|
||||
"""Set item value to registry json.
|
||||
def _set_item(self, name: str, value: str) -> None:
|
||||
"""Set item value to the registry.
|
||||
|
||||
Note:
|
||||
See :meth:`ayon_core.lib.JSONSettingRegistry.set_item`
|
||||
|
|
@ -528,41 +507,39 @@ class JSONSettingRegistry(ASettingRegistry):
|
|||
cfg.truncate(0)
|
||||
cfg.seek(0)
|
||||
json.dump(data, cfg, indent=4)
|
||||
|
||||
def set_item(self, name, value):
|
||||
# type: (str, object) -> None
|
||||
"""Set item and its value into json registry file.
|
||||
|
||||
Args:
|
||||
name (str): name of the item.
|
||||
value (Any): value of the item.
|
||||
|
||||
"""
|
||||
self._set_item(name, value)
|
||||
|
||||
def _delete_item(self, name):
|
||||
# type: (str) -> None
|
||||
self._get_item.cache_clear()
|
||||
|
||||
def _delete_item(self, name: str) -> None:
|
||||
with open(self._registry_file, "r+") as cfg:
|
||||
data = json.load(cfg)
|
||||
del data["registry"][name]
|
||||
cfg.truncate(0)
|
||||
cfg.seek(0)
|
||||
json.dump(data, cfg, indent=4)
|
||||
self._get_item.cache_clear()
|
||||
|
||||
|
||||
class AYONSettingsRegistry(JSONSettingRegistry):
|
||||
"""Class handling AYON general settings registry.
|
||||
|
||||
Args:
|
||||
name (Optional[str]): Name of the registry.
|
||||
"""
|
||||
name (Optional[str]): Name of the registry. Using 'None' or not
|
||||
passing name is deprecated.
|
||||
|
||||
def __init__(self, name=None):
|
||||
"""
|
||||
def __init__(self, name: Optional[str] = None) -> None:
|
||||
if not name:
|
||||
name = "AYON_settings"
|
||||
warnings.warn(
|
||||
(
|
||||
"Used 'AYONSettingsRegistry' without 'name' argument."
|
||||
" The argument will be required in future versions."
|
||||
),
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
path = get_launcher_storage_dir()
|
||||
super(AYONSettingsRegistry, self).__init__(name, path)
|
||||
super().__init__(name, path)
|
||||
|
||||
|
||||
def get_local_site_id():
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import collections
|
|||
import tempfile
|
||||
import subprocess
|
||||
import platform
|
||||
import warnings
|
||||
import functools
|
||||
from typing import Optional
|
||||
|
||||
import xml.etree.ElementTree
|
||||
|
|
@ -67,6 +69,47 @@ VIDEO_EXTENSIONS = {
|
|||
}
|
||||
|
||||
|
||||
def deprecated(new_destination):
|
||||
"""Mark functions as deprecated.
|
||||
|
||||
It will result in a warning being emitted when the function is used.
|
||||
"""
|
||||
|
||||
func = None
|
||||
if callable(new_destination):
|
||||
func = new_destination
|
||||
new_destination = None
|
||||
|
||||
def _decorator(decorated_func):
|
||||
if new_destination is None:
|
||||
warning_message = (
|
||||
" Please check content of deprecated function to figure out"
|
||||
" possible replacement."
|
||||
)
|
||||
else:
|
||||
warning_message = " Please replace your usage with '{}'.".format(
|
||||
new_destination
|
||||
)
|
||||
|
||||
@functools.wraps(decorated_func)
|
||||
def wrapper(*args, **kwargs):
|
||||
warnings.simplefilter("always", DeprecationWarning)
|
||||
warnings.warn(
|
||||
(
|
||||
"Call to deprecated function '{}'"
|
||||
"\nFunction was moved or removed.{}"
|
||||
).format(decorated_func.__name__, warning_message),
|
||||
category=DeprecationWarning,
|
||||
stacklevel=4
|
||||
)
|
||||
return decorated_func(*args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
if func is None:
|
||||
return _decorator
|
||||
return _decorator(func)
|
||||
|
||||
|
||||
def get_transcode_temp_directory():
|
||||
"""Creates temporary folder for transcoding.
|
||||
|
||||
|
|
@ -966,6 +1009,8 @@ def convert_ffprobe_fps_to_float(value):
|
|||
return dividend / divisor
|
||||
|
||||
|
||||
# --- Deprecated functions ---
|
||||
@deprecated("oiio_color_convert")
|
||||
def convert_colorspace(
|
||||
input_path,
|
||||
output_path,
|
||||
|
|
@ -977,7 +1022,62 @@ def convert_colorspace(
|
|||
additional_command_args=None,
|
||||
logger=None,
|
||||
):
|
||||
"""Convert source file from one color space to another.
|
||||
"""DEPRECATED function use `oiio_color_convert` instead
|
||||
|
||||
Args:
|
||||
input_path (str): Path to input file that should be converted.
|
||||
output_path (str): Path to output file where result will be stored.
|
||||
config_path (str): Path to OCIO config file.
|
||||
source_colorspace (str): OCIO valid color space of source files.
|
||||
target_colorspace (str, optional): OCIO valid target color space.
|
||||
If filled, 'view' and 'display' must be empty.
|
||||
view (str, optional): Name for target viewer space (OCIO valid).
|
||||
Both 'view' and 'display' must be filled
|
||||
(if not 'target_colorspace').
|
||||
display (str, optional): Name for target display-referred
|
||||
reference space. Both 'view' and 'display' must be filled
|
||||
(if not 'target_colorspace').
|
||||
additional_command_args (list, optional): Additional arguments
|
||||
for oiiotool (like binary depth for .dpx).
|
||||
logger (logging.Logger, optional): Logger used for logging.
|
||||
|
||||
Returns:
|
||||
None: Function returns None.
|
||||
|
||||
Raises:
|
||||
ValueError: If parameters are misconfigured.
|
||||
"""
|
||||
return oiio_color_convert(
|
||||
input_path,
|
||||
output_path,
|
||||
config_path,
|
||||
source_colorspace,
|
||||
target_colorspace=target_colorspace,
|
||||
target_display=display,
|
||||
target_view=view,
|
||||
additional_command_args=additional_command_args,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
|
||||
def oiio_color_convert(
|
||||
input_path,
|
||||
output_path,
|
||||
config_path,
|
||||
source_colorspace,
|
||||
source_display=None,
|
||||
source_view=None,
|
||||
target_colorspace=None,
|
||||
target_display=None,
|
||||
target_view=None,
|
||||
additional_command_args=None,
|
||||
logger=None,
|
||||
):
|
||||
"""Transcode source file to other with colormanagement.
|
||||
|
||||
Oiiotool also support additional arguments for transcoding.
|
||||
For more information, see the official documentation:
|
||||
https://openimageio.readthedocs.io/en/latest/oiiotool.html
|
||||
|
||||
Args:
|
||||
input_path (str): Path that should be converted. It is expected that
|
||||
|
|
@ -989,17 +1089,26 @@ def convert_colorspace(
|
|||
sequence in 'file.FRAMESTART-FRAMEEND#.ext', `output.1-3#.tif`)
|
||||
config_path (str): path to OCIO config file
|
||||
source_colorspace (str): ocio valid color space of source files
|
||||
source_display (str, optional): name for source display-referred
|
||||
reference space (ocio valid). If provided, source_view must also be
|
||||
provided, and source_colorspace will be ignored
|
||||
source_view (str, optional): name for source viewer space (ocio valid)
|
||||
If provided, source_display must also be provided, and
|
||||
source_colorspace will be ignored
|
||||
target_colorspace (str): ocio valid target color space
|
||||
if filled, 'view' and 'display' must be empty
|
||||
view (str): name for viewer space (ocio valid)
|
||||
both 'view' and 'display' must be filled (if 'target_colorspace')
|
||||
display (str): name for display-referred reference space (ocio valid)
|
||||
target_display (str): name for target display-referred reference space
|
||||
(ocio valid) both 'view' and 'display' must be filled (if
|
||||
'target_colorspace')
|
||||
target_view (str): name for target viewer space (ocio valid)
|
||||
both 'view' and 'display' must be filled (if 'target_colorspace')
|
||||
additional_command_args (list): arguments for oiiotool (like binary
|
||||
depth for .dpx)
|
||||
logger (logging.Logger): Logger used for logging.
|
||||
|
||||
Raises:
|
||||
ValueError: if misconfigured
|
||||
|
||||
"""
|
||||
if logger is None:
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -1024,23 +1133,82 @@ def convert_colorspace(
|
|||
"--ch", channels_arg
|
||||
])
|
||||
|
||||
if all([target_colorspace, view, display]):
|
||||
raise ValueError("Colorspace and both screen and display"
|
||||
" cannot be set together."
|
||||
"Choose colorspace or screen and display")
|
||||
if not target_colorspace and not all([view, display]):
|
||||
raise ValueError("Both screen and display must be set.")
|
||||
# Validate input parameters
|
||||
if target_colorspace and target_view and target_display:
|
||||
raise ValueError(
|
||||
"Colorspace and both view and display cannot be set together."
|
||||
"Choose colorspace or screen and display"
|
||||
)
|
||||
|
||||
if not target_colorspace and not target_view and not target_display:
|
||||
raise ValueError(
|
||||
"Both view and display must be set if target_colorspace is not "
|
||||
"provided."
|
||||
)
|
||||
|
||||
if (
|
||||
(source_view and not source_display)
|
||||
or (source_display and not source_view)
|
||||
):
|
||||
raise ValueError(
|
||||
"Both source_view and source_display must be provided if using "
|
||||
"display/view inputs."
|
||||
)
|
||||
|
||||
if source_view and source_display and source_colorspace:
|
||||
logger.warning(
|
||||
"Both source display/view and source_colorspace provided. "
|
||||
"Using source display/view pair and ignoring source_colorspace."
|
||||
)
|
||||
|
||||
if additional_command_args:
|
||||
oiio_cmd.extend(additional_command_args)
|
||||
|
||||
# Handle the different conversion cases
|
||||
# Source view and display are known
|
||||
if source_view and source_display:
|
||||
if target_colorspace:
|
||||
oiio_cmd.extend(["--colorconvert:subimages=0",
|
||||
# This is a two-step conversion process since there's no direct
|
||||
# display/view to colorspace command
|
||||
# This could be a config parameter or determined from OCIO config
|
||||
# Use temporarty role space 'scene_linear'
|
||||
color_convert_args = ("scene_linear", target_colorspace)
|
||||
elif source_display != target_display or source_view != target_view:
|
||||
# Complete display/view pair conversion
|
||||
# - go through a reference space
|
||||
color_convert_args = (target_display, target_view)
|
||||
else:
|
||||
color_convert_args = None
|
||||
logger.debug(
|
||||
"Source and target display/view pairs are identical."
|
||||
" No color conversion needed."
|
||||
)
|
||||
|
||||
if color_convert_args:
|
||||
oiio_cmd.extend([
|
||||
"--ociodisplay:inverse=1:subimages=0",
|
||||
source_display,
|
||||
source_view,
|
||||
"--colorconvert:subimages=0",
|
||||
*color_convert_args
|
||||
])
|
||||
|
||||
elif target_colorspace:
|
||||
# Standard color space to color space conversion
|
||||
oiio_cmd.extend([
|
||||
"--colorconvert:subimages=0",
|
||||
source_colorspace,
|
||||
target_colorspace])
|
||||
if view and display:
|
||||
oiio_cmd.extend(["--iscolorspace", source_colorspace])
|
||||
oiio_cmd.extend(["--ociodisplay:subimages=0", display, view])
|
||||
target_colorspace,
|
||||
])
|
||||
else:
|
||||
# Standard conversion from colorspace to display/view
|
||||
oiio_cmd.extend([
|
||||
"--iscolorspace",
|
||||
source_colorspace,
|
||||
"--ociodisplay:subimages=0",
|
||||
target_display,
|
||||
target_view,
|
||||
])
|
||||
|
||||
oiio_cmd.extend(["-o", output_path])
|
||||
|
||||
|
|
|
|||
|
|
@ -19,11 +19,7 @@ from .create import (
|
|||
CreatedInstance,
|
||||
CreatorError,
|
||||
|
||||
LegacyCreator,
|
||||
legacy_create,
|
||||
|
||||
discover_creator_plugins,
|
||||
discover_legacy_creator_plugins,
|
||||
register_creator_plugin,
|
||||
deregister_creator_plugin,
|
||||
register_creator_plugin_path,
|
||||
|
|
@ -141,12 +137,7 @@ __all__ = (
|
|||
|
||||
"CreatorError",
|
||||
|
||||
# - legacy creation
|
||||
"LegacyCreator",
|
||||
"legacy_create",
|
||||
|
||||
"discover_creator_plugins",
|
||||
"discover_legacy_creator_plugins",
|
||||
"register_creator_plugin",
|
||||
"deregister_creator_plugin",
|
||||
"register_creator_plugin_path",
|
||||
|
|
|
|||
|
|
@ -37,16 +37,19 @@ class LauncherActionSelection:
|
|||
project_name,
|
||||
folder_id,
|
||||
task_id,
|
||||
workfile_id,
|
||||
folder_path=None,
|
||||
task_name=None,
|
||||
project_entity=None,
|
||||
folder_entity=None,
|
||||
task_entity=None,
|
||||
workfile_entity=None,
|
||||
project_settings=None,
|
||||
):
|
||||
self._project_name = project_name
|
||||
self._folder_id = folder_id
|
||||
self._task_id = task_id
|
||||
self._workfile_id = workfile_id
|
||||
|
||||
self._folder_path = folder_path
|
||||
self._task_name = task_name
|
||||
|
|
@ -54,6 +57,7 @@ class LauncherActionSelection:
|
|||
self._project_entity = project_entity
|
||||
self._folder_entity = folder_entity
|
||||
self._task_entity = task_entity
|
||||
self._workfile_entity = workfile_entity
|
||||
|
||||
self._project_settings = project_settings
|
||||
|
||||
|
|
@ -213,6 +217,15 @@ class LauncherActionSelection:
|
|||
self._task_name = self.task_entity["name"]
|
||||
return self._task_name
|
||||
|
||||
def get_workfile_id(self):
|
||||
"""Selected workfile id.
|
||||
|
||||
Returns:
|
||||
Union[str, None]: Selected workfile id.
|
||||
|
||||
"""
|
||||
return self._workfile_id
|
||||
|
||||
def get_project_entity(self):
|
||||
"""Project entity for the selection.
|
||||
|
||||
|
|
@ -259,6 +272,24 @@ class LauncherActionSelection:
|
|||
)
|
||||
return self._task_entity
|
||||
|
||||
def get_workfile_entity(self):
|
||||
"""Workfile entity for the selection.
|
||||
|
||||
Returns:
|
||||
Union[dict[str, Any], None]: Workfile entity.
|
||||
|
||||
"""
|
||||
if (
|
||||
self._project_name is None
|
||||
or self._workfile_id is None
|
||||
):
|
||||
return None
|
||||
if self._workfile_entity is None:
|
||||
self._workfile_entity = ayon_api.get_workfile_info_by_id(
|
||||
self._project_name, self._workfile_id
|
||||
)
|
||||
return self._workfile_entity
|
||||
|
||||
def get_project_settings(self):
|
||||
"""Project settings for the selection.
|
||||
|
||||
|
|
@ -305,15 +336,27 @@ class LauncherActionSelection:
|
|||
"""
|
||||
return self._task_id is not None
|
||||
|
||||
@property
|
||||
def is_workfile_selected(self):
|
||||
"""Return whether a task is selected.
|
||||
|
||||
Returns:
|
||||
bool: Whether a task is selected.
|
||||
|
||||
"""
|
||||
return self._workfile_id is not None
|
||||
|
||||
project_name = property(get_project_name)
|
||||
folder_id = property(get_folder_id)
|
||||
task_id = property(get_task_id)
|
||||
workfile_id = property(get_workfile_id)
|
||||
folder_path = property(get_folder_path)
|
||||
task_name = property(get_task_name)
|
||||
|
||||
project_entity = property(get_project_entity)
|
||||
folder_entity = property(get_folder_entity)
|
||||
task_entity = property(get_task_entity)
|
||||
workfile_entity = property(get_workfile_entity)
|
||||
|
||||
|
||||
class LauncherAction(object):
|
||||
|
|
|
|||
|
|
@ -1403,7 +1403,12 @@ def _get_display_view_colorspace_name(config_path, display, view):
|
|||
|
||||
"""
|
||||
config = _get_ocio_config(config_path)
|
||||
return config.getDisplayViewColorSpaceName(display, view)
|
||||
colorspace = config.getDisplayViewColorSpaceName(display, view)
|
||||
# Special token. See https://opencolorio.readthedocs.io/en/latest/guides/authoring/authoring.html#shared-views # noqa
|
||||
if colorspace == "<USE_DISPLAY_NAME>":
|
||||
colorspace = display
|
||||
|
||||
return colorspace
|
||||
|
||||
|
||||
def _get_ocio_config_colorspaces(config_path):
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"""Core pipeline functionality"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import logging
|
||||
import platform
|
||||
|
|
@ -12,11 +13,10 @@ import pyblish.api
|
|||
from pyblish.lib import MessageHandler
|
||||
|
||||
from ayon_core import AYON_CORE_ROOT
|
||||
from ayon_core.host import HostBase
|
||||
from ayon_core.host import AbstractHost
|
||||
from ayon_core.lib import (
|
||||
is_in_tests,
|
||||
initialize_ayon_connection,
|
||||
version_up
|
||||
)
|
||||
from ayon_core.addon import load_addons, AddonsManager
|
||||
from ayon_core.settings import get_project_settings
|
||||
|
|
@ -24,12 +24,7 @@ from ayon_core.settings import get_project_settings
|
|||
from .publish.lib import filter_pyblish_plugins
|
||||
from .anatomy import Anatomy
|
||||
from .template_data import get_template_data_with_names
|
||||
from .workfile import (
|
||||
get_custom_workfile_template_by_string_context,
|
||||
get_workfile_template_key_from_context,
|
||||
get_last_workfile,
|
||||
MissingWorkdirError,
|
||||
)
|
||||
from .workfile import get_custom_workfile_template_by_string_context
|
||||
from . import (
|
||||
register_loader_plugin_path,
|
||||
register_inventory_action_path,
|
||||
|
|
@ -75,7 +70,7 @@ def _get_addons_manager():
|
|||
|
||||
|
||||
def register_root(path):
|
||||
"""Register currently active root"""
|
||||
"""DEPRECATED Register currently active root."""
|
||||
log.info("Registering root: %s" % path)
|
||||
_registered_root["_"] = path
|
||||
|
||||
|
|
@ -94,18 +89,29 @@ def registered_root():
|
|||
|
||||
Returns:
|
||||
dict[str, str]: Root paths.
|
||||
"""
|
||||
|
||||
"""
|
||||
warnings.warn(
|
||||
"Used deprecated function 'registered_root'. Please use 'Anatomy'"
|
||||
" to get roots.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
return _registered_root["_"]
|
||||
|
||||
|
||||
def install_host(host):
|
||||
def install_host(host: AbstractHost) -> None:
|
||||
"""Install `host` into the running Python session.
|
||||
|
||||
Args:
|
||||
host (HostBase): A host interface object.
|
||||
host (AbstractHost): A host interface object.
|
||||
|
||||
"""
|
||||
if not isinstance(host, AbstractHost):
|
||||
log.error(
|
||||
f"Host must be a subclass of 'AbstractHost', got '{type(host)}'."
|
||||
)
|
||||
|
||||
global _is_installed
|
||||
|
||||
_is_installed = True
|
||||
|
|
@ -183,7 +189,7 @@ def install_ayon_plugins(project_name=None, host_name=None):
|
|||
register_inventory_action_path(INVENTORY_PATH)
|
||||
|
||||
if host_name is None:
|
||||
host_name = os.environ.get("AYON_HOST_NAME")
|
||||
host_name = get_current_host_name()
|
||||
|
||||
addons_manager = _get_addons_manager()
|
||||
publish_plugin_dirs = addons_manager.collect_publish_plugin_paths(
|
||||
|
|
@ -304,7 +310,7 @@ def get_current_host_name():
|
|||
"""
|
||||
|
||||
host = registered_host()
|
||||
if isinstance(host, HostBase):
|
||||
if isinstance(host, AbstractHost):
|
||||
return host.name
|
||||
return os.environ.get("AYON_HOST_NAME")
|
||||
|
||||
|
|
@ -340,32 +346,50 @@ def get_global_context():
|
|||
|
||||
def get_current_context():
|
||||
host = registered_host()
|
||||
if isinstance(host, HostBase):
|
||||
if isinstance(host, AbstractHost):
|
||||
return host.get_current_context()
|
||||
return get_global_context()
|
||||
|
||||
|
||||
def get_current_project_name():
|
||||
host = registered_host()
|
||||
if isinstance(host, HostBase):
|
||||
if isinstance(host, AbstractHost):
|
||||
return host.get_current_project_name()
|
||||
return get_global_context()["project_name"]
|
||||
|
||||
|
||||
def get_current_folder_path():
|
||||
host = registered_host()
|
||||
if isinstance(host, HostBase):
|
||||
if isinstance(host, AbstractHost):
|
||||
return host.get_current_folder_path()
|
||||
return get_global_context()["folder_path"]
|
||||
|
||||
|
||||
def get_current_task_name():
|
||||
host = registered_host()
|
||||
if isinstance(host, HostBase):
|
||||
if isinstance(host, AbstractHost):
|
||||
return host.get_current_task_name()
|
||||
return get_global_context()["task_name"]
|
||||
|
||||
|
||||
def get_current_project_settings() -> dict[str, Any]:
|
||||
"""Project settings for the current context project.
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: Project settings for the current context project.
|
||||
|
||||
Raises:
|
||||
ValueError: If current project is not set.
|
||||
|
||||
"""
|
||||
project_name = get_current_project_name()
|
||||
if not project_name:
|
||||
raise ValueError(
|
||||
"Current project is not set. Can't get project settings."
|
||||
)
|
||||
return get_project_settings(project_name)
|
||||
|
||||
|
||||
def get_current_project_entity(fields=None):
|
||||
"""Helper function to get project document based on global Session.
|
||||
|
||||
|
|
@ -552,6 +576,7 @@ def change_current_context(
|
|||
" It is not necessary to pass it in anymore."
|
||||
),
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
host = registered_host()
|
||||
|
|
@ -580,53 +605,16 @@ def get_process_id():
|
|||
|
||||
|
||||
def version_up_current_workfile():
|
||||
"""Function to increment and save workfile
|
||||
"""DEPRECATED Function to increment and save workfile.
|
||||
|
||||
Please use 'save_next_version' from 'ayon_core.pipeline.workfile' instead.
|
||||
|
||||
"""
|
||||
host = registered_host()
|
||||
|
||||
project_name = get_current_project_name()
|
||||
folder_path = get_current_folder_path()
|
||||
task_name = get_current_task_name()
|
||||
host_name = get_current_host_name()
|
||||
|
||||
template_key = get_workfile_template_key_from_context(
|
||||
project_name,
|
||||
folder_path,
|
||||
task_name,
|
||||
host_name,
|
||||
warnings.warn(
|
||||
"Used deprecated 'version_up_current_workfile' please use"
|
||||
" 'save_next_version' from 'ayon_core.pipeline.workfile' instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
anatomy = Anatomy(project_name)
|
||||
|
||||
data = get_template_data_with_names(
|
||||
project_name, folder_path, task_name, host_name
|
||||
)
|
||||
data["root"] = anatomy.roots
|
||||
|
||||
work_template = anatomy.get_template_item("work", template_key)
|
||||
|
||||
# Define saving file extension
|
||||
extensions = host.get_workfile_extensions()
|
||||
current_file = host.get_current_workfile()
|
||||
if current_file:
|
||||
extensions = [os.path.splitext(current_file)[-1]]
|
||||
|
||||
work_root = work_template["directory"].format_strict(data)
|
||||
file_template = work_template["file"].template
|
||||
last_workfile_path = get_last_workfile(
|
||||
work_root, file_template, data, extensions, True
|
||||
)
|
||||
# `get_last_workfile` will return the first expected file version
|
||||
# if no files exist yet. In that case, if they do not exist we will
|
||||
# want to save v001
|
||||
new_workfile_path = last_workfile_path
|
||||
if os.path.exists(new_workfile_path):
|
||||
new_workfile_path = version_up(new_workfile_path)
|
||||
|
||||
# Raise an error if the parent folder doesn't exist as `host.save_workfile`
|
||||
# is not supposed/able to create missing folders.
|
||||
parent_folder = os.path.dirname(new_workfile_path)
|
||||
if not os.path.exists(parent_folder):
|
||||
raise MissingWorkdirError(
|
||||
f"Work area directory '{parent_folder}' does not exist.")
|
||||
|
||||
host.save_workfile(new_workfile_path)
|
||||
from ayon_core.pipeline.workfile import save_next_version
|
||||
save_next_version()
|
||||
|
|
|
|||
|
|
@ -21,12 +21,14 @@ from .exceptions import (
|
|||
TemplateFillError,
|
||||
)
|
||||
from .structures import (
|
||||
ParentFlags,
|
||||
CreatedInstance,
|
||||
ConvertorItem,
|
||||
AttributeValues,
|
||||
CreatorAttributeValues,
|
||||
PublishAttributeValues,
|
||||
PublishAttributes,
|
||||
InstanceContextInfo,
|
||||
)
|
||||
from .utils import (
|
||||
get_last_versions_for_instances,
|
||||
|
|
@ -44,9 +46,6 @@ from .creator_plugins import (
|
|||
AutoCreator,
|
||||
HiddenCreator,
|
||||
|
||||
discover_legacy_creator_plugins,
|
||||
get_legacy_creator_by_name,
|
||||
|
||||
discover_creator_plugins,
|
||||
register_creator_plugin,
|
||||
deregister_creator_plugin,
|
||||
|
|
@ -58,11 +57,6 @@ from .creator_plugins import (
|
|||
|
||||
from .context import CreateContext
|
||||
|
||||
from .legacy_create import (
|
||||
LegacyCreator,
|
||||
legacy_create,
|
||||
)
|
||||
|
||||
|
||||
__all__ = (
|
||||
"PRODUCT_NAME_ALLOWED_SYMBOLS",
|
||||
|
|
@ -85,12 +79,14 @@ __all__ = (
|
|||
"TaskNotSetError",
|
||||
"TemplateFillError",
|
||||
|
||||
"ParentFlags",
|
||||
"CreatedInstance",
|
||||
"ConvertorItem",
|
||||
"AttributeValues",
|
||||
"CreatorAttributeValues",
|
||||
"PublishAttributeValues",
|
||||
"PublishAttributes",
|
||||
"InstanceContextInfo",
|
||||
|
||||
"get_last_versions_for_instances",
|
||||
"get_next_versions_for_instances",
|
||||
|
|
@ -105,9 +101,6 @@ __all__ = (
|
|||
"AutoCreator",
|
||||
"HiddenCreator",
|
||||
|
||||
"discover_legacy_creator_plugins",
|
||||
"get_legacy_creator_by_name",
|
||||
|
||||
"discover_creator_plugins",
|
||||
"register_creator_plugin",
|
||||
"deregister_creator_plugin",
|
||||
|
|
@ -117,7 +110,4 @@ __all__ = (
|
|||
"cache_and_get_instances",
|
||||
|
||||
"CreateContext",
|
||||
|
||||
"LegacyCreator",
|
||||
"legacy_create",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -41,7 +41,12 @@ from .exceptions import (
|
|||
HostMissRequiredMethod,
|
||||
)
|
||||
from .changes import TrackChangesItem
|
||||
from .structures import PublishAttributes, ConvertorItem, InstanceContextInfo
|
||||
from .structures import (
|
||||
PublishAttributes,
|
||||
ConvertorItem,
|
||||
InstanceContextInfo,
|
||||
ParentFlags,
|
||||
)
|
||||
from .creator_plugins import (
|
||||
Creator,
|
||||
AutoCreator,
|
||||
|
|
@ -49,15 +54,12 @@ from .creator_plugins import (
|
|||
discover_convertor_plugins,
|
||||
)
|
||||
if typing.TYPE_CHECKING:
|
||||
from ayon_core.host import HostBase
|
||||
from ayon_core.lib import AbstractAttrDef
|
||||
from ayon_core.lib.events import EventCallback, Event
|
||||
|
||||
from .structures import CreatedInstance
|
||||
from .creator_plugins import BaseCreator
|
||||
|
||||
class PublishHost(HostBase, IPublishHost):
|
||||
pass
|
||||
|
||||
# Import of functions and classes that were moved to different file
|
||||
# TODO Should be removed in future release - Added 24/08/28, 0.4.3-dev.1
|
||||
|
|
@ -80,6 +82,7 @@ INSTANCE_ADDED_TOPIC = "instances.added"
|
|||
INSTANCE_REMOVED_TOPIC = "instances.removed"
|
||||
VALUE_CHANGED_TOPIC = "values.changed"
|
||||
INSTANCE_REQUIREMENT_CHANGED_TOPIC = "instance.requirement.changed"
|
||||
INSTANCE_PARENT_CHANGED_TOPIC = "instance.parent.changed"
|
||||
PRE_CREATE_ATTR_DEFS_CHANGED_TOPIC = "pre.create.attr.defs.changed"
|
||||
CREATE_ATTR_DEFS_CHANGED_TOPIC = "create.attr.defs.changed"
|
||||
PUBLISH_ATTR_DEFS_CHANGED_TOPIC = "publish.attr.defs.changed"
|
||||
|
|
@ -163,7 +166,7 @@ class CreateContext:
|
|||
context which should be handled by host.
|
||||
|
||||
Args:
|
||||
host (PublishHost): Host implementation which handles implementation
|
||||
host (IPublishHost): Host implementation which handles implementation
|
||||
and global metadata.
|
||||
headless (bool): Context is created out of UI (Current not used).
|
||||
reset (bool): Reset context on initialization.
|
||||
|
|
@ -173,7 +176,7 @@ class CreateContext:
|
|||
|
||||
def __init__(
|
||||
self,
|
||||
host: "PublishHost",
|
||||
host: IPublishHost,
|
||||
headless: bool = False,
|
||||
reset: bool = True,
|
||||
discover_publish_plugins: bool = True,
|
||||
|
|
@ -262,6 +265,8 @@ class CreateContext:
|
|||
# - right now used only for 'mandatory' but can be extended
|
||||
# in future
|
||||
"requirement_change": BulkInfo(),
|
||||
# Instance parent changed
|
||||
"parent_change": BulkInfo(),
|
||||
}
|
||||
self._bulk_order = []
|
||||
|
||||
|
|
@ -1083,6 +1088,35 @@ class CreateContext:
|
|||
INSTANCE_REQUIREMENT_CHANGED_TOPIC, callback
|
||||
)
|
||||
|
||||
def add_instance_parent_change_callback(
|
||||
self, callback: Callable
|
||||
) -> "EventCallback":
|
||||
"""Register callback to listen to instance parent changes.
|
||||
|
||||
Instance changed parent or parent flags.
|
||||
|
||||
Data structure of event:
|
||||
|
||||
```python
|
||||
{
|
||||
"instances": [CreatedInstance, ...],
|
||||
"create_context": CreateContext
|
||||
}
|
||||
```
|
||||
|
||||
Args:
|
||||
callback (Callable): Callback function that will be called when
|
||||
instance requirement changed.
|
||||
|
||||
Returns:
|
||||
EventCallback: Created callback object which can be used to
|
||||
stop listening.
|
||||
|
||||
"""
|
||||
return self._event_hub.add_callback(
|
||||
INSTANCE_PARENT_CHANGED_TOPIC, callback
|
||||
)
|
||||
|
||||
def context_data_to_store(self) -> dict[str, Any]:
|
||||
"""Data that should be stored by host function.
|
||||
|
||||
|
|
@ -1364,6 +1398,13 @@ class CreateContext:
|
|||
) as bulk_info:
|
||||
yield bulk_info
|
||||
|
||||
@contextmanager
|
||||
def bulk_instance_parent_change(self, sender: Optional[str] = None):
|
||||
with self._bulk_context(
|
||||
"parent_change", sender
|
||||
) as bulk_info:
|
||||
yield bulk_info
|
||||
|
||||
@contextmanager
|
||||
def bulk_publish_attr_defs_change(self, sender: Optional[str] = None):
|
||||
with self._bulk_context("publish_attrs_change", sender) as bulk_info:
|
||||
|
|
@ -1444,6 +1485,19 @@ class CreateContext:
|
|||
with self.bulk_instance_requirement_change() as bulk_item:
|
||||
bulk_item.append(instance_id)
|
||||
|
||||
def instance_parent_changed(self, instance_id: str) -> None:
|
||||
"""Instance parent changed.
|
||||
|
||||
Triggered by `CreatedInstance`.
|
||||
|
||||
Args:
|
||||
instance_id (Optional[str]): Instance id.
|
||||
|
||||
"""
|
||||
if self._is_instance_events_ready(instance_id):
|
||||
with self.bulk_instance_parent_change() as bulk_item:
|
||||
bulk_item.append(instance_id)
|
||||
|
||||
# --- context change callbacks ---
|
||||
def publish_attribute_value_changed(
|
||||
self, plugin_name: str, value: dict[str, Any]
|
||||
|
|
@ -2046,21 +2100,46 @@ class CreateContext:
|
|||
sender (Optional[str]): Sender of the event.
|
||||
|
||||
"""
|
||||
instance_ids_by_parent_id = collections.defaultdict(set)
|
||||
for instance in self.instances:
|
||||
instance_ids_by_parent_id[instance.parent_instance_id].add(
|
||||
instance.id
|
||||
)
|
||||
|
||||
instances_to_remove = list(instances)
|
||||
ids_to_remove = {
|
||||
instance.id
|
||||
for instance in instances_to_remove
|
||||
}
|
||||
_queue = collections.deque()
|
||||
_queue.extend(instances_to_remove)
|
||||
# Add children with parent lifetime flag
|
||||
while _queue:
|
||||
instance = _queue.popleft()
|
||||
ids_to_remove.add(instance.id)
|
||||
children_ids = instance_ids_by_parent_id[instance.id]
|
||||
for children_id in children_ids:
|
||||
if children_id in ids_to_remove:
|
||||
continue
|
||||
instance = self._instances_by_id[children_id]
|
||||
if instance.parent_flags & ParentFlags.parent_lifetime:
|
||||
instances_to_remove.append(instance)
|
||||
ids_to_remove.add(instance.id)
|
||||
_queue.append(instance)
|
||||
|
||||
instances_by_identifier = collections.defaultdict(list)
|
||||
for instance in instances:
|
||||
for instance in instances_to_remove:
|
||||
identifier = instance.creator_identifier
|
||||
instances_by_identifier[identifier].append(instance)
|
||||
|
||||
# Just remove instances from context if creator is not available
|
||||
missing_creators = set(instances_by_identifier) - set(self.creators)
|
||||
instances = []
|
||||
miss_creator_instances = []
|
||||
for identifier in missing_creators:
|
||||
instances.extend(
|
||||
instance
|
||||
for instance in instances_by_identifier[identifier]
|
||||
)
|
||||
miss_creator_instances.extend(instances_by_identifier[identifier])
|
||||
|
||||
self._remove_instances(instances, sender)
|
||||
with self.bulk_remove_instances(sender):
|
||||
self._remove_instances(miss_creator_instances, sender)
|
||||
|
||||
error_message = "Instances removement of creator \"{}\" failed. {}"
|
||||
failed_info = []
|
||||
|
|
@ -2069,7 +2148,16 @@ class CreateContext:
|
|||
instances_by_identifier.keys()
|
||||
):
|
||||
identifier = creator.identifier
|
||||
creator_instances = instances_by_identifier[identifier]
|
||||
# Filter instances by current state of 'CreateContext'
|
||||
# - in case instances were already removed as subroutine of
|
||||
# previous create plugin.
|
||||
creator_instances = [
|
||||
instance
|
||||
for instance in instances_by_identifier[identifier]
|
||||
if instance.id in self._instances_by_id
|
||||
]
|
||||
if not creator_instances:
|
||||
continue
|
||||
|
||||
label = creator.label
|
||||
failed = False
|
||||
|
|
@ -2305,6 +2393,8 @@ class CreateContext:
|
|||
self._bulk_publish_attrs_change_finished(data, sender)
|
||||
elif key == "requirement_change":
|
||||
self._bulk_instance_requirement_change_finished(data, sender)
|
||||
elif key == "parent_change":
|
||||
self._bulk_instance_parent_change_finished(data, sender)
|
||||
|
||||
def _bulk_add_instances_finished(
|
||||
self,
|
||||
|
|
@ -2518,3 +2608,22 @@ class CreateContext:
|
|||
{"instances": instances},
|
||||
sender,
|
||||
)
|
||||
|
||||
def _bulk_instance_parent_change_finished(
|
||||
self,
|
||||
instance_ids: list[str],
|
||||
sender: Optional[str],
|
||||
):
|
||||
if not instance_ids:
|
||||
return
|
||||
|
||||
instances = [
|
||||
self.get_instance_by_id(instance_id)
|
||||
for instance_id in set(instance_ids)
|
||||
]
|
||||
|
||||
self._emit_event(
|
||||
INSTANCE_PARENT_CHANGED_TOPIC,
|
||||
{"instances": instances},
|
||||
sender,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ from typing import TYPE_CHECKING, Optional, Dict, Any
|
|||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from ayon_core.settings import get_project_settings
|
||||
from ayon_core.lib import Logger, get_version_from_path
|
||||
from ayon_core.pipeline.plugin_discover import (
|
||||
discover,
|
||||
|
|
@ -20,7 +19,6 @@ from ayon_core.pipeline.staging_dir import get_staging_dir_info, StagingDir
|
|||
from .constants import DEFAULT_VARIANT_VALUE
|
||||
from .product_name import get_product_name
|
||||
from .utils import get_next_versions_for_instances
|
||||
from .legacy_create import LegacyCreator
|
||||
from .structures import CreatedInstance
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -975,62 +973,10 @@ def discover_convertor_plugins(*args, **kwargs):
|
|||
return discover(ProductConvertorPlugin, *args, **kwargs)
|
||||
|
||||
|
||||
def discover_legacy_creator_plugins():
|
||||
from ayon_core.pipeline import get_current_project_name
|
||||
|
||||
log = Logger.get_logger("CreatorDiscover")
|
||||
|
||||
plugins = discover(LegacyCreator)
|
||||
project_name = get_current_project_name()
|
||||
project_settings = get_project_settings(project_name)
|
||||
for plugin in plugins:
|
||||
try:
|
||||
plugin.apply_settings(project_settings)
|
||||
except Exception:
|
||||
log.warning(
|
||||
"Failed to apply settings to creator {}".format(
|
||||
plugin.__name__
|
||||
),
|
||||
exc_info=True
|
||||
)
|
||||
return plugins
|
||||
|
||||
|
||||
def get_legacy_creator_by_name(creator_name, case_sensitive=False):
|
||||
"""Find creator plugin by name.
|
||||
|
||||
Args:
|
||||
creator_name (str): Name of creator class that should be returned.
|
||||
case_sensitive (bool): Match of creator plugin name is case sensitive.
|
||||
Set to `False` by default.
|
||||
|
||||
Returns:
|
||||
Creator: Return first matching plugin or `None`.
|
||||
"""
|
||||
|
||||
# Lower input creator name if is not case sensitive
|
||||
if not case_sensitive:
|
||||
creator_name = creator_name.lower()
|
||||
|
||||
for creator_plugin in discover_legacy_creator_plugins():
|
||||
_creator_name = creator_plugin.__name__
|
||||
|
||||
# Lower creator plugin name if is not case sensitive
|
||||
if not case_sensitive:
|
||||
_creator_name = _creator_name.lower()
|
||||
|
||||
if _creator_name == creator_name:
|
||||
return creator_plugin
|
||||
return None
|
||||
|
||||
|
||||
def register_creator_plugin(plugin):
|
||||
if issubclass(plugin, BaseCreator):
|
||||
register_plugin(BaseCreator, plugin)
|
||||
|
||||
elif issubclass(plugin, LegacyCreator):
|
||||
register_plugin(LegacyCreator, plugin)
|
||||
|
||||
elif issubclass(plugin, ProductConvertorPlugin):
|
||||
register_plugin(ProductConvertorPlugin, plugin)
|
||||
|
||||
|
|
@ -1039,22 +985,17 @@ def deregister_creator_plugin(plugin):
|
|||
if issubclass(plugin, BaseCreator):
|
||||
deregister_plugin(BaseCreator, plugin)
|
||||
|
||||
elif issubclass(plugin, LegacyCreator):
|
||||
deregister_plugin(LegacyCreator, plugin)
|
||||
|
||||
elif issubclass(plugin, ProductConvertorPlugin):
|
||||
deregister_plugin(ProductConvertorPlugin, plugin)
|
||||
|
||||
|
||||
def register_creator_plugin_path(path):
|
||||
register_plugin_path(BaseCreator, path)
|
||||
register_plugin_path(LegacyCreator, path)
|
||||
register_plugin_path(ProductConvertorPlugin, path)
|
||||
|
||||
|
||||
def deregister_creator_plugin_path(path):
|
||||
deregister_plugin_path(BaseCreator, path)
|
||||
deregister_plugin_path(LegacyCreator, path)
|
||||
deregister_plugin_path(ProductConvertorPlugin, path)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,216 +0,0 @@
|
|||
"""Create workflow moved from avalon-core repository.
|
||||
|
||||
Renamed classes and functions
|
||||
- 'Creator' -> 'LegacyCreator'
|
||||
- 'create' -> 'legacy_create'
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
import collections
|
||||
|
||||
from ayon_core.pipeline.constants import AYON_INSTANCE_ID
|
||||
|
||||
from .product_name import get_product_name
|
||||
|
||||
|
||||
class LegacyCreator:
|
||||
"""Determine how assets are created"""
|
||||
label = None
|
||||
product_type = None
|
||||
defaults = None
|
||||
maintain_selection = True
|
||||
enabled = True
|
||||
|
||||
dynamic_product_name_keys = []
|
||||
|
||||
log = logging.getLogger("LegacyCreator")
|
||||
log.propagate = True
|
||||
|
||||
def __init__(self, name, folder_path, options=None, data=None):
|
||||
self.name = name # For backwards compatibility
|
||||
self.options = options
|
||||
|
||||
# Default data
|
||||
self.data = collections.OrderedDict()
|
||||
# TODO use 'AYON_INSTANCE_ID' when all hosts support it
|
||||
self.data["id"] = AYON_INSTANCE_ID
|
||||
self.data["productType"] = self.product_type
|
||||
self.data["folderPath"] = folder_path
|
||||
self.data["productName"] = name
|
||||
self.data["active"] = True
|
||||
|
||||
self.data.update(data or {})
|
||||
|
||||
@classmethod
|
||||
def apply_settings(cls, project_settings):
|
||||
"""Apply AYON settings to a plugin class."""
|
||||
|
||||
host_name = os.environ.get("AYON_HOST_NAME")
|
||||
plugin_type = "create"
|
||||
plugin_type_settings = (
|
||||
project_settings
|
||||
.get(host_name, {})
|
||||
.get(plugin_type, {})
|
||||
)
|
||||
global_type_settings = (
|
||||
project_settings
|
||||
.get("core", {})
|
||||
.get(plugin_type, {})
|
||||
)
|
||||
if not global_type_settings and not plugin_type_settings:
|
||||
return
|
||||
|
||||
plugin_name = cls.__name__
|
||||
|
||||
plugin_settings = None
|
||||
# Look for plugin settings in host specific settings
|
||||
if plugin_name in plugin_type_settings:
|
||||
plugin_settings = plugin_type_settings[plugin_name]
|
||||
|
||||
# Look for plugin settings in global settings
|
||||
elif plugin_name in global_type_settings:
|
||||
plugin_settings = global_type_settings[plugin_name]
|
||||
|
||||
if not plugin_settings:
|
||||
return
|
||||
|
||||
cls.log.debug(">>> We have preset for {}".format(plugin_name))
|
||||
for option, value in plugin_settings.items():
|
||||
if option == "enabled" and value is False:
|
||||
cls.log.debug(" - is disabled by preset")
|
||||
else:
|
||||
cls.log.debug(" - setting `{}`: `{}`".format(option, value))
|
||||
setattr(cls, option, value)
|
||||
|
||||
def process(self):
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def get_dynamic_data(
|
||||
cls, project_name, folder_entity, task_entity, variant, host_name
|
||||
):
|
||||
"""Return dynamic data for current Creator plugin.
|
||||
|
||||
By default return keys from `dynamic_product_name_keys` attribute
|
||||
as mapping to keep formatted template unchanged.
|
||||
|
||||
```
|
||||
dynamic_product_name_keys = ["my_key"]
|
||||
---
|
||||
output = {
|
||||
"my_key": "{my_key}"
|
||||
}
|
||||
```
|
||||
|
||||
Dynamic keys may override default Creator keys (productType, task,
|
||||
folderPath, ...) but do it wisely if you need.
|
||||
|
||||
All of keys will be converted into 3 variants unchanged, capitalized
|
||||
and all upper letters. Because of that are all keys lowered.
|
||||
|
||||
This method can be modified to prefill some values just keep in mind it
|
||||
is class method.
|
||||
|
||||
Args:
|
||||
project_name (str): Context's project name.
|
||||
folder_entity (dict[str, Any]): Folder entity.
|
||||
task_entity (dict[str, Any]): Task entity.
|
||||
variant (str): What is entered by user in creator tool.
|
||||
host_name (str): Name of host.
|
||||
|
||||
Returns:
|
||||
dict: Fill data for product name template.
|
||||
"""
|
||||
dynamic_data = {}
|
||||
for key in cls.dynamic_product_name_keys:
|
||||
key = key.lower()
|
||||
dynamic_data[key] = "{" + key + "}"
|
||||
return dynamic_data
|
||||
|
||||
@classmethod
|
||||
def get_product_name(
|
||||
cls, project_name, folder_entity, task_entity, variant, host_name=None
|
||||
):
|
||||
"""Return product name created with entered arguments.
|
||||
|
||||
Logic extracted from Creator tool. This method should give ability
|
||||
to get product name without the tool.
|
||||
|
||||
TODO: Maybe change `variant` variable.
|
||||
|
||||
By default is output concatenated product type with variant.
|
||||
|
||||
Args:
|
||||
project_name (str): Context's project name.
|
||||
folder_entity (dict[str, Any]): Folder entity.
|
||||
task_entity (dict[str, Any]): Task entity.
|
||||
variant (str): What is entered by user in creator tool.
|
||||
host_name (str): Name of host.
|
||||
|
||||
Returns:
|
||||
str: Formatted product name with entered arguments. Should match
|
||||
config's logic.
|
||||
"""
|
||||
|
||||
dynamic_data = cls.get_dynamic_data(
|
||||
project_name, folder_entity, task_entity, variant, host_name
|
||||
)
|
||||
task_name = task_type = None
|
||||
if task_entity:
|
||||
task_name = task_entity["name"]
|
||||
task_type = task_entity["taskType"]
|
||||
return get_product_name(
|
||||
project_name,
|
||||
task_name,
|
||||
task_type,
|
||||
host_name,
|
||||
cls.product_type,
|
||||
variant,
|
||||
dynamic_data=dynamic_data
|
||||
)
|
||||
|
||||
|
||||
def legacy_create(
|
||||
Creator, product_name, folder_path, options=None, data=None
|
||||
):
|
||||
"""Create a new instance
|
||||
|
||||
Associate nodes with a product name and type. These nodes are later
|
||||
validated, according to their `product type`, and integrated into the
|
||||
shared environment, relative their `productName`.
|
||||
|
||||
Data relative each product type, along with default data, are imprinted
|
||||
into the resulting objectSet. This data is later used by extractors
|
||||
and finally asset browsers to help identify the origin of the asset.
|
||||
|
||||
Arguments:
|
||||
Creator (Creator): Class of creator.
|
||||
product_name (str): Name of product.
|
||||
folder_path (str): Folder path.
|
||||
options (dict, optional): Additional options from GUI.
|
||||
data (dict, optional): Additional data from GUI.
|
||||
|
||||
Raises:
|
||||
NameError on `productName` already exists
|
||||
KeyError on invalid dynamic property
|
||||
RuntimeError on host error
|
||||
|
||||
Returns:
|
||||
Name of instance
|
||||
|
||||
"""
|
||||
from ayon_core.pipeline import registered_host
|
||||
|
||||
host = registered_host()
|
||||
plugin = Creator(product_name, folder_path, options, data)
|
||||
|
||||
if plugin.maintain_selection is True:
|
||||
with host.maintained_selection():
|
||||
print("Running %s with maintained selection" % plugin)
|
||||
instance = plugin.process()
|
||||
return instance
|
||||
|
||||
print("Running %s" % plugin)
|
||||
instance = plugin.process()
|
||||
return instance
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import copy
|
||||
import collections
|
||||
from uuid import uuid4
|
||||
from enum import Enum
|
||||
import typing
|
||||
from typing import Optional, Dict, List, Any
|
||||
|
||||
|
|
@ -22,6 +23,23 @@ if typing.TYPE_CHECKING:
|
|||
from .creator_plugins import BaseCreator
|
||||
|
||||
|
||||
class IntEnum(int, Enum):
|
||||
"""An int-based Enum class that allows for int comparison."""
|
||||
|
||||
def __int__(self) -> int:
|
||||
return self.value
|
||||
|
||||
|
||||
class ParentFlags(IntEnum):
|
||||
# Delete instance if parent is deleted
|
||||
parent_lifetime = 1
|
||||
# Active state is propagated from parent to children
|
||||
# - the active state is propagated in collection phase
|
||||
# NOTE It might be helpful to have a function that would return "real"
|
||||
# active state for instances
|
||||
share_active = 1 << 1
|
||||
|
||||
|
||||
class ConvertorItem:
|
||||
"""Item representing convertor plugin.
|
||||
|
||||
|
|
@ -507,7 +525,9 @@ class CreatedInstance:
|
|||
if transient_data is None:
|
||||
transient_data = {}
|
||||
self._transient_data = transient_data
|
||||
self._is_mandatory = False
|
||||
self._is_mandatory: bool = False
|
||||
self._parent_instance_id: Optional[str] = None
|
||||
self._parent_flags: int = 0
|
||||
|
||||
# Create a copy of passed data to avoid changing them on the fly
|
||||
data = copy.deepcopy(data or {})
|
||||
|
|
@ -752,6 +772,39 @@ class CreatedInstance:
|
|||
self["active"] = True
|
||||
self._create_context.instance_requirement_changed(self.id)
|
||||
|
||||
@property
|
||||
def parent_instance_id(self) -> Optional[str]:
|
||||
return self._parent_instance_id
|
||||
|
||||
@property
|
||||
def parent_flags(self) -> int:
|
||||
return self._parent_flags
|
||||
|
||||
def set_parent(
|
||||
self, instance_id: Optional[str], flags: int
|
||||
) -> None:
|
||||
"""Set parent instance id and parenting flags.
|
||||
|
||||
Args:
|
||||
instance_id (Optional[str]): Parent instance id.
|
||||
flags (int): Parenting flags.
|
||||
|
||||
"""
|
||||
changed = False
|
||||
if instance_id != self._parent_instance_id:
|
||||
changed = True
|
||||
self._parent_instance_id = instance_id
|
||||
|
||||
if flags is None:
|
||||
flags = 0
|
||||
|
||||
if self._parent_flags != flags:
|
||||
self._parent_flags = flags
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
self._create_context.instance_parent_changed(self.id)
|
||||
|
||||
def changes(self):
|
||||
"""Calculate and return changes."""
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,10 @@ import opentimelineio as otio
|
|||
from opentimelineio import opentime as _ot
|
||||
|
||||
|
||||
# https://github.com/AcademySoftwareFoundation/OpenTimelineIO/issues/1822
|
||||
OTIO_EPSILON = 1e-9
|
||||
|
||||
|
||||
def otio_range_to_frame_range(otio_range):
|
||||
start = _ot.to_frames(
|
||||
otio_range.start_time, otio_range.start_time.rate)
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from abc import abstractmethod
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Optional, Type
|
||||
|
||||
from ayon_core.lib import Logger
|
||||
from ayon_core.pipeline.plugin_discover import (
|
||||
deregister_plugin,
|
||||
deregister_plugin_path,
|
||||
|
|
@ -31,8 +31,7 @@ class LoaderPlugin(list):
|
|||
|
||||
options = []
|
||||
|
||||
log = logging.getLogger("ProductLoader")
|
||||
log.propagate = True
|
||||
log = Logger.get_logger("ProductLoader")
|
||||
|
||||
@classmethod
|
||||
def apply_settings(cls, project_settings):
|
||||
|
|
@ -373,7 +372,7 @@ def discover_loader_plugins(project_name=None):
|
|||
if not project_name:
|
||||
project_name = get_current_project_name()
|
||||
project_settings = get_project_settings(project_name)
|
||||
plugins = discover(LoaderPlugin)
|
||||
plugins = discover(LoaderPlugin, allow_duplicates=False)
|
||||
hooks = discover(LoaderHookPlugin)
|
||||
sorted_hooks = sorted(hooks, key=lambda hook: hook.order)
|
||||
for plugin in plugins:
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ from typing import Optional, Union, Any
|
|||
|
||||
import ayon_api
|
||||
|
||||
from ayon_core.host import ILoadHost
|
||||
from ayon_core.host import ILoadHost, AbstractHost
|
||||
from ayon_core.lib import (
|
||||
StringTemplate,
|
||||
TemplateUnsolved,
|
||||
|
|
@ -720,11 +720,13 @@ def get_representation_path(representation, root=None):
|
|||
str: fullpath of the representation
|
||||
|
||||
"""
|
||||
|
||||
if root is None:
|
||||
from ayon_core.pipeline import registered_root
|
||||
from ayon_core.pipeline import get_current_project_name, Anatomy
|
||||
|
||||
root = registered_root()
|
||||
anatomy = Anatomy(get_current_project_name())
|
||||
return get_representation_path_with_anatomy(
|
||||
representation, anatomy
|
||||
)
|
||||
|
||||
def path_from_representation():
|
||||
try:
|
||||
|
|
@ -772,7 +774,7 @@ def get_representation_path(representation, root=None):
|
|||
|
||||
dir_path, file_name = os.path.split(path)
|
||||
if not os.path.exists(dir_path):
|
||||
return
|
||||
return None
|
||||
|
||||
base_name, ext = os.path.splitext(file_name)
|
||||
file_name_items = None
|
||||
|
|
@ -782,7 +784,7 @@ def get_representation_path(representation, root=None):
|
|||
file_name_items = base_name.split("%")
|
||||
|
||||
if not file_name_items:
|
||||
return
|
||||
return None
|
||||
|
||||
filename_start = file_name_items[0]
|
||||
|
||||
|
|
@ -940,15 +942,21 @@ def any_outdated_containers(host=None, project_name=None):
|
|||
return False
|
||||
|
||||
|
||||
def get_outdated_containers(host=None, project_name=None):
|
||||
def get_outdated_containers(
|
||||
host: Optional[AbstractHost] = None,
|
||||
project_name: Optional[str] = None,
|
||||
ignore_locked_versions: bool = False,
|
||||
):
|
||||
"""Collect outdated containers from host scene.
|
||||
|
||||
Currently registered host and project in global session are used if
|
||||
arguments are not passed.
|
||||
|
||||
Args:
|
||||
host (ModuleType): Host implementation with 'ls' function available.
|
||||
project_name (str): Name of project in which context we are.
|
||||
host (Optional[AbstractHost]): Host implementation.
|
||||
project_name (Optional[str]): Name of project in which context we are.
|
||||
ignore_locked_versions (bool): Locked versions are ignored.
|
||||
|
||||
"""
|
||||
from ayon_core.pipeline import registered_host, get_current_project_name
|
||||
|
||||
|
|
@ -962,7 +970,16 @@ def get_outdated_containers(host=None, project_name=None):
|
|||
containers = host.get_containers()
|
||||
else:
|
||||
containers = host.ls()
|
||||
return filter_containers(containers, project_name).outdated
|
||||
|
||||
outdated_containers = []
|
||||
for container in filter_containers(containers, project_name).outdated:
|
||||
if (
|
||||
not ignore_locked_versions
|
||||
and container.get("version_locked") is True
|
||||
):
|
||||
continue
|
||||
outdated_containers.append(container)
|
||||
return outdated_containers
|
||||
|
||||
|
||||
def _is_valid_representation_id(repre_id: Any) -> bool:
|
||||
|
|
@ -983,6 +1000,9 @@ def filter_containers(containers, project_name):
|
|||
'invalid' are invalid containers (invalid content) and 'not_found' has
|
||||
some missing entity in database.
|
||||
|
||||
Todos:
|
||||
Respect 'project_name' on containers if is available.
|
||||
|
||||
Args:
|
||||
containers (Iterable[dict]): List of containers referenced into scene.
|
||||
project_name (str): Name of project in which context shoud look for
|
||||
|
|
@ -991,8 +1011,8 @@ def filter_containers(containers, project_name):
|
|||
Returns:
|
||||
ContainersFilterResult: Named tuple with 'latest', 'outdated',
|
||||
'invalid' and 'not_found' containers.
|
||||
"""
|
||||
|
||||
"""
|
||||
# Make sure containers is list that won't change
|
||||
containers = list(containers)
|
||||
|
||||
|
|
@ -1040,13 +1060,13 @@ def filter_containers(containers, project_name):
|
|||
hero=True,
|
||||
fields={"id", "productId", "version"}
|
||||
)
|
||||
verisons_by_id = {}
|
||||
versions_by_id = {}
|
||||
versions_by_product_id = collections.defaultdict(list)
|
||||
hero_version_ids = set()
|
||||
for version_entity in version_entities:
|
||||
version_id = version_entity["id"]
|
||||
# Store versions by their ids
|
||||
verisons_by_id[version_id] = version_entity
|
||||
versions_by_id[version_id] = version_entity
|
||||
# There's no need to query products for hero versions
|
||||
# - they are considered as latest?
|
||||
if version_entity["version"] < 0:
|
||||
|
|
@ -1081,24 +1101,23 @@ def filter_containers(containers, project_name):
|
|||
|
||||
repre_entity = repre_entities_by_id.get(repre_id)
|
||||
if not repre_entity:
|
||||
log.debug((
|
||||
"Container '{}' has an invalid representation."
|
||||
log.debug(
|
||||
f"Container '{container_name}' has an invalid representation."
|
||||
" It is missing in the database."
|
||||
).format(container_name))
|
||||
)
|
||||
not_found_containers.append(container)
|
||||
continue
|
||||
|
||||
version_id = repre_entity["versionId"]
|
||||
if version_id in outdated_version_ids:
|
||||
outdated_containers.append(container)
|
||||
|
||||
elif version_id not in verisons_by_id:
|
||||
log.debug((
|
||||
"Representation on container '{}' has an invalid version."
|
||||
" It is missing in the database."
|
||||
).format(container_name))
|
||||
if version_id not in versions_by_id:
|
||||
log.debug(
|
||||
f"Representation on container '{container_name}' has an"
|
||||
" invalid version. It is missing in the database."
|
||||
)
|
||||
not_found_containers.append(container)
|
||||
|
||||
elif version_id in outdated_version_ids:
|
||||
outdated_containers.append(container)
|
||||
else:
|
||||
uptodate_containers.append(container)
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import sys
|
|||
import inspect
|
||||
import copy
|
||||
import warnings
|
||||
import hashlib
|
||||
import xml.etree.ElementTree
|
||||
from typing import TYPE_CHECKING, Optional, Union, List
|
||||
|
||||
|
|
@ -243,32 +244,38 @@ def publish_plugins_discover(
|
|||
|
||||
for path in paths:
|
||||
path = os.path.normpath(path)
|
||||
if not os.path.isdir(path):
|
||||
continue
|
||||
|
||||
for fname in os.listdir(path):
|
||||
if fname.startswith("_"):
|
||||
continue
|
||||
|
||||
abspath = os.path.join(path, fname)
|
||||
|
||||
if not os.path.isfile(abspath):
|
||||
continue
|
||||
|
||||
mod_name, mod_ext = os.path.splitext(fname)
|
||||
|
||||
if mod_ext != ".py":
|
||||
filenames = []
|
||||
if os.path.isdir(path):
|
||||
filenames.extend(
|
||||
name
|
||||
for name in os.listdir(path)
|
||||
if (
|
||||
os.path.isfile(os.path.join(path, name))
|
||||
and not name.startswith("_")
|
||||
)
|
||||
)
|
||||
else:
|
||||
filenames.append(os.path.basename(path))
|
||||
path = os.path.dirname(path)
|
||||
|
||||
dirpath_hash = hashlib.md5(path.encode("utf-8")).hexdigest()
|
||||
for filename in filenames:
|
||||
basename, ext = os.path.splitext(filename)
|
||||
if ext.lower() != ".py":
|
||||
continue
|
||||
|
||||
filepath = os.path.join(path, filename)
|
||||
module_name = f"{dirpath_hash}.{basename}"
|
||||
try:
|
||||
module = import_filepath(
|
||||
abspath, mod_name, sys_module_name=mod_name)
|
||||
filepath, module_name, sys_module_name=module_name
|
||||
)
|
||||
|
||||
except Exception as err: # noqa: BLE001
|
||||
# we need broad exception to catch all possible errors.
|
||||
result.crashed_file_paths[abspath] = sys.exc_info()
|
||||
result.crashed_file_paths[filepath] = sys.exc_info()
|
||||
|
||||
log.debug('Skipped: "%s" (%s)', mod_name, err)
|
||||
log.debug('Skipped: "%s" (%s)', filepath, err)
|
||||
continue
|
||||
|
||||
for plugin in pyblish.plugin.plugins_from_module(module):
|
||||
|
|
@ -354,12 +361,18 @@ def get_plugin_settings(plugin, project_settings, log, category=None):
|
|||
# Use project settings based on a category name
|
||||
if category:
|
||||
try:
|
||||
return (
|
||||
output = (
|
||||
project_settings
|
||||
[category]
|
||||
["publish"]
|
||||
[plugin.__name__]
|
||||
)
|
||||
warnings.warn(
|
||||
"Please fill 'settings_category'"
|
||||
f" for plugin '{plugin.__name__}'.",
|
||||
DeprecationWarning
|
||||
)
|
||||
return output
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
|
|
@ -384,12 +397,18 @@ def get_plugin_settings(plugin, project_settings, log, category=None):
|
|||
category_from_file = "core"
|
||||
|
||||
try:
|
||||
return (
|
||||
output = (
|
||||
project_settings
|
||||
[category_from_file]
|
||||
[plugin_kind]
|
||||
[plugin.__name__]
|
||||
)
|
||||
warnings.warn(
|
||||
"Please fill 'settings_category'"
|
||||
f" for plugin '{plugin.__name__}'.",
|
||||
DeprecationWarning
|
||||
)
|
||||
return output
|
||||
except KeyError:
|
||||
pass
|
||||
return {}
|
||||
|
|
|
|||
|
|
@ -22,9 +22,11 @@ from .utils import (
|
|||
should_open_workfiles_tool_on_launch,
|
||||
MissingWorkdirError,
|
||||
|
||||
save_workfile_info,
|
||||
save_current_workfile_to,
|
||||
save_workfile_with_current_context,
|
||||
save_workfile_info,
|
||||
save_next_version,
|
||||
copy_workfile_to_context,
|
||||
find_workfile_rootless_path,
|
||||
)
|
||||
|
||||
|
|
@ -63,9 +65,11 @@ __all__ = (
|
|||
"should_open_workfiles_tool_on_launch",
|
||||
"MissingWorkdirError",
|
||||
|
||||
"save_workfile_info",
|
||||
"save_current_workfile_to",
|
||||
"save_workfile_with_current_context",
|
||||
"save_workfile_info",
|
||||
"save_next_version",
|
||||
"copy_workfile_to_context",
|
||||
|
||||
"BuildWorkfile",
|
||||
|
||||
|
|
|
|||
|
|
@ -207,6 +207,7 @@ def save_workfile_info(
|
|||
comment: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
username: Optional[str] = None,
|
||||
data: Optional[dict[str, Any]] = None,
|
||||
workfile_entities: Optional[list[dict[str, Any]]] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Save workfile info entity for a workfile path.
|
||||
|
|
@ -221,6 +222,7 @@ def save_workfile_info(
|
|||
description (Optional[str]): Workfile description.
|
||||
username (Optional[str]): Username of user who saves the workfile.
|
||||
If not provided, current user is used.
|
||||
data (Optional[dict[str, Any]]): Additional workfile entity data.
|
||||
workfile_entities (Optional[list[dict[str, Any]]]): Pre-fetched
|
||||
workfile entities related to task.
|
||||
|
||||
|
|
@ -246,6 +248,18 @@ def save_workfile_info(
|
|||
if username is None:
|
||||
username = get_ayon_username()
|
||||
|
||||
attrib = {}
|
||||
extension = os.path.splitext(rootless_path)[1]
|
||||
for key, value in (
|
||||
("extension", extension),
|
||||
("description", description),
|
||||
):
|
||||
if value is not None:
|
||||
attrib[key] = value
|
||||
|
||||
if data is None:
|
||||
data = {}
|
||||
|
||||
if not workfile_entity:
|
||||
return _create_workfile_info_entity(
|
||||
project_name,
|
||||
|
|
@ -255,34 +269,38 @@ def save_workfile_info(
|
|||
username,
|
||||
version,
|
||||
comment,
|
||||
description,
|
||||
attrib,
|
||||
data,
|
||||
)
|
||||
|
||||
data = {
|
||||
key: value
|
||||
for key, value in (
|
||||
("host_name", host_name),
|
||||
("version", version),
|
||||
("comment", comment),
|
||||
)
|
||||
if value is not None
|
||||
}
|
||||
|
||||
old_data = workfile_entity["data"]
|
||||
):
|
||||
if value is not None:
|
||||
data[key] = value
|
||||
|
||||
changed_data = {}
|
||||
old_data = workfile_entity["data"]
|
||||
for key, value in data.items():
|
||||
if key not in old_data or old_data[key] != value:
|
||||
changed_data[key] = value
|
||||
workfile_entity["data"][key] = value
|
||||
|
||||
changed_attrib = {}
|
||||
old_attrib = workfile_entity["attrib"]
|
||||
for key, value in attrib.items():
|
||||
if key not in old_attrib or old_attrib[key] != value:
|
||||
changed_attrib[key] = value
|
||||
workfile_entity["attrib"][key] = value
|
||||
|
||||
update_data = {}
|
||||
if changed_data:
|
||||
update_data["data"] = changed_data
|
||||
|
||||
old_description = workfile_entity["attrib"].get("description")
|
||||
if description is not None and old_description != description:
|
||||
update_data["attrib"] = {"description": description}
|
||||
workfile_entity["attrib"]["description"] = description
|
||||
if changed_attrib:
|
||||
update_data["attrib"] = changed_attrib
|
||||
|
||||
# Automatically fix 'createdBy' and 'updatedBy' fields
|
||||
# NOTE both fields were not automatically filled by server
|
||||
|
|
@ -411,13 +429,14 @@ def save_next_version(
|
|||
) -> None:
|
||||
"""Save workfile using current context, version and comment.
|
||||
|
||||
Helper function to save workfile using current context. Last workfile
|
||||
version + 1 is used if is not passed in.
|
||||
Helper function to save a workfile using the current context. Last
|
||||
workfile version + 1 is used if is not passed in.
|
||||
|
||||
Args:
|
||||
version (Optional[int]): Workfile version that will be used. Last
|
||||
version + 1 is used if is not passed in.
|
||||
comment (optional[str]): Workfile comment.
|
||||
comment (optional[str]): Workfile comment. Pass '""' to clear comment.
|
||||
The current workfile comment is used if it is not passed.
|
||||
description (Optional[str]): Workfile description.
|
||||
prepared_data (Optional[SaveWorkfileOptionalData]): Prepared data
|
||||
for speed enhancements.
|
||||
|
|
@ -427,6 +446,11 @@ def save_next_version(
|
|||
from ayon_core.pipeline.context_tools import registered_host
|
||||
|
||||
host = registered_host()
|
||||
current_path = host.get_current_workfile()
|
||||
if not current_path:
|
||||
current_path = None
|
||||
else:
|
||||
current_path = os.path.normpath(current_path)
|
||||
|
||||
context = host.get_current_context()
|
||||
project_name = context["project_name"]
|
||||
|
|
@ -480,10 +504,9 @@ def save_next_version(
|
|||
project_settings=project_settings,
|
||||
)
|
||||
rootless_dir = workdir.rootless
|
||||
if version is None:
|
||||
workfile_extensions = host.get_workfile_extensions()
|
||||
if not workfile_extensions:
|
||||
raise ValueError("Host does not have defined file extensions")
|
||||
last_workfile = None
|
||||
current_workfile = None
|
||||
if version is None or comment is None:
|
||||
workfiles = host.list_workfiles(
|
||||
project_name, folder_entity, task_entity,
|
||||
prepared_data=ListWorkfilesOptionalData(
|
||||
|
|
@ -493,14 +516,21 @@ def save_next_version(
|
|||
template_key=template_key,
|
||||
)
|
||||
)
|
||||
versions = {
|
||||
workfile.version
|
||||
for workfile in workfiles
|
||||
if workfile.version is not None
|
||||
}
|
||||
version = None
|
||||
if versions:
|
||||
version = max(versions) + 1
|
||||
for workfile in workfiles:
|
||||
if current_workfile is None and workfile.filepath == current_path:
|
||||
current_workfile = workfile
|
||||
|
||||
if workfile.version is None:
|
||||
continue
|
||||
|
||||
if (
|
||||
last_workfile is None
|
||||
or last_workfile.version < workfile.version
|
||||
):
|
||||
last_workfile = workfile
|
||||
|
||||
if version is None and last_workfile is not None:
|
||||
version = last_workfile.version + 1
|
||||
|
||||
if version is None:
|
||||
version = get_versioning_start(
|
||||
|
|
@ -511,9 +541,34 @@ def save_next_version(
|
|||
product_type="workfile"
|
||||
)
|
||||
|
||||
# Re-use comment from the current workfile if is not passed in
|
||||
if comment is None and current_workfile is not None:
|
||||
comment = current_workfile.comment
|
||||
|
||||
template_data["version"] = version
|
||||
if comment:
|
||||
template_data["comment"] = comment
|
||||
|
||||
# Resolve extension
|
||||
# - Don't fill any if the host does not have defined any -> e.g. if host
|
||||
# uses directory instead of a file.
|
||||
# 1. Use the current file extension.
|
||||
# 2. Use the last known workfile extension.
|
||||
# 3. Use the first extensions from 'get_workfile_extensions'.
|
||||
ext = None
|
||||
workfile_extensions = host.get_workfile_extensions()
|
||||
if workfile_extensions:
|
||||
if current_path:
|
||||
ext = os.path.splitext(current_path)[1]
|
||||
elif last_workfile is not None:
|
||||
ext = os.path.splitext(last_workfile.filepath)[1]
|
||||
else:
|
||||
ext = next(iter(workfile_extensions))
|
||||
ext = ext.lstrip(".")
|
||||
|
||||
if ext:
|
||||
template_data["ext"] = ext
|
||||
|
||||
filename = file_template.format_strict(template_data)
|
||||
workfile_path = os.path.join(workdir, filename)
|
||||
rootless_path = f"{rootless_dir}/{filename}"
|
||||
|
|
@ -632,6 +687,11 @@ def copy_workfile_to_context(
|
|||
if comment:
|
||||
template_data["comment"] = comment
|
||||
|
||||
workfile_extensions = host.get_workfile_extensions()
|
||||
if workfile_extensions:
|
||||
ext = os.path.splitext(src_workfile_path)[1].lstrip(".")
|
||||
template_data["ext"] = ext
|
||||
|
||||
workfile_template = anatomy.get_template_item(
|
||||
"work", template_key, "path"
|
||||
)
|
||||
|
|
@ -707,7 +767,8 @@ def _create_workfile_info_entity(
|
|||
username: str,
|
||||
version: Optional[int],
|
||||
comment: Optional[str],
|
||||
description: Optional[str],
|
||||
attrib: dict[str, Any],
|
||||
data: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Create workfile entity data.
|
||||
|
||||
|
|
@ -719,27 +780,18 @@ def _create_workfile_info_entity(
|
|||
username (str): Username.
|
||||
version (Optional[int]): Workfile version.
|
||||
comment (Optional[str]): Workfile comment.
|
||||
description (Optional[str]): Workfile description.
|
||||
attrib (dict[str, Any]): Workfile entity attributes.
|
||||
data (dict[str, Any]): Workfile entity data.
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: Created workfile entity data.
|
||||
|
||||
"""
|
||||
extension = os.path.splitext(rootless_path)[1]
|
||||
|
||||
attrib = {}
|
||||
for key, value in (
|
||||
("extension", extension),
|
||||
("description", description),
|
||||
):
|
||||
if value is not None:
|
||||
attrib[key] = value
|
||||
|
||||
data = {
|
||||
data.update({
|
||||
"host_name": host_name,
|
||||
"version": version,
|
||||
"comment": comment,
|
||||
}
|
||||
})
|
||||
|
||||
workfile_info = {
|
||||
"id": uuid.uuid4().hex,
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import re
|
|||
import collections
|
||||
import copy
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional
|
||||
|
||||
import ayon_api
|
||||
from ayon_api import (
|
||||
|
|
@ -29,7 +30,7 @@ from ayon_api import (
|
|||
)
|
||||
|
||||
from ayon_core.settings import get_project_settings
|
||||
from ayon_core.host import IWorkfileHost, HostBase
|
||||
from ayon_core.host import IWorkfileHost, AbstractHost
|
||||
from ayon_core.lib import (
|
||||
Logger,
|
||||
StringTemplate,
|
||||
|
|
@ -53,7 +54,6 @@ from ayon_core.pipeline.plugin_discover import (
|
|||
)
|
||||
|
||||
from ayon_core.pipeline.create import (
|
||||
discover_legacy_creator_plugins,
|
||||
CreateContext,
|
||||
HiddenCreator,
|
||||
)
|
||||
|
|
@ -126,15 +126,14 @@ class AbstractTemplateBuilder(ABC):
|
|||
placeholder population.
|
||||
|
||||
Args:
|
||||
host (Union[HostBase, ModuleType]): Implementation of host.
|
||||
host (Union[AbstractHost, ModuleType]): Implementation of host.
|
||||
"""
|
||||
|
||||
_log = None
|
||||
use_legacy_creators = False
|
||||
|
||||
def __init__(self, host):
|
||||
# Get host name
|
||||
if isinstance(host, HostBase):
|
||||
if isinstance(host, AbstractHost):
|
||||
host_name = host.name
|
||||
else:
|
||||
host_name = os.environ.get("AYON_HOST_NAME")
|
||||
|
|
@ -162,24 +161,24 @@ class AbstractTemplateBuilder(ABC):
|
|||
|
||||
@property
|
||||
def project_name(self):
|
||||
if isinstance(self._host, HostBase):
|
||||
if isinstance(self._host, AbstractHost):
|
||||
return self._host.get_current_project_name()
|
||||
return os.getenv("AYON_PROJECT_NAME")
|
||||
|
||||
@property
|
||||
def current_folder_path(self):
|
||||
if isinstance(self._host, HostBase):
|
||||
if isinstance(self._host, AbstractHost):
|
||||
return self._host.get_current_folder_path()
|
||||
return os.getenv("AYON_FOLDER_PATH")
|
||||
|
||||
@property
|
||||
def current_task_name(self):
|
||||
if isinstance(self._host, HostBase):
|
||||
if isinstance(self._host, AbstractHost):
|
||||
return self._host.get_current_task_name()
|
||||
return os.getenv("AYON_TASK_NAME")
|
||||
|
||||
def get_current_context(self):
|
||||
if isinstance(self._host, HostBase):
|
||||
if isinstance(self._host, AbstractHost):
|
||||
return self._host.get_current_context()
|
||||
return {
|
||||
"project_name": self.project_name,
|
||||
|
|
@ -201,12 +200,6 @@ class AbstractTemplateBuilder(ABC):
|
|||
)
|
||||
return self._current_folder_entity
|
||||
|
||||
@property
|
||||
def linked_folder_entities(self):
|
||||
if self._linked_folder_entities is _NOT_SET:
|
||||
self._linked_folder_entities = self._get_linked_folder_entities()
|
||||
return self._linked_folder_entities
|
||||
|
||||
@property
|
||||
def current_task_entity(self):
|
||||
if self._current_task_entity is _NOT_SET:
|
||||
|
|
@ -261,7 +254,7 @@ class AbstractTemplateBuilder(ABC):
|
|||
"""Access to host implementation.
|
||||
|
||||
Returns:
|
||||
Union[HostBase, ModuleType]: Implementation of host.
|
||||
Union[AbstractHost, ModuleType]: Implementation of host.
|
||||
"""
|
||||
|
||||
return self._host
|
||||
|
|
@ -307,13 +300,16 @@ class AbstractTemplateBuilder(ABC):
|
|||
self._loaders_by_name = get_loaders_by_name()
|
||||
return self._loaders_by_name
|
||||
|
||||
def _get_linked_folder_entities(self):
|
||||
def get_linked_folder_entities(self, link_type: Optional[str]):
|
||||
if not link_type:
|
||||
return []
|
||||
project_name = self.project_name
|
||||
folder_entity = self.current_folder_entity
|
||||
if not folder_entity:
|
||||
return []
|
||||
links = get_folder_links(
|
||||
project_name, folder_entity["id"], link_direction="in"
|
||||
project_name,
|
||||
folder_entity["id"], link_types=[link_type], link_direction="in"
|
||||
)
|
||||
linked_folder_ids = {
|
||||
link["entityId"]
|
||||
|
|
@ -323,19 +319,6 @@ class AbstractTemplateBuilder(ABC):
|
|||
|
||||
return list(get_folders(project_name, folder_ids=linked_folder_ids))
|
||||
|
||||
def _collect_legacy_creators(self):
|
||||
creators_by_name = {}
|
||||
for creator in discover_legacy_creator_plugins():
|
||||
if not creator.enabled:
|
||||
continue
|
||||
creator_name = creator.__name__
|
||||
if creator_name in creators_by_name:
|
||||
raise KeyError(
|
||||
"Duplicated creator name {} !".format(creator_name)
|
||||
)
|
||||
creators_by_name[creator_name] = creator
|
||||
self._creators_by_name = creators_by_name
|
||||
|
||||
def _collect_creators(self):
|
||||
self._creators_by_name = {
|
||||
identifier: creator
|
||||
|
|
@ -347,9 +330,6 @@ class AbstractTemplateBuilder(ABC):
|
|||
|
||||
def get_creators_by_name(self):
|
||||
if self._creators_by_name is None:
|
||||
if self.use_legacy_creators:
|
||||
self._collect_legacy_creators()
|
||||
else:
|
||||
self._collect_creators()
|
||||
|
||||
return self._creators_by_name
|
||||
|
|
@ -1429,10 +1409,27 @@ class PlaceholderLoadMixin(object):
|
|||
|
||||
builder_type_enum_items = [
|
||||
{"label": "Current folder", "value": "context_folder"},
|
||||
# TODO implement linked folders
|
||||
# {"label": "Linked folders", "value": "linked_folders"},
|
||||
{"label": "Linked folders", "value": "linked_folders"},
|
||||
{"label": "All folders", "value": "all_folders"},
|
||||
]
|
||||
|
||||
link_types = ayon_api.get_link_types(self.builder.project_name)
|
||||
|
||||
# Filter link types for folder to folder links
|
||||
link_types_enum_items = [
|
||||
{"label": link_type["name"], "value": link_type["linkType"]}
|
||||
for link_type in link_types
|
||||
if (
|
||||
link_type["inputType"] == "folder"
|
||||
and link_type["outputType"] == "folder"
|
||||
)
|
||||
]
|
||||
|
||||
if not link_types_enum_items:
|
||||
link_types_enum_items.append(
|
||||
{"label": "<No link types>", "value": None}
|
||||
)
|
||||
|
||||
build_type_label = "Folder Builder Type"
|
||||
build_type_help = (
|
||||
"Folder Builder Type\n"
|
||||
|
|
@ -1461,6 +1458,16 @@ class PlaceholderLoadMixin(object):
|
|||
items=builder_type_enum_items,
|
||||
tooltip=build_type_help
|
||||
),
|
||||
attribute_definitions.EnumDef(
|
||||
"link_type",
|
||||
label="Link Type",
|
||||
items=link_types_enum_items,
|
||||
tooltip=(
|
||||
"Link Type\n"
|
||||
"\nDefines what type of link will be used to"
|
||||
" link the asset to the current folder."
|
||||
)
|
||||
),
|
||||
attribute_definitions.EnumDef(
|
||||
"product_type",
|
||||
label="Product type",
|
||||
|
|
@ -1607,10 +1614,7 @@ class PlaceholderLoadMixin(object):
|
|||
|
||||
builder_type = placeholder.data["builder_type"]
|
||||
folder_ids = []
|
||||
if builder_type == "context_folder":
|
||||
folder_ids = [current_folder_entity["id"]]
|
||||
|
||||
elif builder_type == "all_folders":
|
||||
if builder_type == "all_folders":
|
||||
folder_ids = {
|
||||
folder_entity["id"]
|
||||
for folder_entity in get_folders(
|
||||
|
|
@ -1620,6 +1624,23 @@ class PlaceholderLoadMixin(object):
|
|||
)
|
||||
}
|
||||
|
||||
elif builder_type == "context_folder":
|
||||
folder_ids = [current_folder_entity["id"]]
|
||||
|
||||
elif builder_type == "linked_folders":
|
||||
# link type from placeholder data or default to "template"
|
||||
link_type = placeholder.data.get("link_type", "template")
|
||||
# Get all linked folders for the current folder
|
||||
if hasattr(self, "builder") and isinstance(
|
||||
self.builder, AbstractTemplateBuilder):
|
||||
# self.builder: AbstractTemplateBuilder
|
||||
folder_ids = [
|
||||
linked_folder_entity["id"]
|
||||
for linked_folder_entity in (
|
||||
self.builder.get_linked_folder_entities(
|
||||
link_type=link_type))
|
||||
]
|
||||
|
||||
if not folder_ids:
|
||||
return []
|
||||
|
||||
|
|
@ -1899,8 +1920,6 @@ class PlaceholderCreateMixin(object):
|
|||
pre_create_data (dict): dictionary of configuration from Creator
|
||||
configuration in UI
|
||||
"""
|
||||
|
||||
legacy_create = self.builder.use_legacy_creators
|
||||
creator_name = placeholder.data["creator"]
|
||||
create_variant = placeholder.data["create_variant"]
|
||||
active = placeholder.data.get("active")
|
||||
|
|
@ -1940,12 +1959,6 @@ class PlaceholderCreateMixin(object):
|
|||
|
||||
# compile product name from variant
|
||||
try:
|
||||
if legacy_create:
|
||||
creator_instance = creator_plugin(
|
||||
product_name,
|
||||
folder_path
|
||||
).process()
|
||||
else:
|
||||
creator_instance = self.builder.create_context.create(
|
||||
creator_plugin.identifier,
|
||||
create_variant,
|
||||
|
|
|
|||
630
client/ayon_core/plugins/load/create_hero_version.py
Normal file
630
client/ayon_core/plugins/load/create_hero_version.py
Normal file
|
|
@ -0,0 +1,630 @@
|
|||
"""Plugin to create hero version from selected context."""
|
||||
from __future__ import annotations
|
||||
import os
|
||||
import copy
|
||||
import shutil
|
||||
import errno
|
||||
import itertools
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Any, Optional
|
||||
|
||||
from speedcopy import copyfile
|
||||
import clique
|
||||
import ayon_api
|
||||
from ayon_api.operations import OperationsSession, new_version_entity
|
||||
from ayon_api.utils import create_entity_id
|
||||
from qtpy import QtWidgets, QtCore
|
||||
from ayon_core import style
|
||||
from ayon_core.pipeline import load, Anatomy
|
||||
from ayon_core.lib import create_hard_link, source_hash, StringTemplate
|
||||
from ayon_core.lib.file_transaction import wait_for_future_errors
|
||||
from ayon_core.pipeline.publish import get_publish_template_name
|
||||
from ayon_core.pipeline.template_data import get_template_data
|
||||
|
||||
|
||||
def prepare_changes(old_entity: dict, new_entity: dict) -> dict:
|
||||
"""Prepare changes dict for update entity operation.
|
||||
|
||||
Args:
|
||||
old_entity (dict): Existing entity data from database.
|
||||
new_entity (dict): New entity data to compare against old.
|
||||
|
||||
Returns:
|
||||
dict: Changes to apply to old entity to make it like new entity.
|
||||
|
||||
"""
|
||||
changes = {}
|
||||
for key in set(new_entity.keys()):
|
||||
if key == "attrib":
|
||||
continue
|
||||
if key in new_entity and new_entity[key] != old_entity.get(key):
|
||||
changes[key] = new_entity[key]
|
||||
attrib_changes = {}
|
||||
if "attrib" in new_entity:
|
||||
for key, value in new_entity["attrib"].items():
|
||||
if value != old_entity["attrib"].get(key):
|
||||
attrib_changes[key] = value
|
||||
if attrib_changes:
|
||||
changes["attrib"] = attrib_changes
|
||||
return changes
|
||||
|
||||
|
||||
class CreateHeroVersion(load.ProductLoaderPlugin):
|
||||
"""Create hero version from selected context."""
|
||||
|
||||
is_multiple_contexts_compatible = False
|
||||
representations = {"*"}
|
||||
product_types = {"*"}
|
||||
label = "Create Hero Version"
|
||||
order = 36
|
||||
icon = "star"
|
||||
color = "#ffd700"
|
||||
|
||||
ignored_representation_names: list[str] = []
|
||||
db_representation_context_keys = [
|
||||
"project", "folder", "asset", "hierarchy", "task", "product",
|
||||
"subset", "family", "representation", "username", "user", "output"
|
||||
]
|
||||
use_hardlinks = False
|
||||
|
||||
@staticmethod
|
||||
def message(text: str) -> None:
|
||||
"""Show message box with text."""
|
||||
msgBox = QtWidgets.QMessageBox()
|
||||
msgBox.setText(text)
|
||||
msgBox.setStyleSheet(style.load_stylesheet())
|
||||
msgBox.setWindowFlags(
|
||||
msgBox.windowFlags() | QtCore.Qt.WindowType.FramelessWindowHint
|
||||
)
|
||||
msgBox.exec_()
|
||||
|
||||
def load(self, context, name=None, namespace=None, options=None) -> None:
|
||||
"""Load hero version from context (dict as in context.py)."""
|
||||
success = True
|
||||
errors = []
|
||||
|
||||
# Extract project, product, version, folder from context
|
||||
project = context.get("project")
|
||||
product = context.get("product")
|
||||
version = context.get("version")
|
||||
folder = context.get("folder")
|
||||
task_entity = ayon_api.get_task_by_id(
|
||||
task_id=version.get("taskId"), project_name=project["name"]
|
||||
)
|
||||
|
||||
anatomy = Anatomy(project["name"])
|
||||
|
||||
version_id = version["id"]
|
||||
project_name = project["name"]
|
||||
repres = list(
|
||||
ayon_api.get_representations(
|
||||
project_name, version_ids={version_id}
|
||||
)
|
||||
)
|
||||
anatomy_data = get_template_data(
|
||||
project_entity=project,
|
||||
folder_entity=folder,
|
||||
task_entity=task_entity,
|
||||
)
|
||||
anatomy_data["product"] = {
|
||||
"name": product["name"],
|
||||
"type": product["productType"],
|
||||
}
|
||||
anatomy_data["version"] = version["version"]
|
||||
published_representations = {}
|
||||
for repre in repres:
|
||||
repre_anatomy = copy.deepcopy(anatomy_data)
|
||||
if "ext" not in repre_anatomy:
|
||||
repre_anatomy["ext"] = repre.get("context", {}).get("ext", "")
|
||||
published_representations[repre["id"]] = {
|
||||
"representation": repre,
|
||||
"published_files": [f["path"] for f in repre.get("files", [])],
|
||||
"anatomy_data": repre_anatomy
|
||||
}
|
||||
# get the publish directory
|
||||
publish_template_key = get_publish_template_name(
|
||||
project_name,
|
||||
context.get("hostName"),
|
||||
product["productType"],
|
||||
task_name=anatomy_data.get("task", {}).get("name"),
|
||||
task_type=anatomy_data.get("task", {}).get("type"),
|
||||
project_settings=context.get("project_settings", {}),
|
||||
logger=self.log
|
||||
)
|
||||
published_template_obj = anatomy.get_template_item(
|
||||
"publish", publish_template_key, "directory"
|
||||
)
|
||||
published_dir = os.path.normpath(
|
||||
published_template_obj.format_strict(anatomy_data)
|
||||
)
|
||||
instance_data = {
|
||||
"productName": product["name"],
|
||||
"productType": product["productType"],
|
||||
"anatomyData": anatomy_data,
|
||||
"publishDir": published_dir,
|
||||
"published_representations": published_representations,
|
||||
"versionEntity": version,
|
||||
}
|
||||
|
||||
try:
|
||||
self.create_hero_version(instance_data, anatomy, context)
|
||||
except Exception as exc:
|
||||
success = False
|
||||
errors.append(str(exc))
|
||||
if success:
|
||||
self.message("Hero version created successfully.")
|
||||
else:
|
||||
self.message(
|
||||
f"Failed to create hero version:\n{chr(10).join(errors)}")
|
||||
|
||||
def create_hero_version(
|
||||
self,
|
||||
instance_data: dict[str, Any],
|
||||
anatomy: Anatomy,
|
||||
context: dict[str, Any]) -> None:
|
||||
"""Create hero version from instance data.
|
||||
|
||||
Args:
|
||||
instance_data (dict): Instance data with keys:
|
||||
- productName (str): Name of the product.
|
||||
- productType (str): Type of the product.
|
||||
- anatomyData (dict): Anatomy data for templates.
|
||||
- publishDir (str): Directory where the product is published.
|
||||
- published_representations (dict): Published representations.
|
||||
- versionEntity (dict, optional): Source version entity.
|
||||
anatomy (Anatomy): Anatomy object for the project.
|
||||
context (dict): Context data with keys:
|
||||
- hostName (str): Name of the host application.
|
||||
- project_settings (dict): Project settings.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If any required data is missing or an error occurs
|
||||
during the hero version creation process.
|
||||
|
||||
"""
|
||||
published_repres = instance_data.get("published_representations")
|
||||
if not published_repres:
|
||||
raise RuntimeError("No published representations found.")
|
||||
|
||||
project_name = anatomy.project_name
|
||||
template_key = get_publish_template_name(
|
||||
project_name,
|
||||
context.get("hostName"),
|
||||
instance_data.get("productType"),
|
||||
instance_data.get("anatomyData", {}).get("task", {}).get("name"),
|
||||
instance_data.get("anatomyData", {}).get("task", {}).get("type"),
|
||||
project_settings=context.get("project_settings", {}),
|
||||
hero=True,
|
||||
)
|
||||
hero_template = anatomy.get_template_item(
|
||||
"hero", template_key, "path", default=None
|
||||
)
|
||||
if hero_template is None:
|
||||
raise RuntimeError("Project anatomy does not have hero "
|
||||
f"template key: {template_key}")
|
||||
|
||||
self.log.info(f"Hero template: {hero_template.template}")
|
||||
|
||||
hero_publish_dir = self.get_publish_dir(
|
||||
instance_data, anatomy, template_key
|
||||
)
|
||||
|
||||
self.log.info(f"Hero publish dir: {hero_publish_dir}")
|
||||
|
||||
src_version_entity = instance_data.get("versionEntity")
|
||||
filtered_repre_ids = []
|
||||
for repre_id, repre_info in published_repres.items():
|
||||
repre = repre_info["representation"]
|
||||
if repre["name"].lower() in self.ignored_representation_names:
|
||||
filtered_repre_ids.append(repre_id)
|
||||
for repre_id in filtered_repre_ids:
|
||||
published_repres.pop(repre_id, None)
|
||||
if not published_repres:
|
||||
raise RuntimeError(
|
||||
"All published representations were filtered by name."
|
||||
)
|
||||
|
||||
if src_version_entity is None:
|
||||
src_version_entity = self.version_from_representations(
|
||||
project_name, published_repres)
|
||||
if not src_version_entity:
|
||||
raise RuntimeError("Can't find origin version in database.")
|
||||
if src_version_entity["version"] == 0:
|
||||
raise RuntimeError("Version 0 cannot have hero version.")
|
||||
|
||||
all_copied_files = []
|
||||
transfers = instance_data.get("transfers", [])
|
||||
for _src, dst in transfers:
|
||||
dst = os.path.normpath(dst)
|
||||
if dst not in all_copied_files:
|
||||
all_copied_files.append(dst)
|
||||
hardlinks = instance_data.get("hardlinks", [])
|
||||
for _src, dst in hardlinks:
|
||||
dst = os.path.normpath(dst)
|
||||
if dst not in all_copied_files:
|
||||
all_copied_files.append(dst)
|
||||
|
||||
all_repre_file_paths = []
|
||||
for repre_info in published_repres.values():
|
||||
published_files = repre_info.get("published_files") or []
|
||||
for file_path in published_files:
|
||||
file_path = os.path.normpath(file_path)
|
||||
if file_path not in all_repre_file_paths:
|
||||
all_repre_file_paths.append(file_path)
|
||||
|
||||
publish_dir = instance_data.get("publishDir", "")
|
||||
if not publish_dir:
|
||||
raise RuntimeError(
|
||||
"publishDir is empty in instance_data, cannot continue."
|
||||
)
|
||||
instance_publish_dir = os.path.normpath(publish_dir)
|
||||
other_file_paths_mapping = []
|
||||
for file_path in all_copied_files:
|
||||
if not file_path.startswith(instance_publish_dir):
|
||||
continue
|
||||
if file_path in all_repre_file_paths:
|
||||
continue
|
||||
dst_filepath = file_path.replace(
|
||||
instance_publish_dir, hero_publish_dir
|
||||
)
|
||||
other_file_paths_mapping.append((file_path, dst_filepath))
|
||||
|
||||
old_version, old_repres = self.current_hero_ents(
|
||||
project_name, src_version_entity
|
||||
)
|
||||
inactive_old_repres_by_name = {}
|
||||
old_repres_by_name = {}
|
||||
for repre in old_repres:
|
||||
low_name = repre["name"].lower()
|
||||
if repre["active"]:
|
||||
old_repres_by_name[low_name] = repre
|
||||
else:
|
||||
inactive_old_repres_by_name[low_name] = repre
|
||||
|
||||
op_session = OperationsSession()
|
||||
entity_id = old_version["id"] if old_version else None
|
||||
new_hero_version = new_version_entity(
|
||||
-src_version_entity["version"],
|
||||
src_version_entity["productId"],
|
||||
task_id=src_version_entity.get("taskId"),
|
||||
data=copy.deepcopy(src_version_entity["data"]),
|
||||
attribs=copy.deepcopy(src_version_entity["attrib"]),
|
||||
entity_id=entity_id,
|
||||
)
|
||||
if old_version:
|
||||
update_data = prepare_changes(old_version, new_hero_version)
|
||||
op_session.update_entity(
|
||||
project_name, "version", old_version["id"], update_data
|
||||
)
|
||||
else:
|
||||
op_session.create_entity(project_name, "version", new_hero_version)
|
||||
|
||||
# Store hero entity to instance_data
|
||||
instance_data["heroVersionEntity"] = new_hero_version
|
||||
|
||||
old_repres_to_replace = {}
|
||||
for repre_info in published_repres.values():
|
||||
repre = repre_info["representation"]
|
||||
repre_name_low = repre["name"].lower()
|
||||
if repre_name_low in old_repres_by_name:
|
||||
old_repres_to_replace[repre_name_low] = (
|
||||
old_repres_by_name.pop(repre_name_low)
|
||||
)
|
||||
old_repres_to_delete = old_repres_by_name or {}
|
||||
backup_hero_publish_dir = None
|
||||
if os.path.exists(hero_publish_dir):
|
||||
base_backup_dir = f"{hero_publish_dir}.BACKUP"
|
||||
max_idx = 10
|
||||
# Find the first available backup directory name
|
||||
for idx in range(max_idx + 1):
|
||||
if idx == 0:
|
||||
candidate_backup_dir = base_backup_dir
|
||||
else:
|
||||
candidate_backup_dir = f"{base_backup_dir}{idx}"
|
||||
if not os.path.exists(candidate_backup_dir):
|
||||
backup_hero_publish_dir = candidate_backup_dir
|
||||
break
|
||||
else:
|
||||
raise AssertionError(
|
||||
f"Backup folders are fully occupied to max index {max_idx}"
|
||||
)
|
||||
|
||||
try:
|
||||
os.rename(hero_publish_dir, backup_hero_publish_dir)
|
||||
except PermissionError as e:
|
||||
raise AssertionError(
|
||||
"Could not create hero version because it is "
|
||||
"not possible to replace current hero files."
|
||||
) from e
|
||||
|
||||
try:
|
||||
src_to_dst_file_paths = []
|
||||
repre_integrate_data = []
|
||||
path_template_obj = anatomy.get_template_item(
|
||||
"hero", template_key, "path")
|
||||
anatomy_root = {"root": anatomy.roots}
|
||||
for repre_info in published_repres.values():
|
||||
published_files = repre_info["published_files"]
|
||||
if len(published_files) == 0:
|
||||
continue
|
||||
anatomy_data = copy.deepcopy(repre_info["anatomy_data"])
|
||||
anatomy_data.pop("version", None)
|
||||
template_filled = path_template_obj.format_strict(anatomy_data)
|
||||
repre_context = template_filled.used_values
|
||||
for key in self.db_representation_context_keys:
|
||||
value = anatomy_data.get(key)
|
||||
if value is not None:
|
||||
repre_context[key] = value
|
||||
repre_entity = copy.deepcopy(repre_info["representation"])
|
||||
repre_entity.pop("id", None)
|
||||
repre_entity["versionId"] = new_hero_version["id"]
|
||||
repre_entity["context"] = repre_context
|
||||
repre_entity["attrib"] = {
|
||||
"path": str(template_filled),
|
||||
"template": hero_template.template
|
||||
}
|
||||
dst_paths = []
|
||||
|
||||
if len(published_files) == 1:
|
||||
dst_paths.append(str(template_filled))
|
||||
mapped_published_file = StringTemplate(
|
||||
published_files[0]).format_strict(
|
||||
anatomy_root
|
||||
)
|
||||
src_to_dst_file_paths.append(
|
||||
(mapped_published_file, template_filled)
|
||||
)
|
||||
self.log.info(
|
||||
f"Single published file: {mapped_published_file} -> "
|
||||
f"{template_filled}"
|
||||
)
|
||||
else:
|
||||
collections, remainders = clique.assemble(published_files)
|
||||
if remainders or not collections or len(collections) > 1:
|
||||
raise RuntimeError(
|
||||
(
|
||||
"Integrity error. Files of published "
|
||||
"representation is combination of frame "
|
||||
"collections and single files."
|
||||
)
|
||||
)
|
||||
src_col = collections[0]
|
||||
frame_splitter = "_-_FRAME_SPLIT_-_"
|
||||
anatomy_data["frame"] = frame_splitter
|
||||
_template_filled = path_template_obj.format_strict(
|
||||
anatomy_data
|
||||
)
|
||||
head, tail = _template_filled.split(frame_splitter)
|
||||
padding = anatomy.templates_obj.frame_padding
|
||||
dst_col = clique.Collection(
|
||||
head=head, padding=padding, tail=tail
|
||||
)
|
||||
dst_col.indexes.clear()
|
||||
dst_col.indexes.update(src_col.indexes)
|
||||
for src_file, dst_file in zip(src_col, dst_col):
|
||||
src_file = StringTemplate(src_file).format_strict(
|
||||
anatomy_root
|
||||
)
|
||||
src_to_dst_file_paths.append((src_file, dst_file))
|
||||
dst_paths.append(dst_file)
|
||||
self.log.info(
|
||||
f"Collection published file: {src_file} "
|
||||
f"-> {dst_file}"
|
||||
)
|
||||
repre_integrate_data.append((repre_entity, dst_paths))
|
||||
|
||||
# Copy files
|
||||
with ThreadPoolExecutor(max_workers=8) as executor:
|
||||
futures = [
|
||||
executor.submit(self.copy_file, src_path, dst_path)
|
||||
for src_path, dst_path in itertools.chain(
|
||||
src_to_dst_file_paths, other_file_paths_mapping)
|
||||
]
|
||||
wait_for_future_errors(executor, futures)
|
||||
|
||||
# Update/create representations
|
||||
for repre_entity, dst_paths in repre_integrate_data:
|
||||
repre_files = self.get_files_info(dst_paths, anatomy)
|
||||
repre_entity["files"] = repre_files
|
||||
repre_name_low = repre_entity["name"].lower()
|
||||
if repre_name_low in old_repres_to_replace:
|
||||
old_repre = old_repres_to_replace.pop(repre_name_low)
|
||||
repre_entity["id"] = old_repre["id"]
|
||||
update_data = prepare_changes(old_repre, repre_entity)
|
||||
op_session.update_entity(
|
||||
project_name,
|
||||
"representation",
|
||||
old_repre["id"],
|
||||
update_data
|
||||
)
|
||||
elif repre_name_low in inactive_old_repres_by_name:
|
||||
inactive_repre = inactive_old_repres_by_name.pop(
|
||||
repre_name_low
|
||||
)
|
||||
repre_entity["id"] = inactive_repre["id"]
|
||||
update_data = prepare_changes(inactive_repre, repre_entity)
|
||||
op_session.update_entity(
|
||||
project_name,
|
||||
"representation",
|
||||
inactive_repre["id"],
|
||||
update_data
|
||||
)
|
||||
else:
|
||||
op_session.create_entity(
|
||||
project_name,
|
||||
"representation",
|
||||
repre_entity
|
||||
)
|
||||
|
||||
for repre in old_repres_to_delete.values():
|
||||
op_session.update_entity(
|
||||
project_name,
|
||||
"representation",
|
||||
repre["id"],
|
||||
{"active": False}
|
||||
)
|
||||
|
||||
op_session.commit()
|
||||
|
||||
if backup_hero_publish_dir is not None and os.path.exists(
|
||||
backup_hero_publish_dir
|
||||
):
|
||||
shutil.rmtree(backup_hero_publish_dir)
|
||||
|
||||
except Exception:
|
||||
if backup_hero_publish_dir is not None and os.path.exists(
|
||||
backup_hero_publish_dir):
|
||||
if os.path.exists(hero_publish_dir):
|
||||
shutil.rmtree(hero_publish_dir)
|
||||
os.rename(backup_hero_publish_dir, hero_publish_dir)
|
||||
raise
|
||||
|
||||
def get_files_info(
|
||||
self, filepaths: list[str], anatomy: Anatomy) -> list[dict]:
|
||||
"""Get list of file info dictionaries for given file paths.
|
||||
|
||||
Args:
|
||||
filepaths (list[str]): List of absolute file paths.
|
||||
anatomy (Anatomy): Anatomy object for the project.
|
||||
|
||||
Returns:
|
||||
list[dict]: List of file info dictionaries.
|
||||
|
||||
"""
|
||||
file_infos = []
|
||||
for filepath in filepaths:
|
||||
file_info = self.prepare_file_info(filepath, anatomy)
|
||||
file_infos.append(file_info)
|
||||
return file_infos
|
||||
|
||||
def prepare_file_info(self, path: str, anatomy: Anatomy) -> dict:
|
||||
"""Prepare file info dictionary for given path.
|
||||
|
||||
Args:
|
||||
path (str): Absolute file path.
|
||||
anatomy (Anatomy): Anatomy object for the project.
|
||||
|
||||
Returns:
|
||||
dict: File info dictionary with keys:
|
||||
- id (str): Unique identifier for the file.
|
||||
- name (str): Base name of the file.
|
||||
- path (str): Rootless file path.
|
||||
- size (int): Size of the file in bytes.
|
||||
- hash (str): Hash of the file content.
|
||||
- hash_type (str): Type of the hash used.
|
||||
|
||||
"""
|
||||
return {
|
||||
"id": create_entity_id(),
|
||||
"name": os.path.basename(path),
|
||||
"path": self.get_rootless_path(anatomy, path),
|
||||
"size": os.path.getsize(path),
|
||||
"hash": source_hash(path),
|
||||
"hash_type": "op3",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def get_publish_dir(
|
||||
instance_data: dict,
|
||||
anatomy: Anatomy,
|
||||
template_key: str) -> str:
|
||||
"""Get publish directory from instance data and anatomy.
|
||||
|
||||
Args:
|
||||
instance_data (dict): Instance data with "anatomyData" key.
|
||||
anatomy (Anatomy): Anatomy object for the project.
|
||||
template_key (str): Template key for the hero template.
|
||||
|
||||
Returns:
|
||||
str: Normalized publish directory path.
|
||||
|
||||
"""
|
||||
template_data = copy.deepcopy(instance_data.get("anatomyData", {}))
|
||||
if "originalBasename" in instance_data:
|
||||
template_data["originalBasename"] = (
|
||||
instance_data["originalBasename"]
|
||||
)
|
||||
template_obj = anatomy.get_template_item(
|
||||
"hero", template_key, "directory"
|
||||
)
|
||||
return os.path.normpath(template_obj.format_strict(template_data))
|
||||
|
||||
@staticmethod
|
||||
def get_rootless_path(anatomy: Anatomy, path: str) -> str:
|
||||
"""Get rootless path from absolute path.
|
||||
|
||||
Args:
|
||||
anatomy (Anatomy): Anatomy object for the project.
|
||||
path (str): Absolute file path.
|
||||
|
||||
Returns:
|
||||
str: Rootless file path if root found, else original path.
|
||||
|
||||
"""
|
||||
success, rootless_path = anatomy.find_root_template_from_path(path)
|
||||
if success:
|
||||
path = rootless_path
|
||||
return path
|
||||
|
||||
def copy_file(self, src_path: str, dst_path: str) -> None:
|
||||
"""Copy file from src to dst with creating directories.
|
||||
|
||||
Args:
|
||||
src_path (str): Source file path.
|
||||
dst_path (str): Destination file path.
|
||||
|
||||
Raises:
|
||||
OSError: If copying or linking fails.
|
||||
|
||||
"""
|
||||
dirname = os.path.dirname(dst_path)
|
||||
try:
|
||||
os.makedirs(dirname)
|
||||
except OSError as exc:
|
||||
if exc.errno != errno.EEXIST:
|
||||
raise
|
||||
if self.use_hardlinks:
|
||||
try:
|
||||
create_hard_link(src_path, dst_path)
|
||||
return
|
||||
except OSError as exc:
|
||||
if exc.errno not in [errno.EXDEV, errno.EINVAL]:
|
||||
raise
|
||||
copyfile(src_path, dst_path)
|
||||
|
||||
@staticmethod
|
||||
def version_from_representations(
|
||||
project_name: str, repres: dict) -> Optional[dict[str, Any]]:
|
||||
"""Find version from representations.
|
||||
|
||||
Args:
|
||||
project_name (str): Name of the project.
|
||||
repres (dict): Dictionary of representations info.
|
||||
|
||||
Returns:
|
||||
Optional[dict]: Version entity if found, else None.
|
||||
|
||||
"""
|
||||
for repre_info in repres.values():
|
||||
version = ayon_api.get_version_by_id(
|
||||
project_name, repre_info["representation"]["versionId"]
|
||||
)
|
||||
if version:
|
||||
return version
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def current_hero_ents(
|
||||
project_name: str,
|
||||
version: dict[str, Any]) -> tuple[Any, list[dict[str, Any]]]:
|
||||
hero_version = ayon_api.get_hero_version_by_product_id(
|
||||
project_name, version["productId"]
|
||||
)
|
||||
if not hero_version:
|
||||
return None, []
|
||||
hero_repres = list(
|
||||
ayon_api.get_representations(
|
||||
project_name, version_ids={hero_version["id"]}
|
||||
)
|
||||
)
|
||||
return hero_version, hero_repres
|
||||
|
|
@ -6,15 +6,15 @@ from ayon_core.pipeline import load
|
|||
from ayon_core.pipeline.load import LoadError
|
||||
|
||||
|
||||
class PushToLibraryProject(load.ProductLoaderPlugin):
|
||||
"""Export selected versions to folder structure from Template"""
|
||||
class PushToProject(load.ProductLoaderPlugin):
|
||||
"""Export selected versions to different project"""
|
||||
|
||||
is_multiple_contexts_compatible = True
|
||||
|
||||
representations = {"*"}
|
||||
product_types = {"*"}
|
||||
|
||||
label = "Push to Library project"
|
||||
label = "Push to project"
|
||||
order = 35
|
||||
icon = "send"
|
||||
color = "#d8d8d8"
|
||||
|
|
@ -28,10 +28,12 @@ class PushToLibraryProject(load.ProductLoaderPlugin):
|
|||
if not filtered_contexts:
|
||||
raise LoadError("Nothing to push for your selection")
|
||||
|
||||
if len(filtered_contexts) > 1:
|
||||
raise LoadError("Please select only one item")
|
||||
|
||||
context = tuple(filtered_contexts)[0]
|
||||
folder_ids = set(
|
||||
context["folder"]["id"]
|
||||
for context in filtered_contexts
|
||||
)
|
||||
if len(folder_ids) > 1:
|
||||
raise LoadError("Please select products from single folder")
|
||||
|
||||
push_tool_script_path = os.path.join(
|
||||
AYON_CORE_ROOT,
|
||||
|
|
@ -39,14 +41,16 @@ class PushToLibraryProject(load.ProductLoaderPlugin):
|
|||
"push_to_project",
|
||||
"main.py"
|
||||
)
|
||||
project_name = filtered_contexts[0]["project"]["name"]
|
||||
|
||||
project_name = context["project"]["name"]
|
||||
version_id = context["version"]["id"]
|
||||
version_ids = {
|
||||
context["version"]["id"]
|
||||
for context in filtered_contexts
|
||||
}
|
||||
|
||||
args = get_ayon_launcher_args(
|
||||
"run",
|
||||
push_tool_script_path,
|
||||
"--project", project_name,
|
||||
"--version", version_id
|
||||
"--versions", ",".join(version_ids)
|
||||
)
|
||||
run_detached_process(args)
|
||||
|
|
@ -38,6 +38,8 @@ class CleanUp(pyblish.api.InstancePlugin):
|
|||
"webpublisher",
|
||||
"shell"
|
||||
]
|
||||
settings_category = "core"
|
||||
|
||||
exclude_families = ["clip"]
|
||||
optional = True
|
||||
active = True
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ class CleanUpFarm(pyblish.api.ContextPlugin):
|
|||
|
||||
order = pyblish.api.IntegratorOrder + 11
|
||||
label = "Clean Up Farm"
|
||||
|
||||
settings_category = "core"
|
||||
enabled = True
|
||||
|
||||
# Keep "filesequence" for backwards compatibility of older jobs
|
||||
|
|
|
|||
|
|
@ -46,6 +46,8 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin):
|
|||
order = pyblish.api.CollectorOrder + 0.49
|
||||
label = "Collect Anatomy Instance data"
|
||||
|
||||
settings_category = "core"
|
||||
|
||||
follow_workfile_version = False
|
||||
|
||||
def process(self, context):
|
||||
|
|
|
|||
|
|
@ -39,8 +39,9 @@ class CollectAudio(pyblish.api.ContextPlugin):
|
|||
"blender",
|
||||
"houdini",
|
||||
"max",
|
||||
"circuit",
|
||||
"batchdelivery",
|
||||
]
|
||||
settings_category = "core"
|
||||
|
||||
audio_product_name = "audioMain"
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ class CollectFramesFixDef(
|
|||
targets = ["local"]
|
||||
hosts = ["nuke"]
|
||||
families = ["render", "prerender"]
|
||||
settings_category = "core"
|
||||
|
||||
rewrite_version_enable = False
|
||||
|
||||
|
|
|
|||
|
|
@ -2,11 +2,13 @@
|
|||
|
||||
"""
|
||||
import os
|
||||
import collections
|
||||
|
||||
import pyblish.api
|
||||
|
||||
from ayon_core.host import IPublishHost
|
||||
from ayon_core.pipeline import registered_host
|
||||
from ayon_core.pipeline.create import CreateContext
|
||||
from ayon_core.pipeline.create import CreateContext, ParentFlags
|
||||
|
||||
|
||||
class CollectFromCreateContext(pyblish.api.ContextPlugin):
|
||||
|
|
@ -36,9 +38,42 @@ class CollectFromCreateContext(pyblish.api.ContextPlugin):
|
|||
if project_name:
|
||||
context.data["projectName"] = project_name
|
||||
|
||||
# Separate root instances and parented instances
|
||||
instances_by_parent_id = collections.defaultdict(list)
|
||||
root_instances = []
|
||||
for created_instance in create_context.instances:
|
||||
parent_id = created_instance.parent_instance_id
|
||||
if parent_id is None:
|
||||
root_instances.append(created_instance)
|
||||
else:
|
||||
instances_by_parent_id[parent_id].append(created_instance)
|
||||
|
||||
# Traverse instances from top to bottom
|
||||
# - All instances without an existing parent are automatically
|
||||
# eliminated
|
||||
filtered_instances = []
|
||||
_queue = collections.deque()
|
||||
_queue.append((root_instances, True))
|
||||
while _queue:
|
||||
created_instances, parent_is_active = _queue.popleft()
|
||||
for created_instance in created_instances:
|
||||
is_active = created_instance["active"]
|
||||
# Use a parent's active state if parent flags defines that
|
||||
if (
|
||||
created_instance.parent_flags & ParentFlags.share_active
|
||||
and is_active
|
||||
):
|
||||
is_active = parent_is_active
|
||||
|
||||
if is_active:
|
||||
filtered_instances.append(created_instance)
|
||||
|
||||
children = instances_by_parent_id[created_instance.id]
|
||||
if children:
|
||||
_queue.append((children, is_active))
|
||||
|
||||
for created_instance in filtered_instances:
|
||||
instance_data = created_instance.data_to_store()
|
||||
if instance_data["active"]:
|
||||
thumbnail_path = thumbnail_paths_by_instance_id.get(
|
||||
created_instance.id
|
||||
)
|
||||
|
|
|
|||
|
|
@ -8,13 +8,7 @@ This module contains a unified plugin that handles:
|
|||
|
||||
from pprint import pformat
|
||||
|
||||
import opentimelineio as otio
|
||||
import pyblish.api
|
||||
from ayon_core.pipeline.editorial import (
|
||||
get_media_range_with_retimes,
|
||||
otio_range_to_frame_range,
|
||||
otio_range_with_handles,
|
||||
)
|
||||
|
||||
|
||||
def validate_otio_clip(instance, logger):
|
||||
|
|
@ -74,6 +68,8 @@ class CollectOtioRanges(pyblish.api.InstancePlugin):
|
|||
if not validate_otio_clip(instance, self.log):
|
||||
return
|
||||
|
||||
import opentimelineio as otio
|
||||
|
||||
otio_clip = instance.data["otioClip"]
|
||||
|
||||
# Collect timeline ranges if workfile start frame is available
|
||||
|
|
@ -100,6 +96,11 @@ class CollectOtioRanges(pyblish.api.InstancePlugin):
|
|||
|
||||
def _collect_timeline_ranges(self, instance, otio_clip):
|
||||
"""Collect basic timeline frame ranges."""
|
||||
from ayon_core.pipeline.editorial import (
|
||||
otio_range_to_frame_range,
|
||||
otio_range_with_handles,
|
||||
)
|
||||
|
||||
workfile_start = instance.data["workfileFrameStart"]
|
||||
|
||||
# Get timeline ranges
|
||||
|
|
@ -129,6 +130,8 @@ class CollectOtioRanges(pyblish.api.InstancePlugin):
|
|||
|
||||
def _collect_source_ranges(self, instance, otio_clip):
|
||||
"""Collect source media frame ranges."""
|
||||
import opentimelineio as otio
|
||||
|
||||
# Get source ranges
|
||||
otio_src_range = otio_clip.source_range
|
||||
otio_available_range = otio_clip.available_range()
|
||||
|
|
@ -178,6 +181,8 @@ class CollectOtioRanges(pyblish.api.InstancePlugin):
|
|||
|
||||
def _collect_retimed_ranges(self, instance, otio_clip):
|
||||
"""Handle retimed clip frame ranges."""
|
||||
from ayon_core.pipeline.editorial import get_media_range_with_retimes
|
||||
|
||||
retimed_attributes = get_media_range_with_retimes(otio_clip, 0, 0)
|
||||
self.log.debug(f"Retimed attributes: {retimed_attributes}")
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import ayon_api
|
||||
import ayon_api.utils
|
||||
|
||||
from ayon_core.host import ILoadHost
|
||||
from ayon_core.pipeline import registered_host
|
||||
|
||||
import pyblish.api
|
||||
|
||||
|
||||
|
|
@ -13,16 +15,23 @@ class CollectSceneLoadedVersions(pyblish.api.ContextPlugin):
|
|||
def process(self, context):
|
||||
host = registered_host()
|
||||
if host is None:
|
||||
self.log.warn("No registered host.")
|
||||
self.log.warning("No registered host.")
|
||||
return
|
||||
|
||||
if not hasattr(host, "ls"):
|
||||
host_name = host.__name__
|
||||
self.log.warn("Host %r doesn't have ls() implemented." % host_name)
|
||||
if not isinstance(host, ILoadHost):
|
||||
host_name = host.name
|
||||
self.log.warning(
|
||||
f"Host {host_name} does not implement ILoadHost. "
|
||||
"Skipping querying of loaded versions in scene."
|
||||
)
|
||||
return
|
||||
|
||||
containers = list(host.get_containers())
|
||||
if not containers:
|
||||
# Opt out early if there are no containers
|
||||
self.log.debug("No loaded containers found in scene.")
|
||||
return
|
||||
|
||||
loaded_versions = []
|
||||
containers = list(host.ls())
|
||||
repre_ids = {
|
||||
container["representation"]
|
||||
for container in containers
|
||||
|
|
@ -47,6 +56,7 @@ class CollectSceneLoadedVersions(pyblish.api.ContextPlugin):
|
|||
|
||||
# QUESTION should we add same representation id when loaded multiple
|
||||
# times?
|
||||
loaded_versions = []
|
||||
for con in containers:
|
||||
repre_id = con["representation"]
|
||||
repre_entity = repre_entities_by_id.get(repre_id)
|
||||
|
|
@ -66,4 +76,5 @@ class CollectSceneLoadedVersions(pyblish.api.ContextPlugin):
|
|||
}
|
||||
loaded_versions.append(version)
|
||||
|
||||
self.log.debug(f"Collected {len(loaded_versions)} loaded versions.")
|
||||
context.data["loadedVersions"] = loaded_versions
|
||||
|
|
|
|||
|
|
@ -12,9 +12,10 @@ class CollectSceneVersion(pyblish.api.ContextPlugin):
|
|||
"""
|
||||
|
||||
order = pyblish.api.CollectorOrder
|
||||
label = 'Collect Scene Version'
|
||||
label = "Collect Scene Version"
|
||||
# configurable in Settings
|
||||
hosts = ["*"]
|
||||
settings_category = "core"
|
||||
|
||||
# in some cases of headless publishing (for example webpublisher using PS)
|
||||
# you want to ignore version from name and let integrate use next version
|
||||
|
|
|
|||
|
|
@ -55,8 +55,9 @@ class ExtractBurnin(publish.Extractor):
|
|||
"max",
|
||||
"blender",
|
||||
"unreal",
|
||||
"circuit",
|
||||
"batchdelivery",
|
||||
]
|
||||
settings_category = "core"
|
||||
|
||||
optional = True
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ from ayon_core.lib import (
|
|||
is_oiio_supported,
|
||||
)
|
||||
from ayon_core.lib.transcoding import (
|
||||
convert_colorspace,
|
||||
oiio_color_convert,
|
||||
)
|
||||
|
||||
from ayon_core.lib.profiles_filtering import filter_profiles
|
||||
|
|
@ -55,6 +55,8 @@ class ExtractOIIOTranscode(publish.Extractor):
|
|||
label = "Transcode color spaces"
|
||||
order = pyblish.api.ExtractorOrder + 0.019
|
||||
|
||||
settings_category = "core"
|
||||
|
||||
optional = True
|
||||
|
||||
# Supported extensions
|
||||
|
|
@ -85,6 +87,14 @@ class ExtractOIIOTranscode(publish.Extractor):
|
|||
new_representations = []
|
||||
repres = instance.data["representations"]
|
||||
for idx, repre in enumerate(list(repres)):
|
||||
# target space, display and view might be defined upstream
|
||||
# TODO: address https://github.com/ynput/ayon-core/pull/1268#discussion_r2156555474
|
||||
# Implement upstream logic to handle target_colorspace,
|
||||
# target_display, target_view in other DCCs
|
||||
target_colorspace = False
|
||||
target_display = instance.data.get("colorspaceDisplay")
|
||||
target_view = instance.data.get("colorspaceView")
|
||||
|
||||
self.log.debug("repre ({}): `{}`".format(idx + 1, repre["name"]))
|
||||
if not self._repre_is_valid(repre):
|
||||
continue
|
||||
|
|
@ -94,6 +104,8 @@ class ExtractOIIOTranscode(publish.Extractor):
|
|||
|
||||
colorspace_data = repre["colorspaceData"]
|
||||
source_colorspace = colorspace_data["colorspace"]
|
||||
source_display = colorspace_data.get("display")
|
||||
source_view = colorspace_data.get("view")
|
||||
config_path = colorspace_data.get("config", {}).get("path")
|
||||
if not config_path or not os.path.exists(config_path):
|
||||
self.log.warning("Config file doesn't exist, skipping")
|
||||
|
|
@ -124,7 +136,6 @@ class ExtractOIIOTranscode(publish.Extractor):
|
|||
|
||||
transcoding_type = output_def["transcoding_type"]
|
||||
|
||||
target_colorspace = view = display = None
|
||||
# NOTE: we use colorspace_data as the fallback values for
|
||||
# the target colorspace.
|
||||
if transcoding_type == "colorspace":
|
||||
|
|
@ -136,18 +147,20 @@ class ExtractOIIOTranscode(publish.Extractor):
|
|||
colorspace_data.get("colorspace"))
|
||||
elif transcoding_type == "display_view":
|
||||
display_view = output_def["display_view"]
|
||||
view = display_view["view"] or colorspace_data.get("view")
|
||||
display = (
|
||||
target_view = (
|
||||
display_view["view"]
|
||||
or colorspace_data.get("view"))
|
||||
target_display = (
|
||||
display_view["display"]
|
||||
or colorspace_data.get("display")
|
||||
)
|
||||
|
||||
# both could be already collected by DCC,
|
||||
# but could be overwritten when transcoding
|
||||
if view:
|
||||
new_repre["colorspaceData"]["view"] = view
|
||||
if display:
|
||||
new_repre["colorspaceData"]["display"] = display
|
||||
if target_view:
|
||||
new_repre["colorspaceData"]["view"] = target_view
|
||||
if target_display:
|
||||
new_repre["colorspaceData"]["display"] = target_display
|
||||
if target_colorspace:
|
||||
new_repre["colorspaceData"]["colorspace"] = \
|
||||
target_colorspace
|
||||
|
|
@ -166,16 +179,18 @@ class ExtractOIIOTranscode(publish.Extractor):
|
|||
new_staging_dir,
|
||||
output_extension)
|
||||
|
||||
convert_colorspace(
|
||||
input_path,
|
||||
output_path,
|
||||
config_path,
|
||||
source_colorspace,
|
||||
target_colorspace,
|
||||
view,
|
||||
display,
|
||||
additional_command_args,
|
||||
self.log
|
||||
oiio_color_convert(
|
||||
input_path=input_path,
|
||||
output_path=output_path,
|
||||
config_path=config_path,
|
||||
source_colorspace=source_colorspace,
|
||||
target_colorspace=target_colorspace,
|
||||
target_display=target_display,
|
||||
target_view=target_view,
|
||||
source_display=source_display,
|
||||
source_view=source_view,
|
||||
additional_command_args=additional_command_args,
|
||||
logger=self.log
|
||||
)
|
||||
|
||||
# cleanup temporary transcoded files
|
||||
|
|
|
|||
|
|
@ -158,6 +158,7 @@ class ExtractOtioAudioTracks(pyblish.api.ContextPlugin):
|
|||
"""
|
||||
# Not all hosts can import this module.
|
||||
import opentimelineio as otio
|
||||
from ayon_core.pipeline.editorial import OTIO_EPSILON
|
||||
|
||||
output = []
|
||||
# go trough all audio tracks
|
||||
|
|
@ -172,6 +173,14 @@ class ExtractOtioAudioTracks(pyblish.api.ContextPlugin):
|
|||
clip_start = otio_clip.source_range.start_time
|
||||
fps = clip_start.rate
|
||||
conformed_av_start = media_av_start.rescaled_to(fps)
|
||||
|
||||
# Avoid rounding issue on media available range.
|
||||
if clip_start.almost_equal(
|
||||
conformed_av_start,
|
||||
OTIO_EPSILON
|
||||
):
|
||||
conformed_av_start = clip_start
|
||||
|
||||
# ffmpeg ignores embedded tc
|
||||
start = clip_start - conformed_av_start
|
||||
duration = otio_clip.source_range.duration
|
||||
|
|
|
|||
|
|
@ -23,7 +23,10 @@ from ayon_core.lib import (
|
|||
get_ffmpeg_tool_args,
|
||||
run_subprocess,
|
||||
)
|
||||
from ayon_core.pipeline import publish
|
||||
from ayon_core.pipeline import (
|
||||
KnownPublishError,
|
||||
publish,
|
||||
)
|
||||
|
||||
|
||||
class ExtractOTIOReview(
|
||||
|
|
@ -97,8 +100,11 @@ class ExtractOTIOReview(
|
|||
|
||||
# skip instance if no reviewable data available
|
||||
if (
|
||||
len(otio_review_clips) == 1
|
||||
and (
|
||||
not isinstance(otio_review_clips[0], otio.schema.Clip)
|
||||
and len(otio_review_clips) == 1
|
||||
or otio_review_clips[0].media_reference.is_missing_reference
|
||||
)
|
||||
):
|
||||
self.log.warning(
|
||||
"Instance `{}` has nothing to process".format(instance))
|
||||
|
|
@ -248,7 +254,7 @@ class ExtractOTIOReview(
|
|||
|
||||
# Single video way.
|
||||
# Extraction via FFmpeg.
|
||||
else:
|
||||
elif hasattr(media_ref, "target_url"):
|
||||
path = media_ref.target_url
|
||||
# Set extract range from 0 (FFmpeg ignores
|
||||
# embedded timecode).
|
||||
|
|
@ -352,6 +358,7 @@ class ExtractOTIOReview(
|
|||
import opentimelineio as otio
|
||||
from ayon_core.pipeline.editorial import (
|
||||
trim_media_range,
|
||||
OTIO_EPSILON,
|
||||
)
|
||||
|
||||
def _round_to_frame(rational_time):
|
||||
|
|
@ -370,6 +377,13 @@ class ExtractOTIOReview(
|
|||
|
||||
avl_start = avl_range.start_time
|
||||
|
||||
# Avoid rounding issue on media available range.
|
||||
if start.almost_equal(
|
||||
avl_start,
|
||||
OTIO_EPSILON
|
||||
):
|
||||
avl_start = start
|
||||
|
||||
# An additional gap is required before the available
|
||||
# range to conform source start point and head handles.
|
||||
if start < avl_start:
|
||||
|
|
@ -388,6 +402,14 @@ class ExtractOTIOReview(
|
|||
# (media duration is shorter then clip requirement).
|
||||
end_point = start + duration
|
||||
avl_end_point = avl_range.end_time_exclusive()
|
||||
|
||||
# Avoid rounding issue on media available range.
|
||||
if end_point.almost_equal(
|
||||
avl_end_point,
|
||||
OTIO_EPSILON
|
||||
):
|
||||
avl_end_point = end_point
|
||||
|
||||
if end_point > avl_end_point:
|
||||
gap_duration = end_point - avl_end_point
|
||||
duration -= gap_duration
|
||||
|
|
@ -444,7 +466,7 @@ class ExtractOTIOReview(
|
|||
command = get_ffmpeg_tool_args("ffmpeg")
|
||||
|
||||
input_extension = None
|
||||
if sequence:
|
||||
if sequence is not None:
|
||||
input_dir, collection, sequence_fps = sequence
|
||||
in_frame_start = min(collection.indexes)
|
||||
|
||||
|
|
@ -478,7 +500,7 @@ class ExtractOTIOReview(
|
|||
"-i", input_path
|
||||
])
|
||||
|
||||
elif video:
|
||||
elif video is not None:
|
||||
video_path, otio_range = video
|
||||
frame_start = otio_range.start_time.value
|
||||
input_fps = otio_range.start_time.rate
|
||||
|
|
@ -496,7 +518,7 @@ class ExtractOTIOReview(
|
|||
"-i", video_path
|
||||
])
|
||||
|
||||
elif gap:
|
||||
elif gap is not None:
|
||||
sec_duration = frames_to_seconds(gap, self.actual_fps)
|
||||
|
||||
# form command for rendering gap files
|
||||
|
|
@ -510,6 +532,9 @@ class ExtractOTIOReview(
|
|||
"-tune", "stillimage"
|
||||
])
|
||||
|
||||
else:
|
||||
raise KnownPublishError("Sequence, video or gap is required.")
|
||||
|
||||
if video or sequence:
|
||||
command.extend([
|
||||
"-vf", f"scale={self.to_width}:{self.to_height}:flags=lanczos",
|
||||
|
|
|
|||
|
|
@ -161,10 +161,11 @@ class ExtractReview(pyblish.api.InstancePlugin):
|
|||
"aftereffects",
|
||||
"flame",
|
||||
"unreal",
|
||||
"circuit",
|
||||
"batchdelivery",
|
||||
"photoshop"
|
||||
]
|
||||
|
||||
settings_category = "core"
|
||||
# Supported extensions
|
||||
image_exts = {"exr", "jpg", "jpeg", "png", "dpx", "tga", "tiff", "tif"}
|
||||
video_exts = {"mov", "mp4"}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ from ayon_core.lib import (
|
|||
path_to_subprocess_arg,
|
||||
run_subprocess,
|
||||
)
|
||||
from ayon_core.lib.transcoding import convert_colorspace
|
||||
from ayon_core.lib.transcoding import oiio_color_convert
|
||||
|
||||
from ayon_core.lib.transcoding import VIDEO_EXTENSIONS, IMAGE_EXTENSIONS
|
||||
|
||||
|
|
@ -41,8 +41,9 @@ class ExtractThumbnail(pyblish.api.InstancePlugin):
|
|||
"photoshop",
|
||||
"unreal",
|
||||
"houdini",
|
||||
"circuit",
|
||||
"batchdelivery",
|
||||
]
|
||||
settings_category = "core"
|
||||
enabled = False
|
||||
|
||||
integrate_thumbnail = False
|
||||
|
|
@ -432,13 +433,15 @@ class ExtractThumbnail(pyblish.api.InstancePlugin):
|
|||
oiio_default_view = display_and_view["view"]
|
||||
|
||||
try:
|
||||
convert_colorspace(
|
||||
oiio_color_convert(
|
||||
src_path,
|
||||
dst_path,
|
||||
colorspace_data["config"]["path"],
|
||||
colorspace_data["colorspace"],
|
||||
display=repre_display or oiio_default_display,
|
||||
view=repre_view or oiio_default_view,
|
||||
source_display=colorspace_data.get("display"),
|
||||
source_view=colorspace_data.get("view"),
|
||||
target_display=repre_display or oiio_default_display,
|
||||
target_view=repre_view or oiio_default_view,
|
||||
target_colorspace=oiio_default_colorspace,
|
||||
additional_command_args=resolution_arg,
|
||||
logger=self.log,
|
||||
|
|
|
|||
|
|
@ -256,6 +256,7 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
|
|||
label = "Collect USD Layer Contributions (Asset/Shot)"
|
||||
families = ["usd"]
|
||||
enabled = True
|
||||
settings_category = "core"
|
||||
|
||||
# A contribution defines a contribution into a (department) layer which
|
||||
# will get layered into the target product, usually the asset or shot.
|
||||
|
|
@ -633,6 +634,8 @@ class ExtractUSDLayerContribution(publish.Extractor):
|
|||
label = "Extract USD Layer Contributions (Asset/Shot)"
|
||||
order = pyblish.api.ExtractorOrder + 0.45
|
||||
|
||||
settings_category = "core"
|
||||
|
||||
use_ayon_entity_uri = False
|
||||
|
||||
def process(self, instance):
|
||||
|
|
@ -795,6 +798,8 @@ class ExtractUSDAssetContribution(publish.Extractor):
|
|||
label = "Extract USD Asset/Shot Contributions"
|
||||
order = ExtractUSDLayerContribution.order + 0.01
|
||||
|
||||
settings_category = "core"
|
||||
|
||||
use_ayon_entity_uri = False
|
||||
|
||||
def process(self, instance):
|
||||
|
|
|
|||
|
|
@ -61,6 +61,8 @@ class IntegrateHeroVersion(
|
|||
# Must happen after IntegrateNew
|
||||
order = pyblish.api.IntegratorOrder + 0.1
|
||||
|
||||
settings_category = "core"
|
||||
|
||||
optional = True
|
||||
active = True
|
||||
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ class IntegrateInputLinksAYON(pyblish.api.ContextPlugin):
|
|||
created links by its type
|
||||
"""
|
||||
if workfile_instance is None:
|
||||
self.log.warn("No workfile in this publish session.")
|
||||
self.log.warning("No workfile in this publish session.")
|
||||
return
|
||||
|
||||
workfile_version_id = workfile_instance.data["versionEntity"]["id"]
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ class IntegrateProductGroup(pyblish.api.InstancePlugin):
|
|||
order = pyblish.api.IntegratorOrder - 0.1
|
||||
label = "Product Group"
|
||||
|
||||
settings_category = "core"
|
||||
|
||||
# Attributes set by settings
|
||||
product_grouping_profiles = None
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ class PreIntegrateThumbnails(pyblish.api.InstancePlugin):
|
|||
label = "Override Integrate Thumbnail Representations"
|
||||
order = pyblish.api.IntegratorOrder - 0.1
|
||||
|
||||
settings_category = "core"
|
||||
|
||||
integrate_profiles = []
|
||||
|
||||
def process(self, instance):
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ class ValidateOutdatedContainers(
|
|||
|
||||
label = "Validate Outdated Containers"
|
||||
order = pyblish.api.ValidatorOrder
|
||||
settings_category = "core"
|
||||
|
||||
optional = True
|
||||
actions = [ShowInventory]
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ class ValidateCurrentSaveFile(pyblish.api.ContextPlugin):
|
|||
label = "Validate File Saved"
|
||||
order = pyblish.api.ValidatorOrder - 0.1
|
||||
hosts = ["fusion", "houdini", "max", "maya", "nuke", "substancepainter",
|
||||
"cinema4d", "silhouette", "gaffer", "blender"]
|
||||
"cinema4d", "silhouette", "gaffer", "blender", "loki"]
|
||||
actions = [SaveByVersionUpAction, ShowWorkfilesAction]
|
||||
|
||||
def process(self, context):
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ class ValidateIntent(pyblish.api.ContextPlugin):
|
|||
order = pyblish.api.ValidatorOrder
|
||||
|
||||
label = "Validate Intent"
|
||||
settings_category = "core"
|
||||
|
||||
enabled = False
|
||||
|
||||
# Can be modified by settings
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ class ValidateVersion(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin):
|
|||
order = pyblish.api.ValidatorOrder
|
||||
|
||||
label = "Validate Version"
|
||||
settings_category = "core"
|
||||
|
||||
optional = False
|
||||
active = True
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import logging
|
|||
import collections
|
||||
import copy
|
||||
import time
|
||||
import warnings
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import ayon_api
|
||||
|
||||
|
|
@ -35,6 +37,37 @@ class CacheItem:
|
|||
return time.time() > self._outdate_time
|
||||
|
||||
|
||||
def _get_addons_settings(
|
||||
studio_bundle_name,
|
||||
project_bundle_name,
|
||||
variant,
|
||||
project_name=None,
|
||||
):
|
||||
"""Modified version of `ayon_api.get_addons_settings` function."""
|
||||
query_values = {
|
||||
key: value
|
||||
for key, value in (
|
||||
("bundle_name", studio_bundle_name),
|
||||
("variant", variant),
|
||||
("project_name", project_name),
|
||||
)
|
||||
if value
|
||||
}
|
||||
if project_bundle_name != studio_bundle_name:
|
||||
query_values["project_bundle_name"] = project_bundle_name
|
||||
|
||||
site_id = ayon_api.get_site_id()
|
||||
if site_id:
|
||||
query_values["site_id"] = site_id
|
||||
|
||||
response = ayon_api.get(f"settings?{urlencode(query_values)}")
|
||||
response.raise_for_status()
|
||||
return {
|
||||
addon["name"]: addon["settings"]
|
||||
for addon in response.data["addons"]
|
||||
}
|
||||
|
||||
|
||||
class _AyonSettingsCache:
|
||||
use_bundles = None
|
||||
variant = None
|
||||
|
|
@ -67,53 +100,70 @@ class _AyonSettingsCache:
|
|||
return _AyonSettingsCache.variant
|
||||
|
||||
@classmethod
|
||||
def _get_bundle_name(cls):
|
||||
def _get_studio_bundle_name(cls):
|
||||
bundle_name = os.environ.get("AYON_STUDIO_BUNDLE_NAME")
|
||||
if bundle_name:
|
||||
return bundle_name
|
||||
return os.environ["AYON_BUNDLE_NAME"]
|
||||
|
||||
@classmethod
|
||||
def _get_project_bundle_name(cls):
|
||||
return os.environ["AYON_BUNDLE_NAME"]
|
||||
|
||||
@classmethod
|
||||
def get_value_by_project(cls, project_name):
|
||||
cache_item = _AyonSettingsCache.cache_by_project_name[project_name]
|
||||
if cache_item.is_outdated:
|
||||
if cls._use_bundles():
|
||||
value = ayon_api.get_addons_settings(
|
||||
bundle_name=cls._get_bundle_name(),
|
||||
cache_item.update_value(
|
||||
_get_addons_settings(
|
||||
studio_bundle_name=cls._get_studio_bundle_name(),
|
||||
project_bundle_name=cls._get_project_bundle_name(),
|
||||
project_name=project_name,
|
||||
variant=cls._get_variant()
|
||||
variant=cls._get_variant(),
|
||||
)
|
||||
)
|
||||
else:
|
||||
value = ayon_api.get_addons_settings(project_name)
|
||||
cache_item.update_value(value)
|
||||
return cache_item.get_value()
|
||||
|
||||
@classmethod
|
||||
def _get_addon_versions_from_bundle(cls):
|
||||
expected_bundle = cls._get_bundle_name()
|
||||
studio_bundle_name = cls._get_studio_bundle_name()
|
||||
project_bundle_name = cls._get_project_bundle_name()
|
||||
bundles = ayon_api.get_bundles()["bundles"]
|
||||
bundle = next(
|
||||
project_bundle = next(
|
||||
(
|
||||
bundle
|
||||
for bundle in bundles
|
||||
if bundle["name"] == expected_bundle
|
||||
if bundle["name"] == project_bundle_name
|
||||
),
|
||||
None
|
||||
)
|
||||
if bundle is not None:
|
||||
return bundle["addons"]
|
||||
studio_bundle = None
|
||||
if studio_bundle_name and project_bundle_name != studio_bundle_name:
|
||||
studio_bundle = next(
|
||||
(
|
||||
bundle
|
||||
for bundle in bundles
|
||||
if bundle["name"] == studio_bundle_name
|
||||
),
|
||||
None
|
||||
)
|
||||
|
||||
if studio_bundle and project_bundle:
|
||||
addons = copy.deepcopy(studio_bundle["addons"])
|
||||
addons.update(project_bundle["addons"])
|
||||
project_bundle["addons"] = addons
|
||||
|
||||
if project_bundle is not None:
|
||||
return project_bundle["addons"]
|
||||
return {}
|
||||
|
||||
@classmethod
|
||||
def get_addon_versions(cls):
|
||||
cache_item = _AyonSettingsCache.addon_versions
|
||||
if cache_item.is_outdated:
|
||||
if cls._use_bundles():
|
||||
addons = cls._get_addon_versions_from_bundle()
|
||||
else:
|
||||
settings_data = ayon_api.get_addons_settings(
|
||||
only_values=False,
|
||||
variant=cls._get_variant()
|
||||
cache_item.update_value(
|
||||
cls._get_addon_versions_from_bundle()
|
||||
)
|
||||
addons = settings_data["versions"]
|
||||
cache_item.update_value(addons)
|
||||
|
||||
return cache_item.get_value()
|
||||
|
||||
|
|
@ -175,17 +225,22 @@ def get_project_environments(project_name, project_settings=None):
|
|||
|
||||
|
||||
def get_current_project_settings():
|
||||
"""Project settings for current context project.
|
||||
"""DEPRECATE Project settings for current context project.
|
||||
|
||||
Function requires access to pipeline context which is in
|
||||
'ayon_core.pipeline'.
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: Project settings for current context project.
|
||||
|
||||
Project name should be stored in environment variable `AYON_PROJECT_NAME`.
|
||||
This function should be used only in host context where environment
|
||||
variable must be set and should not happen that any part of process will
|
||||
change the value of the environment variable.
|
||||
"""
|
||||
project_name = os.environ.get("AYON_PROJECT_NAME")
|
||||
if not project_name:
|
||||
raise ValueError(
|
||||
"Missing context project in environment"
|
||||
" variable `AYON_PROJECT_NAME`."
|
||||
warnings.warn(
|
||||
"Used deprecated function 'get_current_project_settings' in"
|
||||
" 'ayon_core.settings'. The function was moved to"
|
||||
" 'ayon_core.pipeline.context_tools'.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2
|
||||
)
|
||||
return get_project_settings(project_name)
|
||||
from ayon_core.pipeline.context_tools import get_current_project_settings
|
||||
|
||||
return get_current_project_settings()
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@
|
|||
},
|
||||
"publisher": {
|
||||
"error": "#AA5050",
|
||||
"disabled": "#5b6779",
|
||||
"crash": "#FF6432",
|
||||
"success": "#458056",
|
||||
"warning": "#ffc671",
|
||||
|
|
|
|||
|
|
@ -1153,6 +1153,10 @@ PixmapButton:disabled {
|
|||
color: {color:publisher:error};
|
||||
}
|
||||
|
||||
#ListViewProductName[state="disabled"] {
|
||||
color: {color:publisher:disabled};
|
||||
}
|
||||
|
||||
#PublishInfoFrame {
|
||||
background: {color:bg};
|
||||
border-radius: 0.3em;
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ from .projects import (
|
|||
PROJECTS_MODEL_SENDER,
|
||||
FolderTypeItem,
|
||||
TaskTypeItem,
|
||||
ProductTypeIconMapping,
|
||||
)
|
||||
from .hierarchy import (
|
||||
FolderItem,
|
||||
|
|
@ -34,6 +35,7 @@ __all__ = (
|
|||
"PROJECTS_MODEL_SENDER",
|
||||
"FolderTypeItem",
|
||||
"TaskTypeItem",
|
||||
"ProductTypeIconMapping",
|
||||
|
||||
"FolderItem",
|
||||
"TaskItem",
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ from __future__ import annotations
|
|||
|
||||
import contextlib
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Dict, Any
|
||||
from typing import Any, Optional
|
||||
from dataclasses import dataclass
|
||||
|
||||
import ayon_api
|
||||
|
|
@ -51,7 +51,7 @@ class StatusItem:
|
|||
self.icon: str = icon
|
||||
self.state: str = state
|
||||
|
||||
def to_data(self) -> Dict[str, Any]:
|
||||
def to_data(self) -> dict[str, Any]:
|
||||
return {
|
||||
"name": self.name,
|
||||
"color": self.color,
|
||||
|
|
@ -125,16 +125,24 @@ class TaskTypeItem:
|
|||
icon (str): Icon name in MaterialIcons ("fiber_new").
|
||||
|
||||
"""
|
||||
def __init__(self, name, short, icon):
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
short: str,
|
||||
icon: str,
|
||||
color: Optional[str],
|
||||
):
|
||||
self.name = name
|
||||
self.short = short
|
||||
self.icon = icon
|
||||
self.color = color
|
||||
|
||||
def to_data(self):
|
||||
return {
|
||||
"name": self.name,
|
||||
"short": self.short,
|
||||
"icon": self.icon,
|
||||
"color": self.color,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
|
|
@ -147,6 +155,7 @@ class TaskTypeItem:
|
|||
name=task_type_data["name"],
|
||||
short=task_type_data["shortName"],
|
||||
icon=task_type_data["icon"],
|
||||
color=task_type_data.get("color"),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -218,6 +227,54 @@ class ProjectItem:
|
|||
return cls(**data)
|
||||
|
||||
|
||||
class ProductTypeIconMapping:
|
||||
def __init__(
|
||||
self,
|
||||
default: Optional[dict[str, str]] = None,
|
||||
definitions: Optional[list[dict[str, str]]] = None,
|
||||
):
|
||||
self._default = default or {}
|
||||
self._definitions = definitions or []
|
||||
|
||||
self._default_def = None
|
||||
self._definitions_by_name = None
|
||||
|
||||
def get_icon(
|
||||
self,
|
||||
product_base_type: Optional[str] = None,
|
||||
product_type: Optional[str] = None,
|
||||
) -> dict[str, str]:
|
||||
defs = self._get_defs_by_name()
|
||||
icon = defs.get(product_type)
|
||||
if icon is None:
|
||||
icon = defs.get(product_base_type)
|
||||
if icon is None:
|
||||
icon = self._get_default_def()
|
||||
return icon.copy()
|
||||
|
||||
def _get_default_def(self) -> dict[str, str]:
|
||||
if self._default_def is None:
|
||||
self._default_def = {
|
||||
"type": "material-symbols",
|
||||
"name": self._default.get("icon", "deployed_code"),
|
||||
"color": self._default.get("color", "#cccccc"),
|
||||
}
|
||||
|
||||
return self._default_def
|
||||
|
||||
def _get_defs_by_name(self) -> dict[str, dict[str, str]]:
|
||||
if self._definitions_by_name is None:
|
||||
self._definitions_by_name = {
|
||||
product_base_type_def["name"]: {
|
||||
"type": "material-symbols",
|
||||
"name": product_base_type_def.get("icon", "deployed_code"),
|
||||
"color": product_base_type_def.get("color", "#cccccc"),
|
||||
}
|
||||
for product_base_type_def in self._definitions
|
||||
}
|
||||
return self._definitions_by_name
|
||||
|
||||
|
||||
def _get_project_items_from_entitiy(
|
||||
projects: list[dict[str, Any]]
|
||||
) -> list[ProjectItem]:
|
||||
|
|
@ -242,6 +299,9 @@ class ProjectsModel(object):
|
|||
self._projects_by_name = NestedCacheItem(
|
||||
levels=1, default_factory=list
|
||||
)
|
||||
self._product_type_icons_mapping = NestedCacheItem(
|
||||
levels=1, default_factory=ProductTypeIconMapping
|
||||
)
|
||||
self._project_statuses_cache = {}
|
||||
self._folder_types_cache = {}
|
||||
self._task_types_cache = {}
|
||||
|
|
@ -255,6 +315,7 @@ class ProjectsModel(object):
|
|||
self._task_types_cache = {}
|
||||
self._projects_cache.reset()
|
||||
self._projects_by_name.reset()
|
||||
self._product_type_icons_mapping.reset()
|
||||
|
||||
def refresh(self):
|
||||
"""Refresh project items.
|
||||
|
|
@ -390,6 +451,27 @@ class ProjectsModel(object):
|
|||
self._task_type_items_getter,
|
||||
)
|
||||
|
||||
def get_product_type_icons_mapping(
|
||||
self, project_name: Optional[str]
|
||||
) -> ProductTypeIconMapping:
|
||||
cache = self._product_type_icons_mapping[project_name]
|
||||
if cache.is_valid:
|
||||
return cache.get_data()
|
||||
|
||||
project_entity = self.get_project_entity(project_name)
|
||||
icons_mapping = ProductTypeIconMapping()
|
||||
if project_entity:
|
||||
product_base_types = (
|
||||
project_entity["config"].get("productBaseTypes", {})
|
||||
)
|
||||
icons_mapping = ProductTypeIconMapping(
|
||||
product_base_types.get("default"),
|
||||
product_base_types.get("definitions")
|
||||
)
|
||||
|
||||
cache.update_data(icons_mapping)
|
||||
return icons_mapping
|
||||
|
||||
def _get_project_items(
|
||||
self, project_name, sender, item_type, cache_obj, getter
|
||||
):
|
||||
|
|
|
|||
|
|
@ -1,9 +0,0 @@
|
|||
from .window import (
|
||||
show,
|
||||
CreatorWindow
|
||||
)
|
||||
|
||||
__all__ = (
|
||||
"show",
|
||||
"CreatorWindow"
|
||||
)
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
from qtpy import QtCore
|
||||
|
||||
|
||||
PRODUCT_TYPE_ROLE = QtCore.Qt.UserRole + 1
|
||||
ITEM_ID_ROLE = QtCore.Qt.UserRole + 2
|
||||
|
||||
SEPARATOR = "---"
|
||||
SEPARATORS = {"---", "---separator---"}
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
import uuid
|
||||
from qtpy import QtGui, QtCore
|
||||
|
||||
from ayon_core.pipeline import discover_legacy_creator_plugins
|
||||
|
||||
from . constants import (
|
||||
PRODUCT_TYPE_ROLE,
|
||||
ITEM_ID_ROLE
|
||||
)
|
||||
|
||||
|
||||
class CreatorsModel(QtGui.QStandardItemModel):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(CreatorsModel, self).__init__(*args, **kwargs)
|
||||
|
||||
self._creators_by_id = {}
|
||||
|
||||
def reset(self):
|
||||
# TODO change to refresh when clearing is not needed
|
||||
self.clear()
|
||||
self._creators_by_id = {}
|
||||
|
||||
items = []
|
||||
creators = discover_legacy_creator_plugins()
|
||||
for creator in creators:
|
||||
if not creator.enabled:
|
||||
continue
|
||||
item_id = str(uuid.uuid4())
|
||||
self._creators_by_id[item_id] = creator
|
||||
|
||||
label = creator.label or creator.product_type
|
||||
item = QtGui.QStandardItem(label)
|
||||
item.setEditable(False)
|
||||
item.setData(item_id, ITEM_ID_ROLE)
|
||||
item.setData(creator.product_type, PRODUCT_TYPE_ROLE)
|
||||
items.append(item)
|
||||
|
||||
if not items:
|
||||
item = QtGui.QStandardItem("No registered create plugins")
|
||||
item.setEnabled(False)
|
||||
item.setData(False, QtCore.Qt.ItemIsEnabled)
|
||||
items.append(item)
|
||||
|
||||
items.sort(key=lambda item: item.text())
|
||||
self.invisibleRootItem().appendRows(items)
|
||||
|
||||
def get_creator_by_id(self, item_id):
|
||||
return self._creators_by_id.get(item_id)
|
||||
|
||||
def get_indexes_by_product_type(self, product_type):
|
||||
indexes = []
|
||||
for row in range(self.rowCount()):
|
||||
index = self.index(row, 0)
|
||||
item_id = index.data(ITEM_ID_ROLE)
|
||||
creator_plugin = self._creators_by_id.get(item_id)
|
||||
if creator_plugin and (
|
||||
creator_plugin.label.lower() == product_type.lower()
|
||||
or creator_plugin.product_type.lower() == product_type.lower()
|
||||
):
|
||||
indexes.append(index)
|
||||
return indexes
|
||||
|
|
@ -1,275 +0,0 @@
|
|||
import re
|
||||
import inspect
|
||||
|
||||
from qtpy import QtWidgets, QtCore, QtGui
|
||||
|
||||
import qtawesome
|
||||
|
||||
from ayon_core.pipeline.create import PRODUCT_NAME_ALLOWED_SYMBOLS
|
||||
from ayon_core.tools.utils import ErrorMessageBox
|
||||
|
||||
if hasattr(QtGui, "QRegularExpressionValidator"):
|
||||
RegularExpressionValidatorClass = QtGui.QRegularExpressionValidator
|
||||
RegularExpressionClass = QtCore.QRegularExpression
|
||||
else:
|
||||
RegularExpressionValidatorClass = QtGui.QRegExpValidator
|
||||
RegularExpressionClass = QtCore.QRegExp
|
||||
|
||||
|
||||
class CreateErrorMessageBox(ErrorMessageBox):
|
||||
def __init__(
|
||||
self,
|
||||
product_type,
|
||||
product_name,
|
||||
folder_path,
|
||||
exc_msg,
|
||||
formatted_traceback,
|
||||
parent
|
||||
):
|
||||
self._product_type = product_type
|
||||
self._product_name = product_name
|
||||
self._folder_path = folder_path
|
||||
self._exc_msg = exc_msg
|
||||
self._formatted_traceback = formatted_traceback
|
||||
super(CreateErrorMessageBox, self).__init__("Creation failed", parent)
|
||||
|
||||
def _create_top_widget(self, parent_widget):
|
||||
label_widget = QtWidgets.QLabel(parent_widget)
|
||||
label_widget.setText(
|
||||
"<span style='font-size:18pt;'>Failed to create</span>"
|
||||
)
|
||||
return label_widget
|
||||
|
||||
def _get_report_data(self):
|
||||
report_message = (
|
||||
"Failed to create Product: \"{product_name}\""
|
||||
" Type: \"{product_type}\""
|
||||
" in Folder: \"{folder_path}\""
|
||||
"\n\nError: {message}"
|
||||
).format(
|
||||
product_name=self._product_name,
|
||||
product_type=self._product_type,
|
||||
folder_path=self._folder_path,
|
||||
message=self._exc_msg
|
||||
)
|
||||
if self._formatted_traceback:
|
||||
report_message += "\n\n{}".format(self._formatted_traceback)
|
||||
return [report_message]
|
||||
|
||||
def _create_content(self, content_layout):
|
||||
item_name_template = (
|
||||
"<span style='font-weight:bold;'>{}:</span> {{}}<br>"
|
||||
"<span style='font-weight:bold;'>{}:</span> {{}}<br>"
|
||||
"<span style='font-weight:bold;'>{}:</span> {{}}<br>"
|
||||
).format(
|
||||
"Product type",
|
||||
"Product name",
|
||||
"Folder"
|
||||
)
|
||||
exc_msg_template = "<span style='font-weight:bold'>{}</span>"
|
||||
|
||||
line = self._create_line()
|
||||
content_layout.addWidget(line)
|
||||
|
||||
item_name_widget = QtWidgets.QLabel(self)
|
||||
item_name_widget.setText(
|
||||
item_name_template.format(
|
||||
self._product_type, self._product_name, self._folder_path
|
||||
)
|
||||
)
|
||||
content_layout.addWidget(item_name_widget)
|
||||
|
||||
message_label_widget = QtWidgets.QLabel(self)
|
||||
message_label_widget.setText(
|
||||
exc_msg_template.format(self.convert_text_for_html(self._exc_msg))
|
||||
)
|
||||
content_layout.addWidget(message_label_widget)
|
||||
|
||||
if self._formatted_traceback:
|
||||
line_widget = self._create_line()
|
||||
tb_widget = self._create_traceback_widget(
|
||||
self._formatted_traceback
|
||||
)
|
||||
content_layout.addWidget(line_widget)
|
||||
content_layout.addWidget(tb_widget)
|
||||
|
||||
|
||||
class ProductNameValidator(RegularExpressionValidatorClass):
|
||||
invalid = QtCore.Signal(set)
|
||||
pattern = "^[{}]*$".format(PRODUCT_NAME_ALLOWED_SYMBOLS)
|
||||
|
||||
def __init__(self):
|
||||
reg = RegularExpressionClass(self.pattern)
|
||||
super(ProductNameValidator, self).__init__(reg)
|
||||
|
||||
def validate(self, text, pos):
|
||||
results = super(ProductNameValidator, self).validate(text, pos)
|
||||
if results[0] == RegularExpressionValidatorClass.Invalid:
|
||||
self.invalid.emit(self.invalid_chars(text))
|
||||
return results
|
||||
|
||||
def invalid_chars(self, text):
|
||||
invalid = set()
|
||||
re_valid = re.compile(self.pattern)
|
||||
for char in text:
|
||||
if char == " ":
|
||||
invalid.add("' '")
|
||||
continue
|
||||
if not re_valid.match(char):
|
||||
invalid.add(char)
|
||||
return invalid
|
||||
|
||||
|
||||
class VariantLineEdit(QtWidgets.QLineEdit):
|
||||
report = QtCore.Signal(str)
|
||||
colors = {
|
||||
"empty": (QtGui.QColor("#78879b"), ""),
|
||||
"exists": (QtGui.QColor("#4E76BB"), "border-color: #4E76BB;"),
|
||||
"new": (QtGui.QColor("#7AAB8F"), "border-color: #7AAB8F;"),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(VariantLineEdit, self).__init__(*args, **kwargs)
|
||||
|
||||
validator = ProductNameValidator()
|
||||
self.setValidator(validator)
|
||||
self.setToolTip("Only alphanumeric characters (A-Z a-z 0-9), "
|
||||
"'_' and '.' are allowed.")
|
||||
|
||||
self._status_color = self.colors["empty"][0]
|
||||
|
||||
anim = QtCore.QPropertyAnimation()
|
||||
anim.setTargetObject(self)
|
||||
anim.setPropertyName(b"status_color")
|
||||
anim.setEasingCurve(QtCore.QEasingCurve.InCubic)
|
||||
anim.setDuration(300)
|
||||
anim.setStartValue(QtGui.QColor("#C84747")) # `Invalid` status color
|
||||
self.animation = anim
|
||||
|
||||
validator.invalid.connect(self.on_invalid)
|
||||
|
||||
def on_invalid(self, invalid):
|
||||
message = "Invalid character: %s" % ", ".join(invalid)
|
||||
self.report.emit(message)
|
||||
self.animation.stop()
|
||||
self.animation.start()
|
||||
|
||||
def as_empty(self):
|
||||
self._set_border("empty")
|
||||
self.report.emit("Empty product name ..")
|
||||
|
||||
def as_exists(self):
|
||||
self._set_border("exists")
|
||||
self.report.emit("Existing product, appending next version.")
|
||||
|
||||
def as_new(self):
|
||||
self._set_border("new")
|
||||
self.report.emit("New product, creating first version.")
|
||||
|
||||
def _set_border(self, status):
|
||||
qcolor, style = self.colors[status]
|
||||
self.animation.setEndValue(qcolor)
|
||||
self.setStyleSheet(style)
|
||||
|
||||
def _get_status_color(self):
|
||||
return self._status_color
|
||||
|
||||
def _set_status_color(self, color):
|
||||
self._status_color = color
|
||||
self.setStyleSheet("border-color: %s;" % color.name())
|
||||
|
||||
status_color = QtCore.Property(
|
||||
QtGui.QColor, _get_status_color, _set_status_color
|
||||
)
|
||||
|
||||
|
||||
class ProductTypeDescriptionWidget(QtWidgets.QWidget):
|
||||
"""A product type description widget.
|
||||
|
||||
Shows a product type icon, name and a help description.
|
||||
Used in creator header.
|
||||
|
||||
_______________________
|
||||
| ____ |
|
||||
| |icon| PRODUCT TYPE |
|
||||
| |____| help |
|
||||
|_______________________|
|
||||
|
||||
"""
|
||||
|
||||
SIZE = 35
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super(ProductTypeDescriptionWidget, self).__init__(parent=parent)
|
||||
|
||||
icon_label = QtWidgets.QLabel(self)
|
||||
icon_label.setSizePolicy(
|
||||
QtWidgets.QSizePolicy.Maximum,
|
||||
QtWidgets.QSizePolicy.Maximum
|
||||
)
|
||||
|
||||
# Add 4 pixel padding to avoid icon being cut off
|
||||
icon_label.setFixedWidth(self.SIZE + 4)
|
||||
icon_label.setFixedHeight(self.SIZE + 4)
|
||||
|
||||
label_layout = QtWidgets.QVBoxLayout()
|
||||
label_layout.setSpacing(0)
|
||||
|
||||
product_type_label = QtWidgets.QLabel(self)
|
||||
product_type_label.setObjectName("CreatorProductTypeLabel")
|
||||
product_type_label.setAlignment(
|
||||
QtCore.Qt.AlignBottom | QtCore.Qt.AlignLeft
|
||||
)
|
||||
|
||||
help_label = QtWidgets.QLabel(self)
|
||||
help_label.setAlignment(QtCore.Qt.AlignTop | QtCore.Qt.AlignLeft)
|
||||
|
||||
label_layout.addWidget(product_type_label)
|
||||
label_layout.addWidget(help_label)
|
||||
|
||||
layout = QtWidgets.QHBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(5)
|
||||
layout.addWidget(icon_label)
|
||||
layout.addLayout(label_layout)
|
||||
|
||||
self._help_label = help_label
|
||||
self._product_type_label = product_type_label
|
||||
self._icon_label = icon_label
|
||||
|
||||
def set_item(self, creator_plugin):
|
||||
"""Update elements to display information of a product type item.
|
||||
|
||||
Args:
|
||||
creator_plugin (dict): A product type item as registered with
|
||||
name, help and icon.
|
||||
|
||||
Returns:
|
||||
None
|
||||
|
||||
"""
|
||||
if not creator_plugin:
|
||||
self._icon_label.setPixmap(None)
|
||||
self._product_type_label.setText("")
|
||||
self._help_label.setText("")
|
||||
return
|
||||
|
||||
# Support a font-awesome icon
|
||||
icon_name = getattr(creator_plugin, "icon", None) or "info-circle"
|
||||
try:
|
||||
icon = qtawesome.icon("fa.{}".format(icon_name), color="white")
|
||||
pixmap = icon.pixmap(self.SIZE, self.SIZE)
|
||||
except Exception:
|
||||
print("BUG: Couldn't load icon \"fa.{}\"".format(str(icon_name)))
|
||||
# Create transparent pixmap
|
||||
pixmap = QtGui.QPixmap()
|
||||
pixmap.fill(QtCore.Qt.transparent)
|
||||
pixmap = pixmap.scaled(self.SIZE, self.SIZE)
|
||||
|
||||
# Parse a clean line from the Creator's docstring
|
||||
docstring = inspect.getdoc(creator_plugin)
|
||||
creator_help = docstring.splitlines()[0] if docstring else ""
|
||||
|
||||
self._icon_label.setPixmap(pixmap)
|
||||
self._product_type_label.setText(creator_plugin.product_type)
|
||||
self._help_label.setText(creator_help)
|
||||
|
|
@ -1,508 +0,0 @@
|
|||
import sys
|
||||
import traceback
|
||||
import re
|
||||
|
||||
import ayon_api
|
||||
from qtpy import QtWidgets, QtCore
|
||||
|
||||
from ayon_core import style
|
||||
from ayon_core.settings import get_current_project_settings
|
||||
from ayon_core.tools.utils.lib import qt_app_context
|
||||
from ayon_core.pipeline import (
|
||||
get_current_project_name,
|
||||
get_current_folder_path,
|
||||
get_current_task_name,
|
||||
)
|
||||
from ayon_core.pipeline.create import (
|
||||
PRODUCT_NAME_ALLOWED_SYMBOLS,
|
||||
legacy_create,
|
||||
CreatorError,
|
||||
)
|
||||
|
||||
from .model import CreatorsModel
|
||||
from .widgets import (
|
||||
CreateErrorMessageBox,
|
||||
VariantLineEdit,
|
||||
ProductTypeDescriptionWidget
|
||||
)
|
||||
from .constants import (
|
||||
ITEM_ID_ROLE,
|
||||
SEPARATOR,
|
||||
SEPARATORS
|
||||
)
|
||||
|
||||
module = sys.modules[__name__]
|
||||
module.window = None
|
||||
|
||||
|
||||
class CreatorWindow(QtWidgets.QDialog):
|
||||
def __init__(self, parent=None):
|
||||
super(CreatorWindow, self).__init__(parent)
|
||||
self.setWindowTitle("Instance Creator")
|
||||
self.setFocusPolicy(QtCore.Qt.StrongFocus)
|
||||
if not parent:
|
||||
self.setWindowFlags(
|
||||
self.windowFlags() | QtCore.Qt.WindowStaysOnTopHint
|
||||
)
|
||||
|
||||
creator_info = ProductTypeDescriptionWidget(self)
|
||||
|
||||
creators_model = CreatorsModel()
|
||||
|
||||
creators_proxy = QtCore.QSortFilterProxyModel()
|
||||
creators_proxy.setSourceModel(creators_model)
|
||||
|
||||
creators_view = QtWidgets.QListView(self)
|
||||
creators_view.setObjectName("CreatorsView")
|
||||
creators_view.setModel(creators_proxy)
|
||||
|
||||
folder_path_input = QtWidgets.QLineEdit(self)
|
||||
variant_input = VariantLineEdit(self)
|
||||
product_name_input = QtWidgets.QLineEdit(self)
|
||||
product_name_input.setEnabled(False)
|
||||
|
||||
variants_btn = QtWidgets.QPushButton()
|
||||
variants_btn.setFixedWidth(18)
|
||||
variants_menu = QtWidgets.QMenu(variants_btn)
|
||||
variants_btn.setMenu(variants_menu)
|
||||
|
||||
name_layout = QtWidgets.QHBoxLayout()
|
||||
name_layout.addWidget(variant_input)
|
||||
name_layout.addWidget(variants_btn)
|
||||
name_layout.setSpacing(3)
|
||||
name_layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
body_layout = QtWidgets.QVBoxLayout()
|
||||
body_layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
body_layout.addWidget(creator_info, 0)
|
||||
body_layout.addWidget(QtWidgets.QLabel("Product type", self), 0)
|
||||
body_layout.addWidget(creators_view, 1)
|
||||
body_layout.addWidget(QtWidgets.QLabel("Folder path", self), 0)
|
||||
body_layout.addWidget(folder_path_input, 0)
|
||||
body_layout.addWidget(QtWidgets.QLabel("Product name", self), 0)
|
||||
body_layout.addLayout(name_layout, 0)
|
||||
body_layout.addWidget(product_name_input, 0)
|
||||
|
||||
useselection_chk = QtWidgets.QCheckBox("Use selection", self)
|
||||
useselection_chk.setCheckState(QtCore.Qt.Checked)
|
||||
|
||||
create_btn = QtWidgets.QPushButton("Create", self)
|
||||
# Need to store error_msg to prevent garbage collection
|
||||
msg_label = QtWidgets.QLabel(self)
|
||||
|
||||
footer_layout = QtWidgets.QVBoxLayout()
|
||||
footer_layout.addWidget(create_btn, 0)
|
||||
footer_layout.addWidget(msg_label, 0)
|
||||
footer_layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
layout = QtWidgets.QVBoxLayout(self)
|
||||
layout.addLayout(body_layout, 1)
|
||||
layout.addWidget(useselection_chk, 0, QtCore.Qt.AlignLeft)
|
||||
layout.addLayout(footer_layout, 0)
|
||||
|
||||
msg_timer = QtCore.QTimer()
|
||||
msg_timer.setSingleShot(True)
|
||||
msg_timer.setInterval(5000)
|
||||
|
||||
validation_timer = QtCore.QTimer()
|
||||
validation_timer.setSingleShot(True)
|
||||
validation_timer.setInterval(300)
|
||||
|
||||
msg_timer.timeout.connect(self._on_msg_timer)
|
||||
validation_timer.timeout.connect(self._on_validation_timer)
|
||||
|
||||
create_btn.clicked.connect(self._on_create)
|
||||
variant_input.returnPressed.connect(self._on_create)
|
||||
variant_input.textChanged.connect(self._on_data_changed)
|
||||
variant_input.report.connect(self.echo)
|
||||
folder_path_input.textChanged.connect(self._on_data_changed)
|
||||
creators_view.selectionModel().currentChanged.connect(
|
||||
self._on_selection_changed
|
||||
)
|
||||
|
||||
# Store valid states and
|
||||
self._is_valid = False
|
||||
create_btn.setEnabled(self._is_valid)
|
||||
|
||||
self._first_show = True
|
||||
|
||||
# Message dialog when something goes wrong during creation
|
||||
self._message_dialog = None
|
||||
|
||||
self._creator_info = creator_info
|
||||
self._create_btn = create_btn
|
||||
self._useselection_chk = useselection_chk
|
||||
self._variant_input = variant_input
|
||||
self._product_name_input = product_name_input
|
||||
self._folder_path_input = folder_path_input
|
||||
|
||||
self._creators_model = creators_model
|
||||
self._creators_proxy = creators_proxy
|
||||
self._creators_view = creators_view
|
||||
|
||||
self._variants_btn = variants_btn
|
||||
self._variants_menu = variants_menu
|
||||
|
||||
self._msg_label = msg_label
|
||||
|
||||
self._validation_timer = validation_timer
|
||||
self._msg_timer = msg_timer
|
||||
|
||||
# Defaults
|
||||
self.resize(300, 500)
|
||||
variant_input.setFocus()
|
||||
|
||||
def _set_valid_state(self, valid):
|
||||
if self._is_valid == valid:
|
||||
return
|
||||
self._is_valid = valid
|
||||
self._create_btn.setEnabled(valid)
|
||||
|
||||
def _build_menu(self, default_names=None):
|
||||
"""Create optional predefined variants.
|
||||
|
||||
Args:
|
||||
default_names(list): all predefined names
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
if not default_names:
|
||||
default_names = []
|
||||
|
||||
menu = self._variants_menu
|
||||
button = self._variants_btn
|
||||
|
||||
# Get and destroy the action group
|
||||
group = button.findChild(QtWidgets.QActionGroup)
|
||||
if group:
|
||||
group.deleteLater()
|
||||
|
||||
state = any(default_names)
|
||||
button.setEnabled(state)
|
||||
if state is False:
|
||||
return
|
||||
|
||||
# Build new action group
|
||||
group = QtWidgets.QActionGroup(button)
|
||||
for name in default_names:
|
||||
if name in SEPARATORS:
|
||||
menu.addSeparator()
|
||||
continue
|
||||
action = group.addAction(name)
|
||||
menu.addAction(action)
|
||||
|
||||
group.triggered.connect(self._on_action_clicked)
|
||||
|
||||
def _on_action_clicked(self, action):
|
||||
self._variant_input.setText(action.text())
|
||||
|
||||
def _on_data_changed(self, *args):
|
||||
# Set invalid state until it's reconfirmed to be valid by the
|
||||
# scheduled callback so any form of creation is held back until
|
||||
# valid again
|
||||
self._set_valid_state(False)
|
||||
|
||||
self._validation_timer.start()
|
||||
|
||||
def _on_validation_timer(self):
|
||||
index = self._creators_view.currentIndex()
|
||||
item_id = index.data(ITEM_ID_ROLE)
|
||||
creator_plugin = self._creators_model.get_creator_by_id(item_id)
|
||||
user_input_text = self._variant_input.text()
|
||||
folder_path = self._folder_path_input.text()
|
||||
|
||||
# Early exit if no folder path
|
||||
if not folder_path:
|
||||
self._build_menu()
|
||||
self.echo("Folder is required ..")
|
||||
self._set_valid_state(False)
|
||||
return
|
||||
|
||||
project_name = get_current_project_name()
|
||||
folder_entity = None
|
||||
if creator_plugin:
|
||||
# Get the folder from the database which match with the name
|
||||
folder_entity = ayon_api.get_folder_by_path(
|
||||
project_name, folder_path, fields={"id"}
|
||||
)
|
||||
|
||||
# Get plugin
|
||||
if not folder_entity or not creator_plugin:
|
||||
self._build_menu()
|
||||
|
||||
if not creator_plugin:
|
||||
self.echo("No registered product types ..")
|
||||
else:
|
||||
self.echo("Folder '{}' not found ..".format(folder_path))
|
||||
self._set_valid_state(False)
|
||||
return
|
||||
|
||||
folder_id = folder_entity["id"]
|
||||
|
||||
task_name = get_current_task_name()
|
||||
task_entity = ayon_api.get_task_by_name(
|
||||
project_name, folder_id, task_name
|
||||
)
|
||||
|
||||
# Calculate product name with Creator plugin
|
||||
product_name = creator_plugin.get_product_name(
|
||||
project_name, folder_entity, task_entity, user_input_text
|
||||
)
|
||||
# Force replacement of prohibited symbols
|
||||
# QUESTION should Creator care about this and here should be only
|
||||
# validated with schema regex?
|
||||
|
||||
# Allow curly brackets in product name for dynamic keys
|
||||
curly_left = "__cbl__"
|
||||
curly_right = "__cbr__"
|
||||
tmp_product_name = (
|
||||
product_name
|
||||
.replace("{", curly_left)
|
||||
.replace("}", curly_right)
|
||||
)
|
||||
# Replace prohibited symbols
|
||||
tmp_product_name = re.sub(
|
||||
"[^{}]+".format(PRODUCT_NAME_ALLOWED_SYMBOLS),
|
||||
"",
|
||||
tmp_product_name
|
||||
)
|
||||
product_name = (
|
||||
tmp_product_name
|
||||
.replace(curly_left, "{")
|
||||
.replace(curly_right, "}")
|
||||
)
|
||||
self._product_name_input.setText(product_name)
|
||||
|
||||
# Get all products of the current folder
|
||||
product_entities = ayon_api.get_products(
|
||||
project_name, folder_ids={folder_id}, fields={"name"}
|
||||
)
|
||||
existing_product_names = {
|
||||
product_entity["name"]
|
||||
for product_entity in product_entities
|
||||
}
|
||||
existing_product_names_low = set(
|
||||
_name.lower()
|
||||
for _name in existing_product_names
|
||||
)
|
||||
|
||||
# Defaults to dropdown
|
||||
defaults = []
|
||||
# Check if Creator plugin has set defaults
|
||||
if (
|
||||
creator_plugin.defaults
|
||||
and isinstance(creator_plugin.defaults, (list, tuple, set))
|
||||
):
|
||||
defaults = list(creator_plugin.defaults)
|
||||
|
||||
# Replace
|
||||
compare_regex = re.compile(re.sub(
|
||||
user_input_text, "(.+)", product_name, flags=re.IGNORECASE
|
||||
))
|
||||
variant_hints = set()
|
||||
if user_input_text:
|
||||
for _name in existing_product_names:
|
||||
_result = compare_regex.search(_name)
|
||||
if _result:
|
||||
variant_hints |= set(_result.groups())
|
||||
|
||||
if variant_hints:
|
||||
if defaults:
|
||||
defaults.append(SEPARATOR)
|
||||
defaults.extend(variant_hints)
|
||||
self._build_menu(defaults)
|
||||
|
||||
# Indicate product existence
|
||||
if not user_input_text:
|
||||
self._variant_input.as_empty()
|
||||
elif product_name.lower() in existing_product_names_low:
|
||||
# validate existence of product name with lowered text
|
||||
# - "renderMain" vs. "rensermain" mean same path item for
|
||||
# windows
|
||||
self._variant_input.as_exists()
|
||||
else:
|
||||
self._variant_input.as_new()
|
||||
|
||||
# Update the valid state
|
||||
valid = product_name.strip() != ""
|
||||
|
||||
self._set_valid_state(valid)
|
||||
|
||||
def _on_selection_changed(self, old_idx, new_idx):
|
||||
index = self._creators_view.currentIndex()
|
||||
item_id = index.data(ITEM_ID_ROLE)
|
||||
|
||||
creator_plugin = self._creators_model.get_creator_by_id(item_id)
|
||||
|
||||
self._creator_info.set_item(creator_plugin)
|
||||
|
||||
if creator_plugin is None:
|
||||
return
|
||||
|
||||
default = None
|
||||
if hasattr(creator_plugin, "get_default_variant"):
|
||||
default = creator_plugin.get_default_variant()
|
||||
|
||||
if not default:
|
||||
if (
|
||||
creator_plugin.defaults
|
||||
and isinstance(creator_plugin.defaults, list)
|
||||
):
|
||||
default = creator_plugin.defaults[0]
|
||||
else:
|
||||
default = "Default"
|
||||
|
||||
self._variant_input.setText(default)
|
||||
|
||||
self._on_data_changed()
|
||||
|
||||
def keyPressEvent(self, event):
|
||||
"""Custom keyPressEvent.
|
||||
|
||||
Override keyPressEvent to do nothing so that Maya's panels won't
|
||||
take focus when pressing "SHIFT" whilst mouse is over viewport or
|
||||
outliner. This way users don't accidentally perform Maya commands
|
||||
whilst trying to name an instance.
|
||||
|
||||
"""
|
||||
pass
|
||||
|
||||
def showEvent(self, event):
|
||||
super(CreatorWindow, self).showEvent(event)
|
||||
if self._first_show:
|
||||
self._first_show = False
|
||||
self.setStyleSheet(style.load_stylesheet())
|
||||
|
||||
def refresh(self):
|
||||
self._folder_path_input.setText(get_current_folder_path())
|
||||
|
||||
self._creators_model.reset()
|
||||
|
||||
product_types_smart_select = (
|
||||
get_current_project_settings()
|
||||
["core"]
|
||||
["tools"]
|
||||
["creator"]
|
||||
["product_types_smart_select"]
|
||||
)
|
||||
current_index = None
|
||||
product_type = None
|
||||
task_name = get_current_task_name() or None
|
||||
lowered_task_name = task_name.lower()
|
||||
if task_name:
|
||||
for smart_item in product_types_smart_select:
|
||||
_low_task_names = {
|
||||
name.lower() for name in smart_item["task_names"]
|
||||
}
|
||||
for _task_name in _low_task_names:
|
||||
if _task_name in lowered_task_name:
|
||||
product_type = smart_item["name"]
|
||||
break
|
||||
if product_type:
|
||||
break
|
||||
|
||||
if product_type:
|
||||
indexes = self._creators_model.get_indexes_by_product_type(
|
||||
product_type
|
||||
)
|
||||
if indexes:
|
||||
index = indexes[0]
|
||||
current_index = self._creators_proxy.mapFromSource(index)
|
||||
|
||||
if current_index is None or not current_index.isValid():
|
||||
current_index = self._creators_proxy.index(0, 0)
|
||||
|
||||
self._creators_view.setCurrentIndex(current_index)
|
||||
|
||||
def _on_create(self):
|
||||
# Do not allow creation in an invalid state
|
||||
if not self._is_valid:
|
||||
return
|
||||
|
||||
index = self._creators_view.currentIndex()
|
||||
item_id = index.data(ITEM_ID_ROLE)
|
||||
creator_plugin = self._creators_model.get_creator_by_id(item_id)
|
||||
if creator_plugin is None:
|
||||
return
|
||||
|
||||
product_name = self._product_name_input.text()
|
||||
folder_path = self._folder_path_input.text()
|
||||
use_selection = self._useselection_chk.isChecked()
|
||||
|
||||
variant = self._variant_input.text()
|
||||
|
||||
error_info = None
|
||||
try:
|
||||
legacy_create(
|
||||
creator_plugin,
|
||||
product_name,
|
||||
folder_path,
|
||||
options={"useSelection": use_selection},
|
||||
data={"variant": variant}
|
||||
)
|
||||
|
||||
except CreatorError as exc:
|
||||
self.echo("Creator error: {}".format(str(exc)))
|
||||
error_info = (str(exc), None)
|
||||
|
||||
except Exception as exc:
|
||||
self.echo("Program error: %s" % str(exc))
|
||||
|
||||
exc_type, exc_value, exc_traceback = sys.exc_info()
|
||||
formatted_traceback = "".join(traceback.format_exception(
|
||||
exc_type, exc_value, exc_traceback
|
||||
))
|
||||
error_info = (str(exc), formatted_traceback)
|
||||
|
||||
if error_info:
|
||||
box = CreateErrorMessageBox(
|
||||
creator_plugin.product_type,
|
||||
product_name,
|
||||
folder_path,
|
||||
*error_info,
|
||||
parent=self
|
||||
)
|
||||
box.show()
|
||||
# Store dialog so is not garbage collected before is shown
|
||||
self._message_dialog = box
|
||||
|
||||
else:
|
||||
self.echo("Created %s .." % product_name)
|
||||
|
||||
def _on_msg_timer(self):
|
||||
self._msg_label.setText("")
|
||||
|
||||
def echo(self, message):
|
||||
self._msg_label.setText(str(message))
|
||||
self._msg_timer.start()
|
||||
|
||||
|
||||
def show(parent=None):
|
||||
"""Display product creator GUI
|
||||
|
||||
Arguments:
|
||||
debug (bool, optional): Run loader in debug-mode,
|
||||
defaults to False
|
||||
parent (QtCore.QObject, optional): When provided parent the interface
|
||||
to this QObject.
|
||||
|
||||
"""
|
||||
|
||||
try:
|
||||
module.window.close()
|
||||
del module.window
|
||||
except (AttributeError, RuntimeError):
|
||||
pass
|
||||
|
||||
with qt_app_context():
|
||||
window = CreatorWindow(parent)
|
||||
window.refresh()
|
||||
window.show()
|
||||
|
||||
module.window = window
|
||||
|
||||
# Pull window to the front.
|
||||
module.window.raise_()
|
||||
module.window.activateWindow()
|
||||
|
|
@ -4,6 +4,7 @@ from abc import ABC, abstractmethod
|
|||
from dataclasses import dataclass
|
||||
from typing import Optional, Any
|
||||
|
||||
from ayon_core.addon import AddonsManager
|
||||
from ayon_core.tools.common_models import (
|
||||
ProjectItem,
|
||||
FolderItem,
|
||||
|
|
@ -20,6 +21,7 @@ class WebactionContext:
|
|||
project_name: str
|
||||
folder_id: str
|
||||
task_id: str
|
||||
workfile_id: str
|
||||
addon_name: str
|
||||
addon_version: str
|
||||
|
||||
|
|
@ -33,7 +35,7 @@ class ActionItem:
|
|||
identifier (str): Unique identifier of action item.
|
||||
order (int): Action ordering.
|
||||
label (str): Action label.
|
||||
variant_label (Union[str, None]): Variant label, full label is
|
||||
variant_label (Optional[str]): Variant label, full label is
|
||||
concatenated with space. Actions are grouped under single
|
||||
action if it has same 'label' and have set 'variant_label'.
|
||||
full_label (str): Full label, if not set it is generated
|
||||
|
|
@ -56,6 +58,15 @@ class ActionItem:
|
|||
addon_version: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorkfileItem:
|
||||
workfile_id: str
|
||||
filename: str
|
||||
exists: bool
|
||||
icon: Optional[str]
|
||||
version: Optional[int]
|
||||
|
||||
|
||||
class AbstractLauncherCommon(ABC):
|
||||
@abstractmethod
|
||||
def register_event_callback(self, topic, callback):
|
||||
|
|
@ -85,12 +96,16 @@ class AbstractLauncherBackend(AbstractLauncherCommon):
|
|||
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_addons_manager(self) -> AddonsManager:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_project_settings(self, project_name):
|
||||
"""Project settings for current project.
|
||||
|
||||
Args:
|
||||
project_name (Union[str, None]): Project name.
|
||||
project_name (Optional[str]): Project name.
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: Project settings.
|
||||
|
|
@ -254,7 +269,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
|
|||
"""Selected project name.
|
||||
|
||||
Returns:
|
||||
Union[str, None]: Selected project name.
|
||||
Optional[str]: Selected project name.
|
||||
|
||||
"""
|
||||
pass
|
||||
|
|
@ -264,7 +279,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
|
|||
"""Selected folder id.
|
||||
|
||||
Returns:
|
||||
Union[str, None]: Selected folder id.
|
||||
Optional[str]: Selected folder id.
|
||||
|
||||
"""
|
||||
pass
|
||||
|
|
@ -274,7 +289,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
|
|||
"""Selected task id.
|
||||
|
||||
Returns:
|
||||
Union[str, None]: Selected task id.
|
||||
Optional[str]: Selected task id.
|
||||
|
||||
"""
|
||||
pass
|
||||
|
|
@ -284,7 +299,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
|
|||
"""Selected task name.
|
||||
|
||||
Returns:
|
||||
Union[str, None]: Selected task name.
|
||||
Optional[str]: Selected task name.
|
||||
|
||||
"""
|
||||
pass
|
||||
|
|
@ -302,7 +317,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
|
|||
}
|
||||
|
||||
Returns:
|
||||
dict[str, Union[str, None]]: Selected context.
|
||||
dict[str, Optional[str]]: Selected context.
|
||||
|
||||
"""
|
||||
pass
|
||||
|
|
@ -312,7 +327,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
|
|||
"""Change selected folder.
|
||||
|
||||
Args:
|
||||
project_name (Union[str, None]): Project nameor None if no project
|
||||
project_name (Optional[str]): Project nameor None if no project
|
||||
is selected.
|
||||
|
||||
"""
|
||||
|
|
@ -323,7 +338,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
|
|||
"""Change selected folder.
|
||||
|
||||
Args:
|
||||
folder_id (Union[str, None]): Folder id or None if no folder
|
||||
folder_id (Optional[str]): Folder id or None if no folder
|
||||
is selected.
|
||||
|
||||
"""
|
||||
|
|
@ -336,14 +351,24 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
|
|||
"""Change selected task.
|
||||
|
||||
Args:
|
||||
task_id (Union[str, None]): Task id or None if no task
|
||||
task_id (Optional[str]): Task id or None if no task
|
||||
is selected.
|
||||
task_name (Union[str, None]): Task name or None if no task
|
||||
task_name (Optional[str]): Task name or None if no task
|
||||
is selected.
|
||||
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def set_selected_workfile(self, workfile_id: Optional[str]):
|
||||
"""Change selected workfile.
|
||||
|
||||
Args:
|
||||
workfile_id (Optional[str]): Workfile id or None.
|
||||
|
||||
"""
|
||||
pass
|
||||
|
||||
# Actions
|
||||
@abstractmethod
|
||||
def get_action_items(
|
||||
|
|
@ -351,13 +376,15 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
|
|||
project_name: Optional[str],
|
||||
folder_id: Optional[str],
|
||||
task_id: Optional[str],
|
||||
workfile_id: Optional[str],
|
||||
) -> list[ActionItem]:
|
||||
"""Get action items for given context.
|
||||
|
||||
Args:
|
||||
project_name (Union[str, None]): Project name.
|
||||
folder_id (Union[str, None]): Folder id.
|
||||
task_id (Union[str, None]): Task id.
|
||||
project_name (Optional[str]): Project name.
|
||||
folder_id (Optional[str]): Folder id.
|
||||
task_id (Optional[str]): Task id.
|
||||
workfile_id (Optional[str]): Workfile id.
|
||||
|
||||
Returns:
|
||||
list[ActionItem]: List of action items that should be shown
|
||||
|
|
@ -373,14 +400,16 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
|
|||
project_name: Optional[str],
|
||||
folder_id: Optional[str],
|
||||
task_id: Optional[str],
|
||||
workfile_id: Optional[str],
|
||||
):
|
||||
"""Trigger action on given context.
|
||||
|
||||
Args:
|
||||
action_id (str): Action identifier.
|
||||
project_name (Union[str, None]): Project name.
|
||||
folder_id (Union[str, None]): Folder id.
|
||||
task_id (Union[str, None]): Task id.
|
||||
project_name (Optional[str]): Project name.
|
||||
folder_id (Optional[str]): Folder id.
|
||||
task_id (Optional[str]): Task id.
|
||||
workfile_id (Optional[str]): Task id.
|
||||
|
||||
"""
|
||||
pass
|
||||
|
|
@ -465,3 +494,21 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
|
|||
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_workfile_items(
|
||||
self,
|
||||
project_name: Optional[str],
|
||||
task_id: Optional[str],
|
||||
) -> list[WorkfileItem]:
|
||||
"""Get workfile items for a given context.
|
||||
|
||||
Args:
|
||||
project_name (Optional[str]): Project name.
|
||||
task_id (Optional[str]): Task id.
|
||||
|
||||
Returns:
|
||||
list[WorkfileItem]: List of workfile items.
|
||||
|
||||
"""
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -1,10 +1,21 @@
|
|||
from typing import Optional
|
||||
|
||||
from ayon_core.lib import Logger, get_ayon_username
|
||||
from ayon_core.lib.events import QueuedEventSystem
|
||||
from ayon_core.addon import AddonsManager
|
||||
from ayon_core.settings import get_project_settings, get_studio_settings
|
||||
from ayon_core.tools.common_models import ProjectsModel, HierarchyModel
|
||||
|
||||
from .abstract import AbstractLauncherFrontEnd, AbstractLauncherBackend
|
||||
from .models import LauncherSelectionModel, ActionsModel
|
||||
from .abstract import (
|
||||
AbstractLauncherFrontEnd,
|
||||
AbstractLauncherBackend,
|
||||
WorkfileItem,
|
||||
)
|
||||
from .models import (
|
||||
LauncherSelectionModel,
|
||||
ActionsModel,
|
||||
WorkfilesModel,
|
||||
)
|
||||
|
||||
NOT_SET = object()
|
||||
|
||||
|
|
@ -17,12 +28,15 @@ class BaseLauncherController(
|
|||
self._event_system = None
|
||||
self._log = None
|
||||
|
||||
self._addons_manager = None
|
||||
|
||||
self._username = NOT_SET
|
||||
|
||||
self._selection_model = LauncherSelectionModel(self)
|
||||
self._projects_model = ProjectsModel(self)
|
||||
self._hierarchy_model = HierarchyModel(self)
|
||||
self._actions_model = ActionsModel(self)
|
||||
self._workfiles_model = WorkfilesModel(self)
|
||||
|
||||
@property
|
||||
def log(self):
|
||||
|
|
@ -59,6 +73,11 @@ class BaseLauncherController(
|
|||
def register_event_callback(self, topic, callback):
|
||||
self.event_system.add_callback(topic, callback)
|
||||
|
||||
def get_addons_manager(self) -> AddonsManager:
|
||||
if self._addons_manager is None:
|
||||
self._addons_manager = AddonsManager()
|
||||
return self._addons_manager
|
||||
|
||||
# Entity items for UI
|
||||
def get_project_items(self, sender=None):
|
||||
return self._projects_model.get_project_items(sender)
|
||||
|
|
@ -125,6 +144,9 @@ class BaseLauncherController(
|
|||
def set_selected_task(self, task_id, task_name):
|
||||
self._selection_model.set_selected_task(task_id, task_name)
|
||||
|
||||
def set_selected_workfile(self, workfile_id):
|
||||
self._selection_model.set_selected_workfile(workfile_id)
|
||||
|
||||
def get_selected_context(self):
|
||||
return {
|
||||
"project_name": self.get_selected_project_name(),
|
||||
|
|
@ -133,10 +155,24 @@ class BaseLauncherController(
|
|||
"task_name": self.get_selected_task_name(),
|
||||
}
|
||||
|
||||
# Workfiles
|
||||
def get_workfile_items(
|
||||
self,
|
||||
project_name: Optional[str],
|
||||
task_id: Optional[str],
|
||||
) -> list[WorkfileItem]:
|
||||
return self._workfiles_model.get_workfile_items(
|
||||
project_name,
|
||||
task_id,
|
||||
)
|
||||
|
||||
# Actions
|
||||
def get_action_items(self, project_name, folder_id, task_id):
|
||||
def get_action_items(
|
||||
self, project_name, folder_id, task_id, workfile_id
|
||||
):
|
||||
return self._actions_model.get_action_items(
|
||||
project_name, folder_id, task_id)
|
||||
project_name, folder_id, task_id, workfile_id
|
||||
)
|
||||
|
||||
def trigger_action(
|
||||
self,
|
||||
|
|
@ -144,12 +180,14 @@ class BaseLauncherController(
|
|||
project_name,
|
||||
folder_id,
|
||||
task_id,
|
||||
workfile_id,
|
||||
):
|
||||
self._actions_model.trigger_action(
|
||||
identifier,
|
||||
project_name,
|
||||
folder_id,
|
||||
task_id,
|
||||
workfile_id,
|
||||
)
|
||||
|
||||
def trigger_webaction(self, context, action_label, form_data=None):
|
||||
|
|
@ -186,6 +224,8 @@ class BaseLauncherController(
|
|||
self._projects_model.reset()
|
||||
# Refresh actions
|
||||
self._actions_model.refresh()
|
||||
# Reset workfiles model
|
||||
self._workfiles_model.reset()
|
||||
|
||||
self._emit_event("controller.refresh.actions.finished")
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
from .actions import ActionsModel
|
||||
from .selection import LauncherSelectionModel
|
||||
from .workfiles import WorkfilesModel
|
||||
|
||||
|
||||
__all__ = (
|
||||
"ActionsModel",
|
||||
"LauncherSelectionModel",
|
||||
"WorkfilesModel",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ from ayon_core.lib import (
|
|||
get_settings_variant,
|
||||
run_detached_ayon_launcher_process,
|
||||
)
|
||||
from ayon_core.addon import AddonsManager
|
||||
from ayon_core.pipeline.actions import (
|
||||
discover_launcher_actions,
|
||||
LauncherActionSelection,
|
||||
|
|
@ -104,8 +103,6 @@ class ActionsModel:
|
|||
levels=2, default_factory=list, lifetime=20,
|
||||
)
|
||||
|
||||
self._addons_manager = None
|
||||
|
||||
self._variant = get_settings_variant()
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -131,19 +128,28 @@ class ActionsModel:
|
|||
self._get_action_objects()
|
||||
self._controller.emit_event("actions.refresh.finished")
|
||||
|
||||
def get_action_items(self, project_name, folder_id, task_id):
|
||||
def get_action_items(
|
||||
self,
|
||||
project_name: Optional[str],
|
||||
folder_id: Optional[str],
|
||||
task_id: Optional[str],
|
||||
workfile_id: Optional[str],
|
||||
) -> list[ActionItem]:
|
||||
"""Get actions for project.
|
||||
|
||||
Args:
|
||||
project_name (Union[str, None]): Project name.
|
||||
folder_id (Union[str, None]): Folder id.
|
||||
task_id (Union[str, None]): Task id.
|
||||
project_name (Optional[str]): Project name.
|
||||
folder_id (Optional[str]): Folder id.
|
||||
task_id (Optional[str]): Task id.
|
||||
workfile_id (Optional[str]): Workfile id.
|
||||
|
||||
Returns:
|
||||
list[ActionItem]: List of actions.
|
||||
|
||||
"""
|
||||
selection = self._prepare_selection(project_name, folder_id, task_id)
|
||||
selection = self._prepare_selection(
|
||||
project_name, folder_id, task_id, workfile_id
|
||||
)
|
||||
output = []
|
||||
action_items = self._get_action_items(project_name)
|
||||
for identifier, action in self._get_action_objects().items():
|
||||
|
|
@ -159,8 +165,11 @@ class ActionsModel:
|
|||
project_name,
|
||||
folder_id,
|
||||
task_id,
|
||||
workfile_id,
|
||||
):
|
||||
selection = self._prepare_selection(project_name, folder_id, task_id)
|
||||
selection = self._prepare_selection(
|
||||
project_name, folder_id, task_id, workfile_id
|
||||
)
|
||||
failed = False
|
||||
error_message = None
|
||||
action_label = identifier
|
||||
|
|
@ -202,11 +211,15 @@ class ActionsModel:
|
|||
identifier = context.identifier
|
||||
folder_id = context.folder_id
|
||||
task_id = context.task_id
|
||||
workfile_id = context.workfile_id
|
||||
project_name = context.project_name
|
||||
addon_name = context.addon_name
|
||||
addon_version = context.addon_version
|
||||
|
||||
if task_id:
|
||||
if workfile_id:
|
||||
entity_type = "workfile"
|
||||
entity_ids.append(workfile_id)
|
||||
elif task_id:
|
||||
entity_type = "task"
|
||||
entity_ids.append(task_id)
|
||||
elif folder_id:
|
||||
|
|
@ -272,6 +285,7 @@ class ActionsModel:
|
|||
"project_name": project_name,
|
||||
"folder_id": folder_id,
|
||||
"task_id": task_id,
|
||||
"workfile_id": workfile_id,
|
||||
"addon_name": addon_name,
|
||||
"addon_version": addon_version,
|
||||
})
|
||||
|
|
@ -282,7 +296,10 @@ class ActionsModel:
|
|||
|
||||
def get_action_config_values(self, context: WebactionContext):
|
||||
selection = self._prepare_selection(
|
||||
context.project_name, context.folder_id, context.task_id
|
||||
context.project_name,
|
||||
context.folder_id,
|
||||
context.task_id,
|
||||
context.workfile_id,
|
||||
)
|
||||
if not selection.is_project_selected:
|
||||
return {}
|
||||
|
|
@ -309,7 +326,10 @@ class ActionsModel:
|
|||
|
||||
def set_action_config_values(self, context, values):
|
||||
selection = self._prepare_selection(
|
||||
context.project_name, context.folder_id, context.task_id
|
||||
context.project_name,
|
||||
context.folder_id,
|
||||
context.task_id,
|
||||
context.workfile_id,
|
||||
)
|
||||
if not selection.is_project_selected:
|
||||
return {}
|
||||
|
|
@ -333,12 +353,9 @@ class ActionsModel:
|
|||
exc_info=True
|
||||
)
|
||||
|
||||
def _get_addons_manager(self):
|
||||
if self._addons_manager is None:
|
||||
self._addons_manager = AddonsManager()
|
||||
return self._addons_manager
|
||||
|
||||
def _prepare_selection(self, project_name, folder_id, task_id):
|
||||
def _prepare_selection(
|
||||
self, project_name, folder_id, task_id, workfile_id
|
||||
):
|
||||
project_entity = None
|
||||
if project_name:
|
||||
project_entity = self._controller.get_project_entity(project_name)
|
||||
|
|
@ -347,6 +364,7 @@ class ActionsModel:
|
|||
project_name,
|
||||
folder_id,
|
||||
task_id,
|
||||
workfile_id,
|
||||
project_entity=project_entity,
|
||||
project_settings=project_settings,
|
||||
)
|
||||
|
|
@ -355,7 +373,12 @@ class ActionsModel:
|
|||
entity_type = None
|
||||
entity_id = None
|
||||
entity_subtypes = []
|
||||
if selection.is_task_selected:
|
||||
if selection.is_workfile_selected:
|
||||
entity_type = "workfile"
|
||||
entity_id = selection.workfile_id
|
||||
entity_subtypes = []
|
||||
|
||||
elif selection.is_task_selected:
|
||||
entity_type = "task"
|
||||
entity_id = selection.task_entity["id"]
|
||||
entity_subtypes = [selection.task_entity["taskType"]]
|
||||
|
|
@ -399,7 +422,11 @@ class ActionsModel:
|
|||
return cache.get_data()
|
||||
|
||||
try:
|
||||
response = ayon_api.post("actions/list", **request_data)
|
||||
# 'variant' query is supported since AYON backend 1.10.4
|
||||
query = urlencode({"variant": self._variant, "mode": "all"})
|
||||
response = ayon_api.post(
|
||||
f"actions/list?{query}", **request_data
|
||||
)
|
||||
response.raise_for_status()
|
||||
except Exception:
|
||||
self.log.warning("Failed to collect webactions.", exc_info=True)
|
||||
|
|
@ -513,7 +540,12 @@ class ActionsModel:
|
|||
uri = payload["uri"]
|
||||
else:
|
||||
uri = data["uri"]
|
||||
run_detached_ayon_launcher_process(uri)
|
||||
|
||||
# Remove bundles from environment variables
|
||||
env = os.environ.copy()
|
||||
env.pop("AYON_BUNDLE_NAME", None)
|
||||
env.pop("AYON_STUDIO_BUNDLE_NAME", None)
|
||||
run_detached_ayon_launcher_process(uri, env=env)
|
||||
|
||||
elif response_type in ("query", "navigate"):
|
||||
response.error_message = (
|
||||
|
|
@ -533,7 +565,7 @@ class ActionsModel:
|
|||
# NOTE We don't need to register the paths, but that would
|
||||
# require to change discovery logic and deprecate all functions
|
||||
# related to registering and discovering launcher actions.
|
||||
addons_manager = self._get_addons_manager()
|
||||
addons_manager = self._controller.get_addons_manager()
|
||||
actions_paths = addons_manager.collect_launcher_action_paths()
|
||||
for path in actions_paths:
|
||||
if path and os.path.exists(path):
|
||||
|
|
|
|||
|
|
@ -1,26 +1,37 @@
|
|||
class LauncherSelectionModel(object):
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
from typing import Optional
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from ayon_core.tools.launcher.abstract import AbstractLauncherBackend
|
||||
|
||||
|
||||
class LauncherSelectionModel:
|
||||
"""Model handling selection changes.
|
||||
|
||||
Triggering events:
|
||||
- "selection.project.changed"
|
||||
- "selection.folder.changed"
|
||||
- "selection.task.changed"
|
||||
- "selection.workfile.changed"
|
||||
"""
|
||||
|
||||
event_source = "launcher.selection.model"
|
||||
|
||||
def __init__(self, controller):
|
||||
def __init__(self, controller: AbstractLauncherBackend) -> None:
|
||||
self._controller = controller
|
||||
|
||||
self._project_name = None
|
||||
self._folder_id = None
|
||||
self._task_name = None
|
||||
self._task_id = None
|
||||
self._workfile_id = None
|
||||
|
||||
def get_selected_project_name(self):
|
||||
def get_selected_project_name(self) -> Optional[str]:
|
||||
return self._project_name
|
||||
|
||||
def set_selected_project(self, project_name):
|
||||
def set_selected_project(self, project_name: Optional[str]) -> None:
|
||||
if project_name == self._project_name:
|
||||
return
|
||||
|
||||
|
|
@ -31,10 +42,10 @@ class LauncherSelectionModel(object):
|
|||
self.event_source
|
||||
)
|
||||
|
||||
def get_selected_folder_id(self):
|
||||
def get_selected_folder_id(self) -> Optional[str]:
|
||||
return self._folder_id
|
||||
|
||||
def set_selected_folder(self, folder_id):
|
||||
def set_selected_folder(self, folder_id: Optional[str]) -> None:
|
||||
if folder_id == self._folder_id:
|
||||
return
|
||||
|
||||
|
|
@ -48,13 +59,15 @@ class LauncherSelectionModel(object):
|
|||
self.event_source
|
||||
)
|
||||
|
||||
def get_selected_task_name(self):
|
||||
def get_selected_task_name(self) -> Optional[str]:
|
||||
return self._task_name
|
||||
|
||||
def get_selected_task_id(self):
|
||||
def get_selected_task_id(self) -> Optional[str]:
|
||||
return self._task_id
|
||||
|
||||
def set_selected_task(self, task_id, task_name):
|
||||
def set_selected_task(
|
||||
self, task_id: Optional[str], task_name: Optional[str]
|
||||
) -> None:
|
||||
if task_id == self._task_id:
|
||||
return
|
||||
|
||||
|
|
@ -70,3 +83,23 @@ class LauncherSelectionModel(object):
|
|||
},
|
||||
self.event_source
|
||||
)
|
||||
|
||||
def get_selected_workfile(self) -> Optional[str]:
|
||||
return self._workfile_id
|
||||
|
||||
def set_selected_workfile(self, workfile_id: Optional[str]) -> None:
|
||||
if workfile_id == self._workfile_id:
|
||||
return
|
||||
|
||||
self._workfile_id = workfile_id
|
||||
self._controller.emit_event(
|
||||
"selection.workfile.changed",
|
||||
{
|
||||
"project_name": self._project_name,
|
||||
"folder_id": self._folder_id,
|
||||
"task_name": self._task_name,
|
||||
"task_id": self._task_id,
|
||||
"workfile_id": workfile_id,
|
||||
},
|
||||
self.event_source
|
||||
)
|
||||
|
|
|
|||
102
client/ayon_core/tools/launcher/models/workfiles.py
Normal file
102
client/ayon_core/tools/launcher/models/workfiles.py
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import os
|
||||
from typing import Optional, Any
|
||||
|
||||
import ayon_api
|
||||
|
||||
from ayon_core.lib import (
|
||||
Logger,
|
||||
NestedCacheItem,
|
||||
)
|
||||
from ayon_core.pipeline import Anatomy
|
||||
from ayon_core.tools.launcher.abstract import (
|
||||
WorkfileItem,
|
||||
AbstractLauncherBackend,
|
||||
)
|
||||
|
||||
|
||||
class WorkfilesModel:
|
||||
def __init__(self, controller: AbstractLauncherBackend):
|
||||
self._controller = controller
|
||||
|
||||
self._log = Logger.get_logger(self.__class__.__name__)
|
||||
|
||||
self._host_icons = None
|
||||
self._workfile_items = NestedCacheItem(
|
||||
levels=2, default_factory=list, lifetime=60,
|
||||
)
|
||||
|
||||
def reset(self) -> None:
|
||||
self._workfile_items.reset()
|
||||
|
||||
def get_workfile_items(
|
||||
self,
|
||||
project_name: Optional[str],
|
||||
task_id: Optional[str],
|
||||
) -> list[WorkfileItem]:
|
||||
if not project_name or not task_id:
|
||||
return []
|
||||
|
||||
cache = self._workfile_items[project_name][task_id]
|
||||
if cache.is_valid:
|
||||
return cache.get_data()
|
||||
|
||||
project_entity = self._controller.get_project_entity(project_name)
|
||||
anatomy = Anatomy(project_name, project_entity=project_entity)
|
||||
items = []
|
||||
for workfile_entity in ayon_api.get_workfiles_info(
|
||||
project_name, task_ids={task_id}, fields={"id", "path", "data"}
|
||||
):
|
||||
rootless_path = workfile_entity["path"]
|
||||
exists = False
|
||||
try:
|
||||
path = anatomy.fill_root(rootless_path)
|
||||
exists = os.path.exists(path)
|
||||
except Exception:
|
||||
self._log.warning(
|
||||
"Failed to fill root for workfile path",
|
||||
exc_info=True,
|
||||
)
|
||||
workfile_data = workfile_entity["data"]
|
||||
host_name = workfile_data.get("host_name")
|
||||
version = workfile_data.get("version")
|
||||
|
||||
items.append(WorkfileItem(
|
||||
workfile_id=workfile_entity["id"],
|
||||
filename=os.path.basename(rootless_path),
|
||||
exists=exists,
|
||||
icon=self._get_host_icon(host_name),
|
||||
version=version,
|
||||
))
|
||||
cache.update_data(items)
|
||||
return items
|
||||
|
||||
def _get_host_icon(
|
||||
self, host_name: Optional[str]
|
||||
) -> Optional[dict[str, Any]]:
|
||||
if self._host_icons is None:
|
||||
host_icons = {}
|
||||
try:
|
||||
host_icons = self._get_host_icons()
|
||||
except Exception:
|
||||
self._log.warning(
|
||||
"Failed to get host icons",
|
||||
exc_info=True,
|
||||
)
|
||||
self._host_icons = host_icons
|
||||
return self._host_icons.get(host_name)
|
||||
|
||||
def _get_host_icons(self) -> dict[str, Any]:
|
||||
addons_manager = self._controller.get_addons_manager()
|
||||
applications_addon = addons_manager["applications"]
|
||||
apps_manager = applications_addon.get_applications_manager()
|
||||
output = {}
|
||||
for app_group in apps_manager.app_groups.values():
|
||||
host_name = app_group.host_name
|
||||
icon_filename = app_group.icon
|
||||
if not host_name or not icon_filename:
|
||||
continue
|
||||
icon_url = applications_addon.get_app_icon_url(
|
||||
icon_filename, server=True
|
||||
)
|
||||
output[host_name] = icon_url
|
||||
return output
|
||||
|
|
@ -136,6 +136,10 @@ class ActionsQtModel(QtGui.QStandardItemModel):
|
|||
"selection.task.changed",
|
||||
self._on_selection_task_changed,
|
||||
)
|
||||
controller.register_event_callback(
|
||||
"selection.workfile.changed",
|
||||
self._on_selection_workfile_changed,
|
||||
)
|
||||
|
||||
self._controller = controller
|
||||
|
||||
|
|
@ -146,6 +150,7 @@ class ActionsQtModel(QtGui.QStandardItemModel):
|
|||
self._selected_project_name = None
|
||||
self._selected_folder_id = None
|
||||
self._selected_task_id = None
|
||||
self._selected_workfile_id = None
|
||||
|
||||
def get_selected_project_name(self):
|
||||
return self._selected_project_name
|
||||
|
|
@ -156,6 +161,9 @@ class ActionsQtModel(QtGui.QStandardItemModel):
|
|||
def get_selected_task_id(self):
|
||||
return self._selected_task_id
|
||||
|
||||
def get_selected_workfile_id(self):
|
||||
return self._selected_workfile_id
|
||||
|
||||
def get_group_items(self, action_id):
|
||||
return self._groups_by_id[action_id]
|
||||
|
||||
|
|
@ -194,6 +202,7 @@ class ActionsQtModel(QtGui.QStandardItemModel):
|
|||
self._selected_project_name,
|
||||
self._selected_folder_id,
|
||||
self._selected_task_id,
|
||||
self._selected_workfile_id,
|
||||
)
|
||||
if not items:
|
||||
self._clear_items()
|
||||
|
|
@ -286,18 +295,28 @@ class ActionsQtModel(QtGui.QStandardItemModel):
|
|||
self._selected_project_name = event["project_name"]
|
||||
self._selected_folder_id = None
|
||||
self._selected_task_id = None
|
||||
self._selected_workfile_id = None
|
||||
self.refresh()
|
||||
|
||||
def _on_selection_folder_changed(self, event):
|
||||
self._selected_project_name = event["project_name"]
|
||||
self._selected_folder_id = event["folder_id"]
|
||||
self._selected_task_id = None
|
||||
self._selected_workfile_id = None
|
||||
self.refresh()
|
||||
|
||||
def _on_selection_task_changed(self, event):
|
||||
self._selected_project_name = event["project_name"]
|
||||
self._selected_folder_id = event["folder_id"]
|
||||
self._selected_task_id = event["task_id"]
|
||||
self._selected_workfile_id = None
|
||||
self.refresh()
|
||||
|
||||
def _on_selection_workfile_changed(self, event):
|
||||
self._selected_project_name = event["project_name"]
|
||||
self._selected_folder_id = event["folder_id"]
|
||||
self._selected_task_id = event["task_id"]
|
||||
self._selected_workfile_id = event["workfile_id"]
|
||||
self.refresh()
|
||||
|
||||
|
||||
|
|
@ -578,9 +597,6 @@ class ActionMenuPopup(QtWidgets.QWidget):
|
|||
if not index or not index.isValid():
|
||||
return
|
||||
|
||||
if not index.data(ACTION_HAS_CONFIGS_ROLE):
|
||||
return
|
||||
|
||||
action_id = index.data(ACTION_ID_ROLE)
|
||||
self.action_triggered.emit(action_id)
|
||||
|
||||
|
|
@ -970,10 +986,11 @@ class ActionsWidget(QtWidgets.QWidget):
|
|||
event["project_name"],
|
||||
event["folder_id"],
|
||||
event["task_id"],
|
||||
event["workfile_id"],
|
||||
event["addon_name"],
|
||||
event["addon_version"],
|
||||
),
|
||||
event["action_label"],
|
||||
event["full_label"],
|
||||
form_data,
|
||||
)
|
||||
|
||||
|
|
@ -1050,24 +1067,26 @@ class ActionsWidget(QtWidgets.QWidget):
|
|||
project_name = self._model.get_selected_project_name()
|
||||
folder_id = self._model.get_selected_folder_id()
|
||||
task_id = self._model.get_selected_task_id()
|
||||
workfile_id = self._model.get_selected_workfile_id()
|
||||
action_item = self._model.get_action_item_by_id(action_id)
|
||||
|
||||
if action_item.action_type == "webaction":
|
||||
action_item = self._model.get_action_item_by_id(action_id)
|
||||
context = WebactionContext(
|
||||
action_id,
|
||||
project_name,
|
||||
folder_id,
|
||||
task_id,
|
||||
action_item.addon_name,
|
||||
action_item.addon_version
|
||||
identifier=action_id,
|
||||
project_name=project_name,
|
||||
folder_id=folder_id,
|
||||
task_id=task_id,
|
||||
workfile_id=workfile_id,
|
||||
addon_name=action_item.addon_name,
|
||||
addon_version=action_item.addon_version,
|
||||
)
|
||||
self._controller.trigger_webaction(
|
||||
context, action_item.full_label
|
||||
)
|
||||
else:
|
||||
self._controller.trigger_action(
|
||||
action_id, project_name, folder_id, task_id
|
||||
action_id, project_name, folder_id, task_id, workfile_id
|
||||
)
|
||||
|
||||
if index is None:
|
||||
|
|
@ -1087,11 +1106,13 @@ class ActionsWidget(QtWidgets.QWidget):
|
|||
project_name = self._model.get_selected_project_name()
|
||||
folder_id = self._model.get_selected_folder_id()
|
||||
task_id = self._model.get_selected_task_id()
|
||||
workfile_id = self._model.get_selected_workfile_id()
|
||||
context = WebactionContext(
|
||||
action_id,
|
||||
identifier=action_id,
|
||||
project_name=project_name,
|
||||
folder_id=folder_id,
|
||||
task_id=task_id,
|
||||
workfile_id=workfile_id,
|
||||
addon_name=action_item.addon_name,
|
||||
addon_version=action_item.addon_version,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ from ayon_core.tools.utils import (
|
|||
)
|
||||
from ayon_core.tools.utils.lib import checkstate_int_to_enum
|
||||
|
||||
from .workfiles_page import WorkfilesPage
|
||||
|
||||
|
||||
class HierarchyPage(QtWidgets.QWidget):
|
||||
def __init__(self, controller, parent):
|
||||
|
|
@ -73,10 +75,15 @@ class HierarchyPage(QtWidgets.QWidget):
|
|||
# - Tasks widget
|
||||
tasks_widget = TasksWidget(controller, content_body)
|
||||
|
||||
# - Third page - Workfiles
|
||||
workfiles_page = WorkfilesPage(controller, content_body)
|
||||
|
||||
content_body.addWidget(folders_widget)
|
||||
content_body.addWidget(tasks_widget)
|
||||
content_body.setStretchFactor(0, 100)
|
||||
content_body.setStretchFactor(1, 65)
|
||||
content_body.addWidget(workfiles_page)
|
||||
content_body.setStretchFactor(0, 120)
|
||||
content_body.setStretchFactor(1, 85)
|
||||
content_body.setStretchFactor(2, 220)
|
||||
|
||||
main_layout = QtWidgets.QVBoxLayout(self)
|
||||
main_layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
|
@ -99,6 +106,7 @@ class HierarchyPage(QtWidgets.QWidget):
|
|||
self._my_tasks_checkbox = my_tasks_checkbox
|
||||
self._folders_widget = folders_widget
|
||||
self._tasks_widget = tasks_widget
|
||||
self._workfiles_page = workfiles_page
|
||||
|
||||
self._project_name = None
|
||||
|
||||
|
|
@ -117,6 +125,7 @@ class HierarchyPage(QtWidgets.QWidget):
|
|||
def refresh(self):
|
||||
self._folders_widget.refresh()
|
||||
self._tasks_widget.refresh()
|
||||
self._workfiles_page.refresh()
|
||||
self._on_my_tasks_checkbox_state_changed(
|
||||
self._my_tasks_checkbox.checkState()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -177,7 +177,7 @@ class LauncherWindow(QtWidgets.QWidget):
|
|||
self._page_slide_anim = page_slide_anim
|
||||
|
||||
hierarchy_page.setVisible(not self._is_on_projects_page)
|
||||
self.resize(520, 740)
|
||||
self.resize(920, 740)
|
||||
|
||||
def showEvent(self, event):
|
||||
super().showEvent(event)
|
||||
|
|
|
|||
175
client/ayon_core/tools/launcher/ui/workfiles_page.py
Normal file
175
client/ayon_core/tools/launcher/ui/workfiles_page.py
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
from typing import Optional
|
||||
|
||||
import ayon_api
|
||||
from qtpy import QtCore, QtWidgets, QtGui
|
||||
|
||||
from ayon_core.tools.utils import get_qt_icon
|
||||
from ayon_core.tools.launcher.abstract import AbstractLauncherFrontEnd
|
||||
|
||||
VERSION_ROLE = QtCore.Qt.UserRole + 1
|
||||
WORKFILE_ID_ROLE = QtCore.Qt.UserRole + 2
|
||||
|
||||
|
||||
class WorkfilesModel(QtGui.QStandardItemModel):
|
||||
refreshed = QtCore.Signal()
|
||||
|
||||
def __init__(self, controller: AbstractLauncherFrontEnd) -> None:
|
||||
super().__init__()
|
||||
|
||||
self.setColumnCount(1)
|
||||
self.setHeaderData(0, QtCore.Qt.Horizontal, "Workfiles")
|
||||
|
||||
controller.register_event_callback(
|
||||
"selection.project.changed",
|
||||
self._on_selection_project_changed,
|
||||
)
|
||||
controller.register_event_callback(
|
||||
"selection.folder.changed",
|
||||
self._on_selection_folder_changed,
|
||||
)
|
||||
controller.register_event_callback(
|
||||
"selection.task.changed",
|
||||
self._on_selection_task_changed,
|
||||
)
|
||||
|
||||
self._controller = controller
|
||||
self._selected_project_name = None
|
||||
self._selected_folder_id = None
|
||||
self._selected_task_id = None
|
||||
|
||||
self._transparent_icon = None
|
||||
|
||||
self._cached_icons = {}
|
||||
|
||||
def refresh(self) -> None:
|
||||
root_item = self.invisibleRootItem()
|
||||
root_item.removeRows(0, root_item.rowCount())
|
||||
|
||||
workfile_items = self._controller.get_workfile_items(
|
||||
self._selected_project_name, self._selected_task_id
|
||||
)
|
||||
new_items = []
|
||||
for workfile_item in workfile_items:
|
||||
icon = self._get_icon(workfile_item.icon)
|
||||
item = QtGui.QStandardItem(workfile_item.filename)
|
||||
item.setData(icon, QtCore.Qt.DecorationRole)
|
||||
item.setData(workfile_item.version, VERSION_ROLE)
|
||||
item.setData(workfile_item.workfile_id, WORKFILE_ID_ROLE)
|
||||
flags = QtCore.Qt.NoItemFlags
|
||||
if workfile_item.exists:
|
||||
flags = QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable
|
||||
item.setFlags(flags)
|
||||
new_items.append(item)
|
||||
|
||||
if not new_items:
|
||||
title = "< No workfiles >"
|
||||
if not self._selected_project_name:
|
||||
title = "< Select a project >"
|
||||
elif not self._selected_folder_id:
|
||||
title = "< Select a folder >"
|
||||
elif not self._selected_task_id:
|
||||
title = "< Select a task >"
|
||||
item = QtGui.QStandardItem(title)
|
||||
item.setFlags(QtCore.Qt.NoItemFlags)
|
||||
new_items.append(item)
|
||||
root_item.appendRows(new_items)
|
||||
|
||||
self.refreshed.emit()
|
||||
|
||||
def _on_selection_project_changed(self, event) -> None:
|
||||
self._selected_project_name = event["project_name"]
|
||||
self._selected_folder_id = None
|
||||
self._selected_task_id = None
|
||||
self.refresh()
|
||||
|
||||
def _on_selection_folder_changed(self, event) -> None:
|
||||
self._selected_project_name = event["project_name"]
|
||||
self._selected_folder_id = event["folder_id"]
|
||||
self._selected_task_id = None
|
||||
self.refresh()
|
||||
|
||||
def _on_selection_task_changed(self, event) -> None:
|
||||
self._selected_project_name = event["project_name"]
|
||||
self._selected_folder_id = event["folder_id"]
|
||||
self._selected_task_id = event["task_id"]
|
||||
self.refresh()
|
||||
|
||||
def _get_transparent_icon(self) -> QtGui.QIcon:
|
||||
if self._transparent_icon is None:
|
||||
self._transparent_icon = get_qt_icon({
|
||||
"type": "transparent", "size": 256
|
||||
})
|
||||
return self._transparent_icon
|
||||
|
||||
def _get_icon(self, icon_url: Optional[str]) -> QtGui.QIcon:
|
||||
if icon_url is None:
|
||||
return self._get_transparent_icon()
|
||||
icon = self._cached_icons.get(icon_url)
|
||||
if icon is not None:
|
||||
return icon
|
||||
|
||||
base_url = ayon_api.get_base_url()
|
||||
if icon_url.startswith(base_url):
|
||||
icon_def = {
|
||||
"type": "ayon_url",
|
||||
"url": icon_url[len(base_url) + 1:],
|
||||
}
|
||||
else:
|
||||
icon_def = {
|
||||
"type": "url",
|
||||
"url": icon_url,
|
||||
}
|
||||
|
||||
icon = get_qt_icon(icon_def)
|
||||
if icon is None:
|
||||
icon = self._get_transparent_icon()
|
||||
self._cached_icons[icon_url] = icon
|
||||
return icon
|
||||
|
||||
|
||||
class WorkfilesView(QtWidgets.QTreeView):
|
||||
def drawBranches(self, painter, rect, index):
|
||||
return
|
||||
|
||||
|
||||
class WorkfilesPage(QtWidgets.QWidget):
|
||||
def __init__(
|
||||
self,
|
||||
controller: AbstractLauncherFrontEnd,
|
||||
parent: QtWidgets.QWidget,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
|
||||
workfiles_view = WorkfilesView(self)
|
||||
workfiles_view.setIndentation(0)
|
||||
workfiles_model = WorkfilesModel(controller)
|
||||
workfiles_proxy = QtCore.QSortFilterProxyModel()
|
||||
workfiles_proxy.setSourceModel(workfiles_model)
|
||||
|
||||
workfiles_view.setModel(workfiles_proxy)
|
||||
|
||||
layout = QtWidgets.QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.addWidget(workfiles_view, 1)
|
||||
|
||||
workfiles_view.selectionModel().selectionChanged.connect(
|
||||
self._on_selection_changed
|
||||
)
|
||||
workfiles_model.refreshed.connect(self._on_refresh)
|
||||
|
||||
self._controller = controller
|
||||
self._workfiles_view = workfiles_view
|
||||
self._workfiles_model = workfiles_model
|
||||
self._workfiles_proxy = workfiles_proxy
|
||||
|
||||
def refresh(self) -> None:
|
||||
self._workfiles_model.refresh()
|
||||
|
||||
def _on_refresh(self) -> None:
|
||||
self._workfiles_proxy.sort(0, QtCore.Qt.DescendingOrder)
|
||||
|
||||
def _on_selection_changed(self, selected, _deselected) -> None:
|
||||
workfile_id = None
|
||||
for index in selected.indexes():
|
||||
workfile_id = index.data(WORKFILE_ID_ROLE)
|
||||
self._controller.set_selected_workfile(workfile_id)
|
||||
|
|
@ -9,7 +9,11 @@ from ayon_core.lib.attribute_definitions import (
|
|||
deserialize_attr_defs,
|
||||
serialize_attr_defs,
|
||||
)
|
||||
from ayon_core.tools.common_models import TaskItem, TagItem
|
||||
from ayon_core.tools.common_models import (
|
||||
TaskItem,
|
||||
TagItem,
|
||||
ProductTypeIconMapping,
|
||||
)
|
||||
|
||||
|
||||
class ProductTypeItem:
|
||||
|
|
@ -78,7 +82,6 @@ class ProductItem:
|
|||
product_type (str): Product type.
|
||||
product_name (str): Product name.
|
||||
product_icon (dict[str, Any]): Product icon definition.
|
||||
product_type_icon (dict[str, Any]): Product type icon definition.
|
||||
product_in_scene (bool): Is product in scene (only when used in DCC).
|
||||
group_name (str): Group name.
|
||||
folder_id (str): Folder id.
|
||||
|
|
@ -93,8 +96,6 @@ class ProductItem:
|
|||
product_base_type: str,
|
||||
product_name: str,
|
||||
product_icon: dict[str, Any],
|
||||
product_type_icon: dict[str, Any],
|
||||
product_base_type_icon: dict[str, Any],
|
||||
group_name: str,
|
||||
folder_id: str,
|
||||
folder_label: str,
|
||||
|
|
@ -106,8 +107,6 @@ class ProductItem:
|
|||
self.product_base_type = product_base_type
|
||||
self.product_name = product_name
|
||||
self.product_icon = product_icon
|
||||
self.product_type_icon = product_type_icon
|
||||
self.product_base_type_icon = product_base_type_icon
|
||||
self.product_in_scene = product_in_scene
|
||||
self.group_name = group_name
|
||||
self.folder_id = folder_id
|
||||
|
|
@ -121,8 +120,6 @@ class ProductItem:
|
|||
"product_base_type": self.product_base_type,
|
||||
"product_name": self.product_name,
|
||||
"product_icon": self.product_icon,
|
||||
"product_type_icon": self.product_type_icon,
|
||||
"product_base_type_icon": self.product_base_type_icon,
|
||||
"product_in_scene": self.product_in_scene,
|
||||
"group_name": self.group_name,
|
||||
"folder_id": self.folder_id,
|
||||
|
|
@ -499,8 +496,8 @@ class BackendLoaderController(_BaseLoaderController):
|
|||
topic (str): Event topic name.
|
||||
data (Optional[dict[str, Any]]): Event data.
|
||||
source (Optional[str]): Event source.
|
||||
"""
|
||||
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
|
|
@ -509,8 +506,20 @@ class BackendLoaderController(_BaseLoaderController):
|
|||
|
||||
Returns:
|
||||
set[str]: Set of loaded product ids.
|
||||
"""
|
||||
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_product_type_icons_mapping(
|
||||
self, project_name: Optional[str]
|
||||
) -> ProductTypeIconMapping:
|
||||
"""Product type icons mapping.
|
||||
|
||||
Returns:
|
||||
ProductTypeIconMapping: Product type icons mapping.
|
||||
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ from __future__ import annotations
|
|||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
import ayon_api
|
||||
|
||||
|
|
@ -16,6 +17,7 @@ from ayon_core.tools.common_models import (
|
|||
HierarchyModel,
|
||||
ThumbnailsModel,
|
||||
TagItem,
|
||||
ProductTypeIconMapping,
|
||||
)
|
||||
|
||||
from .abstract import (
|
||||
|
|
@ -198,6 +200,13 @@ class LoaderController(BackendLoaderController, FrontendLoaderController):
|
|||
project_name, sender
|
||||
)
|
||||
|
||||
def get_product_type_icons_mapping(
|
||||
self, project_name: Optional[str]
|
||||
) -> ProductTypeIconMapping:
|
||||
return self._projects_model.get_product_type_icons_mapping(
|
||||
project_name
|
||||
)
|
||||
|
||||
def get_folder_items(self, project_name, sender=None):
|
||||
return self._hierarchy_model.get_folder_items(project_name, sender)
|
||||
|
||||
|
|
|
|||
|
|
@ -9,9 +9,9 @@ import arrow
|
|||
import ayon_api
|
||||
from ayon_api.operations import OperationsSession
|
||||
|
||||
|
||||
from ayon_core.lib import NestedCacheItem
|
||||
from ayon_core.style import get_default_entity_icon_color
|
||||
from ayon_core.tools.common_models import ProductTypeIconMapping
|
||||
from ayon_core.tools.loader.abstract import (
|
||||
ProductTypeItem,
|
||||
ProductBaseTypeItem,
|
||||
|
|
@ -21,8 +21,11 @@ from ayon_core.tools.loader.abstract import (
|
|||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ayon_api.typing import ProductBaseTypeDict, ProductDict, VersionDict
|
||||
|
||||
from ayon_api.typing import (
|
||||
ProductBaseTypeDict,
|
||||
ProductDict,
|
||||
VersionDict,
|
||||
)
|
||||
|
||||
PRODUCTS_MODEL_SENDER = "products.model"
|
||||
|
||||
|
|
@ -84,42 +87,18 @@ def version_item_from_entity(version):
|
|||
def product_item_from_entity(
|
||||
product_entity: ProductDict,
|
||||
version_entities,
|
||||
product_type_items_by_name: dict[str, ProductTypeItem],
|
||||
product_base_type_items_by_name: dict[str, ProductBaseTypeItem],
|
||||
folder_label,
|
||||
icons_mapping,
|
||||
product_in_scene,
|
||||
):
|
||||
product_attribs = product_entity["attrib"]
|
||||
group = product_attribs.get("productGroup")
|
||||
product_type = product_entity["productType"]
|
||||
product_type_item = product_type_items_by_name.get(product_type)
|
||||
# NOTE This is needed for cases when products were not created on server
|
||||
# using api functions. In that case product type item may not be
|
||||
# available and we need to create a default.
|
||||
if product_type_item is None:
|
||||
product_type_item = create_default_product_type_item(product_type)
|
||||
# Cache the item for future use
|
||||
product_type_items_by_name[product_type] = product_type_item
|
||||
|
||||
product_base_type = product_entity.get("productBaseType")
|
||||
product_base_type_item = product_base_type_items_by_name.get(
|
||||
product_base_type)
|
||||
# Same as for product type item above. Not sure if this is still needed
|
||||
# though.
|
||||
if product_base_type_item is None:
|
||||
product_base_type_item = create_default_product_base_type_item(
|
||||
product_base_type)
|
||||
# Cache the item for future use
|
||||
product_base_type_items_by_name[product_base_type] = (
|
||||
product_base_type_item)
|
||||
|
||||
product_type_icon = product_type_item.icon
|
||||
product_base_type_icon = product_base_type_item.icon
|
||||
product_icon = {
|
||||
"type": "awesome-font",
|
||||
"name": "fa.file-o",
|
||||
"color": get_default_entity_icon_color(),
|
||||
}
|
||||
product_icon = icons_mapping.get_icon(
|
||||
product_base_type, product_type
|
||||
)
|
||||
version_items = {
|
||||
version_entity["id"]: version_item_from_entity(version_entity)
|
||||
for version_entity in version_entities
|
||||
|
|
@ -131,8 +110,6 @@ def product_item_from_entity(
|
|||
product_base_type=product_base_type,
|
||||
product_name=product_entity["name"],
|
||||
product_icon=product_icon,
|
||||
product_type_icon=product_type_icon,
|
||||
product_base_type_icon=product_base_type_icon,
|
||||
product_in_scene=product_in_scene,
|
||||
group_name=group,
|
||||
folder_id=product_entity["folderId"],
|
||||
|
|
@ -141,20 +118,6 @@ def product_item_from_entity(
|
|||
)
|
||||
|
||||
|
||||
def product_type_item_from_data(
|
||||
product_type_data: ProductDict) -> ProductTypeItem:
|
||||
# TODO implement icon implementation
|
||||
# icon = product_type_data["icon"]
|
||||
# color = product_type_data["color"]
|
||||
icon = {
|
||||
"type": "awesome-font",
|
||||
"name": "fa.folder",
|
||||
"color": "#0091B2",
|
||||
}
|
||||
# TODO implement checked logic
|
||||
return ProductTypeItem(product_type_data["name"], icon)
|
||||
|
||||
|
||||
def product_base_type_item_from_data(
|
||||
product_base_type_data: ProductBaseTypeDict
|
||||
) -> ProductBaseTypeItem:
|
||||
|
|
@ -174,34 +137,8 @@ def product_base_type_item_from_data(
|
|||
}
|
||||
return ProductBaseTypeItem(
|
||||
name=product_base_type_data["name"],
|
||||
icon=icon)
|
||||
|
||||
|
||||
def create_default_product_type_item(product_type: str) -> ProductTypeItem:
|
||||
icon = {
|
||||
"type": "awesome-font",
|
||||
"name": "fa.folder",
|
||||
"color": "#0091B2",
|
||||
}
|
||||
return ProductTypeItem(product_type, icon)
|
||||
|
||||
|
||||
def create_default_product_base_type_item(
|
||||
product_base_type: str) -> ProductBaseTypeItem:
|
||||
"""Create default product base type item.
|
||||
|
||||
Args:
|
||||
product_base_type (str): Product base type name.
|
||||
|
||||
Returns:
|
||||
ProductBaseTypeItem: Default product base type item.
|
||||
"""
|
||||
icon = {
|
||||
"type": "awesome-font",
|
||||
"name": "fa.folder",
|
||||
"color": "#0091B2",
|
||||
}
|
||||
return ProductBaseTypeItem(product_base_type, icon)
|
||||
icon=icon
|
||||
)
|
||||
|
||||
|
||||
class ProductsModel:
|
||||
|
|
@ -247,7 +184,9 @@ class ProductsModel:
|
|||
self._product_items_cache.reset()
|
||||
self._repre_items_cache.reset()
|
||||
|
||||
def get_product_type_items(self, project_name):
|
||||
def get_product_type_items(
|
||||
self, project_name: Optional[str]
|
||||
) -> list[ProductTypeItem]:
|
||||
"""Product type items for project.
|
||||
|
||||
Args:
|
||||
|
|
@ -255,25 +194,33 @@ class ProductsModel:
|
|||
|
||||
Returns:
|
||||
list[ProductTypeItem]: Product type items.
|
||||
"""
|
||||
|
||||
"""
|
||||
if not project_name:
|
||||
return []
|
||||
|
||||
cache = self._product_type_items_cache[project_name]
|
||||
if not cache.is_valid:
|
||||
icons_mapping = self._get_product_type_icons(project_name)
|
||||
product_types = ayon_api.get_project_product_types(project_name)
|
||||
cache.update_data([
|
||||
product_type_item_from_data(product_type)
|
||||
ProductTypeItem(
|
||||
product_type["name"],
|
||||
icons_mapping.get_icon(product_type=product_type["name"]),
|
||||
)
|
||||
for product_type in product_types
|
||||
])
|
||||
return cache.get_data()
|
||||
|
||||
def get_product_base_type_items(
|
||||
self,
|
||||
project_name: Optional[str]) -> list[ProductBaseTypeItem]:
|
||||
self, project_name: Optional[str]
|
||||
) -> list[ProductBaseTypeItem]:
|
||||
"""Product base type items for the project.
|
||||
|
||||
Notes:
|
||||
This will be used for filtering product types in UI when
|
||||
product base types are fully implemented.
|
||||
|
||||
Args:
|
||||
project_name (optional, str): Project name.
|
||||
|
||||
|
|
@ -286,6 +233,7 @@ class ProductsModel:
|
|||
|
||||
cache = self._product_base_type_items_cache[project_name]
|
||||
if not cache.is_valid:
|
||||
icons_mapping = self._get_product_type_icons(project_name)
|
||||
product_base_types = []
|
||||
# TODO add temp implementation here when it is actually
|
||||
# implemented and available on server.
|
||||
|
|
@ -294,7 +242,10 @@ class ProductsModel:
|
|||
project_name
|
||||
)
|
||||
cache.update_data([
|
||||
product_base_type_item_from_data(product_base_type)
|
||||
ProductBaseTypeItem(
|
||||
product_base_type["name"],
|
||||
icons_mapping.get_icon(product_base_type["name"]),
|
||||
)
|
||||
for product_base_type in product_base_types
|
||||
])
|
||||
return cache.get_data()
|
||||
|
|
@ -511,6 +462,11 @@ class ProductsModel:
|
|||
PRODUCTS_MODEL_SENDER
|
||||
)
|
||||
|
||||
def _get_product_type_icons(
|
||||
self, project_name: Optional[str]
|
||||
) -> ProductTypeIconMapping:
|
||||
return self._controller.get_product_type_icons_mapping(project_name)
|
||||
|
||||
def _get_product_items_by_id(self, project_name, product_ids):
|
||||
product_item_by_id = self._product_item_by_id[project_name]
|
||||
missing_product_ids = set()
|
||||
|
|
@ -524,7 +480,7 @@ class ProductsModel:
|
|||
|
||||
output.update(
|
||||
self._query_product_items_by_ids(
|
||||
project_name, missing_product_ids
|
||||
project_name, product_ids=missing_product_ids
|
||||
)
|
||||
)
|
||||
return output
|
||||
|
|
@ -553,36 +509,18 @@ class ProductsModel:
|
|||
products: Iterable[ProductDict],
|
||||
versions: Iterable[VersionDict],
|
||||
folder_items=None,
|
||||
product_type_items=None,
|
||||
product_base_type_items: Optional[Iterable[ProductBaseTypeItem]] = None
|
||||
):
|
||||
if folder_items is None:
|
||||
folder_items = self._controller.get_folder_items(project_name)
|
||||
|
||||
if product_type_items is None:
|
||||
product_type_items = self.get_product_type_items(project_name)
|
||||
|
||||
if product_base_type_items is None:
|
||||
product_base_type_items = self.get_product_base_type_items(
|
||||
project_name
|
||||
)
|
||||
|
||||
loaded_product_ids = self._controller.get_loaded_product_ids()
|
||||
|
||||
versions_by_product_id = collections.defaultdict(list)
|
||||
for version in versions:
|
||||
versions_by_product_id[version["productId"]].append(version)
|
||||
product_type_items_by_name = {
|
||||
product_type_item.name: product_type_item
|
||||
for product_type_item in product_type_items
|
||||
}
|
||||
|
||||
product_base_type_items_by_name: dict[str, ProductBaseTypeItem] = {
|
||||
product_base_type_item.name: product_base_type_item
|
||||
for product_base_type_item in product_base_type_items
|
||||
}
|
||||
|
||||
output: dict[str, ProductItem] = {}
|
||||
icons_mapping = self._get_product_type_icons(project_name)
|
||||
for product in products:
|
||||
product_id = product["id"]
|
||||
folder_id = product["folderId"]
|
||||
|
|
@ -595,9 +533,8 @@ class ProductsModel:
|
|||
product_item = product_item_from_entity(
|
||||
product,
|
||||
versions,
|
||||
product_type_items_by_name,
|
||||
product_base_type_items_by_name,
|
||||
folder_item.label,
|
||||
icons_mapping,
|
||||
product_id in loaded_product_ids,
|
||||
)
|
||||
output[product_id] = product_item
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ PRODUCT_ID_ROLE = QtCore.Qt.UserRole + 6
|
|||
PRODUCT_NAME_ROLE = QtCore.Qt.UserRole + 7
|
||||
PRODUCT_TYPE_ROLE = QtCore.Qt.UserRole + 8
|
||||
PRODUCT_BASE_TYPE_ROLE = QtCore.Qt.UserRole + 9
|
||||
PRODUCT_TYPE_ICON_ROLE = QtCore.Qt.UserRole + 10
|
||||
PRODUCT_IN_SCENE_ROLE = QtCore.Qt.UserRole + 11
|
||||
VERSION_ID_ROLE = QtCore.Qt.UserRole + 12
|
||||
VERSION_HERO_ROLE = QtCore.Qt.UserRole + 13
|
||||
|
|
@ -228,9 +227,6 @@ class ProductsModel(QtGui.QStandardItemModel):
|
|||
return super().data(index, role)
|
||||
|
||||
if role == QtCore.Qt.DecorationRole:
|
||||
if col == 1:
|
||||
role = PRODUCT_TYPE_ICON_ROLE
|
||||
else:
|
||||
return None
|
||||
|
||||
if (
|
||||
|
|
@ -455,7 +451,6 @@ class ProductsModel(QtGui.QStandardItemModel):
|
|||
model_item = QtGui.QStandardItem(product_item.product_name)
|
||||
model_item.setEditable(False)
|
||||
icon = get_qt_icon(product_item.product_icon)
|
||||
product_type_icon = get_qt_icon(product_item.product_type_icon)
|
||||
model_item.setColumnCount(self.columnCount())
|
||||
model_item.setData(icon, QtCore.Qt.DecorationRole)
|
||||
model_item.setData(product_id, PRODUCT_ID_ROLE)
|
||||
|
|
@ -464,7 +459,6 @@ class ProductsModel(QtGui.QStandardItemModel):
|
|||
product_item.product_base_type, PRODUCT_BASE_TYPE_ROLE
|
||||
)
|
||||
model_item.setData(product_item.product_type, PRODUCT_TYPE_ROLE)
|
||||
model_item.setData(product_type_icon, PRODUCT_TYPE_ICON_ROLE)
|
||||
model_item.setData(product_item.folder_id, FOLDER_ID_ROLE)
|
||||
|
||||
self._product_items_by_id[product_id] = product_item
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ from typing import (
|
|||
)
|
||||
|
||||
from ayon_core.lib import AbstractAttrDef
|
||||
from ayon_core.host import HostBase
|
||||
from ayon_core.host import AbstractHost
|
||||
from ayon_core.pipeline.create import (
|
||||
CreateContext,
|
||||
ConvertorItem,
|
||||
|
|
@ -176,7 +176,7 @@ class AbstractPublisherBackend(AbstractPublisherCommon):
|
|||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_host(self) -> HostBase:
|
||||
def get_host(self) -> AbstractHost:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
|
|
|
|||
|
|
@ -219,6 +219,8 @@ class InstanceItem:
|
|||
is_active: bool,
|
||||
is_mandatory: bool,
|
||||
has_promised_context: bool,
|
||||
parent_instance_id: Optional[str],
|
||||
parent_flags: int,
|
||||
):
|
||||
self._instance_id: str = instance_id
|
||||
self._creator_identifier: str = creator_identifier
|
||||
|
|
@ -232,6 +234,8 @@ class InstanceItem:
|
|||
self._is_active: bool = is_active
|
||||
self._is_mandatory: bool = is_mandatory
|
||||
self._has_promised_context: bool = has_promised_context
|
||||
self._parent_instance_id: Optional[str] = parent_instance_id
|
||||
self._parent_flags: int = parent_flags
|
||||
|
||||
@property
|
||||
def id(self):
|
||||
|
|
@ -261,6 +265,14 @@ class InstanceItem:
|
|||
def has_promised_context(self):
|
||||
return self._has_promised_context
|
||||
|
||||
@property
|
||||
def parent_instance_id(self):
|
||||
return self._parent_instance_id
|
||||
|
||||
@property
|
||||
def parent_flags(self) -> int:
|
||||
return self._parent_flags
|
||||
|
||||
def get_variant(self):
|
||||
return self._variant
|
||||
|
||||
|
|
@ -312,6 +324,8 @@ class InstanceItem:
|
|||
instance["active"],
|
||||
instance.is_mandatory,
|
||||
instance.has_promised_context,
|
||||
instance.parent_instance_id,
|
||||
instance.parent_flags,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -486,6 +500,9 @@ class CreateModel:
|
|||
self._create_context.add_instance_requirement_change_callback(
|
||||
self._cc_instance_requirement_changed
|
||||
)
|
||||
self._create_context.add_instance_parent_change_callback(
|
||||
self._cc_instance_parent_changed
|
||||
)
|
||||
|
||||
self._create_context.reset_finalization()
|
||||
|
||||
|
|
@ -566,15 +583,21 @@ class CreateModel:
|
|||
def set_instances_active_state(
|
||||
self, active_state_by_id: Dict[str, bool]
|
||||
):
|
||||
changed_ids = set()
|
||||
with self._create_context.bulk_value_changes(CREATE_EVENT_SOURCE):
|
||||
for instance_id, active in active_state_by_id.items():
|
||||
instance = self._create_context.get_instance_by_id(instance_id)
|
||||
if instance["active"] is not active:
|
||||
instance["active"] = active
|
||||
changed_ids.add(instance_id)
|
||||
|
||||
if not changed_ids:
|
||||
return
|
||||
|
||||
self._emit_event(
|
||||
"create.model.instances.context.changed",
|
||||
{
|
||||
"instance_ids": set(active_state_by_id.keys())
|
||||
"instance_ids": changed_ids
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -1191,6 +1214,16 @@ class CreateModel:
|
|||
{"instance_ids": instance_ids},
|
||||
)
|
||||
|
||||
def _cc_instance_parent_changed(self, event):
|
||||
instance_ids = {
|
||||
instance.id
|
||||
for instance in event.data["instances"]
|
||||
}
|
||||
self._emit_event(
|
||||
"create.model.instance.parent.changed",
|
||||
{"instance_ids": instance_ids},
|
||||
)
|
||||
|
||||
def _get_allowed_creators_pattern(self) -> Union[Pattern, None]:
|
||||
"""Provide regex pattern for configured creator labels in this context
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,3 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Generator
|
||||
|
||||
from qtpy import QtWidgets, QtCore
|
||||
|
||||
from ayon_core.tools.publisher.abstract import AbstractPublisherFrontend
|
||||
|
|
@ -6,6 +10,7 @@ from .border_label_widget import BorderedLabelWidget
|
|||
from .card_view_widgets import InstanceCardView
|
||||
from .list_view_widgets import InstanceListView
|
||||
from .widgets import (
|
||||
AbstractInstanceView,
|
||||
CreateInstanceBtn,
|
||||
RemoveInstanceBtn,
|
||||
ChangeViewBtn,
|
||||
|
|
@ -43,10 +48,16 @@ class OverviewWidget(QtWidgets.QFrame):
|
|||
|
||||
product_view_cards = InstanceCardView(controller, product_views_widget)
|
||||
product_list_view = InstanceListView(controller, product_views_widget)
|
||||
product_list_view.set_parent_grouping(False)
|
||||
product_list_view_grouped = InstanceListView(
|
||||
controller, product_views_widget
|
||||
)
|
||||
product_list_view_grouped.set_parent_grouping(True)
|
||||
|
||||
product_views_layout = QtWidgets.QStackedLayout()
|
||||
product_views_layout.addWidget(product_view_cards)
|
||||
product_views_layout.addWidget(product_list_view)
|
||||
product_views_layout.addWidget(product_list_view_grouped)
|
||||
product_views_layout.setCurrentWidget(product_view_cards)
|
||||
|
||||
# Buttons at the bottom of product view
|
||||
|
|
@ -118,6 +129,12 @@ class OverviewWidget(QtWidgets.QFrame):
|
|||
product_list_view.double_clicked.connect(
|
||||
self.publish_tab_requested
|
||||
)
|
||||
product_list_view_grouped.selection_changed.connect(
|
||||
self._on_product_change
|
||||
)
|
||||
product_list_view_grouped.double_clicked.connect(
|
||||
self.publish_tab_requested
|
||||
)
|
||||
product_view_cards.selection_changed.connect(
|
||||
self._on_product_change
|
||||
)
|
||||
|
|
@ -159,16 +176,22 @@ class OverviewWidget(QtWidgets.QFrame):
|
|||
"create.model.instance.requirement.changed",
|
||||
self._on_instance_requirement_changed
|
||||
)
|
||||
controller.register_event_callback(
|
||||
"create.model.instance.parent.changed",
|
||||
self._on_instance_parent_changed
|
||||
)
|
||||
|
||||
self._product_content_widget = product_content_widget
|
||||
self._product_content_layout = product_content_layout
|
||||
|
||||
self._product_view_cards = product_view_cards
|
||||
self._product_list_view = product_list_view
|
||||
self._product_list_view_grouped = product_list_view_grouped
|
||||
self._product_views_layout = product_views_layout
|
||||
|
||||
self._create_btn = create_btn
|
||||
self._delete_btn = delete_btn
|
||||
self._change_view_btn = change_view_btn
|
||||
|
||||
self._product_attributes_widget = product_attributes_widget
|
||||
self._create_widget = create_widget
|
||||
|
|
@ -246,7 +269,7 @@ class OverviewWidget(QtWidgets.QFrame):
|
|||
)
|
||||
|
||||
def has_items(self):
|
||||
view = self._product_views_layout.currentWidget()
|
||||
view = self._get_current_view()
|
||||
return view.has_items()
|
||||
|
||||
def _on_create_clicked(self):
|
||||
|
|
@ -361,17 +384,18 @@ class OverviewWidget(QtWidgets.QFrame):
|
|||
def _on_instance_requirement_changed(self, event):
|
||||
self._refresh_instance_states(event["instance_ids"])
|
||||
|
||||
def _refresh_instance_states(self, instance_ids):
|
||||
current_idx = self._product_views_layout.currentIndex()
|
||||
for idx in range(self._product_views_layout.count()):
|
||||
if idx == current_idx:
|
||||
continue
|
||||
widget = self._product_views_layout.widget(idx)
|
||||
if widget.refreshed:
|
||||
widget.set_refreshed(False)
|
||||
def _on_instance_parent_changed(self, event):
|
||||
self._refresh_instance_states(event["instance_ids"])
|
||||
|
||||
current_widget = self._product_views_layout.widget(current_idx)
|
||||
current_widget.refresh_instance_states(instance_ids)
|
||||
def _refresh_instance_states(self, instance_ids):
|
||||
current_view = self._get_current_view()
|
||||
for view in self._iter_views():
|
||||
if view is current_view:
|
||||
current_view = view
|
||||
elif view.refreshed:
|
||||
view.set_refreshed(False)
|
||||
|
||||
current_view.refresh_instance_states(instance_ids)
|
||||
|
||||
def _on_convert_requested(self):
|
||||
self.convert_requested.emit()
|
||||
|
|
@ -385,7 +409,7 @@ class OverviewWidget(QtWidgets.QFrame):
|
|||
convertor plugins.
|
||||
"""
|
||||
|
||||
view = self._product_views_layout.currentWidget()
|
||||
view = self._get_current_view()
|
||||
return view.get_selected_items()
|
||||
|
||||
def get_selected_legacy_convertors(self):
|
||||
|
|
@ -400,12 +424,12 @@ class OverviewWidget(QtWidgets.QFrame):
|
|||
return convertor_identifiers
|
||||
|
||||
def _change_view_type(self):
|
||||
old_view = self._get_current_view()
|
||||
|
||||
idx = self._product_views_layout.currentIndex()
|
||||
new_idx = (idx + 1) % self._product_views_layout.count()
|
||||
|
||||
old_view = self._product_views_layout.currentWidget()
|
||||
new_view = self._product_views_layout.widget(new_idx)
|
||||
|
||||
new_view = self._get_view_by_idx(new_idx)
|
||||
if not new_view.refreshed:
|
||||
new_view.refresh()
|
||||
new_view.set_refreshed(True)
|
||||
|
|
@ -418,22 +442,52 @@ class OverviewWidget(QtWidgets.QFrame):
|
|||
new_view.set_selected_items(
|
||||
instance_ids, context_selected, convertor_identifiers
|
||||
)
|
||||
view_type = "list"
|
||||
if new_view is self._product_list_view_grouped:
|
||||
view_type = "card"
|
||||
elif new_view is self._product_list_view:
|
||||
view_type = "list-parent-grouping"
|
||||
|
||||
self._change_view_btn.set_view_type(view_type)
|
||||
self._product_views_layout.setCurrentIndex(new_idx)
|
||||
|
||||
self._on_product_change()
|
||||
|
||||
def _iter_views(self) -> Generator[AbstractInstanceView, None, None]:
|
||||
for idx in range(self._product_views_layout.count()):
|
||||
widget = self._product_views_layout.widget(idx)
|
||||
if not isinstance(widget, AbstractInstanceView):
|
||||
raise TypeError(
|
||||
"Current widget is not instance of 'AbstractInstanceView'"
|
||||
)
|
||||
yield widget
|
||||
|
||||
def _get_current_view(self) -> AbstractInstanceView:
|
||||
widget = self._product_views_layout.currentWidget()
|
||||
if isinstance(widget, AbstractInstanceView):
|
||||
return widget
|
||||
raise TypeError(
|
||||
"Current widget is not instance of 'AbstractInstanceView'"
|
||||
)
|
||||
|
||||
def _get_view_by_idx(self, idx: int) -> AbstractInstanceView:
|
||||
widget = self._product_views_layout.widget(idx)
|
||||
if isinstance(widget, AbstractInstanceView):
|
||||
return widget
|
||||
raise TypeError(
|
||||
"Current widget is not instance of 'AbstractInstanceView'"
|
||||
)
|
||||
|
||||
def _refresh_instances(self):
|
||||
if self._refreshing_instances:
|
||||
return
|
||||
|
||||
self._refreshing_instances = True
|
||||
|
||||
for idx in range(self._product_views_layout.count()):
|
||||
widget = self._product_views_layout.widget(idx)
|
||||
widget.set_refreshed(False)
|
||||
for view in self._iter_views():
|
||||
view.set_refreshed(False)
|
||||
|
||||
view = self._product_views_layout.currentWidget()
|
||||
view = self._get_current_view()
|
||||
view.refresh()
|
||||
view.set_refreshed(True)
|
||||
|
||||
|
|
@ -444,25 +498,22 @@ class OverviewWidget(QtWidgets.QFrame):
|
|||
|
||||
# Give a change to process Resize Request
|
||||
QtWidgets.QApplication.processEvents()
|
||||
# Trigger update geometry of
|
||||
widget = self._product_views_layout.currentWidget()
|
||||
widget.updateGeometry()
|
||||
# Trigger update geometry
|
||||
view.updateGeometry()
|
||||
|
||||
def _on_publish_start(self):
|
||||
"""Publish started."""
|
||||
|
||||
self._create_btn.setEnabled(False)
|
||||
self._product_attributes_wrap.setEnabled(False)
|
||||
for idx in range(self._product_views_layout.count()):
|
||||
widget = self._product_views_layout.widget(idx)
|
||||
widget.set_active_toggle_enabled(False)
|
||||
for view in self._iter_views():
|
||||
view.set_active_toggle_enabled(False)
|
||||
|
||||
def _on_controller_reset_start(self):
|
||||
"""Controller reset started."""
|
||||
|
||||
for idx in range(self._product_views_layout.count()):
|
||||
widget = self._product_views_layout.widget(idx)
|
||||
widget.set_active_toggle_enabled(True)
|
||||
for view in self._iter_views():
|
||||
view.set_active_toggle_enabled(True)
|
||||
|
||||
def _on_publish_reset(self):
|
||||
"""Context in controller has been reseted."""
|
||||
|
|
@ -477,7 +528,19 @@ class OverviewWidget(QtWidgets.QFrame):
|
|||
self._refresh_instances()
|
||||
|
||||
def _on_instances_added(self):
|
||||
view = self._get_current_view()
|
||||
is_card_view = False
|
||||
count = 0
|
||||
if isinstance(view, InstanceCardView):
|
||||
is_card_view = True
|
||||
count = view.get_current_instance_count()
|
||||
|
||||
self._refresh_instances()
|
||||
|
||||
if is_card_view and count < 10:
|
||||
new_count = view.get_current_instance_count()
|
||||
if new_count > count and new_count >= 10:
|
||||
self._change_view_type()
|
||||
|
||||
def _on_instances_removed(self):
|
||||
self._refresh_instances()
|
||||
|
|
|
|||
|
|
@ -1147,6 +1147,8 @@ class LogItemMessage(QtWidgets.QTextEdit):
|
|||
QtWidgets.QSizePolicy.Preferred,
|
||||
QtWidgets.QSizePolicy.Maximum
|
||||
)
|
||||
self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
|
||||
self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
|
||||
document = self.document()
|
||||
document.documentLayout().documentSizeChanged.connect(
|
||||
self._adjust_minimum_size
|
||||
|
|
|
|||
|
|
@ -146,19 +146,19 @@ class TasksModel(QtGui.QStandardItemModel):
|
|||
self._controller.get_current_project_name()
|
||||
)
|
||||
}
|
||||
icon_name_by_task_name = {}
|
||||
type_item_by_task_name = {}
|
||||
for task_items in task_items_by_folder_path.values():
|
||||
for task_item in task_items:
|
||||
task_name = task_item.name
|
||||
if (
|
||||
task_name not in new_task_names
|
||||
or task_name in icon_name_by_task_name
|
||||
or task_name in type_item_by_task_name
|
||||
):
|
||||
continue
|
||||
task_type_name = task_item.task_type
|
||||
task_type_item = task_type_items.get(task_type_name)
|
||||
if task_type_item:
|
||||
icon_name_by_task_name[task_name] = task_type_item.icon
|
||||
type_item_by_task_name[task_name] = task_type_item
|
||||
|
||||
for task_name in new_task_names:
|
||||
item = self._items_by_name.get(task_name)
|
||||
|
|
@ -171,13 +171,18 @@ class TasksModel(QtGui.QStandardItemModel):
|
|||
if not task_name:
|
||||
continue
|
||||
|
||||
icon_name = icon_name_by_task_name.get(task_name)
|
||||
icon = None
|
||||
icon = icon_name = icon_color = None
|
||||
task_type_item = type_item_by_task_name.get(task_name)
|
||||
if task_type_item is not None:
|
||||
icon_name = task_type_item.icon
|
||||
icon_color = task_type_item.color
|
||||
if icon_name:
|
||||
if not icon_color:
|
||||
icon_color = get_default_entity_icon_color()
|
||||
icon = get_qt_icon({
|
||||
"type": "material-symbols",
|
||||
"name": icon_name,
|
||||
"color": get_default_entity_icon_color(),
|
||||
"color": icon_color,
|
||||
})
|
||||
if icon is None:
|
||||
icon = default_icon
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ from ayon_core.tools.flickcharm import FlickCharm
|
|||
from ayon_core.tools.utils import (
|
||||
IconButton,
|
||||
PixmapLabel,
|
||||
get_qt_icon,
|
||||
)
|
||||
from ayon_core.tools.publisher.constants import ResetKeySequence
|
||||
|
||||
|
|
@ -287,12 +288,32 @@ class RemoveInstanceBtn(PublishIconBtn):
|
|||
self.setToolTip("Remove selected instances")
|
||||
|
||||
|
||||
class ChangeViewBtn(PublishIconBtn):
|
||||
"""Create toggle view button."""
|
||||
class ChangeViewBtn(IconButton):
|
||||
"""Toggle views button."""
|
||||
def __init__(self, parent=None):
|
||||
icon_path = get_icon_path("change_view")
|
||||
super().__init__(icon_path, parent)
|
||||
self.setToolTip("Swap between views")
|
||||
super().__init__(parent)
|
||||
self.set_view_type("list")
|
||||
|
||||
def set_view_type(self, view_type):
|
||||
if view_type == "list":
|
||||
# icon_name = "data_table"
|
||||
icon_name = "dehaze"
|
||||
tooltip = "Change to list view"
|
||||
elif view_type == "card":
|
||||
icon_name = "view_agenda"
|
||||
tooltip = "Change to card view"
|
||||
else:
|
||||
icon_name = "segment"
|
||||
tooltip = "Change to parent grouping view"
|
||||
|
||||
# "format_align_right"
|
||||
# "segment"
|
||||
icon = get_qt_icon({
|
||||
"type": "material-symbols",
|
||||
"name": icon_name,
|
||||
})
|
||||
self.setIcon(icon)
|
||||
self.setToolTip(tooltip)
|
||||
|
||||
|
||||
class AbstractInstanceView(QtWidgets.QWidget):
|
||||
|
|
@ -370,6 +391,20 @@ class AbstractInstanceView(QtWidgets.QWidget):
|
|||
"{} Method 'set_active_toggle_enabled' is not implemented."
|
||||
).format(self.__class__.__name__))
|
||||
|
||||
def refresh_instance_states(self, instance_ids=None):
|
||||
"""Refresh instance states.
|
||||
|
||||
Args:
|
||||
instance_ids: Optional[Iterable[str]]: Instance ids to refresh.
|
||||
If not passed then all instances are refreshed.
|
||||
|
||||
"""
|
||||
|
||||
raise NotImplementedError(
|
||||
f"{self.__class__.__name__} Method 'refresh_instance_states'"
|
||||
" is not implemented."
|
||||
)
|
||||
|
||||
|
||||
class ClickableLineEdit(QtWidgets.QLineEdit):
|
||||
"""QLineEdit capturing left mouse click.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import threading
|
||||
from typing import Dict
|
||||
|
||||
import ayon_api
|
||||
|
||||
|
|
@ -13,10 +14,11 @@ from .models import (
|
|||
UserPublishValuesModel,
|
||||
IntegrateModel,
|
||||
)
|
||||
from .models.integrate import ProjectPushItemProcess
|
||||
|
||||
|
||||
class PushToContextController:
|
||||
def __init__(self, project_name=None, version_id=None):
|
||||
def __init__(self, project_name=None, version_ids=None):
|
||||
self._event_system = self._create_event_system()
|
||||
|
||||
self._projects_model = ProjectsModel(self)
|
||||
|
|
@ -27,18 +29,20 @@ class PushToContextController:
|
|||
self._user_values = UserPublishValuesModel(self)
|
||||
|
||||
self._src_project_name = None
|
||||
self._src_version_id = None
|
||||
self._src_version_ids = []
|
||||
self._src_folder_entity = None
|
||||
self._src_folder_task_entities = {}
|
||||
self._src_product_entity = None
|
||||
self._src_version_entity = None
|
||||
self._src_version_entities = []
|
||||
self._src_product_entities = {}
|
||||
self._src_label = None
|
||||
|
||||
self._submission_enabled = False
|
||||
self._process_thread = None
|
||||
self._process_item_id = None
|
||||
|
||||
self.set_source(project_name, version_id)
|
||||
self._use_original_name = False
|
||||
|
||||
self.set_source(project_name, version_ids)
|
||||
|
||||
# Events system
|
||||
def emit_event(self, topic, data=None, source=None):
|
||||
|
|
@ -51,38 +55,47 @@ class PushToContextController:
|
|||
def register_event_callback(self, topic, callback):
|
||||
self._event_system.add_callback(topic, callback)
|
||||
|
||||
def set_source(self, project_name, version_id):
|
||||
def set_source(self, project_name, version_ids):
|
||||
"""Set source project and version.
|
||||
|
||||
There is currently assumption that tool is working on products of same
|
||||
folder.
|
||||
|
||||
Args:
|
||||
project_name (Union[str, None]): Source project name.
|
||||
version_id (Union[str, None]): Source version id.
|
||||
version_ids (Optional[list[str]]): Version ids.
|
||||
"""
|
||||
|
||||
if not project_name or not version_ids:
|
||||
return
|
||||
if (
|
||||
project_name == self._src_project_name
|
||||
and version_id == self._src_version_id
|
||||
and version_ids == self._src_version_ids
|
||||
):
|
||||
return
|
||||
|
||||
self._src_project_name = project_name
|
||||
self._src_version_id = version_id
|
||||
self._src_version_ids = version_ids
|
||||
self._src_label = None
|
||||
folder_entity = None
|
||||
task_entities = {}
|
||||
product_entity = None
|
||||
version_entity = None
|
||||
if project_name and version_id:
|
||||
version_entity = ayon_api.get_version_by_id(
|
||||
project_name, version_id
|
||||
product_entities = []
|
||||
version_entities = []
|
||||
if project_name and self._src_version_ids:
|
||||
version_entities = list(ayon_api.get_versions(
|
||||
project_name, version_ids=self._src_version_ids))
|
||||
|
||||
if version_entities:
|
||||
product_ids = [
|
||||
version_entity["productId"]
|
||||
for version_entity in version_entities
|
||||
]
|
||||
product_entities = list(ayon_api.get_products(
|
||||
project_name, product_ids=product_ids)
|
||||
)
|
||||
|
||||
if version_entity:
|
||||
product_entity = ayon_api.get_product_by_id(
|
||||
project_name, version_entity["productId"]
|
||||
)
|
||||
|
||||
if product_entity:
|
||||
if product_entities:
|
||||
# all products for same folder
|
||||
product_entity = product_entities[0]
|
||||
folder_entity = ayon_api.get_folder_by_id(
|
||||
project_name, product_entity["folderId"]
|
||||
)
|
||||
|
|
@ -97,15 +110,18 @@ class PushToContextController:
|
|||
|
||||
self._src_folder_entity = folder_entity
|
||||
self._src_folder_task_entities = task_entities
|
||||
self._src_product_entity = product_entity
|
||||
self._src_version_entity = version_entity
|
||||
self._src_version_entities = version_entities
|
||||
self._src_product_entities = {
|
||||
product["id"]: product
|
||||
for product in product_entities
|
||||
}
|
||||
if folder_entity:
|
||||
self._user_values.set_new_folder_name(folder_entity["name"])
|
||||
variant = self._get_src_variant()
|
||||
if variant:
|
||||
self._user_values.set_variant(variant)
|
||||
|
||||
comment = version_entity["attrib"].get("comment")
|
||||
comment = version_entities[0]["attrib"].get("comment")
|
||||
if comment:
|
||||
self._user_values.set_comment(comment)
|
||||
|
||||
|
|
@ -113,7 +129,7 @@ class PushToContextController:
|
|||
"source.changed",
|
||||
{
|
||||
"project_name": project_name,
|
||||
"version_id": version_id
|
||||
"version_ids": self._src_version_ids
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -142,6 +158,14 @@ class PushToContextController:
|
|||
def get_user_values(self):
|
||||
return self._user_values.get_data()
|
||||
|
||||
def original_names_required(self):
|
||||
"""Checks if original product names must be used.
|
||||
|
||||
Currently simple check if multiple versions, but if multiple products
|
||||
with different product_type were used, it wouldn't be necessary.
|
||||
"""
|
||||
return len(self._src_version_entities) > 1
|
||||
|
||||
def set_user_value_folder_name(self, folder_name):
|
||||
self._user_values.set_new_folder_name(folder_name)
|
||||
self._invalidate()
|
||||
|
|
@ -165,8 +189,9 @@ class PushToContextController:
|
|||
def set_selected_task(self, task_id, task_name):
|
||||
self._selection_model.set_selected_task(task_id, task_name)
|
||||
|
||||
def get_process_item_status(self, item_id):
|
||||
return self._integrate_model.get_item_status(item_id)
|
||||
def get_process_items(self) -> Dict[str, ProjectPushItemProcess]:
|
||||
"""Returns dict of all ProjectPushItemProcess items """
|
||||
return self._integrate_model.get_items()
|
||||
|
||||
# Processing methods
|
||||
def submit(self, wait=True):
|
||||
|
|
@ -176,29 +201,33 @@ class PushToContextController:
|
|||
if self._process_thread is not None:
|
||||
return
|
||||
|
||||
item_ids = []
|
||||
for src_version_entity in self._src_version_entities:
|
||||
item_id = self._integrate_model.create_process_item(
|
||||
self._src_project_name,
|
||||
self._src_version_id,
|
||||
src_version_entity["id"],
|
||||
self._selection_model.get_selected_project_name(),
|
||||
self._selection_model.get_selected_folder_id(),
|
||||
self._selection_model.get_selected_task_name(),
|
||||
self._user_values.variant,
|
||||
comment=self._user_values.comment,
|
||||
new_folder_name=self._user_values.new_folder_name,
|
||||
dst_version=1
|
||||
dst_version=1,
|
||||
use_original_name=self._use_original_name,
|
||||
)
|
||||
item_ids.append(item_id)
|
||||
|
||||
self._process_item_id = item_id
|
||||
self._process_item_ids = item_ids
|
||||
self._emit_event("submit.started")
|
||||
if wait:
|
||||
self._submit_callback()
|
||||
self._process_item_id = None
|
||||
self._process_item_ids = []
|
||||
return item_id
|
||||
|
||||
thread = threading.Thread(target=self._submit_callback)
|
||||
self._process_thread = thread
|
||||
thread.start()
|
||||
return item_id
|
||||
return item_ids
|
||||
|
||||
def wait_for_process_thread(self):
|
||||
if self._process_thread is None:
|
||||
|
|
@ -207,7 +236,7 @@ class PushToContextController:
|
|||
self._process_thread = None
|
||||
|
||||
def _prepare_source_label(self):
|
||||
if not self._src_project_name or not self._src_version_id:
|
||||
if not self._src_project_name or not self._src_version_ids:
|
||||
return "Source is not defined"
|
||||
|
||||
folder_entity = self._src_folder_entity
|
||||
|
|
@ -215,14 +244,21 @@ class PushToContextController:
|
|||
return "Source is invalid"
|
||||
|
||||
folder_path = folder_entity["path"]
|
||||
product_entity = self._src_product_entity
|
||||
version_entity = self._src_version_entity
|
||||
return "Source: {}{}/{}/v{:0>3}".format(
|
||||
src_labels = []
|
||||
for version_entity in self._src_version_entities:
|
||||
product_entity = self._src_product_entities.get(
|
||||
version_entity["productId"]
|
||||
)
|
||||
src_labels.append(
|
||||
"Source: {}{}/{}/v{:0>3}".format(
|
||||
self._src_project_name,
|
||||
folder_path,
|
||||
product_entity["name"],
|
||||
version_entity["version"]
|
||||
version_entity["version"],
|
||||
)
|
||||
)
|
||||
|
||||
return "\n".join(src_labels)
|
||||
|
||||
def _get_task_info_from_repre_entities(
|
||||
self, task_entities, repre_entities
|
||||
|
|
@ -256,7 +292,8 @@ class PushToContextController:
|
|||
|
||||
def _get_src_variant(self):
|
||||
project_name = self._src_project_name
|
||||
version_entity = self._src_version_entity
|
||||
# parse variant only from first version
|
||||
version_entity = self._src_version_entities[0]
|
||||
task_entities = self._src_folder_task_entities
|
||||
repre_entities = ayon_api.get_representations(
|
||||
project_name, version_ids={version_entity["id"]}
|
||||
|
|
@ -264,9 +301,12 @@ class PushToContextController:
|
|||
task_name, task_type = self._get_task_info_from_repre_entities(
|
||||
task_entities, repre_entities
|
||||
)
|
||||
product_entity = self._src_product_entities.get(
|
||||
version_entity["productId"]
|
||||
)
|
||||
|
||||
project_settings = get_project_settings(project_name)
|
||||
product_type = self._src_product_entity["productType"]
|
||||
product_type = product_entity["productType"]
|
||||
template = get_product_name_template(
|
||||
self._src_project_name,
|
||||
product_type,
|
||||
|
|
@ -300,7 +340,7 @@ class PushToContextController:
|
|||
print("Failed format", exc)
|
||||
return ""
|
||||
|
||||
product_name = self._src_product_entity["name"]
|
||||
product_name = product_entity["name"]
|
||||
if (
|
||||
(product_s and not product_name.startswith(product_s))
|
||||
or (product_e and not product_name.endswith(product_e))
|
||||
|
|
@ -314,9 +354,6 @@ class PushToContextController:
|
|||
return product_name
|
||||
|
||||
def _check_submit_validations(self):
|
||||
if not self._user_values.is_valid:
|
||||
return False
|
||||
|
||||
if not self._selection_model.get_selected_project_name():
|
||||
return False
|
||||
|
||||
|
|
@ -325,6 +362,13 @@ class PushToContextController:
|
|||
and not self._selection_model.get_selected_folder_id()
|
||||
):
|
||||
return False
|
||||
|
||||
if self._use_original_name:
|
||||
return True
|
||||
|
||||
if not self._user_values.is_valid:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def _invalidate(self):
|
||||
|
|
@ -338,13 +382,14 @@ class PushToContextController:
|
|||
)
|
||||
|
||||
def _submit_callback(self):
|
||||
process_item_id = self._process_item_id
|
||||
if process_item_id is None:
|
||||
return
|
||||
process_item_ids = self._process_item_ids
|
||||
for process_item_id in process_item_ids:
|
||||
self._integrate_model.integrate_item(process_item_id)
|
||||
|
||||
self._emit_event("submit.finished", {})
|
||||
if process_item_id == self._process_item_id:
|
||||
self._process_item_id = None
|
||||
|
||||
if process_item_ids is self._process_item_ids:
|
||||
self._process_item_ids = []
|
||||
|
||||
def _emit_event(self, topic, data=None):
|
||||
if data is None:
|
||||
|
|
|
|||
|
|
@ -4,28 +4,28 @@ from ayon_core.tools.utils import get_ayon_qt_app
|
|||
from ayon_core.tools.push_to_project.ui import PushToContextSelectWindow
|
||||
|
||||
|
||||
def main_show(project_name, version_id):
|
||||
def main_show(project_name, version_ids):
|
||||
app = get_ayon_qt_app()
|
||||
|
||||
window = PushToContextSelectWindow()
|
||||
window.show()
|
||||
window.set_source(project_name, version_id)
|
||||
window.set_source(project_name, version_ids)
|
||||
|
||||
app.exec_()
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--project", help="Source project name")
|
||||
@click.option("--version", help="Source version id")
|
||||
def main(project, version):
|
||||
@click.option("--versions", help="Source version ids")
|
||||
def main(project, versions):
|
||||
"""Run PushToProject tool to integrate version in different project.
|
||||
|
||||
Args:
|
||||
project (str): Source project name.
|
||||
version (str): Version id.
|
||||
versions (str): comma separated versions for same context
|
||||
"""
|
||||
|
||||
main_show(project, version)
|
||||
main_show(project, versions.split(","))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import itertools
|
|||
import sys
|
||||
import traceback
|
||||
import uuid
|
||||
from typing import Optional, Dict
|
||||
|
||||
import ayon_api
|
||||
from ayon_api.utils import create_entity_id
|
||||
|
|
@ -21,6 +22,7 @@ from ayon_core.lib import (
|
|||
source_hash,
|
||||
)
|
||||
from ayon_core.lib.file_transaction import FileTransaction
|
||||
from ayon_core.pipeline.thumbnails import get_thumbnail_path
|
||||
from ayon_core.settings import get_project_settings
|
||||
from ayon_core.pipeline import Anatomy
|
||||
from ayon_core.pipeline.version_start import get_versioning_start
|
||||
|
|
@ -88,6 +90,7 @@ class ProjectPushItem:
|
|||
new_folder_name,
|
||||
dst_version,
|
||||
item_id=None,
|
||||
use_original_name=False
|
||||
):
|
||||
if not item_id:
|
||||
item_id = uuid.uuid4().hex
|
||||
|
|
@ -102,6 +105,7 @@ class ProjectPushItem:
|
|||
self.comment = comment or ""
|
||||
self.item_id = item_id
|
||||
self._repr_value = None
|
||||
self.use_original_name = use_original_name
|
||||
|
||||
@property
|
||||
def _repr(self):
|
||||
|
|
@ -113,7 +117,8 @@ class ProjectPushItem:
|
|||
str(self.dst_folder_id),
|
||||
str(self.new_folder_name),
|
||||
str(self.dst_task_name),
|
||||
str(self.dst_version)
|
||||
str(self.dst_version),
|
||||
self.use_original_name
|
||||
])
|
||||
return self._repr_value
|
||||
|
||||
|
|
@ -132,6 +137,7 @@ class ProjectPushItem:
|
|||
"comment": self.comment,
|
||||
"new_folder_name": self.new_folder_name,
|
||||
"item_id": self.item_id,
|
||||
"use_original_name": self.use_original_name
|
||||
}
|
||||
|
||||
@classmethod
|
||||
|
|
@ -311,7 +317,7 @@ class ProjectPushRepreItem:
|
|||
if self._src_files is not None:
|
||||
return self._src_files, self._resource_files
|
||||
|
||||
repre_context = self._repre_entity["context"]
|
||||
repre_context = self.repre_entity["context"]
|
||||
if "frame" in repre_context or "udim" in repre_context:
|
||||
src_files, resource_files = self._get_source_files_with_frames()
|
||||
else:
|
||||
|
|
@ -328,7 +334,7 @@ class ProjectPushRepreItem:
|
|||
udim_placeholder = "__udim__"
|
||||
src_files = []
|
||||
resource_files = []
|
||||
template = self._repre_entity["attrib"]["template"]
|
||||
template = self.repre_entity["attrib"]["template"]
|
||||
# Remove padding from 'udim' and 'frame' formatting keys
|
||||
# - "{frame:0>4}" -> "{frame}"
|
||||
for key in ("udim", "frame"):
|
||||
|
|
@ -336,7 +342,7 @@ class ProjectPushRepreItem:
|
|||
replacement = "{{{}}}".format(key)
|
||||
template = re.sub(sub_part, replacement, template)
|
||||
|
||||
repre_context = self._repre_entity["context"]
|
||||
repre_context = self.repre_entity["context"]
|
||||
fill_repre_context = copy.deepcopy(repre_context)
|
||||
if "frame" in fill_repre_context:
|
||||
fill_repre_context["frame"] = frame_placeholder
|
||||
|
|
@ -357,7 +363,7 @@ class ProjectPushRepreItem:
|
|||
.replace(udim_placeholder, "(?P<udim>[0-9]+)")
|
||||
)
|
||||
src_basename_regex = re.compile("^{}$".format(src_basename))
|
||||
for file_info in self._repre_entity["files"]:
|
||||
for file_info in self.repre_entity["files"]:
|
||||
filepath_template = self._clean_path(file_info["path"])
|
||||
filepath = self._clean_path(
|
||||
filepath_template.format(root=self._roots)
|
||||
|
|
@ -371,7 +377,6 @@ class ProjectPushRepreItem:
|
|||
resource_files.append(ResourceFile(filepath, relative_path))
|
||||
continue
|
||||
|
||||
filepath = os.path.join(src_dirpath, basename)
|
||||
frame = None
|
||||
udim = None
|
||||
for item in src_basename_regex.finditer(basename):
|
||||
|
|
@ -389,8 +394,8 @@ class ProjectPushRepreItem:
|
|||
def _get_source_files(self):
|
||||
src_files = []
|
||||
resource_files = []
|
||||
template = self._repre_entity["attrib"]["template"]
|
||||
repre_context = self._repre_entity["context"]
|
||||
template = self.repre_entity["attrib"]["template"]
|
||||
repre_context = self.repre_entity["context"]
|
||||
fill_repre_context = copy.deepcopy(repre_context)
|
||||
fill_roots = fill_repre_context["root"]
|
||||
for root_name in tuple(fill_roots.keys()):
|
||||
|
|
@ -399,7 +404,7 @@ class ProjectPushRepreItem:
|
|||
fill_repre_context)
|
||||
repre_path = self._clean_path(repre_path)
|
||||
src_dirpath = os.path.dirname(repre_path)
|
||||
for file_info in self._repre_entity["files"]:
|
||||
for file_info in self.repre_entity["files"]:
|
||||
filepath_template = self._clean_path(file_info["path"])
|
||||
filepath = self._clean_path(
|
||||
filepath_template.format(root=self._roots))
|
||||
|
|
@ -492,8 +497,11 @@ class ProjectPushItemProcess:
|
|||
|
||||
except Exception as exc:
|
||||
_exc, _value, _tb = sys.exc_info()
|
||||
product_name = self._src_product_entity["name"]
|
||||
self._status.set_failed(
|
||||
"Unhandled error happened: {}".format(str(exc)),
|
||||
"Unhandled error happened for `{}`: {}".format(
|
||||
product_name, str(exc)
|
||||
),
|
||||
(_exc, _value, _tb)
|
||||
)
|
||||
|
||||
|
|
@ -816,6 +824,9 @@ class ProjectPushItemProcess:
|
|||
self._template_name = template_name
|
||||
|
||||
def _determine_product_name(self):
|
||||
if self._item.use_original_name:
|
||||
product_name = self._src_product_entity["name"]
|
||||
else:
|
||||
product_type = self._product_type
|
||||
task_info = self._task_info
|
||||
task_name = task_type = None
|
||||
|
|
@ -835,9 +846,9 @@ class ProjectPushItemProcess:
|
|||
)
|
||||
except TaskNotSetError:
|
||||
self._status.set_failed(
|
||||
"Target product name template requires task name. To continue"
|
||||
" you have to select target task or change settings"
|
||||
" <b>ayon+settings://core/tools/creator/product_name_profiles"
|
||||
"Target product name template requires task name. To "
|
||||
"continue you have to select target task or change settings " # noqa: E501
|
||||
" <b>ayon+settings://core/tools/creator/product_name_profiles" # noqa: E501
|
||||
f"?project={self._item.dst_project_name}</b>."
|
||||
)
|
||||
raise PushToProjectError(self._status.fail_reason)
|
||||
|
|
@ -917,14 +928,19 @@ class ProjectPushItemProcess:
|
|||
task_name=self._task_info["name"],
|
||||
task_type=self._task_info["taskType"],
|
||||
product_type=product_type,
|
||||
product_name=product_entity["name"]
|
||||
product_name=product_entity["name"],
|
||||
)
|
||||
|
||||
existing_version_entity = ayon_api.get_version_by_name(
|
||||
project_name, version, product_id
|
||||
)
|
||||
thumbnail_id = self._copy_version_thumbnail()
|
||||
|
||||
# Update existing version
|
||||
if existing_version_entity:
|
||||
updata_data = {"attrib": dst_attrib}
|
||||
if thumbnail_id:
|
||||
updata_data["thumbnailId"] = thumbnail_id
|
||||
self._operations.update_entity(
|
||||
project_name,
|
||||
"version",
|
||||
|
|
@ -939,6 +955,7 @@ class ProjectPushItemProcess:
|
|||
version,
|
||||
product_id,
|
||||
attribs=dst_attrib,
|
||||
thumbnail_id=thumbnail_id,
|
||||
)
|
||||
self._operations.create_entity(
|
||||
project_name, "version", version_entity
|
||||
|
|
@ -1005,10 +1022,18 @@ class ProjectPushItemProcess:
|
|||
self, anatomy, template_name, formatting_data, file_template
|
||||
):
|
||||
processed_repre_items = []
|
||||
repre_context = None
|
||||
for repre_item in self._src_repre_items:
|
||||
repre_entity = repre_item.repre_entity
|
||||
repre_name = repre_entity["name"]
|
||||
repre_format_data = copy.deepcopy(formatting_data)
|
||||
|
||||
if not repre_context:
|
||||
repre_context = self._update_repre_context(
|
||||
copy.deepcopy(repre_entity),
|
||||
formatting_data
|
||||
)
|
||||
|
||||
repre_format_data["representation"] = repre_name
|
||||
for src_file in repre_item.src_files:
|
||||
ext = os.path.splitext(src_file.path)[-1]
|
||||
|
|
@ -1024,7 +1049,6 @@ class ProjectPushItemProcess:
|
|||
"publish", template_name, "directory"
|
||||
)
|
||||
folder_path = template_obj.format_strict(formatting_data)
|
||||
repre_context = folder_path.used_values
|
||||
folder_path_rootless = folder_path.rootless
|
||||
repre_filepaths = []
|
||||
published_path = None
|
||||
|
|
@ -1047,7 +1071,6 @@ class ProjectPushItemProcess:
|
|||
)
|
||||
if published_path is None or frame == repre_item.frame:
|
||||
published_path = dst_filepath
|
||||
repre_context.update(filename.used_values)
|
||||
|
||||
repre_filepaths.append((dst_filepath, dst_rootless_path))
|
||||
self._file_transaction.add(src_file.path, dst_filepath)
|
||||
|
|
@ -1134,7 +1157,7 @@ class ProjectPushItemProcess:
|
|||
self._item.dst_project_name,
|
||||
"representation",
|
||||
entity_id,
|
||||
changes
|
||||
changes,
|
||||
)
|
||||
|
||||
existing_repre_names = set(existing_repres_by_low_name.keys())
|
||||
|
|
@ -1147,6 +1170,45 @@ class ProjectPushItemProcess:
|
|||
{"active": False}
|
||||
)
|
||||
|
||||
def _copy_version_thumbnail(self) -> Optional[str]:
|
||||
thumbnail_id = self._src_version_entity["thumbnailId"]
|
||||
if not thumbnail_id:
|
||||
return None
|
||||
path = get_thumbnail_path(
|
||||
self._item.src_project_name,
|
||||
"version",
|
||||
self._src_version_entity["id"],
|
||||
thumbnail_id
|
||||
)
|
||||
if not path:
|
||||
return None
|
||||
return ayon_api.create_thumbnail(
|
||||
self._item.dst_project_name,
|
||||
path
|
||||
)
|
||||
|
||||
def _update_repre_context(self, repre_entity, formatting_data):
|
||||
"""Replace old context value with new ones.
|
||||
|
||||
Folder might change, project definitely changes etc.
|
||||
"""
|
||||
repre_context = repre_entity["context"]
|
||||
for context_key, context_value in repre_context.items():
|
||||
if context_value and isinstance(context_value, dict):
|
||||
for context_sub_key in context_value.keys():
|
||||
value_to_update = formatting_data.get(context_key, {}).get(
|
||||
context_sub_key)
|
||||
if value_to_update:
|
||||
repre_context[context_key][
|
||||
context_sub_key] = value_to_update
|
||||
else:
|
||||
value_to_update = formatting_data.get(context_key)
|
||||
if value_to_update:
|
||||
repre_context[context_key] = value_to_update
|
||||
if "task" not in formatting_data:
|
||||
repre_context.pop("task")
|
||||
return repre_context
|
||||
|
||||
|
||||
class IntegrateModel:
|
||||
def __init__(self, controller):
|
||||
|
|
@ -1170,6 +1232,7 @@ class IntegrateModel:
|
|||
comment,
|
||||
new_folder_name,
|
||||
dst_version,
|
||||
use_original_name
|
||||
):
|
||||
"""Create new item for integration.
|
||||
|
||||
|
|
@ -1183,6 +1246,7 @@ class IntegrateModel:
|
|||
comment (Union[str, None]): Comment.
|
||||
new_folder_name (Union[str, None]): New folder name.
|
||||
dst_version (int): Destination version number.
|
||||
use_original_name (bool): If original product names should be used
|
||||
|
||||
Returns:
|
||||
str: Item id. The id can be used to trigger integration or get
|
||||
|
|
@ -1198,7 +1262,8 @@ class IntegrateModel:
|
|||
variant,
|
||||
comment=comment,
|
||||
new_folder_name=new_folder_name,
|
||||
dst_version=dst_version
|
||||
dst_version=dst_version,
|
||||
use_original_name=use_original_name
|
||||
)
|
||||
process_item = ProjectPushItemProcess(self, item)
|
||||
self._process_items[item.item_id] = process_item
|
||||
|
|
@ -1216,17 +1281,6 @@ class IntegrateModel:
|
|||
return
|
||||
item.integrate()
|
||||
|
||||
def get_item_status(self, item_id):
|
||||
"""Status of an item.
|
||||
|
||||
Args:
|
||||
item_id (str): Item id for which status should be returned.
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: Status data.
|
||||
"""
|
||||
|
||||
item = self._process_items.get(item_id)
|
||||
if item is not None:
|
||||
return item.get_status_data()
|
||||
return None
|
||||
def get_items(self) -> Dict[str, ProjectPushItemProcess]:
|
||||
"""Returns dict of all ProjectPushItemProcess items """
|
||||
return self._process_items
|
||||
|
|
|
|||
|
|
@ -85,6 +85,13 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
|
|||
|
||||
header_widget = QtWidgets.QWidget(main_context_widget)
|
||||
|
||||
library_only_label = QtWidgets.QLabel(
|
||||
"Show only libraries",
|
||||
header_widget
|
||||
)
|
||||
library_only_checkbox = NiceCheckbox(
|
||||
True, parent=header_widget)
|
||||
|
||||
header_label = QtWidgets.QLabel(
|
||||
controller.get_source_label(),
|
||||
header_widget
|
||||
|
|
@ -92,7 +99,9 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
|
|||
|
||||
header_layout = QtWidgets.QHBoxLayout(header_widget)
|
||||
header_layout.setContentsMargins(0, 0, 0, 0)
|
||||
header_layout.addWidget(header_label)
|
||||
header_layout.addWidget(header_label, 1)
|
||||
header_layout.addWidget(library_only_label, 0)
|
||||
header_layout.addWidget(library_only_checkbox, 0)
|
||||
|
||||
main_splitter = QtWidgets.QSplitter(
|
||||
QtCore.Qt.Horizontal, main_context_widget
|
||||
|
|
@ -124,6 +133,7 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
|
|||
inputs_widget = QtWidgets.QWidget(main_splitter)
|
||||
|
||||
new_folder_checkbox = NiceCheckbox(True, parent=inputs_widget)
|
||||
original_names_checkbox = NiceCheckbox(False, parent=inputs_widget)
|
||||
|
||||
folder_name_input = PlaceholderLineEdit(inputs_widget)
|
||||
folder_name_input.setPlaceholderText("< Name of new folder >")
|
||||
|
|
@ -142,6 +152,8 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
|
|||
inputs_layout.addRow("Create new folder", new_folder_checkbox)
|
||||
inputs_layout.addRow("New folder name", folder_name_input)
|
||||
inputs_layout.addRow("Variant", variant_input)
|
||||
inputs_layout.addRow(
|
||||
"Use original product names", original_names_checkbox)
|
||||
inputs_layout.addRow("Comment", comment_input)
|
||||
|
||||
main_splitter.addWidget(context_widget)
|
||||
|
|
@ -196,6 +208,10 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
|
|||
show_detail_btn.setToolTip(
|
||||
"Show error detail dialog to copy full error."
|
||||
)
|
||||
original_names_checkbox.setToolTip(
|
||||
"Required for multi copy, doesn't allow changes "
|
||||
"variant values."
|
||||
)
|
||||
|
||||
overlay_close_btn = QtWidgets.QPushButton(
|
||||
"Close", overlay_btns_widget
|
||||
|
|
@ -240,6 +256,9 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
|
|||
folder_name_input.textChanged.connect(self._on_new_folder_change)
|
||||
variant_input.textChanged.connect(self._on_variant_change)
|
||||
comment_input.textChanged.connect(self._on_comment_change)
|
||||
library_only_checkbox.stateChanged.connect(self._on_library_only_change)
|
||||
original_names_checkbox.stateChanged.connect(
|
||||
self._on_original_names_change)
|
||||
|
||||
publish_btn.clicked.connect(self._on_select_click)
|
||||
cancel_btn.clicked.connect(self._on_close_click)
|
||||
|
|
@ -288,6 +307,7 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
|
|||
self._new_folder_checkbox = new_folder_checkbox
|
||||
self._folder_name_input = folder_name_input
|
||||
self._comment_input = comment_input
|
||||
self._use_original_names_checkbox = original_names_checkbox
|
||||
|
||||
self._publish_btn = publish_btn
|
||||
|
||||
|
|
@ -316,7 +336,6 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
|
|||
self._main_thread_timer = main_thread_timer
|
||||
self._main_thread_timer_can_stop = True
|
||||
self._last_submit_message = None
|
||||
self._process_item_id = None
|
||||
|
||||
self._variant_is_valid = None
|
||||
self._folder_is_valid = None
|
||||
|
|
@ -327,17 +346,17 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
|
|||
overlay_try_btn.setVisible(False)
|
||||
|
||||
# Support of public api function of controller
|
||||
def set_source(self, project_name, version_id):
|
||||
def set_source(self, project_name, version_ids):
|
||||
"""Set source project and version.
|
||||
|
||||
Call the method on controller.
|
||||
|
||||
Args:
|
||||
project_name (Union[str, None]): Name of project.
|
||||
version_id (Union[str, None]): Version id.
|
||||
version_ids (Union[str, None]): comma separated Version ids.
|
||||
"""
|
||||
|
||||
self._controller.set_source(project_name, version_id)
|
||||
self._controller.set_source(project_name, version_ids)
|
||||
|
||||
def showEvent(self, event):
|
||||
super(PushToContextSelectWindow, self).showEvent(event)
|
||||
|
|
@ -352,10 +371,12 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
|
|||
self._folder_name_input.setText(new_folder_name or "")
|
||||
self._variant_input.setText(variant or "")
|
||||
self._invalidate_variant(user_values["is_variant_valid"])
|
||||
self._invalidate_use_original_names(
|
||||
self._use_original_names_checkbox.isChecked())
|
||||
self._invalidate_new_folder_name(
|
||||
new_folder_name, user_values["is_new_folder_name_valid"]
|
||||
)
|
||||
|
||||
self._controller._invalidate()
|
||||
self._projects_combobox.refresh()
|
||||
|
||||
def _on_first_show(self):
|
||||
|
|
@ -394,6 +415,15 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
|
|||
self._comment_input_text = text
|
||||
self._user_input_changed_timer.start()
|
||||
|
||||
def _on_library_only_change(self, state: int) -> None:
|
||||
"""Change toggle state, reset filter, recalculate dropdown"""
|
||||
state = bool(state)
|
||||
self._projects_combobox.set_standard_filter_enabled(state)
|
||||
|
||||
def _on_original_names_change(self, state: int) -> None:
|
||||
use_original_name = bool(state)
|
||||
self._invalidate_use_original_names(use_original_name)
|
||||
|
||||
def _on_user_input_timer(self):
|
||||
folder_name_enabled = self._new_folder_name_enabled
|
||||
folder_name = self._new_folder_name_input_text
|
||||
|
|
@ -456,17 +486,27 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
|
|||
state = ""
|
||||
if folder_name is not None:
|
||||
state = "valid" if is_valid else "invalid"
|
||||
set_style_property(
|
||||
self._folder_name_input, "state", state
|
||||
)
|
||||
set_style_property(self._folder_name_input, "state", state)
|
||||
|
||||
def _invalidate_variant(self, is_valid):
|
||||
if self._variant_is_valid is is_valid:
|
||||
return
|
||||
self._variant_is_valid = is_valid
|
||||
state = "valid" if is_valid else "invalid"
|
||||
set_style_property(self._variant_input, "state", state)
|
||||
|
||||
def _invalidate_use_original_names(self, use_original_names):
|
||||
"""Checks if original names must be used.
|
||||
|
||||
Invalidates Variant if necessary
|
||||
"""
|
||||
if self._controller.original_names_required():
|
||||
use_original_names = True
|
||||
|
||||
self._variant_input.setEnabled(not use_original_names)
|
||||
self._invalidate_variant(not use_original_names)
|
||||
|
||||
self._controller._use_original_name = use_original_names
|
||||
self._use_original_names_checkbox.setChecked(use_original_names)
|
||||
|
||||
def _on_submission_change(self, event):
|
||||
self._publish_btn.setEnabled(event["enabled"])
|
||||
|
||||
|
|
@ -495,31 +535,43 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
|
|||
self._overlay_label.setText(self._last_submit_message)
|
||||
self._last_submit_message = None
|
||||
|
||||
process_status = self._controller.get_process_item_status(
|
||||
self._process_item_id
|
||||
)
|
||||
push_failed = process_status["failed"]
|
||||
fail_traceback = process_status["full_traceback"]
|
||||
failed_pushes = []
|
||||
fail_tracebacks = []
|
||||
for process_item in self._controller.get_process_items().values():
|
||||
process_status = process_item.get_status_data()
|
||||
if process_status["failed"]:
|
||||
failed_pushes.append(process_status)
|
||||
# push_failed = process_status["failed"]
|
||||
# fail_traceback = process_status["full_traceback"]
|
||||
if self._main_thread_timer_can_stop:
|
||||
self._main_thread_timer.stop()
|
||||
self._overlay_close_btn.setVisible(True)
|
||||
if push_failed:
|
||||
if failed_pushes:
|
||||
self._overlay_try_btn.setVisible(True)
|
||||
if fail_traceback:
|
||||
fail_tracebacks = [
|
||||
process_status["full_traceback"]
|
||||
for process_status in failed_pushes
|
||||
if process_status["full_traceback"]
|
||||
]
|
||||
if fail_tracebacks:
|
||||
self._show_detail_btn.setVisible(True)
|
||||
|
||||
if push_failed:
|
||||
reason = process_status["fail_reason"]
|
||||
if fail_traceback:
|
||||
if failed_pushes:
|
||||
reasons = [
|
||||
process_status["fail_reason"]
|
||||
for process_status in failed_pushes
|
||||
]
|
||||
if fail_tracebacks:
|
||||
reason = "\n".join(reasons)
|
||||
message = (
|
||||
"Unhandled error happened."
|
||||
" Check error detail for more information."
|
||||
)
|
||||
self._error_detail_dialog.set_detail(
|
||||
reason, fail_traceback
|
||||
reason, "\n".join(fail_tracebacks)
|
||||
)
|
||||
else:
|
||||
message = f"Push Failed:\n{reason}"
|
||||
message = f"Push Failed:\n{reasons}"
|
||||
|
||||
self._overlay_label.setText(message)
|
||||
set_style_property(self._overlay_close_btn, "state", "error")
|
||||
|
|
@ -534,7 +586,7 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
|
|||
self._main_thread_timer_can_stop = False
|
||||
self._main_thread_timer.start()
|
||||
self._main_layout.setCurrentWidget(self._overlay_widget)
|
||||
self._overlay_label.setText("Submittion started")
|
||||
self._overlay_label.setText("Submission started")
|
||||
|
||||
def _on_controller_submit_end(self):
|
||||
self._main_thread_timer_can_stop = True
|
||||
|
|
|
|||
|
|
@ -1,12 +1,18 @@
|
|||
from typing import Optional
|
||||
|
||||
import ayon_api
|
||||
|
||||
from ayon_core.lib.events import QueuedEventSystem
|
||||
from ayon_core.host import HostBase
|
||||
from ayon_core.host import ILoadHost
|
||||
from ayon_core.pipeline import (
|
||||
registered_host,
|
||||
get_current_context,
|
||||
)
|
||||
from ayon_core.tools.common_models import HierarchyModel, ProjectsModel
|
||||
from ayon_core.tools.common_models import (
|
||||
HierarchyModel,
|
||||
ProjectsModel,
|
||||
ProductTypeIconMapping,
|
||||
)
|
||||
|
||||
from .models import SiteSyncModel, ContainersModel
|
||||
|
||||
|
|
@ -35,7 +41,7 @@ class SceneInventoryController:
|
|||
self._projects_model = ProjectsModel(self)
|
||||
self._event_system = self._create_event_system()
|
||||
|
||||
def get_host(self) -> HostBase:
|
||||
def get_host(self) -> ILoadHost:
|
||||
return self._host
|
||||
|
||||
def emit_event(self, topic, data=None, source=None):
|
||||
|
|
@ -93,6 +99,13 @@ class SceneInventoryController:
|
|||
project_name, None
|
||||
)
|
||||
|
||||
def get_product_type_icons_mapping(
|
||||
self, project_name: Optional[str]
|
||||
) -> ProductTypeIconMapping:
|
||||
return self._projects_model.get_product_type_icons_mapping(
|
||||
project_name
|
||||
)
|
||||
|
||||
# Containers methods
|
||||
def get_containers(self):
|
||||
return self._containers_model.get_containers()
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
from qtpy import QtWidgets, QtCore, QtGui
|
||||
|
||||
from .model import VERSION_LABEL_ROLE
|
||||
from ayon_core.tools.utils import get_qt_icon
|
||||
|
||||
from .model import VERSION_LABEL_ROLE, CONTAINER_VERSION_LOCKED_ROLE
|
||||
|
||||
|
||||
class VersionDelegate(QtWidgets.QStyledItemDelegate):
|
||||
"""A delegate that display version integer formatted as version string."""
|
||||
_locked_icon = None
|
||||
|
||||
def paint(self, painter, option, index):
|
||||
fg_color = index.data(QtCore.Qt.ForegroundRole)
|
||||
if fg_color:
|
||||
|
|
@ -45,10 +49,35 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate):
|
|||
QtWidgets.QStyle.PM_FocusFrameHMargin, option, option.widget
|
||||
) + 1
|
||||
|
||||
text_rect_f = text_rect.adjusted(
|
||||
text_margin, 0, - text_margin, 0
|
||||
)
|
||||
|
||||
painter.drawText(
|
||||
text_rect.adjusted(text_margin, 0, - text_margin, 0),
|
||||
text_rect_f,
|
||||
option.displayAlignment,
|
||||
text
|
||||
)
|
||||
if index.data(CONTAINER_VERSION_LOCKED_ROLE) is True:
|
||||
icon = self._get_locked_icon()
|
||||
size = max(text_rect_f.height() // 2, 16)
|
||||
margin = (text_rect_f.height() - size) // 2
|
||||
|
||||
icon_rect = QtCore.QRect(
|
||||
text_rect_f.right() - size,
|
||||
text_rect_f.top() + margin,
|
||||
size,
|
||||
size
|
||||
)
|
||||
icon.paint(painter, icon_rect)
|
||||
|
||||
painter.restore()
|
||||
|
||||
def _get_locked_icon(cls):
|
||||
if cls._locked_icon is None:
|
||||
cls._locked_icon = get_qt_icon({
|
||||
"type": "material-symbols",
|
||||
"name": "lock",
|
||||
"color": "white",
|
||||
})
|
||||
return cls._locked_icon
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ REMOTE_SITE_ICON_ROLE = QtCore.Qt.UserRole + 23
|
|||
# containers inbetween refresh.
|
||||
ITEM_UNIQUE_NAME_ROLE = QtCore.Qt.UserRole + 24
|
||||
PROJECT_NAME_ROLE = QtCore.Qt.UserRole + 25
|
||||
CONTAINER_VERSION_LOCKED_ROLE = QtCore.Qt.UserRole + 26
|
||||
|
||||
|
||||
class InventoryModel(QtGui.QStandardItemModel):
|
||||
|
|
@ -214,9 +215,6 @@ class InventoryModel(QtGui.QStandardItemModel):
|
|||
group_icon = qtawesome.icon(
|
||||
"fa.object-group", color=self._default_icon_color
|
||||
)
|
||||
product_type_icon = qtawesome.icon(
|
||||
"fa.folder", color="#0091B2"
|
||||
)
|
||||
group_item_font = QtGui.QFont()
|
||||
group_item_font.setBold(True)
|
||||
|
||||
|
|
@ -294,6 +292,10 @@ class InventoryModel(QtGui.QStandardItemModel):
|
|||
item.setData(container_item.object_name, OBJECT_NAME_ROLE)
|
||||
item.setData(True, IS_CONTAINER_ITEM_ROLE)
|
||||
item.setData(unique_name, ITEM_UNIQUE_NAME_ROLE)
|
||||
item.setData(
|
||||
container_item.version_locked,
|
||||
CONTAINER_VERSION_LOCKED_ROLE
|
||||
)
|
||||
container_model_items.append(item)
|
||||
|
||||
progress = progress_by_id[repre_id]
|
||||
|
|
@ -303,7 +305,7 @@ class InventoryModel(QtGui.QStandardItemModel):
|
|||
remote_site_progress = "{}%".format(
|
||||
max(progress["remote_site"], 0) * 100
|
||||
)
|
||||
|
||||
product_type_icon = get_qt_icon(repre_info.product_type_icon)
|
||||
group_item = QtGui.QStandardItem()
|
||||
group_item.setColumnCount(root_item.columnCount())
|
||||
group_item.setData(group_name, QtCore.Qt.DisplayRole)
|
||||
|
|
|
|||
|
|
@ -95,7 +95,8 @@ class ContainerItem:
|
|||
namespace,
|
||||
object_name,
|
||||
item_id,
|
||||
project_name
|
||||
project_name,
|
||||
version_locked,
|
||||
):
|
||||
self.representation_id = representation_id
|
||||
self.loader_name = loader_name
|
||||
|
|
@ -103,6 +104,7 @@ class ContainerItem:
|
|||
self.namespace = namespace
|
||||
self.item_id = item_id
|
||||
self.project_name = project_name
|
||||
self.version_locked = version_locked
|
||||
|
||||
@classmethod
|
||||
def from_container_data(cls, current_project_name, container):
|
||||
|
|
@ -114,7 +116,8 @@ class ContainerItem:
|
|||
item_id=uuid.uuid4().hex,
|
||||
project_name=container.get(
|
||||
"project_name", current_project_name
|
||||
)
|
||||
),
|
||||
version_locked=container.get("version_locked", False),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -126,6 +129,7 @@ class RepresentationInfo:
|
|||
product_id,
|
||||
product_name,
|
||||
product_type,
|
||||
product_type_icon,
|
||||
product_group,
|
||||
version_id,
|
||||
representation_name,
|
||||
|
|
@ -135,6 +139,7 @@ class RepresentationInfo:
|
|||
self.product_id = product_id
|
||||
self.product_name = product_name
|
||||
self.product_type = product_type
|
||||
self.product_type_icon = product_type_icon
|
||||
self.product_group = product_group
|
||||
self.version_id = version_id
|
||||
self.representation_name = representation_name
|
||||
|
|
@ -153,7 +158,17 @@ class RepresentationInfo:
|
|||
|
||||
@classmethod
|
||||
def new_invalid(cls):
|
||||
return cls(None, None, None, None, None, None, None, None)
|
||||
return cls(
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
class VersionItem:
|
||||
|
|
@ -229,6 +244,9 @@ class ContainersModel:
|
|||
def get_representation_info_items(self, project_name, representation_ids):
|
||||
output = {}
|
||||
missing_repre_ids = set()
|
||||
icons_mapping = self._controller.get_product_type_icons_mapping(
|
||||
project_name
|
||||
)
|
||||
for repre_id in representation_ids:
|
||||
try:
|
||||
uuid.UUID(repre_id)
|
||||
|
|
@ -253,6 +271,7 @@ class ContainersModel:
|
|||
"product_id": None,
|
||||
"product_name": None,
|
||||
"product_type": None,
|
||||
"product_type_icon": None,
|
||||
"product_group": None,
|
||||
"version_id": None,
|
||||
"representation_name": None,
|
||||
|
|
@ -265,10 +284,17 @@ class ContainersModel:
|
|||
kwargs["folder_id"] = folder["id"]
|
||||
kwargs["folder_path"] = folder["path"]
|
||||
if product:
|
||||
product_type = product["productType"]
|
||||
product_base_type = product.get("productBaseType")
|
||||
icon = icons_mapping.get_icon(
|
||||
product_base_type=product_base_type,
|
||||
product_type=product_type,
|
||||
)
|
||||
group = product["attrib"]["productGroup"]
|
||||
kwargs["product_id"] = product["id"]
|
||||
kwargs["product_name"] = product["name"]
|
||||
kwargs["product_type"] = product["productType"]
|
||||
kwargs["product_type_icon"] = icon
|
||||
kwargs["product_group"] = group
|
||||
if version:
|
||||
kwargs["version_id"] = version["id"]
|
||||
|
|
|
|||
|
|
@ -127,6 +127,7 @@ class SelectVersionComboBox(QtWidgets.QComboBox):
|
|||
status_text_rect.setLeft(icon_rect.right() + 2)
|
||||
|
||||
if status_text_rect.width() <= 0:
|
||||
painter.restore()
|
||||
return
|
||||
|
||||
if status_text_rect.width() < metrics.width(status_name):
|
||||
|
|
@ -144,6 +145,7 @@ class SelectVersionComboBox(QtWidgets.QComboBox):
|
|||
QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter,
|
||||
status_name
|
||||
)
|
||||
painter.restore()
|
||||
|
||||
def set_current_index(self, index):
|
||||
model = self._combo_view.model()
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ from ayon_core.tools.utils.lib import (
|
|||
format_version,
|
||||
preserve_expanded_rows,
|
||||
preserve_selection,
|
||||
get_qt_icon,
|
||||
)
|
||||
from ayon_core.tools.utils.delegates import StatusDelegate
|
||||
|
||||
|
|
@ -46,7 +47,7 @@ class SceneInventoryView(QtWidgets.QTreeView):
|
|||
hierarchy_view_changed = QtCore.Signal(bool)
|
||||
|
||||
def __init__(self, controller, parent):
|
||||
super(SceneInventoryView, self).__init__(parent=parent)
|
||||
super().__init__(parent=parent)
|
||||
|
||||
# view settings
|
||||
self.setIndentation(12)
|
||||
|
|
@ -524,7 +525,14 @@ class SceneInventoryView(QtWidgets.QTreeView):
|
|||
submenu = QtWidgets.QMenu("Actions", self)
|
||||
for action in custom_actions:
|
||||
color = action.color or DEFAULT_COLOR
|
||||
icon = qtawesome.icon("fa.%s" % action.icon, color=color)
|
||||
icon_def = action.icon
|
||||
if not isinstance(action.icon, dict):
|
||||
icon_def = {
|
||||
"type": "awesome-font",
|
||||
"name": icon_def,
|
||||
"color": color,
|
||||
}
|
||||
icon = get_qt_icon(icon_def)
|
||||
action_item = QtWidgets.QAction(icon, action.label, submenu)
|
||||
action_item.triggered.connect(
|
||||
partial(
|
||||
|
|
@ -622,7 +630,7 @@ class SceneInventoryView(QtWidgets.QTreeView):
|
|||
if isinstance(result, (list, set)):
|
||||
self._select_items_by_action(result)
|
||||
|
||||
if isinstance(result, dict):
|
||||
elif isinstance(result, dict):
|
||||
self._select_items_by_action(
|
||||
result["objectNames"], result["options"]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,19 +0,0 @@
|
|||
Subset manager
|
||||
--------------
|
||||
|
||||
Simple UI showing list of created subset that will be published via Pyblish.
|
||||
Useful for applications (Photoshop, AfterEffects, TVPaint, Harmony) which are
|
||||
storing metadata about instance hidden from user.
|
||||
|
||||
This UI allows listing all created subset and removal of them if needed (
|
||||
in case use doesn't want to publish anymore, its using workfile as a starting
|
||||
file for different task and instances should be completely different etc.
|
||||
)
|
||||
|
||||
Host is expected to implemented:
|
||||
- `list_instances` - returning list of dictionaries (instances), must contain
|
||||
unique uuid field
|
||||
example:
|
||||
```[{"uuid":"15","active":true,"subset":"imageBG","family":"image","id":"ayon.create.instance","asset":"Town"}]```
|
||||
- `remove_instance(instance)` - removes instance from file's metadata
|
||||
instance is a dictionary, with uuid field
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue