mirror of
https://github.com/ynput/ayon-core.git
synced 2025-12-25 05:14:40 +01:00
Merge branch 'develop' of github.com:pypeclub/pype into feature/1784_helper_classes_for_automatic_testing
This commit is contained in:
commit
ee9aef01e1
24 changed files with 557 additions and 100 deletions
2
.github/workflows/prerelease.yml
vendored
2
.github/workflows/prerelease.yml
vendored
|
|
@ -47,7 +47,7 @@ jobs:
|
|||
enhancementLabel: '**🚀 Enhancements**'
|
||||
bugsLabel: '**🐛 Bug fixes**'
|
||||
deprecatedLabel: '**⚠️ Deprecations**'
|
||||
addSections: '{"documentation":{"prefix":"### 📖 Documentation","labels":["documentation"]},"tests":{"prefix":"### ✅ Testing","labels":["tests"]}}'
|
||||
addSections: '{"documentation":{"prefix":"### 📖 Documentation","labels":["documentation"]},"tests":{"prefix":"### ✅ Testing","labels":["tests"]},"feature":{"prefix":"### 🆕 New features","labels":["feature"]},}'
|
||||
issues: false
|
||||
issuesWoLabels: false
|
||||
sinceTag: "3.0.0"
|
||||
|
|
|
|||
2
.gitmodules
vendored
2
.gitmodules
vendored
|
|
@ -6,7 +6,7 @@
|
|||
url = https://github.com/pypeclub/avalon-unreal-integration.git
|
||||
[submodule "openpype/modules/default_modules/ftrack/python2_vendor/arrow"]
|
||||
path = openpype/modules/default_modules/ftrack/python2_vendor/arrow
|
||||
url = git@github.com:arrow-py/arrow.git
|
||||
url = https://github.com/arrow-py/arrow.git
|
||||
[submodule "openpype/modules/default_modules/ftrack/python2_vendor/ftrack-python-api"]
|
||||
path = openpype/modules/default_modules/ftrack/python2_vendor/ftrack-python-api
|
||||
url = https://bitbucket.org/ftrack/ftrack-python-api.git
|
||||
|
|
|
|||
26
CHANGELOG.md
26
CHANGELOG.md
|
|
@ -1,15 +1,29 @@
|
|||
# Changelog
|
||||
|
||||
## [3.4.0-nightly.2](https://github.com/pypeclub/OpenPype/tree/HEAD)
|
||||
## [3.4.0-nightly.4](https://github.com/pypeclub/OpenPype/tree/HEAD)
|
||||
|
||||
[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.3.1...HEAD)
|
||||
|
||||
**Merged pull requests:**
|
||||
|
||||
- Ftrack: Fix hosts attribute in collect ftrack username [\#1972](https://github.com/pypeclub/OpenPype/pull/1972)
|
||||
- Removed deprecated submodules [\#1967](https://github.com/pypeclub/OpenPype/pull/1967)
|
||||
- Launcher: Fix crashes on action click [\#1964](https://github.com/pypeclub/OpenPype/pull/1964)
|
||||
- Settings: Minor fixes in UI and missing default values [\#1963](https://github.com/pypeclub/OpenPype/pull/1963)
|
||||
- Blender: Toggle system console works on windows [\#1962](https://github.com/pypeclub/OpenPype/pull/1962)
|
||||
- Resolve path when adding to zip [\#1960](https://github.com/pypeclub/OpenPype/pull/1960)
|
||||
- Bump url-parse from 1.5.1 to 1.5.3 in /website [\#1958](https://github.com/pypeclub/OpenPype/pull/1958)
|
||||
- Global: Avalon Host name collector [\#1949](https://github.com/pypeclub/OpenPype/pull/1949)
|
||||
- Global: Define hosts in CollectSceneVersion [\#1948](https://github.com/pypeclub/OpenPype/pull/1948)
|
||||
- Maya: Add Xgen family support [\#1947](https://github.com/pypeclub/OpenPype/pull/1947)
|
||||
- Add face sets to exported alembics [\#1942](https://github.com/pypeclub/OpenPype/pull/1942)
|
||||
- Bump path-parse from 1.0.6 to 1.0.7 in /website [\#1933](https://github.com/pypeclub/OpenPype/pull/1933)
|
||||
- \#1894 - adds host to template\_name\_profiles for filtering [\#1915](https://github.com/pypeclub/OpenPype/pull/1915)
|
||||
- Environments: Tool environments in alphabetical order [\#1910](https://github.com/pypeclub/OpenPype/pull/1910)
|
||||
- Disregard publishing time. [\#1888](https://github.com/pypeclub/OpenPype/pull/1888)
|
||||
- Feature/webpublisher backend [\#1876](https://github.com/pypeclub/OpenPype/pull/1876)
|
||||
- Dynamic modules [\#1872](https://github.com/pypeclub/OpenPype/pull/1872)
|
||||
- Houdini: add Camera, Point Cache, Composite, Redshift ROP and VDB Cache support [\#1821](https://github.com/pypeclub/OpenPype/pull/1821)
|
||||
|
||||
## [3.3.1](https://github.com/pypeclub/OpenPype/tree/3.3.1) (2021-08-20)
|
||||
|
||||
|
|
@ -72,21 +86,11 @@
|
|||
- Maya: support for configurable `dirmap` 🗺️ [\#1859](https://github.com/pypeclub/OpenPype/pull/1859)
|
||||
- Maya: don't add reference members as connections to the container set 📦 [\#1855](https://github.com/pypeclub/OpenPype/pull/1855)
|
||||
- Settings list can use template or schema as object type [\#1815](https://github.com/pypeclub/OpenPype/pull/1815)
|
||||
- Maya: expected files -\> render products ⚙️ overhaul [\#1812](https://github.com/pypeclub/OpenPype/pull/1812)
|
||||
- Settings error dialog on show [\#1798](https://github.com/pypeclub/OpenPype/pull/1798)
|
||||
|
||||
## [3.2.0](https://github.com/pypeclub/OpenPype/tree/3.2.0) (2021-07-13)
|
||||
|
||||
[Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.2.0-nightly.7...3.2.0)
|
||||
|
||||
**Merged pull requests:**
|
||||
|
||||
- Build: don't add Poetry to `PATH` [\#1808](https://github.com/pypeclub/OpenPype/pull/1808)
|
||||
- Nuke: ftrack family plugin settings preset [\#1805](https://github.com/pypeclub/OpenPype/pull/1805)
|
||||
- nuke: fixing wrong name of family folder when `used existing frames` [\#1803](https://github.com/pypeclub/OpenPype/pull/1803)
|
||||
- Collect ftrack family bugs [\#1801](https://github.com/pypeclub/OpenPype/pull/1801)
|
||||
- Standalone publisher last project [\#1799](https://github.com/pypeclub/OpenPype/pull/1799)
|
||||
|
||||
## [2.18.4](https://github.com/pypeclub/OpenPype/tree/2.18.4) (2021-06-24)
|
||||
|
||||
[Full Changelog](https://github.com/pypeclub/OpenPype/compare/2.18.3...2.18.4)
|
||||
|
|
|
|||
|
|
@ -71,6 +71,8 @@ from .avalon_context import (
|
|||
get_linked_assets,
|
||||
get_latest_version,
|
||||
|
||||
get_workfile_template_key,
|
||||
get_workfile_template_key_from_context,
|
||||
get_workdir_data,
|
||||
get_workdir,
|
||||
get_workdir_with_workdir_data,
|
||||
|
|
@ -189,6 +191,8 @@ __all__ = [
|
|||
"get_linked_assets",
|
||||
"get_latest_version",
|
||||
|
||||
"get_workfile_template_key",
|
||||
"get_workfile_template_key_from_context",
|
||||
"get_workdir_data",
|
||||
"get_workdir",
|
||||
"get_workdir_with_workdir_data",
|
||||
|
|
|
|||
|
|
@ -28,7 +28,8 @@ from . import (
|
|||
from .local_settings import get_openpype_username
|
||||
from .avalon_context import (
|
||||
get_workdir_data,
|
||||
get_workdir_with_workdir_data
|
||||
get_workdir_with_workdir_data,
|
||||
get_workfile_template_key_from_context
|
||||
)
|
||||
|
||||
from .python_module_tools import (
|
||||
|
|
@ -1236,8 +1237,18 @@ def prepare_context_environments(data):
|
|||
|
||||
anatomy = data["anatomy"]
|
||||
|
||||
template_key = get_workfile_template_key_from_context(
|
||||
asset_doc["name"],
|
||||
task_name,
|
||||
app.host_name,
|
||||
project_name=project_name,
|
||||
dbcon=data["dbcon"]
|
||||
)
|
||||
|
||||
try:
|
||||
workdir = get_workdir_with_workdir_data(workdir_data, anatomy)
|
||||
workdir = get_workdir_with_workdir_data(
|
||||
workdir_data, anatomy, template_key=template_key
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
raise ApplicationLaunchFailed(
|
||||
|
|
|
|||
|
|
@ -344,6 +344,127 @@ def get_latest_version(asset_name, subset_name, dbcon=None, project_name=None):
|
|||
return version_doc
|
||||
|
||||
|
||||
def get_workfile_template_key_from_context(
|
||||
asset_name, task_name, host_name, project_name=None,
|
||||
dbcon=None, project_settings=None
|
||||
):
|
||||
"""Helper function to get template key for workfile template.
|
||||
|
||||
Do the same as `get_workfile_template_key` but returns value for "session
|
||||
context".
|
||||
|
||||
It is required to pass one of 'dbcon' with already set project name or
|
||||
'project_name' arguments.
|
||||
|
||||
Args:
|
||||
asset_name(str): Name of asset document.
|
||||
task_name(str): Task name for which is template key retrieved.
|
||||
Must be available on asset document under `data.tasks`.
|
||||
host_name(str): Name of host implementation for which is workfile
|
||||
used.
|
||||
project_name(str): Project name where asset and task is. Not required
|
||||
when 'dbcon' is passed.
|
||||
dbcon(AvalonMongoDB): Connection to mongo with already set project
|
||||
under `AVALON_PROJECT`. Not required when 'project_name' is passed.
|
||||
project_settings(dict): Project settings for passed 'project_name'.
|
||||
Not required at all but makes function faster.
|
||||
Raises:
|
||||
ValueError: When both 'dbcon' and 'project_name' were not
|
||||
passed.
|
||||
"""
|
||||
if not dbcon:
|
||||
if not project_name:
|
||||
raise ValueError((
|
||||
"`get_workfile_template_key_from_context` requires to pass"
|
||||
" one of 'dbcon' or 'project_name' arguments."
|
||||
))
|
||||
from avalon.api import AvalonMongoDB
|
||||
|
||||
dbcon = AvalonMongoDB()
|
||||
dbcon.Session["AVALON_PROJECT"] = project_name
|
||||
|
||||
elif not project_name:
|
||||
project_name = dbcon.Session["AVALON_PROJECT"]
|
||||
|
||||
asset_doc = dbcon.find_one(
|
||||
{
|
||||
"type": "asset",
|
||||
"name": asset_name
|
||||
},
|
||||
{
|
||||
"data.tasks": 1
|
||||
}
|
||||
)
|
||||
asset_tasks = asset_doc.get("data", {}).get("tasks") or {}
|
||||
task_info = asset_tasks.get(task_name) or {}
|
||||
task_type = task_info.get("type")
|
||||
|
||||
return get_workfile_template_key(
|
||||
task_type, host_name, project_name, project_settings
|
||||
)
|
||||
|
||||
|
||||
def get_workfile_template_key(
|
||||
task_type, host_name, project_name=None, project_settings=None
|
||||
):
|
||||
"""Workfile template key which should be used to get workfile template.
|
||||
|
||||
Function is using profiles from project settings to return right template
|
||||
for passet task type and host name.
|
||||
|
||||
One of 'project_name' or 'project_settings' must be passed it is preffered
|
||||
to pass settings if are already available.
|
||||
|
||||
Args:
|
||||
task_type(str): Name of task type.
|
||||
host_name(str): Name of host implementation (e.g. "maya", "nuke", ...)
|
||||
project_name(str): Name of project in which context should look for
|
||||
settings. Not required if `project_settings` are passed.
|
||||
project_settings(dict): Prepare project settings for project name.
|
||||
Not needed if `project_name` is passed.
|
||||
|
||||
Raises:
|
||||
ValueError: When both 'project_name' and 'project_settings' were not
|
||||
passed.
|
||||
"""
|
||||
default = "work"
|
||||
if not task_type or not host_name:
|
||||
return default
|
||||
|
||||
if not project_settings:
|
||||
if not project_name:
|
||||
raise ValueError((
|
||||
"`get_workfile_template_key` requires to pass"
|
||||
" one of 'project_name' or 'project_settings' arguments."
|
||||
))
|
||||
project_settings = get_project_settings(project_name)
|
||||
|
||||
try:
|
||||
profiles = (
|
||||
project_settings
|
||||
["global"]
|
||||
["tools"]
|
||||
["Workfiles"]
|
||||
["workfile_template_profiles"]
|
||||
)
|
||||
except Exception:
|
||||
profiles = []
|
||||
|
||||
if not profiles:
|
||||
return default
|
||||
|
||||
from .profiles_filtering import filter_profiles
|
||||
|
||||
profile_filter = {
|
||||
"task_types": task_type,
|
||||
"hosts": host_name
|
||||
}
|
||||
profile = filter_profiles(profiles, profile_filter)
|
||||
if profile:
|
||||
return profile["workfile_template"] or default
|
||||
return default
|
||||
|
||||
|
||||
def get_workdir_data(project_doc, asset_doc, task_name, host_name):
|
||||
"""Prepare data for workdir template filling from entered information.
|
||||
|
||||
|
|
@ -373,7 +494,8 @@ def get_workdir_data(project_doc, asset_doc, task_name, host_name):
|
|||
|
||||
|
||||
def get_workdir_with_workdir_data(
|
||||
workdir_data, anatomy=None, project_name=None, template_key=None
|
||||
workdir_data, anatomy=None, project_name=None,
|
||||
template_key=None, dbcon=None
|
||||
):
|
||||
"""Fill workdir path from entered data and project's anatomy.
|
||||
|
||||
|
|
@ -387,8 +509,10 @@ def get_workdir_with_workdir_data(
|
|||
`project_name` is entered.
|
||||
project_name (str): Project's name. Optional if `anatomy` is entered
|
||||
otherwise Anatomy object is created with using the project name.
|
||||
template_key (str): Key of work templates in anatomy templates. By
|
||||
default is seto to `"work"`.
|
||||
template_key (str): Key of work templates in anatomy templates. If not
|
||||
passed `get_workfile_template_key_from_context` is used to get it.
|
||||
dbcon(AvalonMongoDB): Mongo connection. Required only if 'template_key'
|
||||
and 'project_name' are not passed.
|
||||
|
||||
Returns:
|
||||
TemplateResult: Workdir path.
|
||||
|
|
@ -406,7 +530,13 @@ def get_workdir_with_workdir_data(
|
|||
anatomy = Anatomy(project_name)
|
||||
|
||||
if not template_key:
|
||||
template_key = "work"
|
||||
template_key = get_workfile_template_key_from_context(
|
||||
workdir_data["asset"],
|
||||
workdir_data["task"],
|
||||
workdir_data["app"],
|
||||
project_name=workdir_data["project"]["name"],
|
||||
dbcon=dbcon
|
||||
)
|
||||
|
||||
anatomy_filled = anatomy.format(workdir_data)
|
||||
# Output is TemplateResult object which contain usefull data
|
||||
|
|
@ -447,7 +577,9 @@ def get_workdir(
|
|||
project_doc, asset_doc, task_name, host_name
|
||||
)
|
||||
# Output is TemplateResult object which contain usefull data
|
||||
return get_workdir_with_workdir_data(workdir_data, anatomy, template_key)
|
||||
return get_workdir_with_workdir_data(
|
||||
workdir_data, anatomy, template_key=template_key
|
||||
)
|
||||
|
||||
|
||||
@with_avalon
|
||||
|
|
@ -516,7 +648,9 @@ def create_workfile_doc(asset_doc, task_name, filename, workdir, dbcon=None):
|
|||
# Prepare anatomy
|
||||
anatomy = Anatomy(project_doc["name"])
|
||||
# Get workdir path (result is anatomy.TemplateResult)
|
||||
template_workdir = get_workdir_with_workdir_data(workdir_data, anatomy)
|
||||
template_workdir = get_workdir_with_workdir_data(
|
||||
workdir_data, anatomy, dbcon=dbcon
|
||||
)
|
||||
template_workdir_path = str(template_workdir).replace("\\", "/")
|
||||
|
||||
# Replace slashses in workdir path where workfile is located
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ class CollectUsername(pyblish.api.ContextPlugin):
|
|||
"""
|
||||
order = pyblish.api.CollectorOrder - 0.488
|
||||
label = "Collect ftrack username"
|
||||
host = ["webpublisher"]
|
||||
hosts = ["webpublisher"]
|
||||
|
||||
_context = None
|
||||
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ class ExtractJpegEXR(pyblish.api.InstancePlugin):
|
|||
# use same input args like with mov
|
||||
jpeg_items.extend(ffmpeg_args.get("input") or [])
|
||||
# input file
|
||||
jpeg_items.append("-i {}".format(full_input_path))
|
||||
jpeg_items.append("-i \"{}\"".format(full_input_path))
|
||||
# output arguments from presets
|
||||
jpeg_items.extend(ffmpeg_args.get("output") or [])
|
||||
|
||||
|
|
@ -104,7 +104,7 @@ class ExtractJpegEXR(pyblish.api.InstancePlugin):
|
|||
jpeg_items.append("-vframes 1")
|
||||
|
||||
# output file
|
||||
jpeg_items.append(full_output_path)
|
||||
jpeg_items.append("\"{}\"".format(full_output_path))
|
||||
|
||||
subprocess_jpeg = " ".join(jpeg_items)
|
||||
|
||||
|
|
|
|||
|
|
@ -251,6 +251,13 @@
|
|||
]
|
||||
},
|
||||
"Workfiles": {
|
||||
"workfile_template_profiles": [
|
||||
{
|
||||
"task_types": [],
|
||||
"hosts": [],
|
||||
"workfile_template": "work"
|
||||
}
|
||||
],
|
||||
"last_workfile_on_startup": [
|
||||
{
|
||||
"hosts": [],
|
||||
|
|
|
|||
|
|
@ -106,7 +106,8 @@ from .enum_entity import (
|
|||
ToolsEnumEntity,
|
||||
TaskTypeEnumEntity,
|
||||
ProvidersEnum,
|
||||
DeadlineUrlEnumEntity
|
||||
DeadlineUrlEnumEntity,
|
||||
AnatomyTemplatesEnumEntity
|
||||
)
|
||||
|
||||
from .list_entity import ListEntity
|
||||
|
|
@ -162,6 +163,7 @@ __all__ = (
|
|||
"TaskTypeEnumEntity",
|
||||
"ProvidersEnum",
|
||||
"DeadlineUrlEnumEntity",
|
||||
"AnatomyTemplatesEnumEntity",
|
||||
|
||||
"ListEntity",
|
||||
|
||||
|
|
|
|||
|
|
@ -494,3 +494,69 @@ class DeadlineUrlEnumEntity(BaseEnumEntity):
|
|||
|
||||
elif self._current_value not in self.valid_keys:
|
||||
self._current_value = tuple(self.valid_keys)[0]
|
||||
|
||||
|
||||
class AnatomyTemplatesEnumEntity(BaseEnumEntity):
|
||||
schema_types = ["anatomy-templates-enum"]
|
||||
|
||||
def _item_initalization(self):
|
||||
self.multiselection = False
|
||||
|
||||
self.enum_items = []
|
||||
self.valid_keys = set()
|
||||
|
||||
enum_default = self.schema_data.get("default") or "work"
|
||||
|
||||
self.value_on_not_set = enum_default
|
||||
self.valid_value_types = (STRING_TYPE,)
|
||||
|
||||
# GUI attribute
|
||||
self.placeholder = self.schema_data.get("placeholder")
|
||||
|
||||
def _get_enum_values(self):
|
||||
templates_entity = self.get_entity_from_path(
|
||||
"project_anatomy/templates"
|
||||
)
|
||||
|
||||
valid_keys = set()
|
||||
enum_items_list = []
|
||||
|
||||
others_entity = None
|
||||
for key, entity in templates_entity.items():
|
||||
# Skip defaults key
|
||||
if key == "defaults":
|
||||
continue
|
||||
|
||||
if key == "others":
|
||||
others_entity = entity
|
||||
continue
|
||||
|
||||
label = key
|
||||
if hasattr(entity, "label"):
|
||||
label = entity.label or label
|
||||
|
||||
enum_items_list.append({key: label})
|
||||
valid_keys.add(key)
|
||||
|
||||
if others_entity is not None:
|
||||
get_child_label_func = getattr(
|
||||
others_entity, "get_child_label", None
|
||||
)
|
||||
for key, child_entity in others_entity.items():
|
||||
label = key
|
||||
if callable(get_child_label_func):
|
||||
label = get_child_label_func(child_entity) or label
|
||||
|
||||
enum_items_list.append({key: label})
|
||||
valid_keys.add(key)
|
||||
|
||||
return enum_items_list, valid_keys
|
||||
|
||||
def set_override_state(self, *args, **kwargs):
|
||||
super(AnatomyTemplatesEnumEntity, self).set_override_state(
|
||||
*args, **kwargs
|
||||
)
|
||||
|
||||
self.enum_items, self.valid_keys = self._get_enum_values()
|
||||
if self._current_value not in self.valid_keys:
|
||||
self._current_value = self.value_on_not_set
|
||||
|
|
|
|||
|
|
@ -380,6 +380,20 @@ How output of the schema could look like on save:
|
|||
}
|
||||
```
|
||||
|
||||
### anatomy-templates-enum
|
||||
- enumeration of all available anatomy template keys
|
||||
- have only single selection mode
|
||||
- it is possible to define default value `default`
|
||||
- `"work"` is used if default value is not specified
|
||||
```
|
||||
{
|
||||
"key": "host",
|
||||
"label": "Host name",
|
||||
"type": "anatomy-templates-enum",
|
||||
"default": "publish"
|
||||
}
|
||||
```
|
||||
|
||||
### hosts-enum
|
||||
- enumeration of available hosts
|
||||
- multiselection can be allowed with setting key `"multiselection"` to `True` (Default: `False`)
|
||||
|
|
|
|||
|
|
@ -65,6 +65,37 @@
|
|||
"key": "Workfiles",
|
||||
"label": "Workfiles",
|
||||
"children": [
|
||||
{
|
||||
"type": "list",
|
||||
"key": "workfile_template_profiles",
|
||||
"label": "Workfile template profiles",
|
||||
"use_label_wrap": true,
|
||||
"object_type": {
|
||||
"type": "dict",
|
||||
"children": [
|
||||
{
|
||||
"key": "task_types",
|
||||
"label": "Task types",
|
||||
"type": "task-types-enum"
|
||||
},
|
||||
{
|
||||
"type": "hosts-enum",
|
||||
"key": "hosts",
|
||||
"label": "Hosts",
|
||||
"multiselection": true
|
||||
},
|
||||
{
|
||||
"type": "splitter"
|
||||
},
|
||||
{
|
||||
"key": "workfile_template",
|
||||
"label": "Workfile template",
|
||||
"type": "anatomy-templates-enum",
|
||||
"multiselection": false
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"key": "last_workfile_on_startup",
|
||||
|
|
|
|||
|
|
@ -12,10 +12,15 @@ from avalon import style, io, api, pipeline
|
|||
|
||||
from avalon.tools import lib as tools_lib
|
||||
from avalon.tools.widgets import AssetWidget
|
||||
from avalon.tools.models import TasksModel
|
||||
from avalon.tools.delegates import PrettyTimeDelegate
|
||||
|
||||
from .model import FilesModel
|
||||
from .model import (
|
||||
TASK_NAME_ROLE,
|
||||
TASK_TYPE_ROLE,
|
||||
FilesModel,
|
||||
TasksModel,
|
||||
TasksProxyModel
|
||||
)
|
||||
from .view import FilesView
|
||||
|
||||
from openpype.lib import (
|
||||
|
|
@ -23,7 +28,8 @@ from openpype.lib import (
|
|||
get_workdir,
|
||||
get_workfile_doc,
|
||||
create_workfile_doc,
|
||||
save_workfile_data_to_doc
|
||||
save_workfile_data_to_doc,
|
||||
get_workfile_template_key
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
|
@ -55,9 +61,13 @@ class NameWindow(QtWidgets.QDialog):
|
|||
|
||||
# Set work file data for template formatting
|
||||
asset_name = session["AVALON_ASSET"]
|
||||
project_doc = io.find_one({
|
||||
"type": "project"
|
||||
})
|
||||
project_doc = io.find_one(
|
||||
{"type": "project"},
|
||||
{
|
||||
"name": True,
|
||||
"data.code": True
|
||||
}
|
||||
)
|
||||
self.data = {
|
||||
"project": {
|
||||
"name": project_doc["name"],
|
||||
|
|
@ -126,10 +136,14 @@ class NameWindow(QtWidgets.QDialog):
|
|||
# for "{version".
|
||||
if "{version" in self.template:
|
||||
inputs_layout.addRow("Version:", version_widget)
|
||||
else:
|
||||
version_widget.setVisible(False)
|
||||
|
||||
# Add subversion only if template containt `{comment}`
|
||||
if "{comment}" in self.template:
|
||||
inputs_layout.addRow("Subversion:", subversion_input)
|
||||
else:
|
||||
subversion_input.setVisible(False)
|
||||
inputs_layout.addRow("Extension:", ext_combo)
|
||||
inputs_layout.addRow("Preview:", preview_label)
|
||||
|
||||
|
|
@ -305,48 +319,46 @@ class TasksWidget(QtWidgets.QWidget):
|
|||
|
||||
task_changed = QtCore.Signal()
|
||||
|
||||
def __init__(self, parent=None):
|
||||
def __init__(self, dbcon=None, parent=None):
|
||||
super(TasksWidget, self).__init__(parent)
|
||||
self.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
view = QtWidgets.QTreeView()
|
||||
view.setIndentation(0)
|
||||
model = TasksModel(io)
|
||||
view.setModel(model)
|
||||
tasks_view = QtWidgets.QTreeView(self)
|
||||
tasks_view.setIndentation(0)
|
||||
tasks_view.setSortingEnabled(True)
|
||||
if dbcon is None:
|
||||
dbcon = io
|
||||
|
||||
tasks_model = TasksModel(dbcon)
|
||||
tasks_proxy = TasksProxyModel()
|
||||
tasks_proxy.setSourceModel(tasks_model)
|
||||
tasks_view.setModel(tasks_proxy)
|
||||
|
||||
layout = QtWidgets.QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.addWidget(view)
|
||||
layout.addWidget(tasks_view)
|
||||
|
||||
# Hide the default tasks "count" as we don't need that data here.
|
||||
view.setColumnHidden(1, True)
|
||||
selection_model = tasks_view.selectionModel()
|
||||
selection_model.currentChanged.connect(self.task_changed)
|
||||
|
||||
selection = view.selectionModel()
|
||||
selection.currentChanged.connect(self.task_changed)
|
||||
|
||||
self.models = {
|
||||
"tasks": model
|
||||
}
|
||||
|
||||
self.widgets = {
|
||||
"view": view,
|
||||
}
|
||||
self._tasks_model = tasks_model
|
||||
self._tasks_proxy = tasks_proxy
|
||||
self._tasks_view = tasks_view
|
||||
|
||||
self._last_selected_task = None
|
||||
|
||||
def set_asset(self, asset):
|
||||
if asset is None:
|
||||
# Asset deselected
|
||||
def set_asset(self, asset_doc):
|
||||
# Asset deselected
|
||||
if asset_doc is None:
|
||||
return
|
||||
|
||||
# Try and preserve the last selected task and reselect it
|
||||
# after switching assets. If there's no currently selected
|
||||
# asset keep whatever the "last selected" was prior to it.
|
||||
current = self.get_current_task()
|
||||
current = self.get_current_task_name()
|
||||
if current:
|
||||
self._last_selected_task = current
|
||||
|
||||
self.models["tasks"].set_assets(asset_docs=[asset])
|
||||
self._tasks_model.set_asset(asset_doc)
|
||||
|
||||
if self._last_selected_task:
|
||||
self.select_task(self._last_selected_task)
|
||||
|
|
@ -354,7 +366,7 @@ class TasksWidget(QtWidgets.QWidget):
|
|||
# Force a task changed emit.
|
||||
self.task_changed.emit()
|
||||
|
||||
def select_task(self, task):
|
||||
def select_task(self, task_name):
|
||||
"""Select a task by name.
|
||||
|
||||
If the task does not exist in the current model then selection is only
|
||||
|
|
@ -366,39 +378,40 @@ class TasksWidget(QtWidgets.QWidget):
|
|||
"""
|
||||
|
||||
# Clear selection
|
||||
view = self.widgets["view"]
|
||||
model = view.model()
|
||||
selection_model = view.selectionModel()
|
||||
selection_model = self._tasks_view.selectionModel()
|
||||
selection_model.clearSelection()
|
||||
|
||||
# Select the task
|
||||
mode = selection_model.Select | selection_model.Rows
|
||||
for row in range(model.rowCount(QtCore.QModelIndex())):
|
||||
index = model.index(row, 0, QtCore.QModelIndex())
|
||||
name = index.data(QtCore.Qt.DisplayRole)
|
||||
if name == task:
|
||||
for row in range(self._tasks_model.rowCount()):
|
||||
index = self._tasks_model.index(row, 0)
|
||||
name = index.data(TASK_NAME_ROLE)
|
||||
if name == task_name:
|
||||
selection_model.select(index, mode)
|
||||
|
||||
# Set the currently active index
|
||||
view.setCurrentIndex(index)
|
||||
self._tasks_view.setCurrentIndex(index)
|
||||
break
|
||||
|
||||
def get_current_task(self):
|
||||
def get_current_task_name(self):
|
||||
"""Return name of task at current index (selected)
|
||||
|
||||
Returns:
|
||||
str: Name of the current task.
|
||||
|
||||
"""
|
||||
view = self.widgets["view"]
|
||||
index = view.currentIndex()
|
||||
index = index.sibling(index.row(), 0) # ensure column zero for name
|
||||
index = self._tasks_view.currentIndex()
|
||||
selection_model = self._tasks_view.selectionModel()
|
||||
if index.isValid() and selection_model.isSelected(index):
|
||||
return index.data(TASK_NAME_ROLE)
|
||||
return None
|
||||
|
||||
selection = view.selectionModel()
|
||||
if selection.isSelected(index):
|
||||
# Ignore when the current task is not selected as the "No task"
|
||||
# placeholder might be the current index even though it's
|
||||
# disallowed to be selected. So we only return if it is selected.
|
||||
return index.data(QtCore.Qt.DisplayRole)
|
||||
def get_current_task_type(self):
|
||||
index = self._tasks_view.currentIndex()
|
||||
selection_model = self._tasks_view.selectionModel()
|
||||
if index.isValid() and selection_model.isSelected(index):
|
||||
return index.data(TASK_TYPE_ROLE)
|
||||
return None
|
||||
|
||||
|
||||
class FilesWidget(QtWidgets.QWidget):
|
||||
|
|
@ -411,7 +424,8 @@ class FilesWidget(QtWidgets.QWidget):
|
|||
|
||||
# Setup
|
||||
self._asset = None
|
||||
self._task = None
|
||||
self._task_name = None
|
||||
self._task_type = None
|
||||
|
||||
# Pype's anatomy object for current project
|
||||
self.anatomy = Anatomy(io.Session["AVALON_PROJECT"])
|
||||
|
|
@ -506,14 +520,15 @@ class FilesWidget(QtWidgets.QWidget):
|
|||
self.btn_browse = btn_browse
|
||||
self.btn_save = btn_save
|
||||
|
||||
def set_asset_task(self, asset, task):
|
||||
def set_asset_task(self, asset, task_name, task_type):
|
||||
self._asset = asset
|
||||
self._task = task
|
||||
self._task_name = task_name
|
||||
self._task_type = task_type
|
||||
|
||||
# Define a custom session so we can query the work root
|
||||
# for a "Work area" that is not our current Session.
|
||||
# This way we can browse it even before we enter it.
|
||||
if self._asset and self._task:
|
||||
if self._asset and self._task_name and self._task_type:
|
||||
session = self._get_session()
|
||||
self.root = self.host.work_root(session)
|
||||
self.files_model.set_root(self.root)
|
||||
|
|
@ -533,10 +548,16 @@ class FilesWidget(QtWidgets.QWidget):
|
|||
"""Return a modified session for the current asset and task"""
|
||||
|
||||
session = api.Session.copy()
|
||||
self.template_key = get_workfile_template_key(
|
||||
self._task_type,
|
||||
session["AVALON_APP"],
|
||||
project_name=session["AVALON_PROJECT"]
|
||||
)
|
||||
changes = pipeline.compute_session_changes(
|
||||
session,
|
||||
asset=self._asset,
|
||||
task=self._task
|
||||
task=self._task_name,
|
||||
template_key=self.template_key
|
||||
)
|
||||
session.update(changes)
|
||||
|
||||
|
|
@ -549,14 +570,19 @@ class FilesWidget(QtWidgets.QWidget):
|
|||
changes = pipeline.compute_session_changes(
|
||||
session,
|
||||
asset=self._asset,
|
||||
task=self._task
|
||||
task=self._task_name,
|
||||
template_key=self.template_key
|
||||
)
|
||||
if not changes:
|
||||
# Return early if we're already in the right Session context
|
||||
# to avoid any unwanted Task Changed callbacks to be triggered.
|
||||
return
|
||||
|
||||
api.update_current_task(asset=self._asset, task=self._task)
|
||||
api.update_current_task(
|
||||
asset=self._asset,
|
||||
task=self._task_name,
|
||||
template_key=self.template_key
|
||||
)
|
||||
|
||||
def open_file(self, filepath):
|
||||
host = self.host
|
||||
|
|
@ -606,7 +632,7 @@ class FilesWidget(QtWidgets.QWidget):
|
|||
result = messagebox.exec_()
|
||||
if result == messagebox.Yes:
|
||||
return True
|
||||
elif result == messagebox.No:
|
||||
if result == messagebox.No:
|
||||
return False
|
||||
return None
|
||||
|
||||
|
|
@ -700,7 +726,7 @@ class FilesWidget(QtWidgets.QWidget):
|
|||
self._enter_session() # Make sure we are in the right session
|
||||
self.host.save_file(file_path)
|
||||
|
||||
self.set_asset_task(self._asset, self._task)
|
||||
self.set_asset_task(self._asset, self._task_name, self._task_type)
|
||||
|
||||
pipeline.emit("after.workfile.save", [file_path])
|
||||
|
||||
|
|
@ -727,7 +753,8 @@ class FilesWidget(QtWidgets.QWidget):
|
|||
changes = pipeline.compute_session_changes(
|
||||
session,
|
||||
asset=self._asset,
|
||||
task=self._task
|
||||
task=self._task_name,
|
||||
template_key=self.template_key
|
||||
)
|
||||
session.update(changes)
|
||||
|
||||
|
|
@ -750,7 +777,7 @@ class FilesWidget(QtWidgets.QWidget):
|
|||
|
||||
# Force a full to the asset as opposed to just self.refresh() so
|
||||
# that it will actually check again whether the Work directory exists
|
||||
self.set_asset_task(self._asset, self._task)
|
||||
self.set_asset_task(self._asset, self._task_name, self._task_type)
|
||||
|
||||
def refresh(self):
|
||||
"""Refresh listed files for current selection in the interface"""
|
||||
|
|
@ -927,7 +954,7 @@ class Window(QtWidgets.QMainWindow):
|
|||
assets_widget = AssetWidget(io, parent=home_body_widget)
|
||||
assets_widget.set_current_asset_btn_visibility(True)
|
||||
|
||||
tasks_widget = TasksWidget(home_body_widget)
|
||||
tasks_widget = TasksWidget(io, home_body_widget)
|
||||
files_widget = FilesWidget(home_body_widget)
|
||||
side_panel = SidePanelWidget(home_body_widget)
|
||||
|
||||
|
|
@ -999,7 +1026,7 @@ class Window(QtWidgets.QMainWindow):
|
|||
if asset_docs:
|
||||
asset_doc = asset_docs[0]
|
||||
|
||||
task_name = self.tasks_widget.get_current_task()
|
||||
task_name = self.tasks_widget.get_current_task_name()
|
||||
|
||||
workfile_doc = None
|
||||
if asset_doc and task_name and filepath:
|
||||
|
|
@ -1026,7 +1053,7 @@ class Window(QtWidgets.QMainWindow):
|
|||
def _get_current_workfile_doc(self, filepath=None):
|
||||
if filepath is None:
|
||||
filepath = self.files_widget._get_selected_filepath()
|
||||
task_name = self.tasks_widget.get_current_task()
|
||||
task_name = self.tasks_widget.get_current_task_name()
|
||||
asset_docs = self.assets_widget.get_selected_assets()
|
||||
if not task_name or not asset_docs or not filepath:
|
||||
return
|
||||
|
|
@ -1046,7 +1073,7 @@ class Window(QtWidgets.QMainWindow):
|
|||
workdir, filename = os.path.split(filepath)
|
||||
asset_docs = self.assets_widget.get_selected_assets()
|
||||
asset_doc = asset_docs[0]
|
||||
task_name = self.tasks_widget.get_current_task()
|
||||
task_name = self.tasks_widget.get_current_task_name()
|
||||
create_workfile_doc(asset_doc, task_name, filename, workdir, io)
|
||||
|
||||
def set_context(self, context):
|
||||
|
|
@ -1065,7 +1092,6 @@ class Window(QtWidgets.QMainWindow):
|
|||
# Select the asset
|
||||
self.assets_widget.select_assets([asset], expand=True)
|
||||
|
||||
# Force a refresh on Tasks?
|
||||
self.tasks_widget.set_asset(asset_document)
|
||||
|
||||
if "task" in context:
|
||||
|
|
@ -1095,12 +1121,13 @@ class Window(QtWidgets.QMainWindow):
|
|||
asset = self.assets_widget.get_selected_assets() or None
|
||||
if asset is not None:
|
||||
asset = asset[0]
|
||||
task = self.tasks_widget.get_current_task()
|
||||
task_name = self.tasks_widget.get_current_task_name()
|
||||
task_type = self.tasks_widget.get_current_task_type()
|
||||
|
||||
self.tasks_widget.setEnabled(bool(asset))
|
||||
|
||||
self.files_widget.setEnabled(all([bool(task), bool(asset)]))
|
||||
self.files_widget.set_asset_task(asset, task)
|
||||
self.files_widget.setEnabled(all([bool(task_name), bool(asset)]))
|
||||
self.files_widget.set_asset_task(asset, task_name, task_type)
|
||||
self.files_widget.refresh()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import os
|
||||
import logging
|
||||
|
||||
from Qt import QtCore
|
||||
from Qt import QtCore, QtGui
|
||||
|
||||
from avalon import style
|
||||
from avalon.vendor import qtawesome
|
||||
|
|
@ -9,6 +9,10 @@ from avalon.tools.models import TreeModel, Item
|
|||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
TASK_NAME_ROLE = QtCore.Qt.UserRole + 1
|
||||
TASK_TYPE_ROLE = QtCore.Qt.UserRole + 2
|
||||
TASK_ORDER_ROLE = QtCore.Qt.UserRole + 3
|
||||
|
||||
|
||||
class FilesModel(TreeModel):
|
||||
"""Model listing files with specified extensions in a root folder"""
|
||||
|
|
@ -151,3 +155,142 @@ class FilesModel(TreeModel):
|
|||
return "Date modified"
|
||||
|
||||
return super(FilesModel, self).headerData(section, orientation, role)
|
||||
|
||||
|
||||
class TasksProxyModel(QtCore.QSortFilterProxyModel):
|
||||
def lessThan(self, x_index, y_index):
|
||||
x_order = x_index.data(TASK_ORDER_ROLE)
|
||||
y_order = y_index.data(TASK_ORDER_ROLE)
|
||||
if x_order is not None and y_order is not None:
|
||||
if x_order < y_order:
|
||||
return True
|
||||
if x_order > y_order:
|
||||
return False
|
||||
|
||||
elif x_order is None and y_order is not None:
|
||||
return True
|
||||
|
||||
elif y_order is None and x_order is not None:
|
||||
return False
|
||||
|
||||
x_name = x_index.data(QtCore.Qt.DisplayRole)
|
||||
y_name = y_index.data(QtCore.Qt.DisplayRole)
|
||||
if x_name == y_name:
|
||||
return True
|
||||
|
||||
if x_name == tuple(sorted((x_name, y_name)))[0]:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class TasksModel(QtGui.QStandardItemModel):
|
||||
"""A model listing the tasks combined for a list of assets"""
|
||||
def __init__(self, dbcon, parent=None):
|
||||
super(TasksModel, self).__init__(parent=parent)
|
||||
self.dbcon = dbcon
|
||||
self._default_icon = qtawesome.icon(
|
||||
"fa.male",
|
||||
color=style.colors.default
|
||||
)
|
||||
self._no_tasks_icon = qtawesome.icon(
|
||||
"fa.exclamation-circle",
|
||||
color=style.colors.mid
|
||||
)
|
||||
self._cached_icons = {}
|
||||
self._project_task_types = {}
|
||||
|
||||
self._refresh_task_types()
|
||||
|
||||
def _refresh_task_types(self):
|
||||
# Get the project configured icons from database
|
||||
project = self.dbcon.find_one(
|
||||
{"type": "project"},
|
||||
{"config.tasks"}
|
||||
)
|
||||
tasks = project["config"].get("tasks") or {}
|
||||
self._project_task_types = tasks
|
||||
|
||||
def _try_get_awesome_icon(self, icon_name):
|
||||
icon = None
|
||||
if icon_name:
|
||||
try:
|
||||
icon = qtawesome.icon(
|
||||
"fa.{}".format(icon_name),
|
||||
color=style.colors.default
|
||||
)
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
return icon
|
||||
|
||||
def headerData(self, section, orientation, role):
|
||||
# Show nice labels in the header
|
||||
if (
|
||||
role == QtCore.Qt.DisplayRole
|
||||
and orientation == QtCore.Qt.Horizontal
|
||||
):
|
||||
if section == 0:
|
||||
return "Tasks"
|
||||
|
||||
return super(TasksModel, self).headerData(section, orientation, role)
|
||||
|
||||
def _get_icon(self, task_icon, task_type_icon):
|
||||
if task_icon in self._cached_icons:
|
||||
return self._cached_icons[task_icon]
|
||||
|
||||
icon = self._try_get_awesome_icon(task_icon)
|
||||
if icon is not None:
|
||||
self._cached_icons[task_icon] = icon
|
||||
return icon
|
||||
|
||||
if task_type_icon in self._cached_icons:
|
||||
icon = self._cached_icons[task_type_icon]
|
||||
self._cached_icons[task_icon] = icon
|
||||
return icon
|
||||
|
||||
icon = self._try_get_awesome_icon(task_type_icon)
|
||||
if icon is None:
|
||||
icon = self._default_icon
|
||||
|
||||
self._cached_icons[task_icon] = icon
|
||||
self._cached_icons[task_type_icon] = icon
|
||||
|
||||
return icon
|
||||
|
||||
def set_asset(self, asset_doc):
|
||||
"""Set assets to track by their database id
|
||||
|
||||
Arguments:
|
||||
asset_doc (dict): Asset document from MongoDB.
|
||||
"""
|
||||
self.clear()
|
||||
|
||||
if not asset_doc:
|
||||
return
|
||||
|
||||
asset_tasks = asset_doc.get("data", {}).get("tasks") or {}
|
||||
items = []
|
||||
for task_name, task_info in asset_tasks.items():
|
||||
task_icon = task_info.get("icon")
|
||||
task_type = task_info.get("type")
|
||||
task_order = task_info.get("order")
|
||||
task_type_info = self._project_task_types.get(task_type) or {}
|
||||
task_type_icon = task_type_info.get("icon")
|
||||
icon = self._get_icon(task_icon, task_type_icon)
|
||||
|
||||
label = "{} ({})".format(task_name, task_type or "type N/A")
|
||||
item = QtGui.QStandardItem(label)
|
||||
item.setData(task_name, TASK_NAME_ROLE)
|
||||
item.setData(task_type, TASK_TYPE_ROLE)
|
||||
item.setData(task_order, TASK_ORDER_ROLE)
|
||||
item.setData(icon, QtCore.Qt.DecorationRole)
|
||||
item.setFlags(QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable)
|
||||
items.append(item)
|
||||
|
||||
if not items:
|
||||
item = QtGui.QStandardItem("No task")
|
||||
item.setData(self._no_tasks_icon, QtCore.Qt.DecorationRole)
|
||||
item.setFlags(QtCore.Qt.NoItemFlags)
|
||||
items.append(item)
|
||||
|
||||
self.invisibleRootItem().appendRows(items)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""Package declaring Pype version."""
|
||||
__version__ = "3.4.0-nightly.2"
|
||||
__version__ = "3.4.0-nightly.4"
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
Subproject commit 52e24a9993e5223b0a719786e77a4b87e936e556
|
||||
Subproject commit f48fce09c0986c1fd7f6731de33907be46b436c5
|
||||
|
|
@ -135,6 +135,16 @@ progress_bar.close()
|
|||
# iterate over frozen libs and create list to delete
|
||||
libs_dir = build_dir / "lib"
|
||||
|
||||
# On Windows "python3.dll" is needed for PyQt5 from the build.
|
||||
if platform.system().lower() == "windows":
|
||||
src = Path(libs_dir / "PyQt5" / "python3.dll")
|
||||
dst = Path(deps_dir / "PyQt5" / "python3.dll")
|
||||
if src.exists():
|
||||
shutil.copyfile(src, dst)
|
||||
else:
|
||||
_print("Could not find {}".format(src), 1)
|
||||
sys.exit(1)
|
||||
|
||||
to_delete = []
|
||||
# _print("Finding duplicates ...")
|
||||
deps_items = list(deps_dir.iterdir())
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ def get_log_since_tag(version):
|
|||
|
||||
def release_type(log):
|
||||
regex_minor = ["feature/", "(feat)"]
|
||||
regex_patch = ["bugfix/", "fix/", "(fix)"]
|
||||
regex_patch = ["bugfix/", "fix/", "(fix)", "enhancement/"]
|
||||
for reg in regex_minor:
|
||||
if re.search(reg, log):
|
||||
return "minor"
|
||||
|
|
|
|||
|
|
@ -40,14 +40,13 @@ Deploy OP build distribution (Openpype Igniter) on an OS of your choice.
|
|||
```sh
|
||||
#!/usr/bin/env bash
|
||||
export OPENPYPE_DEBUG=3
|
||||
export WEBSERVER_HOST_IP=localhost
|
||||
export FTRACK_BOT_API_USER=YOUR_API_USER
|
||||
export FTRACK_BOT_API_KEY=YOUR_API_KEY
|
||||
export PYTHONDONTWRITEBYTECODE=1
|
||||
export OPENPYPE_MONGO=YOUR_MONGODB_CONNECTION
|
||||
|
||||
pushd /opt/openpype
|
||||
./openpype_console webpublisherwebserver --upload_dir YOUR_SHARED_FOLDER_ON_HOST --executable /opt/openpype/openpype_console > /tmp/openpype.log 2>&1
|
||||
./openpype_console webpublisherwebserver --upload_dir YOUR_SHARED_FOLDER_ON_HOST --executable /opt/openpype/openpype_console --host YOUR_HOST_IP --port YOUR_HOST_PORT > /tmp/openpype.log 2>&1
|
||||
```
|
||||
|
||||
1. create service file `sudo vi /etc/systemd/system/openpye-webserver.service`
|
||||
|
|
|
|||
|
|
@ -120,7 +120,12 @@ const studios = [
|
|||
title: "Bad Clay",
|
||||
image: "/img/badClay_logo.png",
|
||||
infoLink: "https://www.bad-clay.com/",
|
||||
}
|
||||
},
|
||||
{
|
||||
title: "Moonrock Animation Studio",
|
||||
image: "/img/moonrock_logo.png",
|
||||
infoLink: "https://www.moonrock.eu/",
|
||||
}
|
||||
];
|
||||
|
||||
function Service({imageUrl, title, description}) {
|
||||
|
|
|
|||
BIN
website/static/img/moonrock_logo.png
Normal file
BIN
website/static/img/moonrock_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
Loading…
Add table
Add a link
Reference in a new issue