Merge branch 'ynput:develop' into select_instances_in_host_using_double_click_on_publish_instances

This commit is contained in:
Aleks Berland 2025-10-29 11:15:58 -04:00 committed by GitHub
commit 39bcf354f8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
75 changed files with 2620 additions and 944 deletions

View file

@ -35,6 +35,13 @@ body:
label: Version
description: What version are you running? Look to AYON Tray
options:
- 1.6.6
- 1.6.5
- 1.6.4
- 1.6.3
- 1.6.2
- 1.6.1
- 1.6.0
- 1.5.3
- 1.5.2
- 1.5.1

18
.github/workflows/deploy_mkdocs.yml vendored Normal file
View 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 }}

View file

@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
"""Base class for AYON addons."""
import copy
from __future__ import annotations
import os
import sys
import time
@ -11,10 +12,12 @@ import collections
import warnings
from uuid import uuid4
from abc import ABC, abstractmethod
from typing import Optional
from urllib.parse import urlencode
from types import ModuleType
import typing
from typing import Optional, Any, Union
import ayon_api
from semver import VersionInfo
from ayon_core import AYON_CORE_ROOT
from ayon_core.lib import (
@ -30,6 +33,11 @@ from .interfaces import (
IHostAddon,
)
if typing.TYPE_CHECKING:
import click
from ayon_core.host import HostBase
# Files that will be always ignored on addons import
IGNORED_FILENAMES = {
"__pycache__",
@ -39,33 +47,6 @@ IGNORED_DEFAULT_FILENAMES = {
"__init__.py",
}
# When addon was moved from ayon-core codebase
# - this is used to log the missing addon
MOVED_ADDON_MILESTONE_VERSIONS = {
"aftereffects": VersionInfo(0, 2, 0),
"applications": VersionInfo(0, 2, 0),
"blender": VersionInfo(0, 2, 0),
"celaction": VersionInfo(0, 2, 0),
"clockify": VersionInfo(0, 2, 0),
"deadline": VersionInfo(0, 2, 0),
"flame": VersionInfo(0, 2, 0),
"fusion": VersionInfo(0, 2, 0),
"harmony": VersionInfo(0, 2, 0),
"hiero": VersionInfo(0, 2, 0),
"max": VersionInfo(0, 2, 0),
"photoshop": VersionInfo(0, 2, 0),
"timers_manager": VersionInfo(0, 2, 0),
"traypublisher": VersionInfo(0, 2, 0),
"tvpaint": VersionInfo(0, 2, 0),
"maya": VersionInfo(0, 2, 0),
"nuke": VersionInfo(0, 2, 0),
"resolve": VersionInfo(0, 2, 0),
"royalrender": VersionInfo(0, 2, 0),
"substancepainter": VersionInfo(0, 2, 0),
"houdini": VersionInfo(0, 3, 0),
"unreal": VersionInfo(0, 2, 0),
}
class ProcessPreparationError(Exception):
"""Exception that can be used when process preparation failed.
@ -128,7 +109,7 @@ class _LoadCache:
addon_modules = []
def load_addons(force=False):
def load_addons(force: bool = False) -> None:
"""Load AYON addons as python modules.
Modules does not load only classes (like in Interfaces) because there must
@ -155,106 +136,76 @@ def load_addons(force=False):
time.sleep(0.1)
def _get_ayon_bundle_data():
def _get_ayon_bundle_data() -> tuple[
dict[str, Any], Optional[dict[str, Any]]
]:
studio_bundle_name = os.environ.get("AYON_STUDIO_BUNDLE_NAME")
project_bundle_name = os.getenv("AYON_BUNDLE_NAME")
bundles = ayon_api.get_bundles()["bundles"]
project_bundle = next(
studio_bundle = next(
(
bundle
for bundle in bundles
if bundle["name"] == project_bundle_name
if bundle["name"] == studio_bundle_name
),
None
)
studio_bundle = None
if studio_bundle_name and project_bundle_name != studio_bundle_name:
studio_bundle = next(
if studio_bundle is None:
raise RuntimeError(f"Failed to find bundle '{studio_bundle_name}'.")
project_bundle = None
if project_bundle_name and project_bundle_name != studio_bundle_name:
project_bundle = next(
(
bundle
for bundle in bundles
if bundle["name"] == studio_bundle_name
if bundle["name"] == project_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
if project_bundle is None:
raise RuntimeError(
f"Failed to find project bundle '{project_bundle_name}'."
)
return studio_bundle, project_bundle
def _get_ayon_addons_information(bundle_info):
def _get_ayon_addons_information(
studio_bundle: dict[str, Any],
project_bundle: Optional[dict[str, Any]],
) -> dict[str, str]:
"""Receive information about addons to use from server.
Todos:
Actually ask server for the information.
Allow project name as optional argument to be able to query information
about used addons for specific project.
Wrap versions into an object.
Returns:
List[Dict[str, Any]]: List of addon information to use.
list[dict[str, Any]]: List of addon information to use.
"""
key_values = {
"summary": "true",
"bundle_name": studio_bundle["name"],
}
if project_bundle:
key_values["project_bundle_name"] = project_bundle["name"]
output = []
bundle_addons = bundle_info["addons"]
addons = ayon_api.get_addons_info()["addons"]
for addon in addons:
name = addon["name"]
versions = addon.get("versions")
addon_version = bundle_addons.get(name)
if addon_version is None or not versions:
continue
version = versions.get(addon_version)
if version:
version = copy.deepcopy(version)
version["name"] = name
version["version"] = addon_version
output.append(version)
return output
query = urlencode(key_values)
response = ayon_api.get(f"settings?{query}")
return {
addon["name"]: addon["version"]
for addon in response.data["addons"]
}
def _handle_moved_addons(addon_name, milestone_version, log):
"""Log message that addon version is not compatible with current core.
The function can return path to addon client code, but that can happen
only if ayon-core is used from code (for development), but still
logs a warning.
Args:
addon_name (str): Addon name.
milestone_version (str): Milestone addon version.
log (logging.Logger): Logger object.
Returns:
Union[str, None]: Addon dir or None.
"""
# Handle addons which were moved out of ayon-core
# - Try to fix it by loading it directly from server addons dir in
# ayon-core repository. But that will work only if ayon-core is
# used from code.
addon_dir = os.path.join(
os.path.dirname(os.path.dirname(AYON_CORE_ROOT)),
"server_addon",
addon_name,
"client",
)
if not os.path.exists(addon_dir):
log.error(
f"Addon '{addon_name}' is not available. Please update "
f"{addon_name} addon to '{milestone_version}' or higher."
)
return None
log.warning((
"Please update '{}' addon to '{}' or higher."
" Using client code from ayon-core repository."
).format(addon_name, milestone_version))
return addon_dir
def _load_ayon_addons(log):
def _load_ayon_addons(log: logging.Logger) -> list[ModuleType]:
"""Load AYON addons based on information from server.
This function should not trigger downloading of any addons but only use
@ -264,10 +215,13 @@ def _load_ayon_addons(log):
Args:
log (logging.Logger): Logger object.
Returns:
list[ModuleType]: Loaded addon modules.
"""
all_addon_modules = []
bundle_info = _get_ayon_bundle_data()
addons_info = _get_ayon_addons_information(bundle_info)
studio_bundle, project_bundle = _get_ayon_bundle_data()
addons_info = _get_ayon_addons_information(studio_bundle, project_bundle)
if not addons_info:
return all_addon_modules
@ -279,18 +233,16 @@ def _load_ayon_addons(log):
dev_addons_info = {}
if dev_mode_enabled:
# Get dev addons info only when dev mode is enabled
dev_addons_info = bundle_info.get("addonDevelopment", dev_addons_info)
dev_addons_info = studio_bundle.get(
"addonDevelopment", dev_addons_info
)
addons_dir_exists = os.path.exists(addons_dir)
if not addons_dir_exists:
log.warning("Addons directory does not exists. Path \"{}\"".format(
addons_dir
))
for addon_info in addons_info:
addon_name = addon_info["name"]
addon_version = addon_info["version"]
log.warning(
f"Addons directory does not exists. Path \"{addons_dir}\"")
for addon_name, addon_version in addons_info.items():
# core addon does not have any addon object
if addon_name == "core":
continue
@ -299,32 +251,28 @@ def _load_ayon_addons(log):
use_dev_path = dev_addon_info.get("enabled", False)
addon_dir = None
milestone_version = MOVED_ADDON_MILESTONE_VERSIONS.get(addon_name)
if use_dev_path:
addon_dir = dev_addon_info["path"]
if not addon_dir or not os.path.exists(addon_dir):
log.warning((
"Dev addon {} {} path does not exists. Path \"{}\""
).format(addon_name, addon_version, addon_dir))
continue
if addon_dir:
addon_dir = os.path.expandvars(
addon_dir.format_map(os.environ)
)
elif (
milestone_version is not None
and VersionInfo.parse(addon_version) < milestone_version
):
addon_dir = _handle_moved_addons(
addon_name, milestone_version, log
)
if not addon_dir:
if not addon_dir or not os.path.exists(addon_dir):
log.warning(
f"Dev addon {addon_name} {addon_version} path"
f" does not exists. Path \"{addon_dir}\""
)
continue
elif addons_dir_exists:
folder_name = "{}_{}".format(addon_name, addon_version)
folder_name = f"{addon_name}_{addon_version}"
addon_dir = os.path.join(addons_dir, folder_name)
if not os.path.exists(addon_dir):
log.debug((
"No localized client code found for addon {} {}."
).format(addon_name, addon_version))
log.debug(
"No localized client code found"
f" for addon {addon_name} {addon_version}."
)
continue
if not addon_dir:
@ -363,24 +311,22 @@ def _load_ayon_addons(log):
except BaseException:
log.warning(
"Failed to import \"{}\"".format(basename),
f"Failed to import \"{basename}\"",
exc_info=True
)
if not addon_modules:
log.warning("Addon {} {} has no content to import".format(
addon_name, addon_version
))
log.warning(
f"Addon {addon_name} {addon_version} has no content to import"
)
continue
if len(addon_modules) > 1:
log.warning((
"Multiple modules ({}) were found in addon '{}' in dir {}."
).format(
", ".join([m.__name__ for m in addon_modules]),
addon_name,
addon_dir,
))
joined_modules = ", ".join([m.__name__ for m in addon_modules])
log.warning(
f"Multiple modules ({joined_modules}) were found in"
f" addon '{addon_name}' in dir {addon_dir}."
)
all_addon_modules.extend(addon_modules)
return all_addon_modules
@ -398,20 +344,21 @@ class AYONAddon(ABC):
Attributes:
enabled (bool): Is addon enabled.
name (str): Addon name.
Args:
manager (AddonsManager): Manager object who discovered addon.
settings (dict[str, Any]): AYON settings.
"""
enabled = True
enabled: bool = True
_id = None
# Temporary variable for 'version' property
_missing_version_warned = False
def __init__(self, manager, settings):
def __init__(
self, manager: AddonsManager, settings: dict[str, Any]
) -> None:
self.manager = manager
self.log = Logger.get_logger(self.name)
@ -419,7 +366,7 @@ class AYONAddon(ABC):
self.initialize(settings)
@property
def id(self):
def id(self) -> str:
"""Random id of addon object.
Returns:
@ -432,7 +379,7 @@ class AYONAddon(ABC):
@property
@abstractmethod
def name(self):
def name(self) -> str:
"""Addon name.
Returns:
@ -442,7 +389,7 @@ class AYONAddon(ABC):
pass
@property
def version(self):
def version(self) -> str:
"""Addon version.
Todo:
@ -461,7 +408,7 @@ class AYONAddon(ABC):
)
return "0.0.0"
def initialize(self, settings):
def initialize(self, settings: dict[str, Any]) -> None:
"""Initialization of addon attributes.
It is not recommended to override __init__ that's why specific method
@ -473,7 +420,7 @@ class AYONAddon(ABC):
"""
pass
def connect_with_addons(self, enabled_addons):
def connect_with_addons(self, enabled_addons: list[AYONAddon]) -> None:
"""Connect with other enabled addons.
Args:
@ -484,7 +431,7 @@ class AYONAddon(ABC):
def ensure_is_process_ready(
self, process_context: ProcessContext
):
) -> None:
"""Make sure addon is prepared for a process.
This method is called when some action makes sure that addon has set
@ -505,7 +452,7 @@ class AYONAddon(ABC):
"""
pass
def get_global_environments(self):
def get_global_environments(self) -> dict[str, str]:
"""Get global environments values of addon.
Environment variables that can be get only from system settings.
@ -516,20 +463,12 @@ class AYONAddon(ABC):
"""
return {}
def modify_application_launch_arguments(self, application, env):
"""Give option to modify launch environments before application launch.
Implementation is optional. To change environments modify passed
dictionary of environments.
Args:
application (Application): Application that is launched.
env (dict[str, str]): Current environment variables.
"""
pass
def on_host_install(self, host, host_name, project_name):
def on_host_install(
self,
host: HostBase,
host_name: str,
project_name: str,
) -> None:
"""Host was installed which gives option to handle in-host logic.
It is a good option to register in-host event callbacks which are
@ -540,7 +479,7 @@ class AYONAddon(ABC):
to receive from 'host' object.
Args:
host (Union[ModuleType, HostBase]): Access to installed/registered
host (HostBase): Access to installed/registered
host object.
host_name (str): Name of host.
project_name (str): Project name which is main part of host
@ -549,7 +488,7 @@ class AYONAddon(ABC):
"""
pass
def cli(self, addon_click_group):
def cli(self, addon_click_group: click.Group) -> None:
"""Add commands to click group.
The best practise is to create click group for whole addon which is
@ -580,15 +519,21 @@ class AYONAddon(ABC):
class _AddonReportInfo:
def __init__(
self, class_name, name, version, report_value_by_label
):
self,
class_name: str,
name: str,
version: str,
report_value_by_label: dict[str, Optional[str]],
) -> None:
self.class_name = class_name
self.name = name
self.version = version
self.report_value_by_label = report_value_by_label
@classmethod
def from_addon(cls, addon, report):
def from_addon(
cls, addon: AYONAddon, report: dict[str, dict[str, int]]
) -> "_AddonReportInfo":
class_name = addon.__class__.__name__
report_value_by_label = {
label: reported.get(class_name)
@ -615,29 +560,35 @@ class AddonsManager:
_report_total_key = "Total"
_log = None
def __init__(self, settings=None, initialize=True):
def __init__(
self,
settings: Optional[dict[str, Any]] = None,
initialize: bool = True,
) -> None:
self._settings = settings
self._addons = []
self._addons_by_id = {}
self._addons_by_name = {}
self._addons: list[AYONAddon] = []
self._addons_by_id: dict[str, AYONAddon] = {}
self._addons_by_name: dict[str, AYONAddon] = {}
# For report of time consumption
self._report = {}
self._report: dict[str, dict[str, int]] = {}
if initialize:
self.initialize_addons()
self.connect_addons()
def __getitem__(self, addon_name):
def __getitem__(self, addon_name: str) -> AYONAddon:
return self._addons_by_name[addon_name]
@property
def log(self):
def log(self) -> logging.Logger:
if self._log is None:
self._log = logging.getLogger(self.__class__.__name__)
self._log = Logger.get_logger(self.__class__.__name__)
return self._log
def get(self, addon_name, default=None):
def get(
self, addon_name: str, default: Optional[Any] = None
) -> Union[AYONAddon, Any]:
"""Access addon by name.
Args:
@ -651,18 +602,20 @@ class AddonsManager:
return self._addons_by_name.get(addon_name, default)
@property
def addons(self):
def addons(self) -> list[AYONAddon]:
return list(self._addons)
@property
def addons_by_id(self):
def addons_by_id(self) -> dict[str, AYONAddon]:
return dict(self._addons_by_id)
@property
def addons_by_name(self):
def addons_by_name(self) -> dict[str, AYONAddon]:
return dict(self._addons_by_name)
def get_enabled_addon(self, addon_name, default=None):
def get_enabled_addon(
self, addon_name: str, default: Optional[Any] = None
) -> Union[AYONAddon, Any]:
"""Fast access to enabled addon.
If addon is available but is not enabled default value is returned.
@ -673,7 +626,7 @@ class AddonsManager:
not enabled.
Returns:
Union[AYONAddon, None]: Enabled addon found by name or None.
Union[AYONAddon, Any]: Enabled addon found by name or None.
"""
addon = self.get(addon_name)
@ -681,7 +634,7 @@ class AddonsManager:
return addon
return default
def get_enabled_addons(self):
def get_enabled_addons(self) -> list[AYONAddon]:
"""Enabled addons initialized by the manager.
Returns:
@ -694,7 +647,7 @@ class AddonsManager:
if addon.enabled
]
def initialize_addons(self):
def initialize_addons(self) -> None:
"""Import and initialize addons."""
# Make sure modules are loaded
load_addons()
@ -775,7 +728,7 @@ class AddonsManager:
report[self._report_total_key] = time.time() - time_start
self._report["Initialization"] = report
def connect_addons(self):
def connect_addons(self) -> None:
"""Trigger connection with other enabled addons.
Addons should handle their interfaces in `connect_with_addons`.
@ -784,7 +737,7 @@ class AddonsManager:
time_start = time.time()
prev_start_time = time_start
enabled_addons = self.get_enabled_addons()
self.log.debug("Has {} enabled addons.".format(len(enabled_addons)))
self.log.debug(f"Has {len(enabled_addons)} enabled addons.")
for addon in enabled_addons:
try:
addon.connect_with_addons(enabled_addons)
@ -803,7 +756,7 @@ class AddonsManager:
report[self._report_total_key] = time.time() - time_start
self._report["Connect modules"] = report
def collect_global_environments(self):
def collect_global_environments(self) -> dict[str, str]:
"""Helper to collect global environment variabled from modules.
Returns:
@ -826,7 +779,7 @@ class AddonsManager:
module_envs[key] = value
return module_envs
def collect_plugin_paths(self):
def collect_plugin_paths(self) -> dict[str, list[str]]:
"""Helper to collect all plugins from modules inherited IPluginPaths.
Unknown keys are logged out.
@ -885,7 +838,7 @@ class AddonsManager:
# Report unknown keys (Developing purposes)
if unknown_keys_by_addon:
expected_keys = ", ".join([
"\"{}\"".format(key) for key in output.keys()
f'"{key}"' for key in output.keys()
])
msg_template = "Addon: \"{}\" - got key {}"
msg_items = []
@ -894,12 +847,14 @@ class AddonsManager:
"\"{}\"".format(key) for key in keys
])
msg_items.append(msg_template.format(addon_name, joined_keys))
self.log.warning((
"Expected keys from `get_plugin_paths` are {}. {}"
).format(expected_keys, " | ".join(msg_items)))
joined_items = " | ".join(msg_items)
self.log.warning(
f"Expected keys from `get_plugin_paths` are {expected_keys}."
f" {joined_items}"
)
return output
def _collect_plugin_paths(self, method_name, *args, **kwargs):
def _collect_plugin_paths(self, method_name: str, *args, **kwargs):
output = []
for addon in self.get_enabled_addons():
# Skip addon that do not inherit from `IPluginPaths`
@ -930,7 +885,7 @@ class AddonsManager:
output.extend(paths)
return output
def collect_launcher_action_paths(self):
def collect_launcher_action_paths(self) -> list[str]:
"""Helper to collect launcher action paths from addons.
Returns:
@ -945,16 +900,16 @@ class AddonsManager:
output.insert(0, actions_dir)
return output
def collect_create_plugin_paths(self, host_name):
def collect_create_plugin_paths(self, host_name: str) -> list[str]:
"""Helper to collect creator plugin paths from addons.
Args:
host_name (str): For which host are creators meant.
Returns:
list: List of creator plugin paths.
"""
list[str]: List of creator plugin paths.
"""
return self._collect_plugin_paths(
"get_create_plugin_paths",
host_name
@ -962,37 +917,37 @@ class AddonsManager:
collect_creator_plugin_paths = collect_create_plugin_paths
def collect_load_plugin_paths(self, host_name):
def collect_load_plugin_paths(self, host_name: str) -> list[str]:
"""Helper to collect load plugin paths from addons.
Args:
host_name (str): For which host are load plugins meant.
Returns:
list: List of load plugin paths.
"""
list[str]: List of load plugin paths.
"""
return self._collect_plugin_paths(
"get_load_plugin_paths",
host_name
)
def collect_publish_plugin_paths(self, host_name):
def collect_publish_plugin_paths(self, host_name: str) -> list[str]:
"""Helper to collect load plugin paths from addons.
Args:
host_name (str): For which host are load plugins meant.
Returns:
list: List of pyblish plugin paths.
"""
list[str]: List of pyblish plugin paths.
"""
return self._collect_plugin_paths(
"get_publish_plugin_paths",
host_name
)
def collect_inventory_action_paths(self, host_name):
def collect_inventory_action_paths(self, host_name: str) -> list[str]:
"""Helper to collect load plugin paths from addons.
Args:
@ -1000,21 +955,21 @@ class AddonsManager:
Returns:
list: List of pyblish plugin paths.
"""
"""
return self._collect_plugin_paths(
"get_inventory_action_paths",
host_name
)
def get_host_addon(self, host_name):
def get_host_addon(self, host_name: str) -> Optional[AYONAddon]:
"""Find host addon by host name.
Args:
host_name (str): Host name for which is found host addon.
Returns:
Union[AYONAddon, None]: Found host addon by name or `None`.
Optional[AYONAddon]: Found host addon by name or `None`.
"""
for addon in self.get_enabled_addons():
@ -1025,21 +980,21 @@ class AddonsManager:
return addon
return None
def get_host_names(self):
def get_host_names(self) -> set[str]:
"""List of available host names based on host addons.
Returns:
Iterable[str]: All available host names based on enabled addons
set[str]: All available host names based on enabled addons
inheriting 'IHostAddon'.
"""
"""
return {
addon.host_name
for addon in self.get_enabled_addons()
if isinstance(addon, IHostAddon)
}
def print_report(self):
def print_report(self) -> None:
"""Print out report of time spent on addons initialization parts.
Reporting is not automated must be implemented for each initialization

View file

@ -38,18 +38,20 @@ class AddLastWorkfileToLaunchArgs(PreLaunchHook):
launch_types = {LaunchTypes.local}
def execute(self):
if not self.data.get("start_last_workfile"):
self.log.info("It is set to not start last workfile on start.")
return
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:
self.log.warning("Last workfile was not collected.")
return
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)

View file

@ -14,7 +14,7 @@ class OCIOEnvHook(PreLaunchHook):
"fusion",
"blender",
"aftereffects",
"3dsmax",
"max",
"houdini",
"maya",
"nuke",

View file

@ -1,5 +1,5 @@
from .constants import ContextChangeReason
from .abstract import AbstractHost
from .abstract import AbstractHost, ApplicationInformation
from .host import (
HostBase,
ContextChangeData,
@ -21,6 +21,7 @@ __all__ = (
"ContextChangeReason",
"AbstractHost",
"ApplicationInformation",
"HostBase",
"ContextChangeData",

View file

@ -2,6 +2,7 @@ from __future__ import annotations
import logging
from abc import ABC, abstractmethod
from dataclasses import dataclass
import typing
from typing import Optional, Any
@ -13,6 +14,19 @@ if typing.TYPE_CHECKING:
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
@ -26,6 +40,16 @@ class AbstractHost(ABC):
"""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.

View file

@ -12,7 +12,7 @@ import ayon_api
from ayon_core.lib import emit_event
from .constants import ContextChangeReason
from .abstract import AbstractHost
from .abstract import AbstractHost, ApplicationInformation
if typing.TYPE_CHECKING:
from ayon_core.pipeline import Anatomy
@ -96,6 +96,18 @@ class HostBase(AbstractHost):
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.

View file

@ -55,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}",
)
@ -1554,6 +1554,27 @@ class IWorkfileHost(AbstractHost):
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"],
@ -1562,6 +1583,7 @@ class IWorkfileHost(AbstractHost):
version,
comment,
description,
data=data,
workfile_entities=save_workfile_context.workfile_entities,
)
return workfile_info

View file

@ -11,6 +11,7 @@ from .local_settings import (
get_launcher_storage_dir,
get_addons_resources_dir,
get_local_site_id,
get_ayon_user_entity,
get_ayon_username,
)
from .ayon_connection import initialize_ayon_connection
@ -73,6 +74,7 @@ from .log import (
)
from .path_templates import (
DefaultKeysDict,
TemplateUnsolved,
StringTemplate,
FormatObject,
@ -148,6 +150,7 @@ __all__ = [
"get_launcher_storage_dir",
"get_addons_resources_dir",
"get_local_site_id",
"get_ayon_user_entity",
"get_ayon_username",
"initialize_ayon_connection",
@ -228,6 +231,7 @@ __all__ = [
"get_version_from_path",
"get_last_version_from_path",
"DefaultKeysDict",
"TemplateUnsolved",
"StringTemplate",
"FormatObject",

View file

@ -5,6 +5,7 @@ import json
import platform
import configparser
import warnings
import copy
from datetime import datetime
from abc import ABC, abstractmethod
from functools import lru_cache
@ -13,6 +14,8 @@ from typing import Optional, Any
import platformdirs
import ayon_api
from .cache import NestedCacheItem, CacheItem
_PLACEHOLDER = object()
@ -23,6 +26,7 @@ class RegistryItemNotFound(ValueError):
class _Cache:
username = None
user_entities_by_name = NestedCacheItem()
def _get_ayon_appdirs(*args: str) -> str:
@ -569,6 +573,68 @@ def get_local_site_id():
return site_id
def _get_ayon_service_username() -> Optional[str]:
# TODO @iLLiCiTiT - do not use private attribute of 'ServerAPI', rather
# use public method to get username from connection stack.
con = ayon_api.get_server_api_connection()
user_stack = getattr(con, "_as_user_stack", None)
if user_stack is None:
return None
return user_stack.username
def get_ayon_user_entity(username: Optional[str] = None) -> dict[str, Any]:
"""AYON user entity used for templates and publishing.
Note:
Usually only service and admin users can receive the full user entity.
Args:
username (Optional[str]): Username of the user. If not passed, then
the current user in 'ayon_api' is used.
Returns:
dict[str, Any]: User entity.
"""
service_username = _get_ayon_service_username()
# Handle service user handling first
if service_username:
if username is None:
username = service_username
cache: CacheItem = _Cache.user_entities_by_name[username]
if not cache.is_valid:
if username == service_username:
user = ayon_api.get_user()
else:
user = ayon_api.get_user(username)
cache.update_data(user)
return copy.deepcopy(cache.get_data())
# Cache current user
current_user = None
if _Cache.username is None:
current_user = ayon_api.get_user()
_Cache.username = current_user["name"]
if username is None:
username = _Cache.username
cache: CacheItem = _Cache.user_entities_by_name[username]
if not cache.is_valid:
user = None
if username == _Cache.username:
if current_user is None:
current_user = ayon_api.get_user()
user = current_user
if user is None:
user = ayon_api.get_user(username)
cache.update_data(user)
return copy.deepcopy(cache.get_data())
def get_ayon_username():
"""AYON username used for templates and publishing.
@ -578,20 +644,5 @@ def get_ayon_username():
str: Username.
"""
# Look for username in the connection stack
# - this is used when service is working as other user
# (e.g. in background sync)
# TODO @iLLiCiTiT - do not use private attribute of 'ServerAPI', rather
# use public method to get username from connection stack.
con = ayon_api.get_server_api_connection()
user_stack = getattr(con, "_as_user_stack", None)
if user_stack is not None:
username = user_stack.username
if username is not None:
return username
# Cache the username to avoid multiple API calls
# - it is not expected that user would change
if _Cache.username is None:
_Cache.username = ayon_api.get_user()["name"]
return _Cache.username
user = get_ayon_user_entity()
return user["name"]

View file

@ -1,3 +1,5 @@
from __future__ import annotations
import os
import re
import copy
@ -5,11 +7,7 @@ import numbers
import warnings
import platform
from string import Formatter
import typing
from typing import List, Dict, Any, Set
if typing.TYPE_CHECKING:
from typing import Union
from typing import Any, Union, Iterable
SUB_DICT_PATTERN = re.compile(r"([^\[\]]+)")
OPTIONAL_PATTERN = re.compile(r"(<.*?[^{0]*>)[^0-9]*?")
@ -44,6 +42,54 @@ class TemplateUnsolved(Exception):
)
class DefaultKeysDict(dict):
"""Dictionary that supports the default key to use for str conversion.
Is helpful for changes of a key in a template from string to dictionary
for example '{folder}' -> '{folder[name]}'.
>>> data = DefaultKeysDict(
>>> "name",
>>> {"folder": {"name": "FolderName"}}
>>> )
>>> print("{folder[name]}".format_map(data))
FolderName
>>> print("{folder}".format_map(data))
FolderName
Args:
default_key (Union[str, Iterable[str]]): Default key to use for str
conversion. Can also expect multiple keys for more nested
dictionary.
"""
def __init__(
self, default_keys: Union[str, Iterable[str]], *args, **kwargs
) -> None:
if isinstance(default_keys, str):
default_keys = [default_keys]
else:
default_keys = list(default_keys)
if not default_keys:
raise ValueError(
"Default key must be set. Got empty default keys."
)
self._default_keys = default_keys
super().__init__(*args, **kwargs)
def __str__(self) -> str:
return str(self.get_default_value())
def get_default_keys(self) -> list[str]:
return list(self._default_keys)
def get_default_value(self) -> Any:
value = self
for key in self._default_keys:
value = value[key]
return value
class StringTemplate:
"""String that can be formatted."""
def __init__(self, template: str):
@ -84,7 +130,7 @@ class StringTemplate:
if substr:
new_parts.append(substr)
self._parts: List["Union[str, OptionalPart, FormattingPart]"] = (
self._parts: list[Union[str, OptionalPart, FormattingPart]] = (
self.find_optional_parts(new_parts)
)
@ -105,7 +151,7 @@ class StringTemplate:
def template(self) -> str:
return self._template
def format(self, data: Dict[str, Any]) -> "TemplateResult":
def format(self, data: dict[str, Any]) -> "TemplateResult":
""" Figure out with whole formatting.
Separate advanced keys (*Like '{project[name]}') from string which must
@ -145,29 +191,29 @@ class StringTemplate:
invalid_types
)
def format_strict(self, data: Dict[str, Any]) -> "TemplateResult":
def format_strict(self, data: dict[str, Any]) -> "TemplateResult":
result = self.format(data)
result.validate()
return result
@classmethod
def format_template(
cls, template: str, data: Dict[str, Any]
cls, template: str, data: dict[str, Any]
) -> "TemplateResult":
objected_template = cls(template)
return objected_template.format(data)
@classmethod
def format_strict_template(
cls, template: str, data: Dict[str, Any]
cls, template: str, data: dict[str, Any]
) -> "TemplateResult":
objected_template = cls(template)
return objected_template.format_strict(data)
@staticmethod
def find_optional_parts(
parts: List["Union[str, FormattingPart]"]
) -> List["Union[str, OptionalPart, FormattingPart]"]:
parts: list[Union[str, FormattingPart]]
) -> list[Union[str, OptionalPart, FormattingPart]]:
new_parts = []
tmp_parts = {}
counted_symb = -1
@ -192,7 +238,7 @@ class StringTemplate:
len(parts) == 1
and isinstance(parts[0], str)
):
value = "<{}>".format(parts[0])
value = f"<{parts[0]}>"
else:
value = OptionalPart(parts)
@ -223,7 +269,7 @@ class TemplateResult(str):
only used keys.
solved (bool): For check if all required keys were filled.
template (str): Original template.
missing_keys (Iterable[str]): Missing keys that were not in the data.
missing_keys (list[str]): Missing keys that were not in the data.
Include missing optional keys.
invalid_types (dict): When key was found in data, but value had not
allowed DataType. Allowed data types are `numbers`,
@ -232,11 +278,11 @@ class TemplateResult(str):
of number.
"""
used_values: Dict[str, Any] = None
used_values: dict[str, Any] = None
solved: bool = None
template: str = None
missing_keys: List[str] = None
invalid_types: Dict[str, Any] = None
missing_keys: list[str] = None
invalid_types: dict[str, Any] = None
def __new__(
cls, filled_template, template, solved,
@ -296,21 +342,21 @@ class TemplatePartResult:
"""Result to store result of template parts."""
def __init__(self, optional: bool = False):
# Missing keys or invalid value types of required keys
self._missing_keys: Set[str] = set()
self._invalid_types: Dict[str, Any] = {}
self._missing_keys: set[str] = set()
self._invalid_types: dict[str, Any] = {}
# Missing keys or invalid value types of optional keys
self._missing_optional_keys: Set[str] = set()
self._invalid_optional_types: Dict[str, Any] = {}
self._missing_optional_keys: set[str] = set()
self._invalid_optional_types: dict[str, Any] = {}
# Used values stored by key with origin type
# - key without any padding or key modifiers
# - value from filling data
# Example: {"version": 1}
self._used_values: Dict[str, Any] = {}
self._used_values: dict[str, Any] = {}
# Used values stored by key with all modifirs
# - value is already formatted string
# Example: {"version:0>3": "001"}
self._really_used_values: Dict[str, Any] = {}
self._really_used_values: dict[str, Any] = {}
# Concatenated string output after formatting
self._output: str = ""
# Is this result from optional part
@ -336,8 +382,9 @@ class TemplatePartResult:
self._really_used_values.update(other.really_used_values)
else:
raise TypeError("Cannot add data from \"{}\" to \"{}\"".format(
str(type(other)), self.__class__.__name__)
raise TypeError(
f"Cannot add data from \"{type(other)}\""
f" to \"{self.__class__.__name__}\""
)
@property
@ -362,40 +409,41 @@ class TemplatePartResult:
return self._output
@property
def missing_keys(self) -> Set[str]:
def missing_keys(self) -> set[str]:
return self._missing_keys
@property
def missing_optional_keys(self) -> Set[str]:
def missing_optional_keys(self) -> set[str]:
return self._missing_optional_keys
@property
def invalid_types(self) -> Dict[str, Any]:
def invalid_types(self) -> dict[str, Any]:
return self._invalid_types
@property
def invalid_optional_types(self) -> Dict[str, Any]:
def invalid_optional_types(self) -> dict[str, Any]:
return self._invalid_optional_types
@property
def really_used_values(self) -> Dict[str, Any]:
def really_used_values(self) -> dict[str, Any]:
return self._really_used_values
@property
def realy_used_values(self) -> Dict[str, Any]:
def realy_used_values(self) -> dict[str, Any]:
warnings.warn(
"Property 'realy_used_values' is deprecated."
" Use 'really_used_values' instead.",
DeprecationWarning
DeprecationWarning,
stacklevel=2,
)
return self._really_used_values
@property
def used_values(self) -> Dict[str, Any]:
def used_values(self) -> dict[str, Any]:
return self._used_values
@staticmethod
def split_keys_to_subdicts(values: Dict[str, Any]) -> Dict[str, Any]:
def split_keys_to_subdicts(values: dict[str, Any]) -> dict[str, Any]:
output = {}
formatter = Formatter()
for key, value in values.items():
@ -410,7 +458,7 @@ class TemplatePartResult:
data[last_key] = value
return output
def get_clean_used_values(self) -> Dict[str, Any]:
def get_clean_used_values(self) -> dict[str, Any]:
new_used_values = {}
for key, value in self.used_values.items():
if isinstance(value, FormatObject):
@ -426,7 +474,8 @@ class TemplatePartResult:
warnings.warn(
"Method 'add_realy_used_value' is deprecated."
" Use 'add_really_used_value' instead.",
DeprecationWarning
DeprecationWarning,
stacklevel=2,
)
self.add_really_used_value(key, value)
@ -479,7 +528,7 @@ class FormattingPart:
self,
field_name: str,
format_spec: str,
conversion: "Union[str, None]",
conversion: Union[str, None],
):
format_spec_v = ""
if format_spec:
@ -546,7 +595,7 @@ class FormattingPart:
return not queue
@staticmethod
def keys_to_template_base(keys: List[str]):
def keys_to_template_base(keys: list[str]):
if not keys:
return None
# Create copy of keys
@ -556,7 +605,7 @@ class FormattingPart:
return f"{template_base}{joined_keys}"
def format(
self, data: Dict[str, Any], result: TemplatePartResult
self, data: dict[str, Any], result: TemplatePartResult
) -> TemplatePartResult:
"""Format the formattings string.
@ -635,6 +684,12 @@ class FormattingPart:
result.add_output(self.template)
return result
if isinstance(value, DefaultKeysDict):
try:
value = value.get_default_value()
except KeyError:
pass
if not self.validate_value_type(value):
result.add_invalid_type(key, value)
result.add_output(self.template)
@ -687,23 +742,25 @@ class OptionalPart:
def __init__(
self,
parts: List["Union[str, OptionalPart, FormattingPart]"]
parts: list[Union[str, OptionalPart, FormattingPart]]
):
self._parts: List["Union[str, OptionalPart, FormattingPart]"] = parts
self._parts: list[Union[str, OptionalPart, FormattingPart]] = parts
@property
def parts(self) -> List["Union[str, OptionalPart, FormattingPart]"]:
def parts(self) -> list[Union[str, OptionalPart, FormattingPart]]:
return self._parts
def __str__(self) -> str:
return "<{}>".format("".join([str(p) for p in self._parts]))
joined_parts = "".join([str(p) for p in self._parts])
return f"<{joined_parts}>"
def __repr__(self) -> str:
return "<Optional:{}>".format("".join([str(p) for p in self._parts]))
joined_parts = "".join([str(p) for p in self._parts])
return f"<Optional:{joined_parts}>"
def format(
self,
data: Dict[str, Any],
data: dict[str, Any],
result: TemplatePartResult,
) -> TemplatePartResult:
new_result = TemplatePartResult(True)

View file

@ -1,11 +1,9 @@
# -*- coding: utf-8 -*-
"""AYON plugin tools."""
import os
import logging
import re
import collections
log = logging.getLogger(__name__)
CAPITALIZE_REGEX = re.compile(r"[a-zA-Z0-9]")

View file

@ -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.
@ -377,11 +420,14 @@ def get_review_info_by_layer_name(channel_names):
channel = last_part[0].upper()
rgba_by_layer_name[layer_name][channel] = channel_name
# Put empty layer to the beginning of the list
# Put empty layer or 'rgba' to the beginning of the list
# - if input has R, G, B, A channels they should be used for review
if "" in layer_names_order:
layer_names_order.remove("")
layer_names_order.insert(0, "")
# NOTE They are iterated in reversed order because they're inserted to
# the beginning of 'layer_names_order' -> last added will be first.
for name in reversed(["", "rgba"]):
if name in layer_names_order:
layer_names_order.remove(name)
layer_names_order.insert(0, name)
output = []
for layer_name in layer_names_order:
@ -966,6 +1012,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 +1025,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 +1092,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 +1136,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)
if target_colorspace:
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])
# Handle the different conversion cases
# Source view and display are known
if source_view and source_display:
if target_colorspace:
# 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,
])
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])
@ -1351,12 +1522,27 @@ def get_media_mime_type(filepath: str) -> Optional[str]:
Optional[str]: Mime type or None if is unknown mime type.
"""
# The implementation is identical or better with ayon_api >=1.1.0,
# which is used in AYON launcher >=1.3.0.
# NOTE Remove safe import when AYON launcher >=1.2.0.
try:
from ayon_api.utils import (
get_media_mime_type_for_content as _ayon_api_func
)
except ImportError:
_ayon_api_func = None
if not filepath or not os.path.exists(filepath):
return None
with open(filepath, "rb") as stream:
content = stream.read()
if _ayon_api_func is not None:
mime_type = _ayon_api_func(content)
if mime_type is not None:
return mime_type
content_len = len(content)
# Pre-validation (largest definition check)
# - hopefully there cannot be media defined in less than 12 bytes
@ -1383,11 +1569,13 @@ def get_media_mime_type(filepath: str) -> Optional[str]:
if b'xmlns="http://www.w3.org/2000/svg"' in content:
return "image/svg+xml"
# JPEG, JFIF or Exif
if (
content[0:4] == b"\xff\xd8\xff\xdb"
or content[6:10] in (b"JFIF", b"Exif")
):
# JPEG
# - [0:2] is constant b"\xff\xd8"
# (ref. https://www.file-recovery.com/jpg-signature-format.htm)
# - [2:4] Marker identifier b"\xff{?}"
# (ref. https://www.disktuna.com/list-of-jpeg-markers/)
# NOTE: File ends with b"\xff\xd9"
if content[0:3] == b"\xff\xd8\xff":
return "image/jpeg"
# Webp

View file

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

View file

@ -1404,7 +1404,7 @@ def _get_display_view_colorspace_name(config_path, display, view):
"""
config = _get_ocio_config(config_path)
colorspace = config.getDisplayViewColorSpaceName(display, view)
# Special token. See https://opencolorio.readthedocs.io/en/latest/guides/authoring/authoring.html#shared-views # noqa
# Special token. See https://opencolorio.readthedocs.io/en/latest/guides/authoring/authoring.html#shared-views # noqa
if colorspace == "<USE_DISPLAY_NAME>":
colorspace = display

View file

@ -249,7 +249,8 @@ def create_skeleton_instance(
# map inputVersions `ObjectId` -> `str` so json supports it
"inputVersions": list(map(str, data.get("inputVersions", []))),
"colorspace": data.get("colorspace"),
"hasExplicitFrames": data.get("hasExplicitFrames")
"hasExplicitFrames": data.get("hasExplicitFrames", False),
"reuseLastVersion": data.get("reuseLastVersion", False),
}
if data.get("renderlayer"):

View file

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

View file

@ -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,
@ -942,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
@ -964,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:
@ -985,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
@ -993,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)
@ -1042,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:
@ -1083,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)

View file

@ -7,13 +7,20 @@ import copy
import warnings
import hashlib
import xml.etree.ElementTree
from typing import TYPE_CHECKING, Optional, Union, List
from typing import TYPE_CHECKING, Optional, Union, List, Any
import clique
import speedcopy
import logging
import ayon_api
import pyblish.util
import pyblish.plugin
import pyblish.api
from ayon_api import (
get_server_api_connection,
get_representations,
get_last_version_by_product_name
)
from ayon_core.lib import (
import_filepath,
Logger,
@ -34,6 +41,8 @@ if TYPE_CHECKING:
TRAIT_INSTANCE_KEY: str = "representations_with_traits"
log = logging.getLogger(__name__)
def get_template_name_profiles(
project_name, project_settings=None, logger=None
@ -974,7 +983,26 @@ def get_instance_expected_output_path(
"version": version
})
path_template_obj = anatomy.get_template_item("publish", "default")["path"]
# Get instance publish template name
task_name = task_type = None
task_entity = instance.data.get("taskEntity")
if task_entity:
task_name = task_entity["name"]
task_type = task_entity["taskType"]
template_name = get_publish_template_name(
project_name=instance.context.data["projectName"],
host_name=instance.context.data["hostName"],
product_type=instance.data["productType"],
task_name=task_name,
task_type=task_type,
project_settings=instance.context.data["project_settings"],
)
path_template_obj = anatomy.get_template_item(
"publish",
template_name
)["path"]
template_filled = path_template_obj.format_strict(template_data)
return os.path.normpath(template_filled)
@ -1030,7 +1058,7 @@ def main_cli_publish(
# NOTE: ayon-python-api does not have public api function to find
# out if is used service user. So we need to have try > except
# block.
con = ayon_api.get_server_api_connection()
con = get_server_api_connection()
try:
con.set_default_service_username(username)
except ValueError:
@ -1143,3 +1171,90 @@ def get_trait_representations(
"""
return instance.data.get(TRAIT_INSTANCE_KEY, [])
def fill_sequence_gaps_with_previous_version(
collection: str,
staging_dir: str,
instance: pyblish.plugin.Instance,
current_repre_name: str,
start_frame: int,
end_frame: int
) -> tuple[Optional[dict[str, Any]], Optional[dict[int, str]]]:
"""Tries to replace missing frames from ones from last version"""
used_version_entity, repre_file_paths = _get_last_version_files(
instance, current_repre_name
)
if repre_file_paths is None:
# issues in getting last version files
return (None, None)
prev_collection = clique.assemble(
repre_file_paths,
patterns=[clique.PATTERNS["frames"]],
minimum_items=1
)[0][0]
prev_col_format = prev_collection.format("{head}{padding}{tail}")
added_files = {}
anatomy = instance.context.data["anatomy"]
col_format = collection.format("{head}{padding}{tail}")
for frame in range(start_frame, end_frame + 1):
if frame in collection.indexes:
continue
hole_fpath = os.path.join(staging_dir, col_format % frame)
previous_version_path = prev_col_format % frame
previous_version_path = anatomy.fill_root(previous_version_path)
if not os.path.exists(previous_version_path):
log.warning(
"Missing frame should be replaced from "
f"'{previous_version_path}' but that doesn't exist. "
)
return (None, None)
log.warning(
f"Replacing missing '{hole_fpath}' with "
f"'{previous_version_path}'"
)
speedcopy.copyfile(previous_version_path, hole_fpath)
added_files[frame] = hole_fpath
return (used_version_entity, added_files)
def _get_last_version_files(
instance: pyblish.plugin.Instance,
current_repre_name: str,
) -> tuple[Optional[dict[str, Any]], Optional[list[str]]]:
product_name = instance.data["productName"]
project_name = instance.data["projectEntity"]["name"]
folder_entity = instance.data["folderEntity"]
version_entity = get_last_version_by_product_name(
project_name,
product_name,
folder_entity["id"],
fields={"id", "attrib"}
)
if not version_entity:
return None, None
matching_repres = get_representations(
project_name,
version_ids=[version_entity["id"]],
representation_names=[current_repre_name],
fields={"files"}
)
matching_repre = next(matching_repres, None)
if not matching_repre:
return None, None
repre_file_paths = [
file_info["path"]
for file_info in matching_repre["files"]
]
return (version_entity, repre_file_paths)

View file

@ -1,27 +1,50 @@
from __future__ import annotations
from typing import Optional, Any
import ayon_api
from ayon_core.settings import get_studio_settings
from ayon_core.lib.local_settings import get_ayon_username
from ayon_core.lib import DefaultKeysDict
from ayon_core.lib.local_settings import get_ayon_user_entity
def get_general_template_data(settings=None, username=None):
def get_general_template_data(
settings: Optional[dict[str, Any]] = None,
username: Optional[str] = None,
user_entity: Optional[dict[str, Any]] = None,
):
"""General template data based on system settings or machine.
Output contains formatting keys:
- 'studio[name]' - Studio name filled from system settings
- 'studio[code]' - Studio code filled from system settings
- 'user' - User's name using 'get_ayon_username'
- 'studio[name]' - Studio name filled from system settings
- 'studio[code]' - Studio code filled from system settings
- 'user[name]' - User's name
- 'user[attrib][...]' - User's attributes
- 'user[data][...]' - User's data
Args:
settings (Dict[str, Any]): Studio or project settings.
username (Optional[str]): AYON Username.
"""
user_entity (Optional[dict[str, Any]]): User entity.
"""
if not settings:
settings = get_studio_settings()
if username is None:
username = get_ayon_username()
if user_entity is None:
user_entity = get_ayon_user_entity(username)
# Use dictionary with default value for backwards compatibility
# - we did support '{user}' now it should be '{user[name]}'
user_data = DefaultKeysDict(
"name",
{
"name": user_entity["name"],
"attrib": user_entity["attrib"],
"data": user_entity["data"],
}
)
core_settings = settings["core"]
return {
@ -29,7 +52,7 @@ def get_general_template_data(settings=None, username=None):
"name": core_settings["studio_name"],
"code": core_settings["studio_code"]
},
"user": username
"user": user_data,
}
@ -150,7 +173,8 @@ def get_template_data(
task_entity=None,
host_name=None,
settings=None,
username=None
username=None,
user_entity=None,
):
"""Prepare data for templates filling from entered documents and info.
@ -173,13 +197,18 @@ def get_template_data(
host_name (Optional[str]): Used to fill '{app}' key.
settings (Union[Dict, None]): Prepared studio or project settings.
They're queried if not passed (may be slower).
username (Optional[str]): AYON Username.
username (Optional[str]): DEPRECATED AYON Username.
user_entity (Optional[dict[str, Any]): AYON user entity.
Returns:
Dict[str, Any]: Data prepared for filling workdir template.
"""
template_data = get_general_template_data(settings, username=username)
template_data = get_general_template_data(
settings,
username=username,
user_entity=user_entity,
)
template_data.update(get_project_template_data(project_entity))
if folder_entity:
template_data.update(get_folder_template_data(

View file

@ -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"]
for key, value in (
("host_name", host_name),
("version", version),
("comment", comment),
):
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
@ -749,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.
@ -761,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,

View file

@ -16,6 +16,7 @@ Provides:
import json
import pyblish.api
from ayon_core.lib import get_ayon_user_entity
from ayon_core.pipeline.template_data import get_template_data
@ -55,17 +56,18 @@ class CollectAnatomyContextData(pyblish.api.ContextPlugin):
if folder_entity:
task_entity = context.data["taskEntity"]
username = context.data["user"]
user_entity = get_ayon_user_entity(username)
anatomy_data = get_template_data(
project_entity,
folder_entity,
task_entity,
host_name,
project_settings
host_name=host_name,
settings=project_settings,
user_entity=user_entity,
)
anatomy_data.update(context.data.get("datetimeData") or {})
username = context.data["user"]
anatomy_data["user"] = username
# Backwards compatibility for 'username' key
anatomy_data["username"] = username

View file

@ -32,6 +32,7 @@ class CollectCoreJobEnvVars(pyblish.api.ContextPlugin):
for key in [
"AYON_BUNDLE_NAME",
"AYON_STUDIO_BUNDLE_NAME",
"AYON_USE_STAGING",
"AYON_IN_TESTS",
# NOTE Not sure why workdir is needed?

View file

@ -71,6 +71,12 @@ class CollectOtioRanges(pyblish.api.InstancePlugin):
import opentimelineio as otio
otio_clip = instance.data["otioClip"]
if isinstance(
otio_clip.media_reference,
otio.schema.MissingReference
):
self.log.info("Clip has no media reference")
return
# Collect timeline ranges if workfile start frame is available
if "workfileFrameStart" in instance.data:

View file

@ -60,6 +60,13 @@ class CollectOtioSubsetResources(
# get basic variables
otio_clip = instance.data["otioClip"]
if isinstance(
otio_clip.media_reference,
otio.schema.MissingReference
):
self.log.info("Clip has no media reference")
return
otio_available_range = otio_clip.available_range()
media_fps = otio_available_range.start_time.rate
available_duration = otio_available_range.duration.value

View file

@ -13,6 +13,8 @@ import copy
import pyblish.api
from ayon_core.pipeline.publish import get_publish_template_name
class CollectResourcesPath(pyblish.api.InstancePlugin):
"""Generate directory path where the files and resources will be stored.
@ -77,16 +79,29 @@ class CollectResourcesPath(pyblish.api.InstancePlugin):
# This is for cases of Deprecated anatomy without `folder`
# TODO remove when all clients have solved this issue
template_data.update({
"frame": "FRAME_TEMP",
"representation": "TEMP"
})
template_data.update({"frame": "FRAME_TEMP", "representation": "TEMP"})
publish_templates = anatomy.get_template_item(
"publish", "default", "directory"
task_name = task_type = None
task_entity = instance.data.get("taskEntity")
if task_entity:
task_name = task_entity["name"]
task_type = task_entity["taskType"]
template_name = get_publish_template_name(
project_name=instance.context.data["projectName"],
host_name=instance.context.data["hostName"],
product_type=instance.data["productType"],
task_name=task_name,
task_type=task_type,
project_settings=instance.context.data["project_settings"],
logger=self.log,
)
publish_template = anatomy.get_template_item(
"publish", template_name, "directory")
publish_folder = os.path.normpath(
publish_templates.format_strict(template_data)
publish_template.format_strict(template_data)
)
resources_folder = os.path.join(publish_folder, "resources")

View file

@ -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
@ -87,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
@ -96,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")
@ -126,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":
@ -138,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
@ -168,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

View file

@ -130,7 +130,7 @@ class ExtractOTIOReview(
# NOTE it looks like it is set only in hiero integration
res_data = {"width": self.to_width, "height": self.to_height}
for key in res_data:
for meta_prefix in ("ayon.source.", "openpype.source."):
for meta_prefix in ("ayon.source", "openpype.source"):
meta_key = f"{meta_prefix}.{key}"
value = media_metadata.get(meta_key)
if value is not None:

View file

@ -13,14 +13,15 @@ import clique
import speedcopy
import pyblish.api
from ayon_api import get_last_version_by_product_name, get_representations
from ayon_core.lib import (
get_ffmpeg_tool_args,
filter_profiles,
path_to_subprocess_arg,
run_subprocess,
)
from ayon_core.pipeline.publish.lib import (
fill_sequence_gaps_with_previous_version
)
from ayon_core.lib.transcoding import (
IMAGE_EXTENSIONS,
get_ffprobe_streams,
@ -130,7 +131,7 @@ def frame_to_timecode(frame: int, fps: float) -> str:
class ExtractReview(pyblish.api.InstancePlugin):
"""Extracting Review mov file for Ftrack
"""Extracting Reviewable medias
Compulsory attribute of representation is tags list with "review",
otherwise the representation is ignored.
@ -508,10 +509,10 @@ class ExtractReview(pyblish.api.InstancePlugin):
resolution_width=temp_data.resolution_width,
resolution_height=temp_data.resolution_height,
extension=temp_data.input_ext,
temp_data=temp_data
temp_data=temp_data,
)
elif fill_missing_frames == "previous_version":
new_frame_files = self.fill_sequence_gaps_with_previous(
fill_output = fill_sequence_gaps_with_previous_version(
collection=collection,
staging_dir=new_repre["stagingDir"],
instance=instance,
@ -519,8 +520,13 @@ class ExtractReview(pyblish.api.InstancePlugin):
start_frame=temp_data.frame_start,
end_frame=temp_data.frame_end,
)
_, new_frame_files = fill_output
# fallback to original workflow
if new_frame_files is None:
self.log.warning(
"Falling back to filling from currently "
"last rendered."
)
new_frame_files = (
self.fill_sequence_gaps_from_existing(
collection=collection,
@ -612,8 +618,6 @@ class ExtractReview(pyblish.api.InstancePlugin):
"name": "{}_{}".format(output_name, output_ext),
"outputName": output_name,
"outputDef": output_def,
"frameStartFtrack": temp_data.output_frame_start,
"frameEndFtrack": temp_data.output_frame_end,
"ffmpeg_cmd": subprcs_cmd
})
@ -1050,92 +1054,6 @@ class ExtractReview(pyblish.api.InstancePlugin):
return all_args
def fill_sequence_gaps_with_previous(
self,
collection: str,
staging_dir: str,
instance: pyblish.plugin.Instance,
current_repre_name: str,
start_frame: int,
end_frame: int
) -> Optional[dict[int, str]]:
"""Tries to replace missing frames from ones from last version"""
repre_file_paths = self._get_last_version_files(
instance, current_repre_name)
if repre_file_paths is None:
# issues in getting last version files, falling back
return None
prev_collection = clique.assemble(
repre_file_paths,
patterns=[clique.PATTERNS["frames"]],
minimum_items=1
)[0][0]
prev_col_format = prev_collection.format("{head}{padding}{tail}")
added_files = {}
anatomy = instance.context.data["anatomy"]
col_format = collection.format("{head}{padding}{tail}")
for frame in range(start_frame, end_frame + 1):
if frame in collection.indexes:
continue
hole_fpath = os.path.join(staging_dir, col_format % frame)
previous_version_path = prev_col_format % frame
previous_version_path = anatomy.fill_root(previous_version_path)
if not os.path.exists(previous_version_path):
self.log.warning(
"Missing frame should be replaced from "
f"'{previous_version_path}' but that doesn't exist. "
"Falling back to filling from currently last rendered."
)
return None
self.log.warning(
f"Replacing missing '{hole_fpath}' with "
f"'{previous_version_path}'"
)
speedcopy.copyfile(previous_version_path, hole_fpath)
added_files[frame] = hole_fpath
return added_files
def _get_last_version_files(
self,
instance: pyblish.plugin.Instance,
current_repre_name: str,
):
product_name = instance.data["productName"]
project_name = instance.data["projectEntity"]["name"]
folder_entity = instance.data["folderEntity"]
version_entity = get_last_version_by_product_name(
project_name,
product_name,
folder_entity["id"],
fields={"id"}
)
if not version_entity:
return None
matching_repres = get_representations(
project_name,
version_ids=[version_entity["id"]],
representation_names=[current_repre_name],
fields={"files"}
)
if not matching_repres:
return None
matching_repre = list(matching_repres)[0]
repre_file_paths = [
file_info["path"]
for file_info in matching_repre["files"]
]
return repre_file_paths
def fill_sequence_gaps_with_blanks(
self,
collection: str,
@ -1384,15 +1302,7 @@ class ExtractReview(pyblish.api.InstancePlugin):
return audio_in_args, audio_filters, audio_out_args
for audio in audio_inputs:
# NOTE modified, always was expected "frameStartFtrack" which is
# STRANGE?!!! There should be different key, right?
# TODO use different frame start!
offset_seconds = 0
frame_start_ftrack = instance.data.get("frameStartFtrack")
if frame_start_ftrack is not None:
offset_frames = frame_start_ftrack - audio["offset"]
offset_seconds = offset_frames / temp_data.fps
if offset_seconds > 0:
audio_in_args.append(
"-ss {}".format(offset_seconds)

View file

@ -6,6 +6,7 @@ import re
import pyblish.api
from ayon_core.lib import (
get_oiio_tool_args,
get_ffmpeg_tool_args,
get_ffprobe_data,
@ -15,7 +16,11 @@ 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,
get_oiio_input_and_channel_args,
get_oiio_info_for_input,
)
from ayon_core.lib.transcoding import VIDEO_EXTENSIONS, IMAGE_EXTENSIONS
@ -210,6 +215,12 @@ class ExtractThumbnail(pyblish.api.InstancePlugin):
full_output_path = os.path.join(dst_staging, jpeg_file)
colorspace_data = repre.get("colorspaceData")
# NOTE We should find out what is happening here. Why don't we
# use oiiotool all the time if it is available? Only possible
# reason might be that video files should be converted using
# ffmpeg, but other then that, we should use oiio all the time.
# - We should also probably get rid of the ffmpeg settings...
# only use OIIO if it is supported and representation has
# colorspace data
if oiio_supported and colorspace_data:
@ -219,7 +230,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin):
)
# If the input can read by OIIO then use OIIO method for
# conversion otherwise use ffmpeg
repre_thumb_created = self._create_thumbnail_oiio(
repre_thumb_created = self._create_colorspace_thumbnail(
full_input_path,
full_output_path,
colorspace_data
@ -229,17 +240,16 @@ class ExtractThumbnail(pyblish.api.InstancePlugin):
# oiiotool isn't available or representation is not having
# colorspace data
if not repre_thumb_created:
if oiio_supported:
self.log.debug(
"Converting with FFMPEG because input"
" can't be read by OIIO."
)
repre_thumb_created = self._create_thumbnail_ffmpeg(
full_input_path, full_output_path
)
# Skip representation and try next one if wasn't created
# Skip representation and try next one if wasn't created
if not repre_thumb_created and oiio_supported:
repre_thumb_created = self._create_thumbnail_oiio(
full_input_path, full_output_path
)
if not repre_thumb_created:
continue
@ -382,7 +392,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin):
return ext in IMAGE_EXTENSIONS or ext in VIDEO_EXTENSIONS
def _create_thumbnail_oiio(
def _create_colorspace_thumbnail(
self,
src_path,
dst_path,
@ -433,13 +443,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,
@ -453,9 +465,50 @@ class ExtractThumbnail(pyblish.api.InstancePlugin):
return True
def _create_thumbnail_oiio(self, src_path, dst_path):
self.log.debug(f"Extracting thumbnail with OIIO: {dst_path}")
try:
resolution_arg = self._get_resolution_arg("oiiotool", src_path)
except RuntimeError:
self.log.warning(
"Failed to create thumbnail using oiio", exc_info=True
)
return False
input_info = get_oiio_info_for_input(src_path, logger=self.log)
input_arg, channels_arg = get_oiio_input_and_channel_args(input_info)
oiio_cmd = get_oiio_tool_args(
"oiiotool",
input_arg, src_path,
# Tell oiiotool which channels should be put to top stack
# (and output)
"--ch", channels_arg,
# Use first subimage
"--subimage", "0"
)
oiio_cmd.extend(resolution_arg)
oiio_cmd.extend(("-o", dst_path))
self.log.debug("Running: {}".format(" ".join(oiio_cmd)))
try:
run_subprocess(oiio_cmd, logger=self.log)
return True
except Exception:
self.log.warning(
"Failed to create thumbnail using oiiotool",
exc_info=True
)
return False
def _create_thumbnail_ffmpeg(self, src_path, dst_path):
self.log.debug("Extracting thumbnail with FFMPEG: {}".format(dst_path))
resolution_arg = self._get_resolution_arg("ffmpeg", src_path)
try:
resolution_arg = self._get_resolution_arg("ffmpeg", src_path)
except RuntimeError:
self.log.warning(
"Failed to create thumbnail using ffmpeg", exc_info=True
)
return False
ffmpeg_path_args = get_ffmpeg_tool_args("ffmpeg")
ffmpeg_args = self.ffmpeg_args or {}

View file

@ -121,7 +121,6 @@ class IntegrateAsset(pyblish.api.InstancePlugin):
"version",
"representation",
"username",
"user",
"output",
# OpenPype keys - should be removed
"asset", # folder[name]
@ -796,6 +795,14 @@ class IntegrateAsset(pyblish.api.InstancePlugin):
if value is not None:
repre_context[key] = value
# Keep only username
# NOTE This is to avoid storing all user attributes and data
# to representation
if "user" not in repre_context:
repre_context["user"] = {
"name": template_data["user"]["name"]
}
# Use previous representation's id if there is a name match
existing = existing_repres_by_name.get(repre["name"].lower())
repre_id = None

View file

@ -89,7 +89,6 @@ class IntegrateHeroVersion(
"family",
"representation",
"username",
"user",
"output"
]
# QUESTION/TODO this process should happen on server if crashed due to
@ -364,6 +363,14 @@ class IntegrateHeroVersion(
if value is not None:
repre_context[key] = value
# Keep only username
# NOTE This is to avoid storing all user attributes and data
# to representation
if "user" not in repre_context:
repre_context["user"] = {
"name": anatomy_data["user"]["name"]
}
# Prepare new repre
repre_entity = copy.deepcopy(repre_info["representation"])
repre_entity.pop("id", None)

View file

@ -6,7 +6,12 @@ import json
import tempfile
from string import Formatter
import opentimelineio_contrib.adapters.ffmpeg_burnins as ffmpeg_burnins
try:
from otio_burnins_adapter import ffmpeg_burnins
except ImportError:
import opentimelineio_contrib.adapters.ffmpeg_burnins as ffmpeg_burnins
from PIL import ImageFont
from ayon_core.lib import (
get_ffmpeg_tool_args,
get_ffmpeg_codec_args,
@ -36,6 +41,39 @@ TIMECODE_KEY = "{timecode}"
SOURCE_TIMECODE_KEY = "{source_timecode}"
def _drawtext(align, resolution, text, options):
"""
:rtype: {'x': int, 'y': int}
"""
x_pos = "0"
if align in (ffmpeg_burnins.TOP_CENTERED, ffmpeg_burnins.BOTTOM_CENTERED):
x_pos = "w/2-tw/2"
elif align in (ffmpeg_burnins.TOP_RIGHT, ffmpeg_burnins.BOTTOM_RIGHT):
ifont = ImageFont.truetype(options["font"], options["font_size"])
if hasattr(ifont, "getbbox"):
left, top, right, bottom = ifont.getbbox(text)
box_size = right - left, bottom - top
else:
box_size = ifont.getsize(text)
x_pos = resolution[0] - (box_size[0] + options["x_offset"])
elif align in (ffmpeg_burnins.TOP_LEFT, ffmpeg_burnins.BOTTOM_LEFT):
x_pos = options["x_offset"]
if align in (
ffmpeg_burnins.TOP_CENTERED,
ffmpeg_burnins.TOP_RIGHT,
ffmpeg_burnins.TOP_LEFT
):
y_pos = "%d" % options["y_offset"]
else:
y_pos = "h-text_h-%d" % (options["y_offset"])
return {"x": x_pos, "y": y_pos}
ffmpeg_burnins._drawtext = _drawtext
def _get_ffprobe_data(source):
"""Reimplemented from otio burnins to be able use full path to ffprobe
:param str source: source media file

View file

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

View file

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

View file

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

View file

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

View file

@ -1,8 +1,10 @@
from .actions import ActionsModel
from .selection import LauncherSelectionModel
from .workfiles import WorkfilesModel
__all__ = (
"ActionsModel",
"LauncherSelectionModel",
"WorkfilesModel",
)

View file

@ -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"]]
@ -400,7 +423,7 @@ class ActionsModel:
try:
# 'variant' query is supported since AYON backend 1.10.4
query = urlencode({"variant": self._variant})
query = urlencode({"variant": self._variant, "mode": "all"})
response = ayon_api.post(
f"actions/list?{query}", **request_data
)
@ -542,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):

View file

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

View 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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

@ -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,22 +118,8 @@ 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
product_base_type_data: ProductBaseTypeDict
) -> ProductBaseTypeItem:
"""Create product base type item from data.
@ -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

View file

@ -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,10 +227,7 @@ 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
return None
if (
role == VERSION_NAME_EDIT_ROLE
@ -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

View file

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

View file

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

View file

@ -5,7 +5,7 @@ import itertools
import sys
import traceback
import uuid
from typing import Optional, Dict
from typing import Optional, Any
import ayon_api
from ayon_api.utils import create_entity_id
@ -225,8 +225,8 @@ class ProjectPushRepreItem:
but filenames are not template based.
Args:
repre_entity (Dict[str, Ant]): Representation entity.
roots (Dict[str, str]): Project roots (based on project anatomy).
repre_entity (dict[str, Ant]): Representation entity.
roots (dict[str, str]): Project roots (based on project anatomy).
"""
def __init__(self, repre_entity, roots):
@ -482,6 +482,8 @@ class ProjectPushItemProcess:
self._log_info("Destination project was found")
self._fill_or_create_destination_folder()
self._log_info("Destination folder was determined")
self._fill_or_create_destination_task()
self._log_info("Destination task was determined")
self._determine_product_type()
self._determine_publish_template_name()
self._determine_product_name()
@ -650,10 +652,10 @@ class ProjectPushItemProcess:
def _create_folder(
self,
src_folder_entity,
project_entity,
parent_folder_entity,
folder_name
src_folder_entity: dict[str, Any],
project_entity: dict[str, Any],
parent_folder_entity: dict[str, Any],
folder_name: str
):
parent_id = None
if parent_folder_entity:
@ -702,10 +704,14 @@ class ProjectPushItemProcess:
if new_folder_name != folder_name:
folder_label = folder_name
# TODO find out how to define folder type
src_folder_type = src_folder_entity["folderType"]
dst_folder_type = self._get_dst_folder_type(
project_entity,
src_folder_type
)
folder_entity = new_folder_entity(
folder_name,
"Folder",
dst_folder_type,
parent_id=parent_id,
attribs=new_folder_attrib
)
@ -727,10 +733,25 @@ class ProjectPushItemProcess:
folder_entity["path"] = "/".join([parent_path, folder_name])
return folder_entity
def _get_dst_folder_type(
self,
project_entity: dict[str, Any],
src_folder_type: str
) -> str:
"""Get new folder type."""
for folder_type in project_entity["folderTypes"]:
if folder_type["name"].lower() == src_folder_type.lower():
return folder_type["name"]
self._status.set_failed(
f"'{src_folder_type}' folder type is not configured in "
f"project Anatomy."
)
raise PushToProjectError(self._status.fail_reason)
def _fill_or_create_destination_folder(self):
dst_project_name = self._item.dst_project_name
dst_folder_id = self._item.dst_folder_id
dst_task_name = self._item.dst_task_name
new_folder_name = self._item.new_folder_name
if not dst_folder_id and not new_folder_name:
self._status.set_failed(
@ -761,9 +782,11 @@ class ProjectPushItemProcess:
new_folder_name
)
self._folder_entity = folder_entity
if not dst_task_name:
self._task_info = {}
return
def _fill_or_create_destination_task(self):
folder_entity = self._folder_entity
dst_task_name = self._item.dst_task_name
dst_project_name = self._item.dst_project_name
folder_path = folder_entity["path"]
folder_tasks = {
@ -772,6 +795,20 @@ class ProjectPushItemProcess:
dst_project_name, folder_ids=[folder_entity["id"]]
)
}
if not dst_task_name:
src_task_info = self._get_src_task_info()
if not src_task_info: # really no task selected nor on source
self._task_info = {}
return
dst_task_name = src_task_info["name"]
if dst_task_name.lower() not in folder_tasks:
task_info = self._make_sure_task_exists(
folder_entity, src_task_info
)
folder_tasks[dst_task_name.lower()] = task_info
task_info = folder_tasks.get(dst_task_name.lower())
if not task_info:
self._status.set_failed(
@ -790,7 +827,10 @@ class ProjectPushItemProcess:
task_type["name"]: task_type
for task_type in self._project_entity["taskTypes"]
}
task_type_info = task_types_by_name.get(task_type_name, {})
task_type_info = copy.deepcopy(
task_types_by_name.get(task_type_name, {})
)
task_type_info.pop("name") # do not overwrite real task name
task_info.update(task_type_info)
self._task_info = task_info
@ -925,8 +965,8 @@ class ProjectPushItemProcess:
version = get_versioning_start(
project_name,
self.host_name,
task_name=self._task_info["name"],
task_type=self._task_info["taskType"],
task_name=self._task_info.get("name"),
task_type=self._task_info.get("taskType"),
product_type=product_type,
product_name=product_entity["name"],
)
@ -950,10 +990,16 @@ class ProjectPushItemProcess:
existing_version_entity["attrib"].update(dst_attrib)
self._version_entity = existing_version_entity
return
copied_tags = self._get_transferable_tags(src_version_entity)
copied_status = self._get_transferable_status(src_version_entity)
version_entity = new_version_entity(
version,
product_id,
author=src_version_entity["author"],
status=copied_status,
tags=copied_tags,
task_id=self._task_info.get("id"),
attribs=dst_attrib,
thumbnail_id=thumbnail_id,
)
@ -962,6 +1008,47 @@ class ProjectPushItemProcess:
)
self._version_entity = version_entity
def _make_sure_task_exists(
self,
folder_entity: dict[str, Any],
task_info: dict[str, Any],
) -> dict[str, Any]:
"""Creates destination task from source task information"""
project_name = self._item.dst_project_name
found_task_type = False
src_task_type = task_info["taskType"]
for task_type in self._project_entity["taskTypes"]:
if task_type["name"].lower() == src_task_type.lower():
found_task_type = True
break
if not found_task_type:
self._status.set_failed(
f"'{src_task_type}' task type is not configured in "
"project Anatomy."
)
raise PushToProjectError(self._status.fail_reason)
task_info = self._operations.create_task(
project_name,
task_info["name"],
folder_id=folder_entity["id"],
task_type=src_task_type,
attrib=task_info["attrib"],
)
self._task_info = task_info.data
return self._task_info
def _get_src_task_info(self):
src_version_entity = self._src_version_entity
if not src_version_entity["taskId"]:
return None
src_task = ayon_api.get_task_by_id(
self._item.src_project_name, src_version_entity["taskId"]
)
return src_task
def _integrate_representations(self):
try:
self._real_integrate_representations()
@ -1197,18 +1284,42 @@ class ProjectPushItemProcess:
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)
context_sub_key
)
if value_to_update:
repre_context[context_key][
context_sub_key] = 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")
repre_context.pop("task", None)
return repre_context
def _get_transferable_tags(self, src_version_entity):
"""Copy over only tags present in destination project"""
dst_project_tags = [
tag["name"] for tag in self._project_entity["tags"]
]
copied_tags = []
for src_tag in src_version_entity["tags"]:
if src_tag in dst_project_tags:
copied_tags.append(src_tag)
return copied_tags
def _get_transferable_status(self, src_version_entity):
"""Copy over status, first status if not matching found"""
dst_project_statuses = {
status["name"]: status
for status in self._project_entity["statuses"]
}
copied_status = dst_project_statuses.get(src_version_entity["status"])
if copied_status:
return copied_status["name"]
return None
class IntegrateModel:
def __init__(self, controller):
@ -1281,6 +1392,6 @@ class IntegrateModel:
return
item.integrate()
def get_items(self) -> Dict[str, ProjectPushItemProcess]:
def get_items(self) -> dict[str, ProjectPushItemProcess]:
"""Returns dict of all ProjectPushItemProcess items """
return self._process_items

View file

@ -1,3 +1,5 @@
from typing import Optional
import ayon_api
from ayon_core.lib.events import QueuedEventSystem
@ -6,7 +8,11 @@ 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
@ -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()

View file

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

View file

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

View file

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

View file

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

View file

@ -186,8 +186,15 @@ class StatusDelegate(QtWidgets.QStyledItemDelegate):
)
fm = QtGui.QFontMetrics(option.font)
if text_rect.width() < fm.width(text):
text = self._get_status_short_name(index)
if text_rect.width() < fm.width(text):
short_text = self._get_status_short_name(index)
if short_text:
text = short_text
text = fm.elidedText(
text, QtCore.Qt.ElideRight, text_rect.width()
)
# Allow at least one character
if len(text) < 2:
text = ""
fg_color = self._get_status_color(index)

View file

@ -41,7 +41,7 @@ class ScrollMessageBox(QtWidgets.QDialog):
"""
def __init__(self, icon, title, messages, cancelable=False):
super(ScrollMessageBox, self).__init__()
super().__init__()
self.setWindowTitle(title)
self.icon = icon
@ -49,8 +49,6 @@ class ScrollMessageBox(QtWidgets.QDialog):
self.setWindowFlags(QtCore.Qt.WindowTitleHint)
layout = QtWidgets.QVBoxLayout(self)
scroll_widget = QtWidgets.QScrollArea(self)
scroll_widget.setWidgetResizable(True)
content_widget = QtWidgets.QWidget(self)
@ -63,14 +61,8 @@ class ScrollMessageBox(QtWidgets.QDialog):
content_layout.addWidget(label_widget)
message_len = max(message_len, len(message))
# guess size of scrollable area
# WARNING: 'desktop' method probably won't work in PySide6
desktop = QtWidgets.QApplication.desktop()
max_width = desktop.availableGeometry().width()
scroll_widget.setMinimumWidth(
min(max_width, message_len * 6)
)
layout.addWidget(scroll_widget)
# Set minimum width
scroll_widget.setMinimumWidth(360)
buttons = QtWidgets.QDialogButtonBox.Ok
if cancelable:
@ -86,7 +78,9 @@ class ScrollMessageBox(QtWidgets.QDialog):
btn.clicked.connect(self._on_copy_click)
btn_box.addButton(btn, QtWidgets.QDialogButtonBox.NoRole)
layout.addWidget(btn_box)
main_layout = QtWidgets.QVBoxLayout(self)
main_layout.addWidget(scroll_widget, 1)
main_layout.addWidget(btn_box, 0)
def _on_copy_click(self):
clipboard = QtWidgets.QApplication.clipboard()
@ -104,7 +98,7 @@ class SimplePopup(QtWidgets.QDialog):
on_clicked = QtCore.Signal()
def __init__(self, parent=None, *args, **kwargs):
super(SimplePopup, self).__init__(parent=parent, *args, **kwargs)
super().__init__(parent=parent, *args, **kwargs)
# Set default title
self.setWindowTitle("Popup")
@ -161,7 +155,7 @@ class SimplePopup(QtWidgets.QDialog):
geo = self._calculate_window_geometry()
self.setGeometry(geo)
return super(SimplePopup, self).showEvent(event)
return super().showEvent(event)
def _on_clicked(self):
"""Callback for when the 'show' button is clicked.
@ -228,9 +222,7 @@ class PopupUpdateKeys(SimplePopup):
on_clicked_state = QtCore.Signal(bool)
def __init__(self, parent=None, *args, **kwargs):
super(PopupUpdateKeys, self).__init__(
parent=parent, *args, **kwargs
)
super().__init__(parent=parent, *args, **kwargs)
layout = self.layout()

View file

@ -234,10 +234,11 @@ class TasksQtModel(QtGui.QStandardItemModel):
)
icon = None
if task_type_item is not None:
color = task_type_item.color or get_default_entity_icon_color()
icon = get_qt_icon({
"type": "material-symbols",
"name": task_type_item.icon,
"color": get_default_entity_icon_color()
"color": color,
})
if icon is None:

View file

@ -418,7 +418,7 @@ class ExpandingTextEdit(QtWidgets.QTextEdit):
"""QTextEdit which does not have sroll area but expands height."""
def __init__(self, parent=None):
super(ExpandingTextEdit, self).__init__(parent)
super().__init__(parent)
size_policy = self.sizePolicy()
size_policy.setHeightForWidth(True)
@ -441,14 +441,18 @@ class ExpandingTextEdit(QtWidgets.QTextEdit):
margins = self.contentsMargins()
document_width = 0
if width >= margins.left() + margins.right():
document_width = width - margins.left() - margins.right()
margins_size = margins.left() + margins.right()
if width >= margins_size:
document_width = width - margins_size
document = self.document().clone()
document.setTextWidth(document_width)
return math.ceil(
margins.top() + document.size().height() + margins.bottom()
margins.top()
+ document.size().height()
+ margins.bottom()
+ 2
)
def sizeHint(self):

View file

@ -358,9 +358,8 @@ class WorkfilesToolWindow(QtWidgets.QWidget):
if not self._host_is_valid:
return
self._folders_widget.set_project_name(
self._controller.get_current_project_name()
)
self._project_name = self._controller.get_current_project_name()
self._folders_widget.set_project_name(self._project_name)
def _on_save_as_finished(self, event):
if event["failed"]:

View file

@ -85,12 +85,13 @@ account_circle_off f7b3
account_tree e97a
action_key f502
activity_zone e1e6
acupuncture f2c4
acute e4cb
ad e65a
ad_group e65b
ad_group_off eae5
ad_off f7b2
ad_units ef39
ad_units f2eb
adaptive_audio_mic f4cc
adaptive_audio_mic_off f4cb
adb e60e
@ -127,7 +128,7 @@ add_row_below f422
add_shopping_cart e854
add_task f23a
add_to_drive e65c
add_to_home_screen e1fe
add_to_home_screen f2b9
add_to_photos e39d
add_to_queue e05c
add_triangle f48e
@ -208,10 +209,36 @@ amp_stories ea13
analytics ef3e
anchor f1cd
android e859
android_cell_4_bar ef06
android_cell_4_bar_alert ef09
android_cell_4_bar_off ef08
android_cell_4_bar_plus ef07
android_cell_5_bar ef02
android_cell_5_bar_alert ef05
android_cell_5_bar_off ef04
android_cell_5_bar_plus ef03
android_cell_dual_4_bar ef0d
android_cell_dual_4_bar_alert ef0f
android_cell_dual_4_bar_plus ef0e
android_cell_dual_5_bar ef0a
android_cell_dual_5_bar_alert ef0c
android_cell_dual_5_bar_plus ef0b
android_wifi_3_bar ef16
android_wifi_3_bar_alert ef1b
android_wifi_3_bar_lock ef1a
android_wifi_3_bar_off ef19
android_wifi_3_bar_plus ef18
android_wifi_3_bar_question ef17
android_wifi_4_bar ef10
android_wifi_4_bar_alert ef15
android_wifi_4_bar_lock ef14
android_wifi_4_bar_off ef13
android_wifi_4_bar_plus ef12
android_wifi_4_bar_question ef11
animated_images f49a
animation e71c
announcement e87f
aod efda
aod f2e6
aod_tablet f89f
aod_watch f6ac
apartment ea40
@ -219,14 +246,15 @@ api f1b7
apk_document f88e
apk_install f88f
app_badging f72f
app_blocking ef3f
app_promo e981
app_blocking f2e5
app_promo f2cd
app_registration ef40
app_settings_alt ef41
app_shortcut eae4
app_settings_alt f2d9
app_shortcut f2df
apparel ef7b
approval e982
approval_delegation f84a
approval_delegation_off f2c5
apps e5c3
apps_outage e7cc
aq f55a
@ -265,6 +293,9 @@ arrow_range f69b
arrow_right e5df
arrow_right_alt e941
arrow_selector_tool f82f
arrow_shape_up eef6
arrow_shape_up_stack eef7
arrow_shape_up_stack_2 eef8
arrow_split ea04
arrow_top_left f72e
arrow_top_right f72d
@ -287,6 +318,7 @@ aspect_ratio e85b
assessment f0cc
assignment e85d
assignment_add f848
assignment_globe eeec
assignment_ind e85e
assignment_late e85f
assignment_return e860
@ -336,6 +368,7 @@ auto_read_pause f219
auto_read_play f216
auto_schedule e214
auto_stories e666
auto_stories_off f267
auto_timer ef7f
auto_towing e71e
auto_transmission f53f
@ -352,6 +385,7 @@ av_timer e01b
avc f4af
avg_pace f6bb
avg_time f813
award_meal f241
award_star f612
azm f6ec
baby_changing_station f19b
@ -370,6 +404,7 @@ backup e864
backup_table ef43
badge ea67
badge_critical_battery f156
badminton f2a8
bakery_dining ea53
balance eaf6
balcony e58f
@ -382,9 +417,11 @@ barcode_reader f85c
barcode_scanner e70c
barefoot f871
batch_prediction f0f5
bath_bedrock f286
bath_outdoor f6fb
bath_private f6fa
bath_public_large f6f9
bath_soak f2a0
bathroom efdd
bathtub ea41
battery_0_bar ebdc
@ -410,6 +447,19 @@ battery_android_5 f308
battery_android_6 f307
battery_android_alert f306
battery_android_bolt f305
battery_android_frame_1 f257
battery_android_frame_2 f256
battery_android_frame_3 f255
battery_android_frame_4 f254
battery_android_frame_5 f253
battery_android_frame_6 f252
battery_android_frame_alert f251
battery_android_frame_bolt f250
battery_android_frame_full f24f
battery_android_frame_plus f24e
battery_android_frame_question f24d
battery_android_frame_share f24c
battery_android_frame_shield f24b
battery_android_full f304
battery_android_plus f303
battery_android_question f302
@ -449,6 +499,7 @@ bedroom_parent efe2
bedtime f159
bedtime_off eb76
beenhere e52d
beer_meal f285
bento f1f4
bia f6eb
bid_landscape e678
@ -490,7 +541,7 @@ book_3 f53d
book_4 f53c
book_5 f53b
book_6 f3df
book_online f217
book_online f2e4
book_ribbon f3e7
bookmark e8e7
bookmark_add e598
@ -537,6 +588,7 @@ breaking_news ea08
breaking_news_alt_1 f0ba
breastfeeding f856
brick f388
briefcase_meal f246
brightness_1 e3fa
brightness_2 f036
brightness_3 e3a8
@ -564,6 +616,7 @@ brush e3ae
bubble ef83
bubble_chart e6dd
bubbles f64e
bucket_check ef2a
bug_report e868
build f8cd
build_circle ef48
@ -586,7 +639,11 @@ cake_add f85b
calculate ea5f
calendar_add_on ef85
calendar_apps_script f0bb
calendar_check f243
calendar_clock f540
calendar_lock f242
calendar_meal f296
calendar_meal_2 f240
calendar_month ebcc
calendar_today e935
calendar_view_day e936
@ -607,10 +664,10 @@ call_to_action e06c
camera e3af
camera_alt e412
camera_enhance e8fc
camera_front e3b1
camera_front f2c9
camera_indoor efe9
camera_outdoor efea
camera_rear e3b2
camera_rear f2c8
camera_roll e3b3
camera_video f7a6
cameraswitch efeb
@ -628,7 +685,9 @@ car_crash ebf2
car_defrost_left f344
car_defrost_low_left f343
car_defrost_low_right f342
car_defrost_mid_left f278
car_defrost_mid_low_left f341
car_defrost_mid_low_right f277
car_defrost_mid_right f340
car_defrost_right f33f
car_fan_low_left f33e
@ -674,17 +733,21 @@ center_focus_strong e3b4
center_focus_weak e3b5
chair efed
chair_alt efee
chair_counter f29f
chair_fireplace f29e
chair_umbrella f29d
chalet e585
change_circle e2e7
change_history e86b
charger e2ae
charging_station f19d
charging_station f2e3
chart_data e473
chat e0c9
chat_add_on f0f3
chat_apps_script f0bd
chat_bubble e0cb
chat_bubble_outline e0cb
chat_dashed eeed
chat_error f7ac
chat_info f52b
chat_paste_go f6bd
@ -695,6 +758,7 @@ check_box_outline_blank e835
check_circle f0be
check_circle_filled f0be
check_circle_outline f0be
check_circle_unread f27e
check_in_out f6f6
check_indeterminate_small f88a
check_small f88b
@ -707,13 +771,22 @@ checkroom f19e
cheer f6a8
chef_hat f357
chess f5e7
chess_bishop f261
chess_bishop_2 f262
chess_king f25f
chess_king_2 f260
chess_knight f25e
chess_pawn f3b6
chess_pawn_2 f25d
chess_queen f25c
chess_rook f25b
chevron_backward f46b
chevron_forward f46a
chevron_left e5cb
chevron_right e5cc
child_care eb41
child_friendly eb42
child_hat ef30
chip_extraction f821
chips e993
chrome_reader_mode e86d
@ -839,6 +912,7 @@ control_camera e074
control_point e3ba
control_point_duplicate e3bb
controller_gen e83d
conversation ef2f
conversion_path f0c1
conversion_path_off f7b4
convert_to_text f41f
@ -984,13 +1058,13 @@ detector_status e1e8
developer_board e30d
developer_board_off e4ff
developer_guide e99e
developer_mode e1b0
developer_mode f2e2
developer_mode_tv e874
device_band f2f5
device_hub e335
device_reset e8b3
device_thermostat e1ff
device_unknown e339
device_unknown f2e1
devices e326
devices_fold ebde
devices_fold_2 f406
@ -1004,10 +1078,14 @@ dialer_sip e0bb
dialogs e99f
dialpad e0bc
diamond ead5
diamond_shine f2b2
dictionary f539
difference eb7d
digital_out_of_home f1de
digital_wellbeing ef86
dine_heart f29c
dine_in f295
dine_lamp f29b
dining eff4
dinner_dining ea57
directions e52e
@ -1057,7 +1135,7 @@ do_not_disturb_on f08f
do_not_disturb_on_total_silence effb
do_not_step f19f
do_not_touch f1b0
dock e30e
dock f2e0
dock_to_bottom f7e6
dock_to_left f7e5
dock_to_right f7e4
@ -1112,6 +1190,8 @@ drive_file_move_rtl e9a1
drive_file_rename_outline e9a2
drive_folder_upload e9a3
drive_fusiontable e678
drone f25a
drone_2 f259
dropdown e9a4
dropper_eye f351
dry f1b3
@ -1139,8 +1219,8 @@ ecg f80f
ecg_heart f6e9
eco ea35
eda f6e8
edgesensor_high f005
edgesensor_low f006
edgesensor_high f2ef
edgesensor_low f2ee
edit f097
edit_arrow_down f380
edit_arrow_up f37f
@ -1266,6 +1346,8 @@ extension e87b
extension_off e4f5
eye_tracking f4c9
eyeglasses f6ee
eyeglasses_2 f2c7
eyeglasses_2_sound f265
face f008
face_2 f8da
face_3 f8db
@ -1285,6 +1367,7 @@ fact_check f0c5
factory ebbc
falling f60d
familiar_face_and_zone e21c
family_group eef2
family_history e0ad
family_home eb26
family_link eb19
@ -1379,6 +1462,7 @@ fit_screen ea10
fit_width f779
fitness_center eb43
fitness_tracker f463
fitness_trackers eef1
flag f0c6
flag_2 f40f
flag_check f3d8
@ -1515,6 +1599,8 @@ forward_media f6f4
forward_to_inbox f187
foundation f200
fragrance f345
frame_bug eeef
frame_exclamation eeee
frame_inspect f772
frame_person f8a6
frame_person_mic f4d5
@ -1541,8 +1627,10 @@ gallery_thumbnail f86f
gamepad e30f
games e30f
garage f011
garage_check f28d
garage_door e714
garage_home e82d
garage_money f28c
garden_cart f8a9
gas_meter ec19
gastroenterology e0f1
@ -1621,9 +1709,12 @@ h_plus_mobiledata f019
h_plus_mobiledata_badge f7df
hail e9b1
hallway e6f8
hanami_dango f23f
hand_bones f894
hand_gesture ef9c
hand_gesture_off f3f3
hand_meal f294
hand_package f293
handheld_controller f4c6
handshake ebcb
handwriting_recognition eb02
@ -1655,6 +1746,7 @@ headset_off e33a
healing e3f3
health_and_beauty ef9d
health_and_safety e1d5
health_cross f2c3
health_metrics f6e2
heap_snapshot_large f76e
heap_snapshot_multiple f76d
@ -1662,11 +1754,14 @@ heap_snapshot_thumbnail f76c
hearing e023
hearing_aid f464
hearing_aid_disabled f3b0
hearing_aid_disabled_left f2ec
hearing_aid_left f2ed
hearing_disabled f104
heart_broken eac2
heart_check f60a
heart_minus f883
heart_plus f884
heart_smile f292
heat f537
heat_pump ec18
heat_pump_balance e27e
@ -1682,6 +1777,7 @@ hexagon eb39
hide ef9e
hide_image f022
hide_source f023
high_chair f29a
high_density f79c
high_quality e024
high_res f54b
@ -1770,6 +1866,7 @@ iframe_off f71c
image e3f4
image_arrow_up f317
image_aspect_ratio e3f5
image_inset f247
image_not_supported f116
image_search e43f
imagesearch_roller e9b4
@ -1815,7 +1912,7 @@ insert_photo e3f4
insert_text f827
insights f092
install_desktop eb71
install_mobile eb72
install_mobile f2cd
instant_mix e026
integration_instructions ef54
interactive_space f7ff
@ -1830,6 +1927,8 @@ ios_share e6b8
iron e583
iso e3f6
jamboard_kiosk e9b5
japanese_curry f284
japanese_flag f283
javascript eb7c
join f84f
join_full f84f
@ -1838,6 +1937,7 @@ join_left eaf2
join_right eaea
joystick f5ee
jump_to_element f719
kanji_alcohol f23e
kayaking e50c
kebab_dining e842
keep f026
@ -2065,9 +2165,11 @@ magnification_small f83c
magnify_docked f7d6
magnify_fullscreen f7d5
mail e159
mail_asterisk eef4
mail_lock ec0a
mail_off f48b
mail_outline e159
mail_shield f249
male e58e
man e4eb
man_2 f8e1
@ -2079,6 +2181,8 @@ manage_search f02f
manga f5e3
manufacturing e726
map e55b
map_pin_heart f298
map_pin_review f297
map_search f3ca
maps_home_work f030
maps_ugc ef58
@ -2097,11 +2201,14 @@ markunread_mailbox e89b
masked_transitions e72e
masked_transitions_add f42b
masks f218
massage f2c2
match_case f6f1
match_case_off f36f
match_word f6f0
matter e907
maximize e930
meal_dinner f23d
meal_lunch f23c
measuring_tape f6af
media_bluetooth_off f031
media_bluetooth_on f032
@ -2120,6 +2227,7 @@ memory_alt f7a3
menstrual_health f6e1
menu e5d2
menu_book ea19
menu_book_2 f291
menu_open e9bd
merge eb98
merge_type e252
@ -2151,17 +2259,57 @@ mist e188
mitre f547
mixture_med e4c8
mms e618
mobile_friendly e200
mobile e7ba
mobile_2 f2db
mobile_3 f2da
mobile_alert f2d3
mobile_arrow_down f2cd
mobile_arrow_right f2d2
mobile_arrow_up_right f2b9
mobile_block f2e5
mobile_camera f44e
mobile_camera_front f2c9
mobile_camera_rear f2c8
mobile_cancel f2ea
mobile_cast f2cc
mobile_charge f2e3
mobile_chat f79f
mobile_check f073
mobile_code f2e2
mobile_dots f2d0
mobile_friendly f073
mobile_gear f2d9
mobile_hand f323
mobile_hand_left f313
mobile_hand_left_off f312
mobile_hand_off f314
mobile_info f2dc
mobile_landscape ed3e
mobile_layout f2bf
mobile_lock_landscape f2d8
mobile_lock_portrait f2be
mobile_loupe f322
mobile_menu f2d1
mobile_off e201
mobile_screen_share e0e7
mobile_question f2e1
mobile_rotate f2d5
mobile_rotate_lock f2d6
mobile_screen_share f2df
mobile_screensaver f321
mobile_sensor_hi f2ef
mobile_sensor_lo f2ee
mobile_share f2df
mobile_share_stack f2de
mobile_sound f2e8
mobile_sound_2 f318
mobile_sound_off f7aa
mobile_speaker f320
mobile_text f2eb
mobile_text_2 f2e6
mobile_theft f2a9
mobile_ticket f2e4
mobile_vibrate f2cb
mobile_wrench f2b0
mobiledata_off f034
mode f097
mode_comment e253
@ -2186,6 +2334,7 @@ money e57d
money_bag f3ee
money_off f038
money_off_csred f038
money_range f245
monitor ef5b
monitor_heart eaa2
monitor_weight f039
@ -2199,6 +2348,7 @@ mood_bad e7f3
moon_stars f34f
mop e28d
moped eb28
moped_package f28b
more e619
more_down f196
more_horiz e5d3
@ -2220,6 +2370,7 @@ motion_sensor_idle e783
motion_sensor_urgent e78e
motorcycle e91b
mountain_flag f5e2
mountain_steam f282
mouse e323
mouse_lock f490
mouse_lock_off f48f
@ -2241,6 +2392,7 @@ movie_edit f840
movie_filter e43a
movie_info e02d
movie_off f499
movie_speaker f2a3
moving e501
moving_beds e73d
moving_ministry e73e
@ -2252,6 +2404,7 @@ multiple_airports efab
multiple_stop f1b9
museum ea36
music_cast eb1a
music_history f2c1
music_note e405
music_note_add f391
music_off e440
@ -2288,6 +2441,11 @@ nest_display f124
nest_display_max f125
nest_doorbell_visitor f8bd
nest_eco_leaf f8be
nest_farsight_cool f27d
nest_farsight_dual f27c
nest_farsight_eco f27b
nest_farsight_heat f27a
nest_farsight_seasonal f279
nest_farsight_weather f8bf
nest_found_savings f8c0
nest_gale_wifi f579
@ -2356,7 +2514,7 @@ night_sight_max f6c3
nightlife ea62
nightlight f03d
nightlight_round f03d
nights_stay ea46
nights_stay f174
no_accounts f03e
no_adult_content f8fe
no_backpack f237
@ -2410,8 +2568,9 @@ odt e6e9
offline_bolt e932
offline_pin e90a
offline_pin_off f4d0
offline_share e9c5
offline_share f2de
oil_barrel ec15
okonomiyaki f281
on_device_training ebfd
on_hub_device e6c3
oncology e114
@ -2424,7 +2583,7 @@ open_in_full f1ce
open_in_new e89e
open_in_new_down f70f
open_in_new_off e4f6
open_in_phone e702
open_in_phone f2d2
open_jam efae
open_run f4b7
open_with e89f
@ -2461,10 +2620,12 @@ pacemaker e656
package e48f
package_2 f569
padding e9c8
padel f2a7
page_control e731
page_footer f383
page_header f384
page_info f614
page_menu_ios eefb
pageless f509
pages e7f9
pageview e8a0
@ -2481,10 +2642,15 @@ panorama_photosphere e9c9
panorama_vertical e40e
panorama_wide_angle e40f
paragliding e50f
parent_child_dining f22d
park ea63
parking_meter f28a
parking_sign f289
parking_valet f288
partly_cloudy_day f172
partly_cloudy_night f174
partner_exchange f7f9
partner_heart ef2e
partner_reports efaf
party_mode e7fa
passkey f87f
@ -2499,6 +2665,8 @@ pause_circle_filled e1a2
pause_circle_outline e1a2
pause_presentation e0ea
payment e8a1
payment_arrow_down f2c0
payment_card f2a1
payments ef63
pedal_bike eb29
pediatrics e11d
@ -2514,12 +2682,13 @@ people ea21
people_alt ea21
people_outline ea21
percent eb58
percent_discount f244
performance_max e51a
pergola e203
perm_camera_mic e8a2
perm_contact_calendar e8a3
perm_data_setting e8a4
perm_device_information e8a5
perm_device_information f2dc
perm_identity f0d3
perm_media e8a7
perm_phone_msg e8a8
@ -2539,6 +2708,7 @@ person_celebrate f7fe
person_check f565
person_edit f4fa
person_filled f0d3
person_heart f290
person_off e510
person_outline f0d3
person_pin e55a
@ -2561,24 +2731,24 @@ pets e91d
phishing ead7
phone f0d4
phone_alt f0d4
phone_android e324
phone_android f2db
phone_bluetooth_speaker e61b
phone_callback e649
phone_disabled e9cc
phone_enabled e9cd
phone_forwarded e61c
phone_in_talk e61d
phone_iphone e325
phone_iphone f2da
phone_locked e61e
phone_missed e61f
phone_paused e620
phonelink e326
phonelink_erase e0db
phonelink_lock e0dc
phonelink_off e327
phonelink_ring e0dd
phonelink_erase f2ea
phonelink_lock f2be
phonelink_off f7a5
phonelink_ring f2e8
phonelink_ring_off f7aa
phonelink_setup ef41
phonelink_setup f2d9
photo e432
photo_album e411
photo_auto_merge f530
@ -2596,6 +2766,7 @@ php eb8f
physical_therapy e11e
piano e521
piano_off e520
pickleball f2a6
picture_as_pdf e415
picture_in_picture e8aa
picture_in_picture_alt e911
@ -2626,6 +2797,7 @@ pivot_table_chart e9ce
place f1db
place_item f1f0
plagiarism ea5a
plane_contrails f2ac
planet f387
planner_banner_ad_pt e692
planner_review e694
@ -2637,6 +2809,8 @@ play_lesson f047
play_music e6ee
play_pause f137
play_shapes f7fc
playground f28e
playground_2 f28f
playing_cards f5dc
playlist_add e03b
playlist_add_check e065
@ -2818,6 +2992,7 @@ report_problem f083
request_page f22c
request_quote f1b6
reset_brightness f482
reset_exposure f266
reset_focus f481
reset_image f824
reset_iso f480
@ -2830,6 +3005,7 @@ reset_wrench f56c
resize f707
respiratory_rate e127
responsive_layout e9da
rest_area f22a
restart_alt f053
restaurant e56c
restaurant_menu e561
@ -2913,11 +3089,11 @@ science_off f542
scooter f471
score e269
scoreboard ebd0
screen_lock_landscape e1be
screen_lock_portrait e1bf
screen_lock_rotation e1c0
screen_lock_landscape f2d8
screen_lock_portrait f2be
screen_lock_rotation f2d6
screen_record f679
screen_rotation e1c1
screen_rotation f2d5
screen_rotation_alt ebee
screen_rotation_up f678
screen_search_desktop ef70
@ -2941,6 +3117,7 @@ search e8b6
search_activity f3e5
search_check f800
search_check_2 f469
search_gear eefa
search_hands_free e696
search_insights f4bc
search_off ea76
@ -2952,9 +3129,9 @@ seat_vent_left f32d
seat_vent_right f32c
security e32a
security_key f503
security_update f072
security_update f2cd
security_update_good f073
security_update_warning f074
security_update_warning f2d3
segment e94b
select f74d
select_all e162
@ -2970,7 +3147,7 @@ send e163
send_and_archive ea0c
send_money e8b7
send_time_extension eadb
send_to_mobile f05c
send_to_mobile f2d2
sensor_door f1b5
sensor_occupied ec10
sensor_window f1b4
@ -3005,7 +3182,7 @@ settings_b_roll f625
settings_backup_restore e8ba
settings_bluetooth e8bb
settings_brightness e8bd
settings_cell e8bc
settings_cell f2d1
settings_cinematic_blur f624
settings_ethernet e8be
settings_heart f522
@ -3022,6 +3199,7 @@ settings_phone e8c5
settings_photo_camera f834
settings_power e8c6
settings_remote e8c7
settings_seating ef2d
settings_slow_motion f623
settings_suggest f05e
settings_system_daydream e1c3
@ -3042,6 +3220,7 @@ share_location f05f
share_off f6cb
share_reviews f8a4
share_windows f613
shaved_ice f225
sheets_rtl f823
shelf_auto_hide f703
shelf_position f702
@ -3052,6 +3231,7 @@ shield_locked f592
shield_moon eaa9
shield_person f650
shield_question f529
shield_toggle f2ad
shield_watch f30f
shield_with_heart e78f
shield_with_house e78d
@ -3081,6 +3261,7 @@ shutter_speed_minus f57d
sick f220
side_navigation e9e2
sign_language ebe5
sign_language_2 f258
signal_cellular_0_bar f0a8
signal_cellular_1_bar f0a9
signal_cellular_2_bar f0aa
@ -3140,9 +3321,9 @@ smart_card_reader f4a5
smart_card_reader_off f4a6
smart_display f06a
smart_outlet e844
smart_screen f06b
smart_screen f2d0
smart_toy f06c
smartphone e32c
smartphone e7ba
smartphone_camera f44e
smb_share f74b
smoke_free eb4a
@ -3157,9 +3338,11 @@ snowing_heavy f61c
snowmobile e503
snowshoeing e514
soap f1b2
soba ef36
social_distance e1cb
social_leaderboard f6a0
solar_power ec0f
solo_dining ef35
sort e164
sort_by_alpha e053
sos ebf7
@ -3283,10 +3466,10 @@ stat_3 e69a
stat_minus_1 e69b
stat_minus_2 e69c
stat_minus_3 e69d
stay_current_landscape e0d3
stay_current_portrait e0d4
stay_primary_landscape e0d5
stay_primary_portrait e0d6
stay_current_landscape ed3e
stay_current_portrait e7ba
stay_primary_landscape ed3e
stay_primary_portrait f2d3
steering_wheel_heat f32b
step f6fe
step_into f701
@ -3340,6 +3523,7 @@ subtitles e048
subtitles_gear f355
subtitles_off ef72
subway e56f
subway_walk f287
summarize f071
sunny e81a
sunny_snowing e819
@ -3395,11 +3579,12 @@ sync_disabled e628
sync_lock eaee
sync_problem e629
sync_saved_locally f820
sync_saved_locally_off f264
syringe e133
system_security_update f072
system_security_update f2cd
system_security_update_good f073
system_security_update_warning f074
system_update f072
system_security_update_warning f2d3
system_update f2cd
system_update_alt e8d7
tab e8d8
tab_close f745
@ -3421,9 +3606,11 @@ table_convert f3c7
table_edit f3c6
table_eye f466
table_lamp e1f2
table_large f299
table_restaurant eac6
table_rows f101
table_rows_narrow f73f
table_sign ef2c
table_view f1be
tablet e32f
tablet_android e330
@ -3434,13 +3621,15 @@ tactic f564
tag e9ef
tag_faces ea22
takeout_dining ea74
takeout_dining_2 ef34
tamper_detection_off e82e
tamper_detection_on f8c8
tap_and_play e62b
tap_and_play f2cc
tapas f1e9
target e719
task f075
task_alt e2e6
tatami_seat ef33
taunt f69f
taxi_alert ef74
team_dashboard e013
@ -3507,6 +3696,7 @@ thumb_up_filled f577
thumb_up_off f577
thumb_up_off_alt f577
thumbnail_bar f734
thumbs_up_double eefc
thumbs_up_down e8dd
thunderstorm ebdb
tibia f89b
@ -3519,9 +3709,11 @@ time_to_leave eff7
timelapse e422
timeline e922
timer e425
timer_1 f2af
timer_10 e423
timer_10_alt_1 efbf
timer_10_select f07a
timer_2 f2ae
timer_3 e424
timer_3_alt_1 efc0
timer_3_select f07b
@ -3544,6 +3736,7 @@ toggle_on e9f6
token ea25
toll e8e0
tonality e427
tonality_2 f2b4
toolbar e9f7
tools_flat_head f8cb
tools_installation_kit e2ab
@ -3593,6 +3786,7 @@ transition_fade f50c
transition_push f50b
transition_slide f50a
translate e8e2
translate_indic f263
transportation e21d
travel ef93
travel_explore e2db
@ -3637,6 +3831,7 @@ two_wheeler e9f9
type_specimen f8f0
u_turn_left eba1
u_turn_right eba2
udon ef32
ulna_radius f89d
ulna_radius_alt f89e
umbrella f1ad
@ -3693,7 +3888,7 @@ vertical_distribute e076
vertical_shades ec0e
vertical_shades_closed ec0d
vertical_split e949
vibration e62d
vibration f2cb
video_call e070
video_camera_back f07f
video_camera_back_add f40c
@ -3738,6 +3933,7 @@ view_stream e8f2
view_timeline eb85
view_week e8f3
vignette e435
vignette_2 f2b3
villa e586
visibility e8f4
visibility_lock f653
@ -3780,7 +3976,9 @@ warning f083
warning_amber f083
warning_off f7ad
wash f1b1
washoku f280
watch e334
watch_arrow f2ca
watch_button_press f6aa
watch_check f468
watch_later efd6
@ -3867,6 +4065,7 @@ window f088
window_closed e77e
window_open e78c
window_sensor e2bb
windshield_defrost_auto f248
windshield_defrost_front f32a
windshield_defrost_rear f329
windshield_heat_front f328
@ -3888,7 +4087,9 @@ wrap_text e25b
wrist f69c
wrong_location ef78
wysiwyg f1c3
yakitori ef31
yard f089
yoshoku f27f
your_trips eb2b
youtube_activity f85a
youtube_searched_for e8fa

View file

@ -86,12 +86,13 @@
"account_tree": 59770,
"action_key": 62722,
"activity_zone": 57830,
"acupuncture": 62148,
"acute": 58571,
"ad": 58970,
"ad_group": 58971,
"ad_group_off": 60133,
"ad_off": 63410,
"ad_units": 61241,
"ad_units": 62187,
"adaptive_audio_mic": 62668,
"adaptive_audio_mic_off": 62667,
"adb": 58894,
@ -128,7 +129,7 @@
"add_shopping_cart": 59476,
"add_task": 62010,
"add_to_drive": 58972,
"add_to_home_screen": 57854,
"add_to_home_screen": 62137,
"add_to_photos": 58269,
"add_to_queue": 57436,
"add_triangle": 62606,
@ -209,10 +210,36 @@
"analytics": 61246,
"anchor": 61901,
"android": 59481,
"android_cell_4_bar": 61190,
"android_cell_4_bar_alert": 61193,
"android_cell_4_bar_off": 61192,
"android_cell_4_bar_plus": 61191,
"android_cell_5_bar": 61186,
"android_cell_5_bar_alert": 61189,
"android_cell_5_bar_off": 61188,
"android_cell_5_bar_plus": 61187,
"android_cell_dual_4_bar": 61197,
"android_cell_dual_4_bar_alert": 61199,
"android_cell_dual_4_bar_plus": 61198,
"android_cell_dual_5_bar": 61194,
"android_cell_dual_5_bar_alert": 61196,
"android_cell_dual_5_bar_plus": 61195,
"android_wifi_3_bar": 61206,
"android_wifi_3_bar_alert": 61211,
"android_wifi_3_bar_lock": 61210,
"android_wifi_3_bar_off": 61209,
"android_wifi_3_bar_plus": 61208,
"android_wifi_3_bar_question": 61207,
"android_wifi_4_bar": 61200,
"android_wifi_4_bar_alert": 61205,
"android_wifi_4_bar_lock": 61204,
"android_wifi_4_bar_off": 61203,
"android_wifi_4_bar_plus": 61202,
"android_wifi_4_bar_question": 61201,
"animated_images": 62618,
"animation": 59164,
"announcement": 59519,
"aod": 61402,
"aod": 62182,
"aod_tablet": 63647,
"aod_watch": 63148,
"apartment": 59968,
@ -220,14 +247,15 @@
"apk_document": 63630,
"apk_install": 63631,
"app_badging": 63279,
"app_blocking": 61247,
"app_promo": 59777,
"app_blocking": 62181,
"app_promo": 62157,
"app_registration": 61248,
"app_settings_alt": 61249,
"app_shortcut": 60132,
"app_settings_alt": 62169,
"app_shortcut": 62175,
"apparel": 61307,
"approval": 59778,
"approval_delegation": 63562,
"approval_delegation_off": 62149,
"apps": 58819,
"apps_outage": 59340,
"aq": 62810,
@ -266,6 +294,9 @@
"arrow_right": 58847,
"arrow_right_alt": 59713,
"arrow_selector_tool": 63535,
"arrow_shape_up": 61174,
"arrow_shape_up_stack": 61175,
"arrow_shape_up_stack_2": 61176,
"arrow_split": 59908,
"arrow_top_left": 63278,
"arrow_top_right": 63277,
@ -288,6 +319,7 @@
"assessment": 61644,
"assignment": 59485,
"assignment_add": 63560,
"assignment_globe": 61164,
"assignment_ind": 59486,
"assignment_late": 59487,
"assignment_return": 59488,
@ -337,6 +369,7 @@
"auto_read_play": 61974,
"auto_schedule": 57876,
"auto_stories": 58982,
"auto_stories_off": 62055,
"auto_timer": 61311,
"auto_towing": 59166,
"auto_transmission": 62783,
@ -353,6 +386,7 @@
"avc": 62639,
"avg_pace": 63163,
"avg_time": 63507,
"award_meal": 62017,
"award_star": 62994,
"azm": 63212,
"baby_changing_station": 61851,
@ -371,6 +405,7 @@
"backup_table": 61251,
"badge": 60007,
"badge_critical_battery": 61782,
"badminton": 62120,
"bakery_dining": 59987,
"balance": 60150,
"balcony": 58767,
@ -383,9 +418,11 @@
"barcode_scanner": 59148,
"barefoot": 63601,
"batch_prediction": 61685,
"bath_bedrock": 62086,
"bath_outdoor": 63227,
"bath_private": 63226,
"bath_public_large": 63225,
"bath_soak": 62112,
"bathroom": 61405,
"bathtub": 59969,
"battery_0_bar": 60380,
@ -411,6 +448,19 @@
"battery_android_6": 62215,
"battery_android_alert": 62214,
"battery_android_bolt": 62213,
"battery_android_frame_1": 62039,
"battery_android_frame_2": 62038,
"battery_android_frame_3": 62037,
"battery_android_frame_4": 62036,
"battery_android_frame_5": 62035,
"battery_android_frame_6": 62034,
"battery_android_frame_alert": 62033,
"battery_android_frame_bolt": 62032,
"battery_android_frame_full": 62031,
"battery_android_frame_plus": 62030,
"battery_android_frame_question": 62029,
"battery_android_frame_share": 62028,
"battery_android_frame_shield": 62027,
"battery_android_full": 62212,
"battery_android_plus": 62211,
"battery_android_question": 62210,
@ -450,6 +500,7 @@
"bedtime": 61785,
"bedtime_off": 60278,
"beenhere": 58669,
"beer_meal": 62085,
"bento": 61940,
"bia": 63211,
"bid_landscape": 59000,
@ -491,7 +542,7 @@
"book_4": 62780,
"book_5": 62779,
"book_6": 62431,
"book_online": 61975,
"book_online": 62180,
"book_ribbon": 62439,
"bookmark": 59623,
"bookmark_add": 58776,
@ -538,6 +589,7 @@
"breaking_news_alt_1": 61626,
"breastfeeding": 63574,
"brick": 62344,
"briefcase_meal": 62022,
"brightness_1": 58362,
"brightness_2": 61494,
"brightness_3": 58280,
@ -565,6 +617,7 @@
"bubble": 61315,
"bubble_chart": 59101,
"bubbles": 63054,
"bucket_check": 61226,
"bug_report": 59496,
"build": 63693,
"build_circle": 61256,
@ -587,7 +640,11 @@
"calculate": 59999,
"calendar_add_on": 61317,
"calendar_apps_script": 61627,
"calendar_check": 62019,
"calendar_clock": 62784,
"calendar_lock": 62018,
"calendar_meal": 62102,
"calendar_meal_2": 62016,
"calendar_month": 60364,
"calendar_today": 59701,
"calendar_view_day": 59702,
@ -608,10 +665,10 @@
"camera": 58287,
"camera_alt": 58386,
"camera_enhance": 59644,
"camera_front": 58289,
"camera_front": 62153,
"camera_indoor": 61417,
"camera_outdoor": 61418,
"camera_rear": 58290,
"camera_rear": 62152,
"camera_roll": 58291,
"camera_video": 63398,
"cameraswitch": 61419,
@ -629,7 +686,9 @@
"car_defrost_left": 62276,
"car_defrost_low_left": 62275,
"car_defrost_low_right": 62274,
"car_defrost_mid_left": 62072,
"car_defrost_mid_low_left": 62273,
"car_defrost_mid_low_right": 62071,
"car_defrost_mid_right": 62272,
"car_defrost_right": 62271,
"car_fan_low_left": 62270,
@ -675,17 +734,21 @@
"center_focus_weak": 58293,
"chair": 61421,
"chair_alt": 61422,
"chair_counter": 62111,
"chair_fireplace": 62110,
"chair_umbrella": 62109,
"chalet": 58757,
"change_circle": 58087,
"change_history": 59499,
"charger": 58030,
"charging_station": 61853,
"charging_station": 62179,
"chart_data": 58483,
"chat": 57545,
"chat_add_on": 61683,
"chat_apps_script": 61629,
"chat_bubble": 57547,
"chat_bubble_outline": 57547,
"chat_dashed": 61165,
"chat_error": 63404,
"chat_info": 62763,
"chat_paste_go": 63165,
@ -696,6 +759,7 @@
"check_circle": 61630,
"check_circle_filled": 61630,
"check_circle_outline": 61630,
"check_circle_unread": 62078,
"check_in_out": 63222,
"check_indeterminate_small": 63626,
"check_small": 63627,
@ -708,13 +772,22 @@
"cheer": 63144,
"chef_hat": 62295,
"chess": 62951,
"chess_bishop": 62049,
"chess_bishop_2": 62050,
"chess_king": 62047,
"chess_king_2": 62048,
"chess_knight": 62046,
"chess_pawn": 62390,
"chess_pawn_2": 62045,
"chess_queen": 62044,
"chess_rook": 62043,
"chevron_backward": 62571,
"chevron_forward": 62570,
"chevron_left": 58827,
"chevron_right": 58828,
"child_care": 60225,
"child_friendly": 60226,
"child_hat": 61232,
"chip_extraction": 63521,
"chips": 59795,
"chrome_reader_mode": 59501,
@ -840,6 +913,7 @@
"control_point": 58298,
"control_point_duplicate": 58299,
"controller_gen": 59453,
"conversation": 61231,
"conversion_path": 61633,
"conversion_path_off": 63412,
"convert_to_text": 62495,
@ -985,13 +1059,13 @@
"developer_board": 58125,
"developer_board_off": 58623,
"developer_guide": 59806,
"developer_mode": 57776,
"developer_mode": 62178,
"developer_mode_tv": 59508,
"device_band": 62197,
"device_hub": 58165,
"device_reset": 59571,
"device_thermostat": 57855,
"device_unknown": 58169,
"device_unknown": 62177,
"devices": 58150,
"devices_fold": 60382,
"devices_fold_2": 62470,
@ -1005,10 +1079,14 @@
"dialogs": 59807,
"dialpad": 57532,
"diamond": 60117,
"diamond_shine": 62130,
"dictionary": 62777,
"difference": 60285,
"digital_out_of_home": 61918,
"digital_wellbeing": 61318,
"dine_heart": 62108,
"dine_in": 62101,
"dine_lamp": 62107,
"dining": 61428,
"dinner_dining": 59991,
"directions": 58670,
@ -1058,7 +1136,7 @@
"do_not_disturb_on_total_silence": 61435,
"do_not_step": 61855,
"do_not_touch": 61872,
"dock": 58126,
"dock": 62176,
"dock_to_bottom": 63462,
"dock_to_left": 63461,
"dock_to_right": 63460,
@ -1113,6 +1191,8 @@
"drive_file_rename_outline": 59810,
"drive_folder_upload": 59811,
"drive_fusiontable": 59000,
"drone": 62042,
"drone_2": 62041,
"dropdown": 59812,
"dropper_eye": 62289,
"dry": 61875,
@ -1140,8 +1220,8 @@
"ecg_heart": 63209,
"eco": 59957,
"eda": 63208,
"edgesensor_high": 61445,
"edgesensor_low": 61446,
"edgesensor_high": 62191,
"edgesensor_low": 62190,
"edit": 61591,
"edit_arrow_down": 62336,
"edit_arrow_up": 62335,
@ -1267,6 +1347,8 @@
"extension_off": 58613,
"eye_tracking": 62665,
"eyeglasses": 63214,
"eyeglasses_2": 62151,
"eyeglasses_2_sound": 62053,
"face": 61448,
"face_2": 63706,
"face_3": 63707,
@ -1286,6 +1368,7 @@
"factory": 60348,
"falling": 62989,
"familiar_face_and_zone": 57884,
"family_group": 61170,
"family_history": 57517,
"family_home": 60198,
"family_link": 60185,
@ -1380,6 +1463,7 @@
"fit_width": 63353,
"fitness_center": 60227,
"fitness_tracker": 62563,
"fitness_trackers": 61169,
"flag": 61638,
"flag_2": 62479,
"flag_check": 62424,
@ -1516,6 +1600,8 @@
"forward_to_inbox": 61831,
"foundation": 61952,
"fragrance": 62277,
"frame_bug": 61167,
"frame_exclamation": 61166,
"frame_inspect": 63346,
"frame_person": 63654,
"frame_person_mic": 62677,
@ -1542,8 +1628,10 @@
"gamepad": 58127,
"games": 58127,
"garage": 61457,
"garage_check": 62093,
"garage_door": 59156,
"garage_home": 59437,
"garage_money": 62092,
"garden_cart": 63657,
"gas_meter": 60441,
"gastroenterology": 57585,
@ -1622,9 +1710,12 @@
"h_plus_mobiledata_badge": 63455,
"hail": 59825,
"hallway": 59128,
"hanami_dango": 62015,
"hand_bones": 63636,
"hand_gesture": 61340,
"hand_gesture_off": 62451,
"hand_meal": 62100,
"hand_package": 62099,
"handheld_controller": 62662,
"handshake": 60363,
"handwriting_recognition": 60162,
@ -1656,6 +1747,7 @@
"healing": 58355,
"health_and_beauty": 61341,
"health_and_safety": 57813,
"health_cross": 62147,
"health_metrics": 63202,
"heap_snapshot_large": 63342,
"heap_snapshot_multiple": 63341,
@ -1663,11 +1755,14 @@
"hearing": 57379,
"hearing_aid": 62564,
"hearing_aid_disabled": 62384,
"hearing_aid_disabled_left": 62188,
"hearing_aid_left": 62189,
"hearing_disabled": 61700,
"heart_broken": 60098,
"heart_check": 62986,
"heart_minus": 63619,
"heart_plus": 63620,
"heart_smile": 62098,
"heat": 62775,
"heat_pump": 60440,
"heat_pump_balance": 57982,
@ -1683,6 +1778,7 @@
"hide": 61342,
"hide_image": 61474,
"hide_source": 61475,
"high_chair": 62106,
"high_density": 63388,
"high_quality": 57380,
"high_res": 62795,
@ -1771,6 +1867,7 @@
"image": 58356,
"image_arrow_up": 62231,
"image_aspect_ratio": 58357,
"image_inset": 62023,
"image_not_supported": 61718,
"image_search": 58431,
"imagesearch_roller": 59828,
@ -1816,7 +1913,7 @@
"insert_text": 63527,
"insights": 61586,
"install_desktop": 60273,
"install_mobile": 60274,
"install_mobile": 62157,
"instant_mix": 57382,
"integration_instructions": 61268,
"interactive_space": 63487,
@ -1831,6 +1928,8 @@
"iron": 58755,
"iso": 58358,
"jamboard_kiosk": 59829,
"japanese_curry": 62084,
"japanese_flag": 62083,
"javascript": 60284,
"join": 63567,
"join_full": 63567,
@ -1839,6 +1938,7 @@
"join_right": 60138,
"joystick": 62958,
"jump_to_element": 63257,
"kanji_alcohol": 62014,
"kayaking": 58636,
"kebab_dining": 59458,
"keep": 61478,
@ -2066,9 +2166,11 @@
"magnify_docked": 63446,
"magnify_fullscreen": 63445,
"mail": 57689,
"mail_asterisk": 61172,
"mail_lock": 60426,
"mail_off": 62603,
"mail_outline": 57689,
"mail_shield": 62025,
"male": 58766,
"man": 58603,
"man_2": 63713,
@ -2080,6 +2182,8 @@
"manga": 62947,
"manufacturing": 59174,
"map": 58715,
"map_pin_heart": 62104,
"map_pin_review": 62103,
"map_search": 62410,
"maps_home_work": 61488,
"maps_ugc": 61272,
@ -2098,11 +2202,14 @@
"masked_transitions": 59182,
"masked_transitions_add": 62507,
"masks": 61976,
"massage": 62146,
"match_case": 63217,
"match_case_off": 62319,
"match_word": 63216,
"matter": 59655,
"maximize": 59696,
"meal_dinner": 62013,
"meal_lunch": 62012,
"measuring_tape": 63151,
"media_bluetooth_off": 61489,
"media_bluetooth_on": 61490,
@ -2121,6 +2228,7 @@
"menstrual_health": 63201,
"menu": 58834,
"menu_book": 59929,
"menu_book_2": 62097,
"menu_open": 59837,
"merge": 60312,
"merge_type": 57938,
@ -2152,17 +2260,57 @@
"mitre": 62791,
"mixture_med": 58568,
"mms": 58904,
"mobile_friendly": 57856,
"mobile": 59322,
"mobile_2": 62171,
"mobile_3": 62170,
"mobile_alert": 62163,
"mobile_arrow_down": 62157,
"mobile_arrow_right": 62162,
"mobile_arrow_up_right": 62137,
"mobile_block": 62181,
"mobile_camera": 62542,
"mobile_camera_front": 62153,
"mobile_camera_rear": 62152,
"mobile_cancel": 62186,
"mobile_cast": 62156,
"mobile_charge": 62179,
"mobile_chat": 63391,
"mobile_check": 61555,
"mobile_code": 62178,
"mobile_dots": 62160,
"mobile_friendly": 61555,
"mobile_gear": 62169,
"mobile_hand": 62243,
"mobile_hand_left": 62227,
"mobile_hand_left_off": 62226,
"mobile_hand_off": 62228,
"mobile_info": 62172,
"mobile_landscape": 60734,
"mobile_layout": 62143,
"mobile_lock_landscape": 62168,
"mobile_lock_portrait": 62142,
"mobile_loupe": 62242,
"mobile_menu": 62161,
"mobile_off": 57857,
"mobile_screen_share": 57575,
"mobile_question": 62177,
"mobile_rotate": 62165,
"mobile_rotate_lock": 62166,
"mobile_screen_share": 62175,
"mobile_screensaver": 62241,
"mobile_sensor_hi": 62191,
"mobile_sensor_lo": 62190,
"mobile_share": 62175,
"mobile_share_stack": 62174,
"mobile_sound": 62184,
"mobile_sound_2": 62232,
"mobile_sound_off": 63402,
"mobile_speaker": 62240,
"mobile_text": 62187,
"mobile_text_2": 62182,
"mobile_theft": 62121,
"mobile_ticket": 62180,
"mobile_vibrate": 62155,
"mobile_wrench": 62128,
"mobiledata_off": 61492,
"mode": 61591,
"mode_comment": 57939,
@ -2187,6 +2335,7 @@
"money_bag": 62446,
"money_off": 61496,
"money_off_csred": 61496,
"money_range": 62021,
"monitor": 61275,
"monitor_heart": 60066,
"monitor_weight": 61497,
@ -2200,6 +2349,7 @@
"moon_stars": 62287,
"mop": 57997,
"moped": 60200,
"moped_package": 62091,
"more": 58905,
"more_down": 61846,
"more_horiz": 58835,
@ -2221,6 +2371,7 @@
"motion_sensor_urgent": 59278,
"motorcycle": 59675,
"mountain_flag": 62946,
"mountain_steam": 62082,
"mouse": 58147,
"mouse_lock": 62608,
"mouse_lock_off": 62607,
@ -2242,6 +2393,7 @@
"movie_filter": 58426,
"movie_info": 57389,
"movie_off": 62617,
"movie_speaker": 62115,
"moving": 58625,
"moving_beds": 59197,
"moving_ministry": 59198,
@ -2253,6 +2405,7 @@
"multiple_stop": 61881,
"museum": 59958,
"music_cast": 60186,
"music_history": 62145,
"music_note": 58373,
"music_note_add": 62353,
"music_off": 58432,
@ -2289,6 +2442,11 @@
"nest_display_max": 61733,
"nest_doorbell_visitor": 63677,
"nest_eco_leaf": 63678,
"nest_farsight_cool": 62077,
"nest_farsight_dual": 62076,
"nest_farsight_eco": 62075,
"nest_farsight_heat": 62074,
"nest_farsight_seasonal": 62073,
"nest_farsight_weather": 63679,
"nest_found_savings": 63680,
"nest_gale_wifi": 62841,
@ -2357,7 +2515,7 @@
"nightlife": 60002,
"nightlight": 61501,
"nightlight_round": 61501,
"nights_stay": 59974,
"nights_stay": 61812,
"no_accounts": 61502,
"no_adult_content": 63742,
"no_backpack": 62007,
@ -2411,8 +2569,9 @@
"offline_bolt": 59698,
"offline_pin": 59658,
"offline_pin_off": 62672,
"offline_share": 59845,
"offline_share": 62174,
"oil_barrel": 60437,
"okonomiyaki": 62081,
"on_device_training": 60413,
"on_hub_device": 59075,
"oncology": 57620,
@ -2425,7 +2584,7 @@
"open_in_new": 59550,
"open_in_new_down": 63247,
"open_in_new_off": 58614,
"open_in_phone": 59138,
"open_in_phone": 62162,
"open_jam": 61358,
"open_run": 62647,
"open_with": 59551,
@ -2462,10 +2621,12 @@
"package": 58511,
"package_2": 62825,
"padding": 59848,
"padel": 62119,
"page_control": 59185,
"page_footer": 62339,
"page_header": 62340,
"page_info": 62996,
"page_menu_ios": 61179,
"pageless": 62729,
"pages": 59385,
"pageview": 59552,
@ -2482,10 +2643,15 @@
"panorama_vertical": 58382,
"panorama_wide_angle": 58383,
"paragliding": 58639,
"parent_child_dining": 61997,
"park": 60003,
"parking_meter": 62090,
"parking_sign": 62089,
"parking_valet": 62088,
"partly_cloudy_day": 61810,
"partly_cloudy_night": 61812,
"partner_exchange": 63481,
"partner_heart": 61230,
"partner_reports": 61359,
"party_mode": 59386,
"passkey": 63615,
@ -2500,6 +2666,8 @@
"pause_circle_outline": 57762,
"pause_presentation": 57578,
"payment": 59553,
"payment_arrow_down": 62144,
"payment_card": 62113,
"payments": 61283,
"pedal_bike": 60201,
"pediatrics": 57629,
@ -2515,12 +2683,13 @@
"people_alt": 59937,
"people_outline": 59937,
"percent": 60248,
"percent_discount": 62020,
"performance_max": 58650,
"pergola": 57859,
"perm_camera_mic": 59554,
"perm_contact_calendar": 59555,
"perm_data_setting": 59556,
"perm_device_information": 59557,
"perm_device_information": 62172,
"perm_identity": 61651,
"perm_media": 59559,
"perm_phone_msg": 59560,
@ -2540,6 +2709,7 @@
"person_check": 62821,
"person_edit": 62714,
"person_filled": 61651,
"person_heart": 62096,
"person_off": 58640,
"person_outline": 61651,
"person_pin": 58714,
@ -2562,24 +2732,24 @@
"phishing": 60119,
"phone": 61652,
"phone_alt": 61652,
"phone_android": 58148,
"phone_android": 62171,
"phone_bluetooth_speaker": 58907,
"phone_callback": 58953,
"phone_disabled": 59852,
"phone_enabled": 59853,
"phone_forwarded": 58908,
"phone_in_talk": 58909,
"phone_iphone": 58149,
"phone_iphone": 62170,
"phone_locked": 58910,
"phone_missed": 58911,
"phone_paused": 58912,
"phonelink": 58150,
"phonelink_erase": 57563,
"phonelink_lock": 57564,
"phonelink_off": 58151,
"phonelink_ring": 57565,
"phonelink_erase": 62186,
"phonelink_lock": 62142,
"phonelink_off": 63397,
"phonelink_ring": 62184,
"phonelink_ring_off": 63402,
"phonelink_setup": 61249,
"phonelink_setup": 62169,
"photo": 58418,
"photo_album": 58385,
"photo_auto_merge": 62768,
@ -2597,6 +2767,7 @@
"physical_therapy": 57630,
"piano": 58657,
"piano_off": 58656,
"pickleball": 62118,
"picture_as_pdf": 58389,
"picture_in_picture": 59562,
"picture_in_picture_alt": 59665,
@ -2627,6 +2798,7 @@
"place": 61915,
"place_item": 61936,
"plagiarism": 59994,
"plane_contrails": 62124,
"planet": 62343,
"planner_banner_ad_pt": 59026,
"planner_review": 59028,
@ -2638,6 +2810,8 @@
"play_music": 59118,
"play_pause": 61751,
"play_shapes": 63484,
"playground": 62094,
"playground_2": 62095,
"playing_cards": 62940,
"playlist_add": 57403,
"playlist_add_check": 57445,
@ -2819,6 +2993,7 @@
"request_page": 61996,
"request_quote": 61878,
"reset_brightness": 62594,
"reset_exposure": 62054,
"reset_focus": 62593,
"reset_image": 63524,
"reset_iso": 62592,
@ -2831,6 +3006,7 @@
"resize": 63239,
"respiratory_rate": 57639,
"responsive_layout": 59866,
"rest_area": 61994,
"restart_alt": 61523,
"restaurant": 58732,
"restaurant_menu": 58721,
@ -2914,11 +3090,11 @@
"scooter": 62577,
"score": 57961,
"scoreboard": 60368,
"screen_lock_landscape": 57790,
"screen_lock_portrait": 57791,
"screen_lock_rotation": 57792,
"screen_lock_landscape": 62168,
"screen_lock_portrait": 62142,
"screen_lock_rotation": 62166,
"screen_record": 63097,
"screen_rotation": 57793,
"screen_rotation": 62165,
"screen_rotation_alt": 60398,
"screen_rotation_up": 63096,
"screen_search_desktop": 61296,
@ -2942,6 +3118,7 @@
"search_activity": 62437,
"search_check": 63488,
"search_check_2": 62569,
"search_gear": 61178,
"search_hands_free": 59030,
"search_insights": 62652,
"search_off": 60022,
@ -2953,9 +3130,9 @@
"seat_vent_right": 62252,
"security": 58154,
"security_key": 62723,
"security_update": 61554,
"security_update": 62157,
"security_update_good": 61555,
"security_update_warning": 61556,
"security_update_warning": 62163,
"segment": 59723,
"select": 63309,
"select_all": 57698,
@ -2971,7 +3148,7 @@
"send_and_archive": 59916,
"send_money": 59575,
"send_time_extension": 60123,
"send_to_mobile": 61532,
"send_to_mobile": 62162,
"sensor_door": 61877,
"sensor_occupied": 60432,
"sensor_window": 61876,
@ -3006,7 +3183,7 @@
"settings_backup_restore": 59578,
"settings_bluetooth": 59579,
"settings_brightness": 59581,
"settings_cell": 59580,
"settings_cell": 62161,
"settings_cinematic_blur": 63012,
"settings_ethernet": 59582,
"settings_heart": 62754,
@ -3023,6 +3200,7 @@
"settings_photo_camera": 63540,
"settings_power": 59590,
"settings_remote": 59591,
"settings_seating": 61229,
"settings_slow_motion": 63011,
"settings_suggest": 61534,
"settings_system_daydream": 57795,
@ -3043,6 +3221,7 @@
"share_off": 63179,
"share_reviews": 63652,
"share_windows": 62995,
"shaved_ice": 61989,
"sheets_rtl": 63523,
"shelf_auto_hide": 63235,
"shelf_position": 63234,
@ -3053,6 +3232,7 @@
"shield_moon": 60073,
"shield_person": 63056,
"shield_question": 62761,
"shield_toggle": 62125,
"shield_watch": 62223,
"shield_with_heart": 59279,
"shield_with_house": 59277,
@ -3082,6 +3262,7 @@
"sick": 61984,
"side_navigation": 59874,
"sign_language": 60389,
"sign_language_2": 62040,
"signal_cellular_0_bar": 61608,
"signal_cellular_1_bar": 61609,
"signal_cellular_2_bar": 61610,
@ -3141,9 +3322,9 @@
"smart_card_reader_off": 62630,
"smart_display": 61546,
"smart_outlet": 59460,
"smart_screen": 61547,
"smart_screen": 62160,
"smart_toy": 61548,
"smartphone": 58156,
"smartphone": 59322,
"smartphone_camera": 62542,
"smb_share": 63307,
"smoke_free": 60234,
@ -3158,9 +3339,11 @@
"snowmobile": 58627,
"snowshoeing": 58644,
"soap": 61874,
"soba": 61238,
"social_distance": 57803,
"social_leaderboard": 63136,
"solar_power": 60431,
"solo_dining": 61237,
"sort": 57700,
"sort_by_alpha": 57427,
"sos": 60407,
@ -3284,10 +3467,10 @@
"stat_minus_1": 59035,
"stat_minus_2": 59036,
"stat_minus_3": 59037,
"stay_current_landscape": 57555,
"stay_current_portrait": 57556,
"stay_primary_landscape": 57557,
"stay_primary_portrait": 57558,
"stay_current_landscape": 60734,
"stay_current_portrait": 59322,
"stay_primary_landscape": 60734,
"stay_primary_portrait": 62163,
"steering_wheel_heat": 62251,
"step": 63230,
"step_into": 63233,
@ -3341,6 +3524,7 @@
"subtitles_gear": 62293,
"subtitles_off": 61298,
"subway": 58735,
"subway_walk": 62087,
"summarize": 61553,
"sunny": 59418,
"sunny_snowing": 59417,
@ -3396,11 +3580,12 @@
"sync_lock": 60142,
"sync_problem": 58921,
"sync_saved_locally": 63520,
"sync_saved_locally_off": 62052,
"syringe": 57651,
"system_security_update": 61554,
"system_security_update": 62157,
"system_security_update_good": 61555,
"system_security_update_warning": 61556,
"system_update": 61554,
"system_security_update_warning": 62163,
"system_update": 62157,
"system_update_alt": 59607,
"tab": 59608,
"tab_close": 63301,
@ -3422,9 +3607,11 @@
"table_edit": 62406,
"table_eye": 62566,
"table_lamp": 57842,
"table_large": 62105,
"table_restaurant": 60102,
"table_rows": 61697,
"table_rows_narrow": 63295,
"table_sign": 61228,
"table_view": 61886,
"tablet": 58159,
"tablet_android": 58160,
@ -3435,13 +3622,15 @@
"tag": 59887,
"tag_faces": 59938,
"takeout_dining": 60020,
"takeout_dining_2": 61236,
"tamper_detection_off": 59438,
"tamper_detection_on": 63688,
"tap_and_play": 58923,
"tap_and_play": 62156,
"tapas": 61929,
"target": 59161,
"task": 61557,
"task_alt": 58086,
"tatami_seat": 61235,
"taunt": 63135,
"taxi_alert": 61300,
"team_dashboard": 57363,
@ -3508,6 +3697,7 @@
"thumb_up_off": 62839,
"thumb_up_off_alt": 62839,
"thumbnail_bar": 63284,
"thumbs_up_double": 61180,
"thumbs_up_down": 59613,
"thunderstorm": 60379,
"tibia": 63643,
@ -3520,9 +3710,11 @@
"timelapse": 58402,
"timeline": 59682,
"timer": 58405,
"timer_1": 62127,
"timer_10": 58403,
"timer_10_alt_1": 61375,
"timer_10_select": 61562,
"timer_2": 62126,
"timer_3": 58404,
"timer_3_alt_1": 61376,
"timer_3_select": 61563,
@ -3545,6 +3737,7 @@
"token": 59941,
"toll": 59616,
"tonality": 58407,
"tonality_2": 62132,
"toolbar": 59895,
"tools_flat_head": 63691,
"tools_installation_kit": 58027,
@ -3594,6 +3787,7 @@
"transition_push": 62731,
"transition_slide": 62730,
"translate": 59618,
"translate_indic": 62051,
"transportation": 57885,
"travel": 61331,
"travel_explore": 58075,
@ -3638,6 +3832,7 @@
"type_specimen": 63728,
"u_turn_left": 60321,
"u_turn_right": 60322,
"udon": 61234,
"ulna_radius": 63645,
"ulna_radius_alt": 63646,
"umbrella": 61869,
@ -3694,7 +3889,7 @@
"vertical_shades": 60430,
"vertical_shades_closed": 60429,
"vertical_split": 59721,
"vibration": 58925,
"vibration": 62155,
"video_call": 57456,
"video_camera_back": 61567,
"video_camera_back_add": 62476,
@ -3739,6 +3934,7 @@
"view_timeline": 60293,
"view_week": 59635,
"vignette": 58421,
"vignette_2": 62131,
"villa": 58758,
"visibility": 59636,
"visibility_lock": 63059,
@ -3781,7 +3977,9 @@
"warning_amber": 61571,
"warning_off": 63405,
"wash": 61873,
"washoku": 62080,
"watch": 58164,
"watch_arrow": 62154,
"watch_button_press": 63146,
"watch_check": 62568,
"watch_later": 61398,
@ -3868,6 +4066,7 @@
"window_closed": 59262,
"window_open": 59276,
"window_sensor": 58043,
"windshield_defrost_auto": 62024,
"windshield_defrost_front": 62250,
"windshield_defrost_rear": 62249,
"windshield_heat_front": 62248,
@ -3889,7 +4088,9 @@
"wrist": 63132,
"wrong_location": 61304,
"wysiwyg": 61891,
"yakitori": 61233,
"yard": 61577,
"yoshoku": 62079,
"your_trips": 60203,
"youtube_activity": 63578,
"youtube_searched_for": 59642,

View file

@ -5,32 +5,12 @@ CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
def get_font_filepath(
font_name: Optional[str] = "MaterialSymbolsOutlined-Regular"
font_name: Optional[str] = "MaterialSymbolsOutlined"
) -> str:
return os.path.join(CURRENT_DIR, f"{font_name}.ttf")
def get_mapping_filepath(
font_name: Optional[str] = "MaterialSymbolsOutlined-Regular"
font_name: Optional[str] = "MaterialSymbolsOutlined"
) -> str:
return os.path.join(CURRENT_DIR, f"{font_name}.json")
def regenerate_mapping():
"""Regenerate the MaterialSymbolsOutlined.json file, assuming
MaterialSymbolsOutlined.codepoints and the TrueType font file have been
updated to support the new symbols.
"""
import json
jfile = get_mapping_filepath()
cpfile = jfile.replace(".json", ".codepoints")
with open(cpfile, "r") as cpf:
codepoints = cpf.read()
mapping = {}
for cp in codepoints.splitlines():
name, code = cp.split()
mapping[name] = int(f"0x{code}", 16)
with open(jfile, "w") as jf:
json.dump(mapping, jf, indent=4)

View file

@ -1,3 +1,3 @@
# -*- coding: utf-8 -*-
"""Package declaring AYON addon 'core' version."""
__version__ = "1.5.3+dev"
__version__ = "1.6.6+dev"

View file

@ -11,12 +11,12 @@ theme:
- media: "(prefers-color-scheme: dark)"
scheme: slate
toggle:
icon: material/toggle-switch-off-outline
icon: material/weather-sunny
name: Switch to light mode
- media: "(prefers-color-scheme: light)"
scheme: default
toggle:
icon: material/toggle-switch
icon: material/weather-night
name: Switch to dark mode
logo: img/ay-symbol-blackw-full.png
favicon: img/favicon.ico

9
mkdocs_requirements.txt Normal file
View file

@ -0,0 +1,9 @@
mkdocs-material >= 9.6.7
mkdocs-autoapi >= 0.4.0
mkdocstrings-python >= 1.16.2
mkdocs-minify-plugin >= 0.8.0
markdown-checklist >= 0.4.4
mdx-gh-links >= 0.4
pymdown-extensions >= 10.14.3
mike >= 2.1.3
mkdocstrings-shell >= 1.0.2

View file

@ -1,6 +1,6 @@
name = "core"
title = "Core"
version = "1.5.3+dev"
version = "1.6.6+dev"
client_dir = "ayon_core"

View file

@ -5,7 +5,7 @@
[tool.poetry]
name = "ayon-core"
version = "1.5.3+dev"
version = "1.6.6+dev"
description = ""
authors = ["Ynput Team <team@ynput.io>"]
readme = "README.md"
@ -27,17 +27,6 @@ codespell = "^2.2.6"
semver = "^3.0.2"
mypy = "^1.14.0"
mock = "^5.0.0"
tomlkit = "^0.13.2"
requests = "^2.32.3"
mkdocs-material = "^9.6.7"
mkdocs-autoapi = "^0.4.0"
mkdocstrings-python = "^1.16.2"
mkdocs-minify-plugin = "^0.8.0"
markdown-checklist = "^0.4.4"
mdx-gh-links = "^0.4"
pymdown-extensions = "^10.14.3"
mike = "^2.1.3"
mkdocstrings-shell = "^1.0.2"
nxtools = "^1.6"
[tool.poetry.group.test.dependencies]

View file

@ -454,7 +454,7 @@ DEFAULT_TOOLS_VALUES = {
"hosts": [],
"task_types": [],
"tasks": [],
"template": "{product[type]}{Task[name]}{Variant}"
"template": "{product[type]}{Task[name]}{Variant}<_{Aov}>"
},
{
"product_types": [

View file

@ -246,75 +246,75 @@ def test_multiple_review_clips_no_gap():
expected = [
# 10 head black frames generated from gap (991-1000)
'/path/to/ffmpeg -t 0.4 -r 25.0 -f lavfi'
' -i color=c=black:s=1280x720 -tune '
' -i color=c=black:s=1920x1080 -tune '
'stillimage -start_number 991 -pix_fmt rgba C:/result/output.%04d.png',
# Alternance 25fps tiff sequence and 24fps exr sequence
# for 100 frames each
'/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i '
f'C:\\no_tc{os.sep}output.%04d.tif '
'-vf scale=1280:720:flags=lanczos -compression_level 5 '
'-vf scale=1920:1080:flags=lanczos -compression_level 5 '
'-start_number 1001 -pix_fmt rgba C:/result/output.%04d.png',
'/path/to/ffmpeg -start_number 1000 -framerate 24.0 -i '
f'C:\\with_tc{os.sep}output.%04d.exr '
'-vf scale=1280:720:flags=lanczos -compression_level 5 '
'-vf scale=1920:1080:flags=lanczos -compression_level 5 '
'-start_number 1102 -pix_fmt rgba C:/result/output.%04d.png',
'/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i '
f'C:\\no_tc{os.sep}output.%04d.tif '
'-vf scale=1280:720:flags=lanczos -compression_level 5 '
'-vf scale=1920:1080:flags=lanczos -compression_level 5 '
'-start_number 1198 -pix_fmt rgba C:/result/output.%04d.png',
'/path/to/ffmpeg -start_number 1000 -framerate 24.0 -i '
f'C:\\with_tc{os.sep}output.%04d.exr '
'-vf scale=1280:720:flags=lanczos -compression_level 5 '
'-vf scale=1920:1080:flags=lanczos -compression_level 5 '
'-start_number 1299 -pix_fmt rgba C:/result/output.%04d.png',
# Repeated 25fps tiff sequence multiple times till the end
'/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i '
f'C:\\no_tc{os.sep}output.%04d.tif '
'-vf scale=1280:720:flags=lanczos -compression_level 5 '
'-vf scale=1920:1080:flags=lanczos -compression_level 5 '
'-start_number 1395 -pix_fmt rgba C:/result/output.%04d.png',
'/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i '
f'C:\\no_tc{os.sep}output.%04d.tif '
'-vf scale=1280:720:flags=lanczos -compression_level 5 '
'-vf scale=1920:1080:flags=lanczos -compression_level 5 '
'-start_number 1496 -pix_fmt rgba C:/result/output.%04d.png',
'/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i '
f'C:\\no_tc{os.sep}output.%04d.tif '
'-vf scale=1280:720:flags=lanczos -compression_level 5 '
'-vf scale=1920:1080:flags=lanczos -compression_level 5 '
'-start_number 1597 -pix_fmt rgba C:/result/output.%04d.png',
'/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i '
f'C:\\no_tc{os.sep}output.%04d.tif '
'-vf scale=1280:720:flags=lanczos -compression_level 5 '
'-vf scale=1920:1080:flags=lanczos -compression_level 5 '
'-start_number 1698 -pix_fmt rgba C:/result/output.%04d.png',
'/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i '
f'C:\\no_tc{os.sep}output.%04d.tif '
'-vf scale=1280:720:flags=lanczos -compression_level 5 '
'-vf scale=1920:1080:flags=lanczos -compression_level 5 '
'-start_number 1799 -pix_fmt rgba C:/result/output.%04d.png',
'/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i '
f'C:\\no_tc{os.sep}output.%04d.tif '
'-vf scale=1280:720:flags=lanczos -compression_level 5 '
'-vf scale=1920:1080:flags=lanczos -compression_level 5 '
'-start_number 1900 -pix_fmt rgba C:/result/output.%04d.png',
'/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i '
f'C:\\no_tc{os.sep}output.%04d.tif '
'-vf scale=1280:720:flags=lanczos -compression_level 5 '
'-vf scale=1920:1080:flags=lanczos -compression_level 5 '
'-start_number 2001 -pix_fmt rgba C:/result/output.%04d.png',
'/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i '
f'C:\\no_tc{os.sep}output.%04d.tif '
'-vf scale=1280:720:flags=lanczos -compression_level 5 '
'-vf scale=1920:1080:flags=lanczos -compression_level 5 '
'-start_number 2102 -pix_fmt rgba C:/result/output.%04d.png',
'/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i '
f'C:\\no_tc{os.sep}output.%04d.tif '
'-vf scale=1280:720:flags=lanczos -compression_level 5 '
'-vf scale=1920:1080:flags=lanczos -compression_level 5 '
'-start_number 2203 -pix_fmt rgba C:/result/output.%04d.png'
]
@ -348,12 +348,12 @@ def test_multiple_review_clips_with_gap():
'/path/to/ffmpeg -start_number 1000 -framerate 24.0 -i '
f'C:\\with_tc{os.sep}output.%04d.exr '
'-vf scale=1280:720:flags=lanczos -compression_level 5 '
'-vf scale=1920:1080:flags=lanczos -compression_level 5 '
'-start_number 1003 -pix_fmt rgba C:/result/output.%04d.png',
'/path/to/ffmpeg -start_number 1000 -framerate 24.0 -i '
f'C:\\with_tc{os.sep}output.%04d.exr '
'-vf scale=1280:720:flags=lanczos -compression_level 5 '
'-vf scale=1920:1080:flags=lanczos -compression_level 5 '
'-start_number 1091 -pix_fmt rgba C:/result/output.%04d.png'
]